Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5fdbcaf65 | ||
|
|
b6ff7c6ad9 | ||
|
|
2c48d78880 | ||
|
|
a4b25ba279 | ||
|
|
95a61709d7 | ||
|
|
229910bcb8 | ||
|
|
e3ac9bad37 | ||
|
|
01d04e8f56 | ||
|
|
d3ff959574 | ||
|
|
df48432133 | ||
|
|
061dfa3fc5 | ||
|
|
132d8916b1 | ||
|
|
93ec545aaa | ||
|
|
a0a8ad04de | ||
|
|
149cdb46e1 | ||
|
|
05db809140 | ||
|
|
f83f8fa2fd | ||
|
|
a50905bb6c | ||
|
|
357c73cf8a | ||
|
|
fe12e57d88 | ||
|
|
993a4fe6f8 | ||
|
|
d36989ae0d | ||
|
|
fe8630eb4f | ||
|
|
7caa3850d5 | ||
|
|
e7d8bcef06 | ||
|
|
0eb01403c5 | ||
|
|
d5d2d8f336 | ||
|
|
fed0d35e03 | ||
|
|
50ebbcdf69 | ||
|
|
3aae289a8c | ||
|
|
6e805678d4 | ||
|
|
bf5fdfefa4 | ||
|
|
5dfaf27591 | ||
|
|
64f423da78 | ||
|
|
1eeda335d1 | ||
|
|
44c4a7dd13 | ||
|
|
896f0fac03 | ||
|
|
5f42309410 | ||
|
|
b0bac3d3ba | ||
|
|
2e66bdeff5 | ||
|
|
b8b2e66fd0 | ||
|
|
377aa03637 | ||
|
|
ba5622f9b2 | ||
|
|
de2892f7a7 | ||
|
|
a59277e33e | ||
|
|
5ed3a0796e | ||
|
|
fcd0b92785 | ||
|
|
865dd61535 | ||
|
|
05029889b9 | ||
|
|
2aadb667fb | ||
|
|
d291f063f5 | ||
|
|
6aace0d9a2 | ||
|
|
48dc2b896a | ||
|
|
93c34bf069 | ||
|
|
8d626abc6d | ||
|
|
62a8af3fc6 | ||
|
|
3636a134d6 | ||
|
|
483f08888e | ||
|
|
b2102d4752 | ||
|
|
59d2a4a55e | ||
|
|
deb81045d5 | ||
|
|
b01e938905 | ||
|
|
38e6c3f96b | ||
|
|
b607d4a064 | ||
|
|
c6a879b424 | ||
|
|
01bcbfffc1 | ||
|
|
551b727256 |
@@ -17,6 +17,15 @@ Dieses Verzeichnis ist zugleich der Web-Root der NAS
|
||||
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
|
||||
@@ -48,14 +57,19 @@ smart/
|
||||
├── 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 ← die acht Seitenvorlagen
|
||||
│ ├── rooms.php Räume, Etagen, Kachelwerte, Symbole
|
||||
│ ├── 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 + meshDb()
|
||||
│ ├── 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
|
||||
@@ -67,8 +81,8 @@ smart/
|
||||
├── js/solar/ der eigene JavaScript-Code
|
||||
├── js/ Fremdbibliotheken, alle lokal (kein CDN)
|
||||
├── css/solar.css das eigene Stylesheet (Theme + alle Bauteile)
|
||||
├── assets/img/ Grundriss-Renderings OG/EG/UG/AG, Icons
|
||||
├── tiles/ Kartenkacheln-Zwischenspeicher (nicht im Git)
|
||||
├── assets/img/ mitgelieferte Grundriss-Renderings dieses Hauses, Icons
|
||||
├── tiles/ beschreibbar, nicht im Git: Kartenkacheln, grundriss/ (hochgeladene Grundrisse)
|
||||
└── *.sql Schema-Dateien zum Nachziehen von Hand
|
||||
```
|
||||
|
||||
@@ -178,14 +192,29 @@ genau die Topics, die auf den Kacheln stehen (`tileTopics()` in `rooms.php`).
|
||||
|
||||
| Seite (`?action=`) | Vorlage | Seitenskripte | holt per fetch | hört auf MQTT |
|
||||
|---|---|---|---|---|
|
||||
| `solar` (Vorgabe) | `solar.php` | `jahresstatistik.js`, `solarMQTT.js` | `getProdData`, `getConsData`, `getForecastData`, `getSunrise`, `getStats`, `carEG`, `carOG`, `heater`, `watering` | `solarManager/#`, `weatherStation/#`, `wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` |
|
||||
| `home` | `home.php` | `autoActionFuncs.js`, `homeMQTT.js` | `room.php`, `AutoAction.php` | die Topics der Kacheln, `Raumtemp/#` |
|
||||
| `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 — das Meteogramm ist ein fremdes Dokument im `<iframe>` (`js/meteogram.js`) | `weatherStation/#` |
|
||||
| `settings` | `settings.php` | `settings.js` | `settings.php?action=…` | — |
|
||||
| `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:
|
||||
|
||||
@@ -240,8 +269,11 @@ flowchart TB
|
||||
AUTOA --> AUTOM
|
||||
SETA --> COSTS & KACH & AUTOM
|
||||
STATS --> COSTS
|
||||
SETA --> GRM["grundriss.php"]
|
||||
SETA --> ENF["energiefluss.php"]
|
||||
ENF --> HOMEMESH
|
||||
KACH --> ROOMS
|
||||
ROOMS --> COSTS
|
||||
GRM --> ROOMS
|
||||
LOGA --> LOGD
|
||||
|
||||
COSTS --> SOLARLOG
|
||||
@@ -273,8 +305,6 @@ eine Bibliothek es vorgibt.
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `submitFormAjax(form)` | Formular abschicken, ohne die Seite zu verlassen |
|
||||
| `bindModalSlider()` | Schieberegler im Modal an ihre Anzeige koppeln |
|
||||
| `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 |
|
||||
@@ -288,15 +318,95 @@ 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"`), runden (`stellen`), Einheit anhängen. Klick auf eine Kachel
|
||||
öffnet das Raum-Modal über `ajax/room.php?room=EG_Bad`.
|
||||
"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()`).
|
||||
|
||||
**`solarMQTT.js`** — die große Anlagenübersicht. `solarSVG` zeichnet den
|
||||
Energiefluss (Breite der Pfeile aus der Leistung), darunter hängen die drei
|
||||
Diagramme Prognose, Verbrauch und Erzeugung über `getData()`. Änderungen am SVG laufen über kleine Setter
|
||||
(`setText`, `setAttr`, `setFlowWidth`) und werden pro Bildrahmen gesammelt
|
||||
(`solarSvgUpdatePending`), damit ein Schwall MQTT-Nachrichten nicht hundertmal
|
||||
Layout auslöst.
|
||||
**`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
|
||||
@@ -314,11 +424,30 @@ Antwort selbst.
|
||||
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.
|
||||
Anfragen dazu, und die Auswahlfelder füllten sich asynchron. Dazu die
|
||||
Übersicht: Nachfolger einklappen (`toggleAutomationGroup`) und die Rückfrage
|
||||
vor dem Löschen (`frageNachfolger`).
|
||||
|
||||
**`settings.js`** — drei Reiter in einer Datei, deutlich getrennt: Preistabellen,
|
||||
Home-Kacheln, Geräte. Jeder Abschnitt hat sein Modell (`kachelModell`,
|
||||
`geraeteModell`), seine `zeichne…()`-Funktion und eine `…Binden()`-Funktion,
|
||||
**`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.
|
||||
|
||||
@@ -333,18 +462,19 @@ klein: Diagramme füllen bzw. Logzeilen nachladen und einfärben.
|
||||
|
||||
| Endpunkt | Aufruf | liefert / tut |
|
||||
|---|---|---|
|
||||
| `room.php` | `?room=EG_Bad`, `POST ?action=command\|temp` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden |
|
||||
| `AutoAction.php` | `?action=editor\|list`, `POST save\|delete\|toggle` | Automatik-Editor und -Übersicht |
|
||||
| `settings.php` | `?action=list\|kacheln\|geraete`, `POST save\|kacheln-save\|geraete-raeume\|geraet-loeschen` | Einstellungsseite |
|
||||
| `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` `heater.php` `watering.php` `tahoma.php` | | Wallboxen, Heizstab, Bewässerung, Tahoma-Bedienung |
|
||||
| `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 |
|
||||
|
||||
@@ -358,13 +488,17 @@ Kennzahlen, `energyHistory.php` wählt die Quelle nach Zeitraum,
|
||||
|
||||
| Datei | Zuständig für | Wichtige Funktionen |
|
||||
|---|---|---|
|
||||
| `rooms.php` | Räume, Etagen, Kachelwerte, Symbole | `allRooms()`, `roomsWithTile()`, `tileTopics()`, `kachelVorgabe()`, `kachelUeberschreibungen()`, `bootstrapIcons()`, `kachelIcon()` |
|
||||
| `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, Verbindung zu `homeMesh` | `meshDb()`, `deviceCatalog()`, `loadAutomation()`, `saveAutomation()`, `geraeteListe()`, `geraetLoeschen()`, `saveRooms()` |
|
||||
| `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()` |
|
||||
@@ -377,19 +511,21 @@ Datenbank folgen kann, ohne zu springen:
|
||||
```mermaid
|
||||
flowchart LR
|
||||
HELPER["helper.php"] --> MYSQL["mysql.php<br/><small>Zugangsdaten</small>"]
|
||||
ROOMS["rooms.php"] --> COSTS["costs.php"] --> MYSQL
|
||||
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 --> COSTS
|
||||
KACH -. "nur wenn vorhanden" .-> AUTOM["automations.php"]
|
||||
AUTOM --> MYSQL
|
||||
AUTOM --> MESH
|
||||
AUTOM --> ROOMS
|
||||
CMD["commands.php"] --> MYSQL
|
||||
CMD --> TAH["tahoma_EG.php"]
|
||||
CMD --> PHPMQTT["ajax/phpMQTT.php"]
|
||||
RCTRL["roomControls.php"] --> REITER["reiter.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
|
||||
```
|
||||
|
||||
@@ -432,9 +568,12 @@ erDiagram
|
||||
| `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` |
|
||||
| `weatherStation`, `weatherHours`, `weatherDays`, `daylight`, `simPower` | Wetter, Sonnenzeiten, Ertragsprognose | Wetterbrücke, Open-Meteo, Prognose |
|
||||
| `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`) |
|
||||
| `kachelWerte` | Überschreibungen der Home-Kacheln, JSON je Raum | Einstellungsseite (`kacheln.php`) |
|
||||
|
||||
**`Logins`** — `users` (Passkeys) und `addUser` (Einmal-Links).
|
||||
|
||||
@@ -442,15 +581,51 @@ erDiagram
|
||||
|
||||
## Vier Bausteine im Detail
|
||||
|
||||
### 1. Die Home-Kacheln
|
||||
### 1. Grundriss und Home-Kacheln
|
||||
|
||||
Was auf einer Kachel steht, hat drei Stufen: die Vorgabe in `rooms.php`, die
|
||||
Überschreibung in `kachelWerte`, und die Anzeige im Browser.
|
||||
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
|
||||
ROOMS["rooms.php<br/><small>$rooms: Vorgabe je Raum</small>"] --> MIT["mitWerten()"]
|
||||
KW[("kachelWerte<br/><small>je Raum, JSON</small>")] --> MIT
|
||||
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>"]
|
||||
@@ -458,8 +633,10 @@ flowchart LR
|
||||
BROKER --> HOMEJS
|
||||
HOMEJS -->|"kachelWert()"| SVG
|
||||
|
||||
SETJS["settings.js<br/><small>Reiter Kacheln</small>"] --> SETA["ajax/settings.php"]
|
||||
SETA --> KACHM["kacheln.php<br/><small>prüfen, speichern</small>"] --> KW
|
||||
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>"]
|
||||
```
|
||||
|
||||
@@ -472,14 +649,13 @@ Eine Wertdefinition ist ein kleines JSON-Objekt, überall in derselben Form:
|
||||
```
|
||||
|
||||
`wechselrichter` + `feld` statt `topic` summiert mehrere Wechselrichter;
|
||||
`format: "leistung"` skaliert selbst zwischen W und kW; `negativ` dreht das
|
||||
`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.
|
||||
|
||||
Fehlt die Tabelle `kachelWerte`, zeigt jede Kachel ihre Vorgabe: die
|
||||
Startseite ist keine Einstellung, ohne die man das Haus nicht mehr sieht.
|
||||
|
||||
### 2. Das Raum-Modal
|
||||
|
||||
@@ -491,8 +667,8 @@ sequenceDiagram
|
||||
participant C as commands.php
|
||||
participant G as Gerät
|
||||
|
||||
B->>R: GET ?room=EG_Bad
|
||||
R->>RC: Geräte des Raums (actors.room) → Bedienformen
|
||||
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()
|
||||
@@ -508,9 +684,9 @@ sequenceDiagram
|
||||
```
|
||||
|
||||
`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 zwei
|
||||
Reglern; hat es drei Parameter rot/grün/blau, wird es eine Lampe mit
|
||||
Farbwähler. Ein neu gefundenes Gerät bekommt so ohne Codeänderung ein
|
||||
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 —
|
||||
@@ -524,20 +700,123 @@ 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 auseinanderlä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, Logic); die Datenbanklogik liegt
|
||||
allein im Hauptskript.
|
||||
Discovery, Gartenwasser, SolarManager-Werte, Wetterstation, Logic); die
|
||||
Datenbanklogik liegt allein im Hauptskript.
|
||||
|
||||
Zwei Eigenschaften, die man kennen muss:
|
||||
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.
|
||||
@@ -561,11 +840,49 @@ Zwei Eigenschaften, die man kennen muss:
|
||||
|
||||
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.
|
||||
@@ -584,8 +901,8 @@ 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, bleibt die Startseite
|
||||
sichtbar; fehlt `kachelWerte`, gilt die Vorgabe.
|
||||
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`
|
||||
@@ -601,9 +918,14 @@ ausgeliefert.
|
||||
|
||||
| Vorhaben | Anlaufstelle |
|
||||
|---|---|
|
||||
| Ein Raum kommt dazu oder heißt anders | `restricted/rooms.php`, Tabelle `$rooms` — Kachel, Menü und MQTT-Abo ergeben sich daraus |
|
||||
| Andere Werte auf einer Kachel | Einstellungen → Kacheln (keine Codeänderung). Vorgabe für neue Räume: `kachelVorgabe()` in `rooms.php` |
|
||||
| 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/` |
|
||||
@@ -611,7 +933,7 @@ ausgeliefert.
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
@@ -640,12 +962,29 @@ 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, Runner, Wecker |
|
||||
| `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.
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
* GET ?action=editor&floor=OG Editor fuer eine neue Automatik
|
||||
* GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen"
|
||||
* POST ?action=save JSON-Rumpf, legt an oder ueberschreibt
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -45,7 +47,12 @@ 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))]);
|
||||
@@ -59,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"];
|
||||
@@ -74,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 = [];
|
||||
@@ -83,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
|
||||
@@ -101,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(" ", $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>";
|
||||
@@ -141,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(),
|
||||
@@ -172,66 +346,156 @@ $nutzdaten = json_encode([
|
||||
</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> Auslöser
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapseOne" class="accordion-collapse collapse show" data-bs-parent="#actionAccordion">
|
||||
<div class="accordion-body">
|
||||
<!--
|
||||
Drei Baender statt eines Akkordeons: Wenn - Wann - Dann.
|
||||
|
||||
Das Akkordeon schloss beim Oeffnen die anderen Faecher, Ausloeser und
|
||||
Aktion waren also nie gleichzeitig zu sehen - ausgerechnet bei einer
|
||||
Regel, die genau daraus besteht. Und das mittlere Fach hiess
|
||||
"Bedingungen", enthielt aber die *Rahmen*bedingungen; die echten
|
||||
Bedingungen standen im ersten.
|
||||
|
||||
Gebaut aus .card mit farbigem Rand und .card-header - kein eigenes
|
||||
Bauteil. Die Ueberschriften tragen .card-title und bekommen darueber die
|
||||
Anzeigeschrift aus solar.css.
|
||||
-->
|
||||
|
||||
<div class="card border-primary mb-2">
|
||||
<div class="card-header d-flex flex-wrap align-items-center gap-2 py-2">
|
||||
<i class="bi bi-lightning-charge-fill text-primary"></i>
|
||||
<span class="card-title mb-0 fs-5 text-primary">Wenn</span>
|
||||
<span class="small text-body-secondary">… das alles zusammenkommt</span>
|
||||
<span class="flex-grow-1"></span>
|
||||
<!-- Wie viele Bedingungen gerade zutreffen. Fuellt renderConditions(). -->
|
||||
<span id="condStand" class="d-flex align-items-center gap-2"></span>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<!-- Ein Block ist eine UND-Gruppe, zwischen den Bloecken steht ODER.
|
||||
Die Klammerung ist damit gezeichnet und nicht bloss vereinbart. -->
|
||||
<div id="condBlocks"></div>
|
||||
<button class="btn btn-outline-secondary btn-sm mt-2" type="button" id="btnAddGroup">
|
||||
<i class="bi bi-plus-lg"></i> Alternative (oder)
|
||||
<i class="bi bi-plus-lg"></i> oder stattdessen …
|
||||
</button>
|
||||
<div class="alert alert-secondary mt-3 mb-0 py-2 small" id="condSummary"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-2">
|
||||
<!--
|
||||
Der Rahmen steht zusammengefaltet da, solange er nur Vorgaben enthaelt -
|
||||
und das ist der Normalfall. Frueher nahm er die meiste Flaeche fuer den
|
||||
seltensten Inhalt.
|
||||
-->
|
||||
<!--
|
||||
Zugeklappt liest man hier, was gilt; aufgeklappt greift man zu. Die drei
|
||||
Vorlagen tauschen deshalb mit der Kurzfassung den Platz, statt eine
|
||||
eigene Zeile im Rumpf zu bekommen - das war eine Zeile mehr und eine
|
||||
Ueberschrift ("An welchen Tagen?"), die neben dem Wort "Wann" ohnehin
|
||||
nichts Neues sagte.
|
||||
|
||||
ACHTUNG beim Umbauen: die Vorlagen duerfen NICHT innerhalb des Elements
|
||||
stehen, das data-bs-toggle traegt. Bootstrap haengt seinen Umschalter
|
||||
nicht an dieses Element, sondern ans Dokument - und dort in der
|
||||
Capture-Phase, die vor jedem Klick auf einen Knopf darin laeuft. Ein
|
||||
stopPropagation() im Knopf kommt deshalb grundsaetzlich zu spaet, und
|
||||
jede Vorlage waere zugleich ein Zuklappen. Deshalb traegt nur der linke
|
||||
Teil den Umschalter, die Vorlagen stehen daneben.
|
||||
-->
|
||||
<div class="card-header d-flex flex-wrap align-items-center gap-2 py-2">
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1" id="rahmenKopf"
|
||||
role="button" data-bs-toggle="collapse" data-bs-target="#rahmenBody"
|
||||
aria-expanded="false">
|
||||
<i class="bi bi-calendar-week text-warning"></i>
|
||||
<span class="card-title mb-0 fs-5 text-warning">Wann</span>
|
||||
<span id="rahmenKurz" class="text-body-secondary"></span>
|
||||
<span class="flex-grow-1"></span>
|
||||
<span class="small text-body-secondary text-nowrap" id="rahmenAendern">ändern <i class="bi bi-chevron-down"></i></span>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group" id="tagVorlagen" hidden></div>
|
||||
<i class="bi bi-chevron-up text-body-secondary" id="rahmenZu" role="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#rahmenBody" hidden></i>
|
||||
</div>
|
||||
<div id="rahmenBody" class="collapse">
|
||||
<div class="card-body">
|
||||
<div class="btn-group btn-group-sm flex-wrap mb-2" role="group" id="weekdayGroup"></div>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-sm-6 d-flex align-items-center gap-2">
|
||||
<span class="text-body-secondary small" style="min-width:5rem">Ferien</span>
|
||||
<div class="btn-group btn-group-sm" role="group" id="ferienGroup"></div>
|
||||
</div>
|
||||
<div class="col-sm-6 d-flex align-items-center gap-2">
|
||||
<span class="text-body-secondary small" style="min-width:5rem">Feiertage</span>
|
||||
<div class="btn-group btn-group-sm" role="group" id="feiertagGroup"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Vorabend: fuer alles, was abends fuer morgen geschieht - "Kinder
|
||||
ins Bett, wenn morgen Schule ist" ist dann Mo-Fr, Ferien nie,
|
||||
Feiertage nie, wie der Wecker. Uhrzeit und Zeitfenster bleiben
|
||||
beim heutigen Tag. -->
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="nextDay">
|
||||
<label class="form-check-label" for="nextDay">
|
||||
Am Vorabend – Wochentage, Ferien und Feiertage gelten für den folgenden Tag
|
||||
</label>
|
||||
</div>
|
||||
<!--
|
||||
.callout ist ein AdminLTE-Bauteil: farbiger Balken links, getoenter
|
||||
Grund, und die Toene kommen aus Bootstraps *-bg-subtle-Variablen -
|
||||
es zieht also mit dem Theme mit, ohne dass hier eine Regel steht.
|
||||
Als .alert stand der Satz vorher in einem eingesunkenen Kasten mit
|
||||
Rahmen ringsum und las sich wie eine Warnung; er ist aber das
|
||||
Ergebnis der Schalter darueber. Die Polsterung stellen Hilfsklassen
|
||||
kleiner - .callout ist auf ganze Absaetze ausgelegt, hier steht ein
|
||||
Satz.
|
||||
-->
|
||||
<div class="callout callout-primary py-2 px-3 mt-0 mb-3" id="rahmenSummary"></div>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-sm-6">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">von</span>
|
||||
<input type="time" class="form-control" id="tSpanFrom" value="00:00">
|
||||
<span class="input-group-text">bis</span>
|
||||
<input type="time" class="form-control" id="tSpanTo" value="23:59">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">Sperre</span>
|
||||
<select class="form-select" id="lockoutSecs"></select>
|
||||
</div>
|
||||
</div>
|
||||
<!--
|
||||
Haltezeit. Sie steht neben der Sperre, weil beide dasselbe Mittel
|
||||
benutzen - Zeit - und trotzdem entgegengesetzt wirken: die Sperre
|
||||
bremst die Wiederholung, die Haltezeit den ersten Lauf.
|
||||
|
||||
Frei einstellbar statt aus einer Auswahlliste: Wonach man sucht,
|
||||
hängt am Messwert - eine offene Tür fällt nach zwei Minuten auf,
|
||||
ein laufender Wasserhahn erst nach Stunden.
|
||||
|
||||
Dasselbe Zeitfeld wie von/bis darüber, nur als Dauer gelesen. Dass
|
||||
es keine Uhrzeit meint, sagt der Text ringsum ("erst nach … am
|
||||
Stück"); die Zeile über dem Rahmen schreibt es beim Tippen aus
|
||||
("erst nach 3,5 Std.").
|
||||
-->
|
||||
<div class="col-sm-6">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">Auslösen erst nach</span>
|
||||
<input type="time" class="form-control" id="holdZeit" value="00:00"
|
||||
title="Stunden und Minuten, die die Bedingung ohne Unterbrechung erfüllt sein muss. 00:00 = sofort.">
|
||||
<span class="input-group-text">am Stück</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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> 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">
|
||||
<input type="time" class="form-control" id="tSpanFrom" value="00:00">
|
||||
<label for="tSpanFrom">Von</label>
|
||||
<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-floating">
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
@@ -239,23 +503,18 @@ $nutzdaten = json_encode([
|
||||
</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> Aktionen
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapseThree" class="accordion-collapse collapse" data-bs-parent="#actionAccordion">
|
||||
<div class="accordion-body">
|
||||
<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> Aktion
|
||||
<i class="bi bi-plus-lg"></i> und noch etwas tun
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,94 +1,99 @@
|
||||
<?php
|
||||
/*
|
||||
* Formular des Auto-Lademodals. carEG.php und carOG.php zeigen dasselbe
|
||||
* Formular; nur die Wallbox dahinter unterscheidet sich - eingebunden wird
|
||||
* es aus carSteuerung.php, das $wallbox und $formId setzt. $formId trennt
|
||||
* die beiden Formulare fuer das JavaScript.
|
||||
* Das Ladefenster, gemeinsam für beide Wallboxen (carEG.php, carOG.php über
|
||||
* carSteuerung.php).
|
||||
*
|
||||
* Der Schieberegler traegt die Wattstunden je Prozent als data-Attribut bei
|
||||
* sich. Frueher stand die Umrechnung als feste 14000 im JavaScript und
|
||||
* passte damit hoechstens zu einem der beiden Autos: eingestellte und
|
||||
* angezeigte Prozente gingen auseinander.
|
||||
* 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];
|
||||
$whProProzent = wallboxWhProProzent($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>
|
||||
<!--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 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 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>
|
||||
<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>
|
||||
<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 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>
|
||||
<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"
|
||||
data-wh-pro-prozent="<?= $whProProzent ?>" />
|
||||
<div class="small text-secondary">
|
||||
<?= htmlspecialchars($auto["name"]) ?>,
|
||||
<?= htmlspecialchars(rtrim(rtrim(number_format($auto["akkuKWh"], 1, ",", "."), "0"), ",")) ?> kWh nutzbar
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -2,86 +2,107 @@
|
||||
/**
|
||||
* Ladesteuerung beider Wallboxen.
|
||||
*
|
||||
* carEG.php und carOG.php setzen nur $wallbox und $formId und binden diese
|
||||
* Datei ein - vorher standen hier zwei fast gleiche Dateien, die sich nur im
|
||||
* Transportweg unterschieden. Was die beiden Wallboxen wirklich trennt,
|
||||
* steht in restricted/wallboxen.php.
|
||||
* carEG.php und carOG.php setzen nur $wallbox und binden diese Datei ein.
|
||||
* Was die beiden Wallboxen wirklich trennt, steht in restricted/wallboxen.php.
|
||||
*
|
||||
* Ohne POST wird das Formular ausgeliefert (carForm.php), mit POST wird
|
||||
* geschaltet und nichts zurueckgegeben - so war es vorher auch, das
|
||||
* JavaScript im Modal erwartet es so.
|
||||
* 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 der Wallbox. Die Namen links kommen aus den
|
||||
// Radiobuttons in carForm.php.
|
||||
// lmo, der Betriebsmodus - beide Wallboxen nutzen dieselben Nummern.
|
||||
const CAR_MODI = [
|
||||
"default" => 3, // Immer laden
|
||||
"eco" => 4, // Ueberschuss (Awattar)
|
||||
"NextTrip" => 5, // Ladeplanung (AutomaticStop)
|
||||
"sofort" => 3, // Default: volle Leistung, auch aus dem Netz
|
||||
"sonne" => 4, // Eco (Awattar): Überschuss
|
||||
"plan" => 5, // Next Trip (AutomaticStop): Energie bis Uhrzeit
|
||||
];
|
||||
|
||||
$close = 0;
|
||||
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;
|
||||
}
|
||||
|
||||
if (checkLogin()) {
|
||||
$konf = wallboxen()[$wallbox];
|
||||
|
||||
if (isset($_POST["evStart/Stop"])) {
|
||||
// frc: 2 gibt das Laden frei, 1 sperrt es, 0 waere "die Wallbox
|
||||
// entscheidet".
|
||||
if ($_POST["evStart/Stop"] === "Laden starten") {
|
||||
wallboxSetzen($wallbox, ["frc" => 2]);
|
||||
} elseif ($_POST["evStart/Stop"] === "Laden stoppen") {
|
||||
wallboxSetzen($wallbox, ["frc" => 1]);
|
||||
}
|
||||
$close = 1;
|
||||
|
||||
} elseif (isset($_POST["evMode"])) {
|
||||
$modus = CAR_MODI[$_POST["evMode"]] ?? null;
|
||||
|
||||
if ($modus !== null) {
|
||||
// Der Schieberegler liefert Prozent zusaetzlich benoetigter
|
||||
// Ladung; die Wallbox will Wattstunden. Der Faktor haengt am
|
||||
// Auto und steht deshalb in restricted/config.php.
|
||||
$prozent = min(100, max(0, intval($_POST["fte"] ?? 0)));
|
||||
$energie = $prozent * wallboxWhProProzent($wallbox);
|
||||
|
||||
$strom = min(16, max(6, intval($_POST["evAmp"] ?? 6)));
|
||||
|
||||
// Das Formularfeld ist ein <input type="time">, liefert also
|
||||
// HH:MM. Alles andere gilt als "keine Ladeplanung" und laesst
|
||||
// Zielzeit und Zielenergie unangetastet. Geprueft wird hier
|
||||
// weiterhin streng: der Wert ging frueher in eine Kommandozeile
|
||||
// und liess sich mit "12:00; befehl" missbrauchen.
|
||||
$sekunden = null;
|
||||
if (isset($_POST["ftt"])
|
||||
&& preg_match('/^([01][0-9]|2[0-3]):([0-5][0-9])$/', $_POST["ftt"], $teile)) {
|
||||
$sekunden = intval($teile[1]) * 3600 + intval($teile[2]) * 60;
|
||||
$body = json_decode(file_get_contents("php://input"), true);
|
||||
if (!is_array($body)) {
|
||||
ladeAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
|
||||
}
|
||||
|
||||
// Reihenfolge zaehlt: Ziel zuerst, Modus zuletzt.
|
||||
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";
|
||||
}
|
||||
if ($sekunden !== null && $energie > 0) {
|
||||
$werte["fte"] = $energie;
|
||||
$werte["ftt"] = $sekunden;
|
||||
}
|
||||
$werte["amp"] = $strom;
|
||||
$werte["fte"] = $wh;
|
||||
$werte["ftt"] = intval($teile[1]) * 3600 + intval($teile[2]) * 60;
|
||||
$werte["fup"] = true;
|
||||
$werte["lmo"] = $modus;
|
||||
$werte["lmo"] = CAR_MODI["plan"];
|
||||
break;
|
||||
|
||||
wallboxSetzen($wallbox, $werte);
|
||||
}
|
||||
$close = 1;
|
||||
}
|
||||
} else {
|
||||
$close = 1;
|
||||
default:
|
||||
ladeAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||
}
|
||||
|
||||
if (!$close) {
|
||||
include __DIR__ . "/carForm.php";
|
||||
if (!wallboxSetzen($wallbox, $werte)) {
|
||||
ladeAntwort(["error" => "Der MQTT-Broker ist nicht erreichbar."], 502);
|
||||
}
|
||||
ladeAntwort(["ok" => true]);
|
||||
|
||||
@@ -1,57 +1,117 @@
|
||||
<?php
|
||||
/**
|
||||
* Heizstab im Pufferspeicher.
|
||||
*
|
||||
* GET das Bedienfenster (Werte und Logik: js/solar/bedienfenster.js)
|
||||
* POST setMode=0 Automatik: der SolarManager regelt nach Überschuss
|
||||
* POST setMode=1..60 feste Leistung in 100 W (60 = 6,0 kW)
|
||||
* POST setMode=-1 aus: feste Leistung von 0 W, der Heizstab bleibt kalt
|
||||
*
|
||||
* Seit September 2026 gilt jede Änderung sofort, ohne "Save changes". Die
|
||||
* Antwort ist leer, wenn der Befehl beim Heizstab ankam, sonst steht dort
|
||||
* eine Meldung für das Fenster.
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
$close = 0;
|
||||
$hostname = "192.168.179.169";
|
||||
|
||||
if (!checkLogin()) {
|
||||
exit;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
}
|
||||
// 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 {
|
||||
$close = 1;
|
||||
echo "Unbekannte Leistungsstufe.";
|
||||
exit;
|
||||
}
|
||||
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;
|
||||
$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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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.
|
||||
@@ -102,12 +120,14 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
exit;
|
||||
}
|
||||
if (($_GET["action"] ?? "") === "temp") {
|
||||
// Bewusst derselbe Weg wie im alten Thermostat-Modal: das Topic
|
||||
// Raumtemp/<Etage>/<Raum>/changeSetTemp.
|
||||
// Das Leerzeichen muss durch: Raumnamen wie "Carport EG" haben
|
||||
// eines, und ohne es findet der Vergleich weiter unten den Raum
|
||||
// nicht mehr.
|
||||
$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);
|
||||
@@ -116,7 +136,7 @@ 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"]);
|
||||
@@ -144,6 +164,83 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
"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);
|
||||
@@ -156,17 +253,13 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
|
||||
|
||||
// --- Inhalt des Modals ----------------------------------------------------
|
||||
// Das Leerzeichen gehoert zu den erlaubten Zeichen. Ohne es wurde aus
|
||||
// "AG_Carport EG" ein "AG_CarportEG", und der Vergleich gegen die
|
||||
// gespeicherte Zuordnung "AG/Carport EG" ging ins Leere - das Modal meldete
|
||||
// dann "Diesem Raum ist noch nichts zugeordnet". Aufgefallen ist es erst mit
|
||||
// dem Aussengelaende: dort haben sechs von neun Raeumen ein Leerzeichen im
|
||||
// Namen, auf den anderen Etagen keiner von achtzehn.
|
||||
$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
|
||||
@@ -197,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>";
|
||||
|
||||
@@ -8,18 +8,37 @@
|
||||
* 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": {"12": "OG/Bad", ...}}
|
||||
* 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 bzw. restricted/kacheln.php, damit Maske und Endpunkt
|
||||
* dieselbe Wahrheit ueber einen Zeitraum oder eine Kachel 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);
|
||||
@@ -36,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)) {
|
||||
@@ -86,11 +119,54 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
$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);
|
||||
}
|
||||
@@ -106,7 +182,7 @@ if ($action === "kacheln") {
|
||||
"katalog" => kachelKatalog(),
|
||||
// Fuer die Eingabe von Hand, wenn etwas nicht im Katalog steht.
|
||||
"topics" => kachelTopicVorschlaege(),
|
||||
"tabelleDa" => kachelTabelleDa(),
|
||||
"tabelleDa" => grundrissDa(),
|
||||
"max" => KACHEL_MAX,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
@@ -114,6 +190,30 @@ if ($action === "kacheln") {
|
||||
}
|
||||
}
|
||||
|
||||
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()]);
|
||||
|
||||
@@ -42,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 b.*, SUM(b.neu) OVER (ORDER BY b.datetime) AS sitzung FROM (
|
||||
SELECT a.*,
|
||||
a.chg_state = 'CHARGING' AS laedt,
|
||||
(a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke) AS weiter,
|
||||
CASE WHEN a.chg_state = 'CHARGING'
|
||||
AND NOT (a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke)
|
||||
THEN 1 ELSE 0 END AS neu,
|
||||
-- Beginn aus der Zeile davor, wenn sie nah genug liegt.
|
||||
CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
|
||||
AND a.dt_vor <= $luecke
|
||||
THEN LEAST(a.soc, COALESCE(a.soc_vor, a.soc))
|
||||
ELSE a.soc END AS soc_start,
|
||||
CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
|
||||
AND a.dt_vor <= $luecke
|
||||
THEN COALESCE(NULLIF(a.wh_vor, 0), NULLIF(a.wb_wh_total, 0))
|
||||
ELSE NULLIF(a.wb_wh_total, 0) END AS wh_start,
|
||||
/*
|
||||
* chg_kw ist die Ladeleistung, die das Fahrzeug selbst meldet -
|
||||
* gemessen HINTER dem Ladegerät. Die API kennt keine
|
||||
* Kilowattstunden, deshalb wird über die Zeit integriert, je
|
||||
* Schritt mit dem Mittel aus vorigem und aktuellem Wert.
|
||||
*
|
||||
* Über mehr als 30 Minuten ohne Meldung wäre die Fläche unter
|
||||
* der Kurve erfunden: die Leistung kann in der Zeit von 4 auf
|
||||
* 10 kW gestiegen sein, ohne dass eine Zeile davon weiß.
|
||||
* zu_lang merkt sich das, und die Ladung bekommt dann keinen
|
||||
* fahrzeugseitigen Wert.
|
||||
*/
|
||||
CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor BETWEEN 1 AND $luecke
|
||||
THEN (COALESCE(a.chg_kw, 0) + COALESCE(a.kw_vor, 0)) / 2
|
||||
* a.dt_vor / 3600
|
||||
ELSE 0 END AS kwh_teil,
|
||||
CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor > 1800
|
||||
THEN 1 ELSE 0 END AS zu_lang
|
||||
FROM (
|
||||
SELECT 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,
|
||||
/*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
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)) s) t
|
||||
WHERE chg_state = 'CHARGING'
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -167,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)
|
||||
{
|
||||
@@ -237,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 -- */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -73,64 +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]],
|
||||
];
|
||||
|
||||
echo <<<HTML
|
||||
<div class="p-3">
|
||||
<div id="watering-feedback" class="alert d-none mb-3" role="status"></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>
|
||||
|
||||
<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 class="wasser-zonen">
|
||||
<?php foreach ($zonen as $zone):
|
||||
$dauern = $zone["dauern"];
|
||||
sort($dauern); ?>
|
||||
<div class="wasser-zone" data-zone="<?= $zone["key"] ?>" data-dauer="<?= $zone["dauern"][0] ?>">
|
||||
<div class="wasser-kopf">
|
||||
<span class="wasser-bild" data-bild="<?= $zone["bild"] ?>" aria-hidden="true"></span>
|
||||
<div class="wasser-titel">
|
||||
<b><?= htmlspecialchars($zone["label"]) ?></b>
|
||||
<span data-w="zustand">–</span>
|
||||
</div>
|
||||
<span class="lade-pille" data-w="pille"></span>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
Startet die Ventilfolge von Hand. Die Regenautomatik läuft davon
|
||||
unabhängig zum Sonnenaufgang; ob eine Zone dabei schon versorgt
|
||||
wurde, steht unten bei „Heute“.
|
||||
<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 ?> 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;
|
||||
|
||||
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 class="lade-meldung" data-w="meldung" aria-live="polite">Befehle gehen sofort an die Ventilsteuerung</div>
|
||||
</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>
|
||||
<button type="button" class="btn btn-success px-4 ms-auto" data-watering-toggle data-watering-zone="$key">Ein</button>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
echo "\n</div>\n";
|
||||
|
||||
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -0,0 +1 @@
|
||||
{"1":{"x":50,"y":75.3},"2":{"x":13,"y":84.5},"3":{"x":88.5,"y":92.5}}
|
||||
|
After Width: | Height: | Size: 80 KiB |
@@ -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}}
|
||||
|
After Width: | Height: | Size: 74 KiB |
@@ -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}}
|
||||
|
After Width: | Height: | Size: 106 KiB |
|
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}}
|
||||
|
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}}
|
||||
|
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}}
|
||||
|
After Width: | Height: | Size: 97 KiB |
@@ -0,0 +1 @@
|
||||
{"1":{"x":33.3,"y":16.6},"2":{"x":93.6,"y":23.5},"3":{"x":26.2,"y":81.4}}
|
||||
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1 @@
|
||||
{"1":{"x":11.5,"y":30.4},"2":{"x":49.4,"y":26.9},"3":{"x":64.9,"y":29.9}}
|
||||
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 62 KiB |
@@ -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}}
|
||||
|
After Width: | Height: | Size: 141 KiB |
|
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}}
|
||||
|
After Width: | Height: | Size: 73 KiB |
@@ -0,0 +1 @@
|
||||
{"1":{"x":50,"y":51.3},"2":{"x":15.6,"y":54.7}}
|
||||
|
After Width: | Height: | Size: 73 KiB |
@@ -0,0 +1 @@
|
||||
{"1":{"x":50,"y":51.5}}
|
||||
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 75 KiB |
@@ -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}}
|
||||
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 94 KiB |
@@ -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}}
|
||||
|
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}}
|
||||
@@ -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>
|
||||
@@ -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 …“.
|
||||
@@ -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 |
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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ätetabellen (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)) |
|
||||
@@ -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` |
|
||||
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 439 KiB |
|
After Width: | Height: | Size: 440 KiB |
@@ -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.
|
||||
|
||||

|
||||
|
||||
```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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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` |
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
| 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 |
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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 |
|
||||
@@ -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;
|
||||
|
||||
--
|
||||
|
||||
@@ -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','AG') 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. AG ist das Aussengelaende - eine Etage ohne Grundriss, siehe restricted/rooms.php',
|
||||
`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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Web-Push-Abos fuer die Benachrichtigungen der Automatiken
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Ein Abo ist ein Geraet, das Meldungen bekommen moechte: Browser oder
|
||||
-- Startbildschirm-Symbol auf einem Handy. Der Browser erzeugt es beim ersten
|
||||
-- "Benachrichtigungen erlauben" und liefert drei Teile:
|
||||
--
|
||||
-- endpoint die Adresse beim Push-Dienst des Browserherstellers (Google,
|
||||
-- Mozilla, Apple). Sie ist eindeutig und zugleich das Geheimnis -
|
||||
-- wer sie hat, kann diesem Geraet etwas schicken.
|
||||
-- p256dh oeffentlicher Schluessel des Geraets
|
||||
-- auth Geheimnis fuer die Verschluesselung
|
||||
--
|
||||
-- Verschluesselt wird gegen p256dh und auth, unterschrieben mit dem
|
||||
-- VAPID-Schluessel aus push_vapid.json (SolarManager). Der Push-Dienst sieht
|
||||
-- nur ein Paket, das er nicht lesen kann.
|
||||
--
|
||||
-- Geloescht wird automatisch: meldet der Dienst 404 oder 410, gibt es das
|
||||
-- Geraet nicht mehr (App entfernt, Browserdaten geloescht) - der Runner
|
||||
-- raeumt die Zeile dann selbst weg.
|
||||
--
|
||||
-- Einspielen:
|
||||
-- mysql -h nas.fritz.box -P 3310 -u homeMesh -p homeMesh < homeMesh_push.sql
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `push_abos` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`endpoint` varchar(500) NOT NULL COMMENT 'Adresse beim Push-Dienst; zugleich der Schluessel der Zeile',
|
||||
`p256dh` varchar(255) NOT NULL COMMENT 'oeffentlicher Schluessel des Geraets',
|
||||
`auth` varchar(64) NOT NULL COMMENT 'Geheimnis fuer die Verschluesselung',
|
||||
`name` varchar(80) NOT NULL DEFAULT '' COMMENT 'Klarname, den der Nutzer vergibt ("Handy Moritz")',
|
||||
`browser` varchar(120) NOT NULL DEFAULT '' COMMENT 'erkannt aus dem User-Agent, nur zum Wiedererkennen',
|
||||
`angelegt` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`zuletzt` datetime DEFAULT NULL COMMENT 'wann zuletzt erfolgreich zugestellt',
|
||||
`fehler` varchar(255) NOT NULL DEFAULT '' COMMENT 'letzter Fehler, leer = alles gut',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_endpoint` (`endpoint`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -100,9 +100,11 @@ if (isset($_GET["addUser"])) {
|
||||
*/
|
||||
$pages = [
|
||||
"solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, "leaflet" => false,
|
||||
"scripts" => ["js/solar/jahresstatistik.js", "js/solar/solarMQTT.js"]],
|
||||
"scripts" => ["js/solar/jahresstatistik.js", "js/solar/anlageKatalog.js", "js/solar/anlage.js", "js/solar/speicher.js",
|
||||
"js/solar/meteogrammKatalog.js", "js/solar/meteogramm.js",
|
||||
"js/solar/energieflussKatalog.js", "js/solar/energiefluss.js", "js/solar/bewaesserungAnzeige.js", "js/solar/ladefenster.js", "js/solar/bedienfenster.js", "js/solar/solarMQTT.js"]],
|
||||
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false,
|
||||
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]],
|
||||
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/zeitleiste.js", "js/solar/raumfenster.js", "js/solar/homeMQTT.js"]],
|
||||
"heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false,
|
||||
"scripts" => ["js/solar/heatMQTT.js"]],
|
||||
"history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, "leaflet" => false,
|
||||
@@ -112,10 +114,21 @@ if (isset($_GET["addUser"])) {
|
||||
"weather" => ["template" => "weather.php", "mqtt" => true, "charts" => false, "meteogram" => true, "leaflet" => false,
|
||||
"scripts" => ["js/solar/weatherMQTT.js"]],
|
||||
"settings" => ["template" => "settings.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false,
|
||||
"scripts" => ["js/solar/settings.js"]],
|
||||
"scripts" => ["js/solar/settings.js", "js/solar/grundriss.js",
|
||||
"js/solar/anlageKatalog.js", "js/solar/energieflussKatalog.js",
|
||||
"js/solar/energiefluss.js", "js/solar/energieflussEinstellungen.js",
|
||||
"js/solar/benachrichtigungen.js"]],
|
||||
"logs" => ["template" => "logs.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false,
|
||||
"scripts" => ["js/solar/logs.js"]],
|
||||
"einfuehrung" => ["template" => "einfuehrung.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false,
|
||||
"scripts" => ["js/solar/einfuehrung.js"]],
|
||||
];
|
||||
// Anzeige für ein festes Display (Raspberry Pi, 800 x 480): die Solar-Seite
|
||||
// ohne Kopfleiste und Menue, mit schmaler Leiste - header.php und
|
||||
// footer.php richten sich nach "anzeige", js/solar/anzeige.js macht den Rest.
|
||||
$pages["anzeige"] = $pages["solar"];
|
||||
$pages["anzeige"]["anzeige"] = true;
|
||||
$pages["anzeige"]["scripts"][] = "js/solar/anzeige.js";
|
||||
|
||||
// Unbekannte oder fehlende Aktion faellt auf die Solar-Seite zurueck.
|
||||
if (!isset($_GET["action"]) || !isset($pages[$_GET["action"]])) {
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
/* ==========================================================================
|
||||
Anlage-Ansicht: die PV-Anlage als Lageplan mit Leistung je Platte.
|
||||
|
||||
const plan = new Anlage(behaelter, { katalog: ANLAGE_KATALOG });
|
||||
plan.zeichnen();
|
||||
plan.aktualisieren(mqttData);
|
||||
|
||||
Was die Anlage ausmacht (Flächen, Wechselrichter, Lage jeder Platte),
|
||||
steht in js/solar/anlageKatalog.js. Hier steht nur, wie ein Plan
|
||||
grundsätzlich aussieht und wie aus den Werten ein Zustand wird - im
|
||||
selben Stil wie die Energiefluss-Übersicht (energiefluss.js).
|
||||
|
||||
Farbe einer Platte: ihre Leistung im Vergleich zum Median ihrer Fläche.
|
||||
So fällt eine verschattete oder defekte Platte auf, ohne dass Sonnenstand
|
||||
oder Bewölkung eine Rolle spielen. Bei schwachem Licht (Median unter
|
||||
SCHWACHLICHT) wird nicht verglichen - dann liefern alle fast nichts.
|
||||
Liefert ein Wechselrichter keine Daten, sind seine Platten "ohne Daten"
|
||||
und nicht "auffällig".
|
||||
|
||||
Den Verlauf je Platte gibt es in der Datenbank nicht; er wird hier seit
|
||||
dem Aufruf der Seite mitgeschrieben.
|
||||
========================================================================== */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const HOCHKANT_UNTER = 720;
|
||||
const SCHWACHLICHT = 30; // W Median, darunter kein Vergleich
|
||||
const VERLAUF_ABSTAND = 15000; // ms zwischen zwei Punkten
|
||||
const VERLAUF_DAUER = 40 * 60000; // ms, die der Verlauf zurückreicht
|
||||
|
||||
let laufendeNummer = 0;
|
||||
|
||||
const kw = w => (Math.abs(w) / 1000).toFixed(Math.abs(w) < 10000 ? 2 : 1).replace(".", ",") + " kW";
|
||||
const watt = w => Math.abs(w) < 1000 ? Math.round(w) + " W" : kw(w);
|
||||
const schutz = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
|
||||
function el(name, attr, eltern) {
|
||||
const e = document.createElementNS(NS, name);
|
||||
if (attr) for (const k in attr) e.setAttribute(k, attr[k]);
|
||||
if (eltern) eltern.appendChild(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
/**
|
||||
* Füllfarbe einer Platte: von gedämpftem Oliv (nichts) zur Sonnenfarbe
|
||||
* (so viel wie die stärksten). Als --fuellung, damit CSS sie weich
|
||||
* überblendet und "ohne Daten" sie überstimmen kann.
|
||||
*/
|
||||
const DUNKEL = [59, 58, 50];
|
||||
function setzeFuellung(g, anteil, farbe) {
|
||||
const ziel = [1, 3, 5].map(i => parseInt(farbe.substr(i, 2), 16));
|
||||
const t = Math.pow(Math.max(0, Math.min(1, anteil)), 0.8);
|
||||
const rgb = "rgb(" + DUNKEL.map((d, i) => Math.round(d + (ziel[i] - d) * t)).join(",") + ")";
|
||||
if (g._fuellung !== rgb) { g._fuellung = rgb; g.style.setProperty("--fuellung", rgb); }
|
||||
}
|
||||
|
||||
function median(werte) {
|
||||
if (!werte.length) return 0;
|
||||
const s = [...werte].sort((a, b) => a - b);
|
||||
return s[Math.floor(s.length / 2)];
|
||||
}
|
||||
|
||||
function leser(daten) {
|
||||
return {
|
||||
zahl(t) { const v = Number(String(daten[t])); return isFinite(v) ? v : 0; },
|
||||
text(t) { return daten[t] === undefined ? undefined : String(daten[t]); },
|
||||
json(t) { try { return JSON.parse(String(daten[t])); } catch (e) { return null; } },
|
||||
};
|
||||
}
|
||||
|
||||
class Anlage {
|
||||
constructor(behaelter, opt = {}) {
|
||||
this.behaelter = behaelter;
|
||||
this.katalog = opt.katalog || ANLAGE_KATALOG;
|
||||
this.hochkantUnter = opt.hochkantUnter ?? HOCHKANT_UNTER; // px; 0 = nie hochkant
|
||||
this.nummer = ++laufendeNummer;
|
||||
this.auswahl = null; // {art: "platte"|"string"|"wr", schluessel}
|
||||
this.verlauf = {}; // Eingang -> [[zeit, W], ...]
|
||||
this.letzterPunkt = 0;
|
||||
this.stand = null;
|
||||
|
||||
const k = this.katalog;
|
||||
this.flaeche = Object.fromEntries(k.flaechen.map(f => [f.id, f]));
|
||||
this.wrNachEingang = {};
|
||||
k.wechselrichter.forEach(w => w.eingaenge.forEach((e, i) => { this.wrNachEingang[e] = { wr: w, index: i }; }));
|
||||
|
||||
this.beobachter = new ResizeObserver(() => {
|
||||
if (!this.behaelter.clientWidth) return;
|
||||
if ((this.behaelter.clientWidth < this.hochkantUnter) !== this.hochkant) this.zeichnen();
|
||||
else this.masstab();
|
||||
});
|
||||
this.beobachter.observe(behaelter);
|
||||
this.sichtbar = true;
|
||||
new IntersectionObserver(e => { this.sichtbar = e[0].isIntersecting; this.pausieren(); }).observe(behaelter);
|
||||
document.addEventListener("visibilitychange", () => this.pausieren());
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- Geometrie */
|
||||
/** Hochkant: um 90 Grad im Uhrzeigersinn gedreht. Norden dreht mit. */
|
||||
rechteck(x, y, w, h) {
|
||||
return this.hochkant ? [-(y + h), x, h, w] : [x, y, w, h];
|
||||
}
|
||||
punkt(x, y) {
|
||||
return this.hochkant ? [-y, x] : [x, y];
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- Zeichnen */
|
||||
zeichnen() {
|
||||
const breite = this.behaelter.clientWidth;
|
||||
this.hochkant = breite > 0 && breite < this.hochkantUnter;
|
||||
const id = "anl" + this.nummer;
|
||||
const k = this.katalog;
|
||||
|
||||
this.behaelter.innerHTML = `
|
||||
<div class="anl">
|
||||
<div class="anl-flaechen"></div>
|
||||
<div class="anl-plan-rahmen">
|
||||
<svg class="anl-plan${this.hochkant ? " anl-hoch" : ""}" role="img" aria-label="Lageplan der PV-Anlage"></svg>
|
||||
</div>
|
||||
<div class="anl-legende">
|
||||
<span><i class="anl-l-verlauf"></i>Leistung im Vergleich zu den stärksten Platten der Fläche</span>
|
||||
<span><i class="anl-l-warnung">!</i>deutlich unter dem Mittel der Fläche</span>
|
||||
<span><i class="anl-l-offline"></i>ohne Daten</span>
|
||||
</div>
|
||||
<div class="anl-unten">
|
||||
<div class="anl-fach anl-auswahl" aria-live="polite"></div>
|
||||
<div class="anl-fach">
|
||||
<h3 class="anl-ueberschrift">Wechselrichter</h3>
|
||||
<ul class="anl-wr"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
this.dom = {
|
||||
flaechen: this.behaelter.querySelector(".anl-flaechen"),
|
||||
svg: this.behaelter.querySelector(".anl-plan"),
|
||||
auswahl: this.behaelter.querySelector(".anl-auswahl"),
|
||||
wr: this.behaelter.querySelector(".anl-wr"),
|
||||
};
|
||||
const svg = this.dom.svg;
|
||||
|
||||
el("pattern", { id: id + "-zellen", width: 10, height: 10, patternUnits: "userSpaceOnUse" }, el("defs", null, svg))
|
||||
.appendChild(el("path", { d: "M10 0V10M0 10H10", fill: "none", stroke: "rgba(0,0,0,.2)", "stroke-width": 1 }));
|
||||
|
||||
// Flächen
|
||||
const umrisse = el("g", { class: "anl-umrisse" }, svg);
|
||||
for (const f of k.flaechen) {
|
||||
const g = el("g", { style: `--farbe:${f.farbe}`, "data-flaeche": f.id }, umrisse);
|
||||
if (Array.isArray(f.umriss)) {
|
||||
f.umriss.forEach(p => el("polygon", {
|
||||
class: "anl-dachflaeche",
|
||||
points: p.split(" ").map(xy => this.punkt(...xy.split(",").map(Number)).join(",")).join(" "),
|
||||
}, g));
|
||||
} else if (f.umriss) {
|
||||
const [x, y, w, h] = this.rechteck(f.umriss.x, f.umriss.y, f.umriss.w, f.umriss.h);
|
||||
el("rect", { class: "anl-bereich", x, y, width: w, height: h, rx: 12 }, g);
|
||||
}
|
||||
}
|
||||
|
||||
// Strings (ohne Einzelwerte)
|
||||
this.stringKnoten = {};
|
||||
const strings = el("g", { class: "anl-platten" }, svg);
|
||||
for (const s of k.strings) {
|
||||
const g = el("g", { class: "anl-string anl-dunkel", "data-string": s.id, style: `--farbe:${s.farbe}` }, strings);
|
||||
s.platten.forEach(r => this.platteZeichnen(g, id, r[0], r[1], r[2], r[3]));
|
||||
this.stringKnoten[s.id] = g;
|
||||
}
|
||||
|
||||
// Platten
|
||||
this.platteKnoten = {};
|
||||
for (const p of k.platten) {
|
||||
const g = el("g", { class: "anl-platte anl-dunkel", "data-eingang": p.eingang }, strings);
|
||||
const r = this.platteZeichnen(g, id, p.x, p.y, p.w || 70, p.h || 40);
|
||||
const t = el("text", { class: "anl-w", x: r[0] + r[2] / 2, y: r[1] + r[3] / 2 }, g);
|
||||
t.textContent = "–";
|
||||
const warn = el("g", { class: "anl-warnung", transform: `translate(${r[0] + r[2] - 3} ${r[1] + 3})` }, g);
|
||||
el("circle", { r: 13, class: "anl-warnung-hof" }, warn);
|
||||
el("circle", { r: 8.5 }, warn);
|
||||
el("text", { y: 0.5 }, warn).textContent = "!";
|
||||
this.platteKnoten[p.eingang] = { g, t };
|
||||
}
|
||||
|
||||
// Stringwerte als Schild
|
||||
this.schilder = {};
|
||||
for (const s of k.strings) {
|
||||
const [x, y] = this.punkt(...s.schild);
|
||||
const g = el("g", { class: "anl-schild", transform: `translate(${x} ${y})` }, svg);
|
||||
el("rect", { x: -64, y: -26, width: 128, height: 52, rx: 12 }, g);
|
||||
const w = el("text", { class: "anl-schild-wert", y: -5 }, g);
|
||||
const n = el("text", { class: "anl-schild-name", y: 15 }, g);
|
||||
n.textContent = s.name;
|
||||
this.schilder[s.id] = w;
|
||||
}
|
||||
|
||||
// Nordpfeil
|
||||
const nord = el("g", { class: "anl-nord" }, svg);
|
||||
const [nx, ny] = this.punkt(k.nord.x, k.nord.y);
|
||||
nord.setAttribute("transform", `translate(${nx} ${ny}) rotate(${k.nord.winkel + (this.hochkant ? 90 : 0)})`);
|
||||
el("path", { d: "M0-26L12 14 0 7-12 14Z" }, nord);
|
||||
const nt = el("text", { y: 34 }, nord);
|
||||
nt.textContent = "N";
|
||||
if (this.hochkant) nt.setAttribute("transform", "rotate(-100 0 34)");
|
||||
|
||||
this.rahmen();
|
||||
this.masstab();
|
||||
this.binden();
|
||||
this.pausieren();
|
||||
if (this.stand) this.anzeigen();
|
||||
}
|
||||
|
||||
platteZeichnen(g, id, x, y, w, h) {
|
||||
const r = this.rechteck(x, y, w, h);
|
||||
// --d: Verzögerung des Auftritts, --glanz: Takt der Lichtwelle - beide
|
||||
// laufen über die Anlage von links oben nach rechts unten.
|
||||
const teil = el("g", { class: "anl-teil", style: `--d:${Math.round((x + y) / 2.2)};--glanz:${((x + y * 0.4) / 1500).toFixed(3)}` }, g);
|
||||
el("rect", { class: "anl-koerper", x: r[0], y: r[1], width: r[2], height: r[3], rx: 4 }, teil);
|
||||
el("rect", { class: "anl-zellen", x: r[0], y: r[1], width: r[2], height: r[3], rx: 4, fill: `url(#${id}-zellen)` }, teil);
|
||||
el("rect", { class: "anl-glanz", x: r[0], y: r[1], width: r[2], height: r[3], rx: 4 }, teil);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Zeichenfläche aus dem Katalog - getBBox() taugt nicht, die Ansicht ist beim Zeichnen oft verborgen. */
|
||||
rahmen() {
|
||||
const k = this.katalog;
|
||||
const punkte = [];
|
||||
const rechteck = (x, y, w, h) => { const r = this.rechteck(x, y, w, h); punkte.push([r[0], r[1]], [r[0] + r[2], r[1] + r[3]]); };
|
||||
k.platten.forEach(p => rechteck(p.x, p.y, p.w || 70, p.h || 40));
|
||||
k.strings.forEach(s => s.platten.forEach(r => rechteck(...r)));
|
||||
k.flaechen.forEach(f => {
|
||||
if (Array.isArray(f.umriss)) f.umriss.forEach(p => p.split(" ").forEach(xy => punkte.push(this.punkt(...xy.split(",").map(Number)))));
|
||||
else if (f.umriss) rechteck(f.umriss.x, f.umriss.y, f.umriss.w, f.umriss.h);
|
||||
});
|
||||
const [nx, ny] = this.punkt(k.nord.x, k.nord.y);
|
||||
punkte.push([nx - 30, ny - 40], [nx + 30, ny + 45]);
|
||||
const xs = punkte.map(p => p[0]), ys = punkte.map(p => p[1]);
|
||||
const rand = 24;
|
||||
const x0 = Math.min(...xs) - rand, y0 = Math.min(...ys) - rand;
|
||||
this.dom.svg.setAttribute("viewBox",
|
||||
[x0, y0, Math.max(...xs) + rand - x0, Math.max(...ys) + rand - y0].map(Math.round).join(" "));
|
||||
}
|
||||
|
||||
/** Beschriftung ausblenden, wenn sie zu klein würde. */
|
||||
masstab() {
|
||||
const vb = this.dom && this.dom.svg.viewBox.baseVal;
|
||||
if (!vb || !vb.width) return;
|
||||
const pxJeEinheit = this.dom.svg.clientWidth / vb.width;
|
||||
this.dom.svg.classList.toggle("anl-klein", pxJeEinheit < 0.55);
|
||||
}
|
||||
|
||||
binden() {
|
||||
this.dom.svg.addEventListener("click", ev => {
|
||||
const p = ev.target.closest("[data-eingang]");
|
||||
const s = ev.target.closest("[data-string]");
|
||||
if (p) this.waehle("platte", Number(p.dataset.eingang));
|
||||
else if (s) this.waehle("string", s.dataset.string);
|
||||
else this.waehle(null);
|
||||
});
|
||||
const zuWahl = ev => {
|
||||
const b = ev.target.closest("[data-wahl]");
|
||||
if (!b) return;
|
||||
const [art, schluessel] = b.dataset.wahl.split(":");
|
||||
this.waehle(art || null, art === "string" ? schluessel : Number(schluessel));
|
||||
};
|
||||
this.dom.wr.addEventListener("click", zuWahl);
|
||||
this.dom.auswahl.addEventListener("click", zuWahl);
|
||||
}
|
||||
|
||||
waehle(art, schluessel) {
|
||||
const gleich = this.auswahl && this.auswahl.art === art && this.auswahl.schluessel === schluessel;
|
||||
this.auswahl = art && !gleich ? { art, schluessel } : null;
|
||||
this.detailSig = null;
|
||||
if (this.stand) this.anzeigen();
|
||||
}
|
||||
|
||||
pausieren() {
|
||||
if (this.dom) this.dom.svg.classList.toggle("anl-pause", !this.sichtbar || document.hidden);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------- Aktualisieren */
|
||||
aktualisieren(daten) {
|
||||
const m = leser(daten);
|
||||
const pvn = m.json("solarManager/P_PVn") || [];
|
||||
const k = this.katalog;
|
||||
|
||||
const wr = {};
|
||||
for (const w of k.wechselrichter) {
|
||||
const b = "solarManager/inverters" + w.nr + "/";
|
||||
const geschaetzt = m.text(b + "estimated");
|
||||
wr[w.nr] = {
|
||||
def: w,
|
||||
ac: m.zahl(b + "p_AC"),
|
||||
temp: m.zahl(b + "temp"),
|
||||
gestoert: m.zahl(b + "error") > 0,
|
||||
geschaetzt: geschaetzt === "True" || Number(geschaetzt) > 0,
|
||||
limit: m.zahl(b + "limit"),
|
||||
gemeldet: m.text(b + "name") !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const platten = k.platten.map(p => {
|
||||
const zu = this.wrNachEingang[p.eingang];
|
||||
const w = Math.max(0, Number(pvn[p.eingang]) || 0);
|
||||
return { def: p, w, wr: zu ? wr[zu.wr.nr] : null, index: zu ? zu.index : 0, offline: zu ? wr[zu.wr.nr].gestoert : false };
|
||||
});
|
||||
// Zwei getrennte Fragen:
|
||||
// hell - wie viel liefert die Platte im Vergleich zu den stärksten
|
||||
// ihrer Fläche (90. Perzentil)? Nur Farbe, kein Urteil.
|
||||
// klasse - fällt sie auf? Nur bei genug Licht, gemessen am Median.
|
||||
// Früher bestimmte der Median beides; auf der Veranda mit Platten in
|
||||
// verschiedenen Richtungen waren dann nachmittags alle "dunkel", auch
|
||||
// die mit 200 W.
|
||||
const mittel = {}, bezug = {};
|
||||
for (const f of k.flaechen) {
|
||||
const werte = platten.filter(p => p.def.flaeche === f.id && !p.offline).map(p => p.w).sort((a, b) => a - b);
|
||||
mittel[f.id] = median(werte);
|
||||
bezug[f.id] = werte.length ? werte[Math.floor(0.9 * (werte.length - 1))] : 0;
|
||||
}
|
||||
for (const p of platten) {
|
||||
const md = mittel[p.def.flaeche];
|
||||
p.anteil = md > 0 ? p.w / md : 0;
|
||||
p.hell = bezug[p.def.flaeche] > 0 ? Math.min(1, p.w / bezug[p.def.flaeche]) : 0;
|
||||
p.klasse = p.offline ? "offline" : md < SCHWACHLICHT ? "dunkel"
|
||||
: p.anteil >= 0.9 ? "gut" : p.anteil >= 0.7 ? "mittel" : "schwach";
|
||||
}
|
||||
const strings = k.strings.map(s => {
|
||||
const w = Math.max(0, Number(pvn[s.eingang]) || 0);
|
||||
return { def: s, w, jePlatte: w / s.platten.length };
|
||||
});
|
||||
for (const s of strings) {
|
||||
const max = Math.max(...strings.filter(x => x.def.flaeche === s.def.flaeche).map(x => x.jePlatte));
|
||||
s.hell = max > 0 ? s.jePlatte / max : 0;
|
||||
}
|
||||
|
||||
// Verlauf mitschreiben
|
||||
const jetzt = Date.now();
|
||||
if (jetzt - this.letzterPunkt >= VERLAUF_ABSTAND) {
|
||||
this.letzterPunkt = jetzt;
|
||||
pvn.forEach((w, i) => {
|
||||
const v = this.verlauf[i] || (this.verlauf[i] = []);
|
||||
v.push([jetzt, Number(w) || 0]);
|
||||
while (v.length && jetzt - v[0][0] > VERLAUF_DAUER) v.shift();
|
||||
});
|
||||
}
|
||||
|
||||
this.stand = { platten, strings, wr, mittel, pvn, gesamt: m.zahl("solarManager/P_PV") };
|
||||
if (this.dom) this.anzeigen();
|
||||
}
|
||||
|
||||
anzeigen() {
|
||||
const { platten, strings, wr, gesamt } = this.stand;
|
||||
const k = this.katalog;
|
||||
const wahl = this.auswahl;
|
||||
const wahlWr = wahl && wahl.art === "wr" ? wahl.schluessel
|
||||
: wahl && wahl.art === "platte" ? (this.wrNachEingang[wahl.schluessel] || {}).wr?.nr
|
||||
: wahl && wahl.art === "string" ? 0 : null;
|
||||
|
||||
// Platten
|
||||
for (const p of platten) {
|
||||
const n = this.platteKnoten[p.def.eingang];
|
||||
if (!n) continue;
|
||||
const klasse = "anl-platte anl-" + p.klasse + (p.hell > 0.45 ? " anl-hell" : "")
|
||||
+ (wahl && wahl.art === "platte" && wahl.schluessel === p.def.eingang ? " anl-gewaehlt"
|
||||
: wahlWr !== null && wahlWr !== undefined && p.wr && p.wr.def.nr === wahlWr ? " anl-geschwister" : "");
|
||||
if (n.g.getAttribute("class") !== klasse) n.g.setAttribute("class", klasse);
|
||||
setzeFuellung(n.g, p.hell, "#f7c000");
|
||||
const text = p.offline ? "–" : String(Math.round(p.w));
|
||||
if (n.t.textContent !== text) n.t.textContent = text;
|
||||
}
|
||||
for (const s of strings) {
|
||||
const g = this.stringKnoten[s.def.id];
|
||||
const klasse = "anl-string" + (s.hell > 0.45 ? " anl-hell" : "")
|
||||
+ (wahl && wahl.art === "string" && wahl.schluessel === s.def.id ? " anl-gewaehlt"
|
||||
: wahlWr === 0 ? " anl-geschwister" : "");
|
||||
if (g.getAttribute("class") !== klasse) g.setAttribute("class", klasse);
|
||||
setzeFuellung(g, s.w < 5 ? 0 : s.hell, s.def.farbe);
|
||||
const text = kw(s.w);
|
||||
if (this.schilder[s.def.id].textContent !== text) this.schilder[s.def.id].textContent = text;
|
||||
}
|
||||
this.dom.svg.classList.toggle("anl-sonnig", gesamt > 300);
|
||||
|
||||
// Flächen oben
|
||||
const summen = k.flaechen.map(f => {
|
||||
const w = platten.filter(p => p.def.flaeche === f.id).reduce((a, p) => a + p.w, 0)
|
||||
+ strings.filter(s => s.def.flaeche === f.id).reduce((a, s) => a + s.w, 0);
|
||||
const n = platten.filter(p => p.def.flaeche === f.id).length
|
||||
+ k.strings.filter(s => s.flaeche === f.id).reduce((a, s) => a + s.platten.length, 0);
|
||||
return { f, w, n };
|
||||
});
|
||||
const summe = summen.reduce((a, s) => a + s.w, 0) || 1;
|
||||
const flaechenHtml = summen.map(s => `
|
||||
<div class="anl-flaeche" style="--farbe:${s.f.farbe}">
|
||||
<div class="anl-flaeche-kopf"><i></i><span>${schutz(s.f.name)}</span><b>${kw(s.w)}</b></div>
|
||||
<div class="anl-flaeche-balken"><span style="width:${(s.w / summe * 100).toFixed(1)}%"></span></div>
|
||||
<small>${s.n} Platten · ${Math.round(s.w / summe * 100)} % der Erzeugung</small>
|
||||
</div>`).join("");
|
||||
if (this.dom.flaechen._html !== flaechenHtml) {
|
||||
// Beim ersten Mal ganz schreiben, danach nur Werte - sonst springen die Balken.
|
||||
if (!this.dom.flaechen.children.length) {
|
||||
this.dom.flaechen.innerHTML = flaechenHtml;
|
||||
} else {
|
||||
[...this.dom.flaechen.children].forEach((c, i) => {
|
||||
const s = summen[i];
|
||||
c.querySelector("b").textContent = kw(s.w);
|
||||
c.querySelector(".anl-flaeche-balken span").style.width = (s.w / summe * 100).toFixed(1) + "%";
|
||||
c.querySelector("small").textContent = s.n + " Platten · " + Math.round(s.w / summe * 100) + " % der Erzeugung";
|
||||
});
|
||||
}
|
||||
this.dom.flaechen._html = flaechenHtml;
|
||||
}
|
||||
|
||||
// Wechselrichter
|
||||
const wrHtml = k.wechselrichter.map(d => {
|
||||
const w = wr[d.nr];
|
||||
const pille = !w.gemeldet || w.gestoert ? '<span class="anl-pille anl-krit">keine Daten</span>'
|
||||
: w.geschaetzt ? '<span class="anl-pille anl-warn">hochgerechnet</span>'
|
||||
: '<span class="anl-pille anl-ok">ok</span>';
|
||||
const ausl = w.limit > 0 ? Math.min(100, w.ac / w.limit * 100) : 0;
|
||||
const aktiv = wahlWr === d.nr ? ' aria-pressed="true"' : ' aria-pressed="false"';
|
||||
return `<li><button type="button" data-wahl="wr:${d.nr}"${aktiv}>
|
||||
<span class="anl-wr-name">${schutz(d.name)}<small>${schutz(d.modell)}${w.temp > 0 && !w.gestoert ? " · " + Math.round(w.temp) + " °C" : ""}</small></span>
|
||||
<span class="anl-wr-wert">${w.gestoert ? "–" : watt(w.ac)}${w.limit > 0 ? `<span class="anl-wr-last"><span style="width:${ausl.toFixed(0)}%"></span></span>` : ""}</span>
|
||||
${pille}
|
||||
</button></li>`;
|
||||
}).join("");
|
||||
if (this.dom.wr._html !== wrHtml) { this.dom.wr.innerHTML = wrHtml; this.dom.wr._html = wrHtml; }
|
||||
|
||||
// Auswahl
|
||||
const detail = this.detailHtml();
|
||||
if (this.dom.auswahl._html !== detail) { this.dom.auswahl.innerHTML = detail; this.dom.auswahl._html = detail; }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Detail */
|
||||
plattenName(p) {
|
||||
const f = this.flaeche[p.def.flaeche];
|
||||
return p.def.nr ? `${f.name} · Platte #${p.def.nr}` : `${p.wr ? p.wr.def.name : f.name} · Eingang ${p.index + 1}`;
|
||||
}
|
||||
|
||||
detailHtml() {
|
||||
const { platten, strings, wr, mittel } = this.stand;
|
||||
const wahl = this.auswahl;
|
||||
|
||||
if (wahl && wahl.art === "platte") {
|
||||
const p = platten.find(x => x.def.eingang === wahl.schluessel);
|
||||
if (p) {
|
||||
const f = this.flaeche[p.def.flaeche];
|
||||
const pille = p.offline ? '<span class="anl-pille anl-krit">keine Daten</span>'
|
||||
: p.klasse === "schwach" ? '<span class="anl-pille anl-krit">auffällig</span>'
|
||||
: p.klasse === "mittel" ? '<span class="anl-pille anl-warn">etwas schwächer</span>'
|
||||
: p.klasse === "dunkel" ? '<span class="anl-pille">wenig Licht</span>'
|
||||
: '<span class="anl-pille anl-ok">normal</span>';
|
||||
const vergleich = p.offline ? "Wechselrichter meldet nichts"
|
||||
: mittel[p.def.flaeche] < SCHWACHLICHT ? "zu wenig Licht zum Vergleichen"
|
||||
: Math.round(p.anteil * 100) + " % des Mittels " + f.name;
|
||||
return `
|
||||
<div class="anl-kopf"><h3 class="anl-ueberschrift">${schutz(this.plattenName(p))}</h3>${pille}</div>
|
||||
<div class="anl-gross"><b>${p.offline ? "–" : watt(p.w)}</b><span>${vergleich}</span></div>
|
||||
${this.funkenlinie(p.def.eingang)}
|
||||
<dl class="anl-paare">
|
||||
<dt>Wechselrichter</dt><dd><button type="button" class="anl-link" data-wahl="wr:${p.wr.def.nr}">${schutz(p.wr.def.name)}</button></dd>
|
||||
<dt>Eingang</dt><dd>${p.index + 1} von ${p.wr.def.eingaenge.length}</dd>
|
||||
<dt>Mittel ${schutz(f.name)}</dt><dd>${watt(mittel[p.def.flaeche])}</dd>
|
||||
<dt>Temperatur Wechselrichter</dt><dd>${p.wr.temp > 0 && !p.offline ? Math.round(p.wr.temp) + " °C" : "–"}</dd>
|
||||
</dl>
|
||||
${p.klasse === "schwach" ? '<p class="anl-hinweis">Liefert deutlich weniger als die anderen Platten dieser Fläche. Mögliche Gründe: Verschattung, Schmutz, ein loser Stecker – oder die Platte liegt anders ausgerichtet.</p>' : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (wahl && wahl.art === "string") {
|
||||
const s = strings.find(x => x.def.id === wahl.schluessel);
|
||||
if (s) {
|
||||
return `
|
||||
<div class="anl-kopf"><h3 class="anl-ueberschrift">${schutz(this.flaeche[s.def.flaeche].name)} · ${schutz(s.def.name)}</h3><span class="anl-pille">Wert je String</span></div>
|
||||
<div class="anl-gross"><b>${kw(s.w)}</b><span>${s.def.platten.length} Platten in Reihe</span></div>
|
||||
${this.funkenlinie(s.def.eingang)}
|
||||
<dl class="anl-paare">
|
||||
<dt>Wechselrichter</dt><dd><button type="button" class="anl-link" data-wahl="wr:0">${schutz(wr[0].def.name)}</button></dd>
|
||||
<dt>je Platte rechnerisch</dt><dd>${watt(s.w / s.def.platten.length)}</dd>
|
||||
</dl>
|
||||
<p class="anl-hinweis">Die Platten hängen in Reihe am Wechselrichter. Er misst nur den ganzen String – eine einzelne schwache Platte lässt sich hier nicht erkennen.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (wahl && wahl.art === "wr") {
|
||||
const w = wr[wahl.schluessel];
|
||||
if (w) {
|
||||
const werte = w.def.eingaenge.map(e => Math.max(0, Number(this.stand.pvn[e]) || 0));
|
||||
const max = Math.max(1, ...werte);
|
||||
const eingaenge = w.def.eingaenge.map((e, i) => {
|
||||
const p = platten.find(x => x.def.eingang === e);
|
||||
const s = strings.find(x => x.def.eingang === e);
|
||||
const name = p ? (p.def.nr ? "Platte #" + p.def.nr : "Eingang " + (i + 1)) : s ? s.def.name : "Eingang " + (i + 1);
|
||||
const wahlZiel = p ? "platte:" + e : s ? "string:" + s.def.id : "";
|
||||
return `<li><button type="button" data-wahl="${wahlZiel}">
|
||||
<span>${schutz(name)}</span>
|
||||
<span class="anl-balken anl-${p ? p.klasse : "gut"}"><span style="width:${(werte[i] / max * 100).toFixed(1)}%"></span></span>
|
||||
<b>${w.gestoert ? "–" : watt(werte[i])}</b>
|
||||
</button></li>`;
|
||||
}).join("");
|
||||
const pille = w.gestoert ? '<span class="anl-pille anl-krit">keine Daten</span>'
|
||||
: w.geschaetzt ? '<span class="anl-pille anl-warn">hochgerechnet</span>' : '<span class="anl-pille anl-ok">ok</span>';
|
||||
return `
|
||||
<div class="anl-kopf"><h3 class="anl-ueberschrift">${schutz(w.def.name)}</h3>${pille}</div>
|
||||
<div class="anl-gross"><b>${w.gestoert ? "–" : watt(w.ac)}</b><span>${schutz(w.def.modell)}</span></div>
|
||||
<ul class="anl-eingaenge">${eingaenge}</ul>
|
||||
<dl class="anl-paare">
|
||||
${w.limit > 0 ? `<dt>Leistungsgrenze</dt><dd>${watt(w.limit)} · ${Math.round(w.ac / w.limit * 100)} % genutzt</dd>` : ""}
|
||||
<dt>Temperatur</dt><dd>${w.temp > 0 && !w.gestoert ? Math.round(w.temp) + " °C" : "–"}</dd>
|
||||
</dl>
|
||||
${w.gestoert ? '<p class="anl-hinweis">Der Wechselrichter antwortet der DTU gerade nicht. Seine Platten stehen deshalb auf „ohne Daten“ – über ihren Zustand sagt das nichts.</p>' : ""}
|
||||
${w.geschaetzt ? '<p class="anl-hinweis">Aktuelle Werte fehlen; die Leistung ist aus den anderen Wechselrichtern hochgerechnet.</p>' : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Ohne Auswahl: Zusammenfassung mit allem, was Aufmerksamkeit braucht.
|
||||
const auffaellig = platten.filter(p => p.klasse === "schwach");
|
||||
const ohne = Object.values(wr).filter(w => w.gestoert || !w.gemeldet);
|
||||
const geschaetzt = Object.values(wr).filter(w => w.geschaetzt && !w.gestoert);
|
||||
const n = platten.length + this.katalog.strings.reduce((a, s) => a + s.platten.length, 0);
|
||||
const punkte = [
|
||||
...ohne.map(w => `<li><button type="button" data-wahl="wr:${w.def.nr}"><span class="anl-pille anl-krit">keine Daten</span>${schutz(w.def.name)}</button></li>`),
|
||||
...geschaetzt.map(w => `<li><button type="button" data-wahl="wr:${w.def.nr}"><span class="anl-pille anl-warn">hochgerechnet</span>${schutz(w.def.name)}</button></li>`),
|
||||
...auffaellig.map(p => `<li><button type="button" data-wahl="platte:${p.def.eingang}"><span class="anl-pille anl-krit">${Math.round(p.anteil * 100)} %</span>${schutz(this.plattenName(p))}</button></li>`),
|
||||
];
|
||||
return `
|
||||
<div class="anl-kopf"><h3 class="anl-ueberschrift">Anlage</h3><span class="anl-pille ${punkte.length ? "anl-warn" : "anl-ok"}">${punkte.length ? punkte.length + " Hinweis" + (punkte.length > 1 ? "e" : "") : "alles in Ordnung"}</span></div>
|
||||
<div class="anl-gross"><b>${kw(this.stand.gesamt)}</b><span>${n} Platten an ${this.katalog.wechselrichter.length} Wechselrichtern</span></div>
|
||||
${punkte.length ? `<ul class="anl-hinweise">${punkte.join("")}</ul>` : ""}
|
||||
<p class="anl-hinweis">Eine Platte oder einen Wechselrichter antippen für Einzelheiten.</p>`;
|
||||
}
|
||||
|
||||
funkenlinie(eingang) {
|
||||
const v = this.verlauf[eingang] || [];
|
||||
if (v.length < 3) {
|
||||
return '<div class="anl-funken-leer">Der Verlauf wird ab jetzt mitgeschrieben …</div>';
|
||||
}
|
||||
const t0 = v[0][0], t1 = v[v.length - 1][0];
|
||||
const max = Math.max(10, ...v.map(p => p[1]));
|
||||
const X = t => 4 + (t - t0) / Math.max(1, t1 - t0) * 272;
|
||||
const Y = w => 58 - w / max * 50;
|
||||
const pfad = v.map((p, i) => (i ? "L" : "M") + X(p[0]).toFixed(1) + " " + Y(p[1]).toFixed(1)).join("");
|
||||
const letzt = v[v.length - 1];
|
||||
const minuten = Math.max(1, Math.round((t1 - t0) / 60000));
|
||||
return `
|
||||
<svg class="anl-funken" viewBox="0 0 280 76" aria-label="Verlauf der letzten ${minuten} Minuten">
|
||||
<line x1="4" x2="276" y1="58" y2="58" class="anl-gitter"/>
|
||||
<path d="${pfad}L${X(t1)} 58L4 58Z" class="anl-funken-flaeche"/>
|
||||
<path d="${pfad}" class="anl-funken-linie"/>
|
||||
<circle cx="${X(letzt[0])}" cy="${Y(letzt[1])}" r="3.5" class="anl-funken-punkt"/>
|
||||
<text x="4" y="73" class="anl-achse">vor ${minuten} min</text>
|
||||
<text x="276" y="73" class="anl-achse" text-anchor="end">jetzt</text>
|
||||
<text x="276" y="10" class="anl-achse" text-anchor="end">${watt(max)}</text>
|
||||
</svg>`;
|
||||
}
|
||||
}
|
||||
|
||||
window.Anlage = Anlage;
|
||||
})();
|
||||
@@ -0,0 +1,81 @@
|
||||
/* ==========================================================================
|
||||
Katalog der Anlage-Ansicht (Solar-Seite, Karte "Realtime" -> "Anlage").
|
||||
|
||||
Beschreibt die PV-Anlage dieses Hauses: Flächen, Wechselrichter und jede
|
||||
Platte mit ihrer tatsächlichen Lage. Gezeichnet wird er von
|
||||
js/solar/anlage.js, ohne dass der Renderer eine Fläche beim Namen kennt.
|
||||
Die Lagen stammen aus dem früheren Plan in realtime.svg (Einheiten wie
|
||||
dort, Platte 70 x 40, Zeichenfläche etwa 1250 x 740).
|
||||
|
||||
Die Leistung kommt aus solarManager/P_PVn - ein Eintrag je Eingang, in der
|
||||
Reihenfolge von solarManager.py -, der Zustand der Geräte aus
|
||||
solarManager/invertersN/... .
|
||||
|
||||
flaechen [{id, name, farbe, umriss: "x,y x,y ..." | {x,y,w,h}}]
|
||||
wechselrichter[{nr, name, modell, eingaenge: [P_PVn-Index, ...]}]
|
||||
nr = N in solarManager/invertersN
|
||||
strings Platten ohne eigenen Messwert (in Reihe am Gen24):
|
||||
[{id, name, flaeche, eingang, farbe, platten: [[x,y,w,h], ...]}]
|
||||
platten [{eingang, flaeche, nr?, x, y, w, h}] - w/h ohne Angabe 70/40;
|
||||
nr ist die Beschriftung (#14), sonst "Eingang n"
|
||||
nord {x, y, winkel} für den Nordpfeil
|
||||
========================================================================== */
|
||||
|
||||
const ANLAGE_KATALOG = (function () {
|
||||
"use strict";
|
||||
|
||||
// Dach, String A: vier Spalten stehend, darunter liegend, rechts stehend.
|
||||
const dachA = [];
|
||||
[600, 640, 680].forEach(x => [80, 150, 220, 290].forEach(y => dachA.push([x - 40, y, 40, 70])));
|
||||
[80, 150, 220].forEach(y => dachA.push([680, y, 40, 70]));
|
||||
[[790, 290], [720, 330], [790, 330], [870, 290], [870, 330], [940, 330]].forEach(([x, y]) => dachA.push([x, y, 70, 40]));
|
||||
[1160, 1120, 1080].forEach(x => dachA.push([x - 40, 300, 40, 70]));
|
||||
|
||||
// Dach, String B: drei Reihen stehend, etwas breitere Platten.
|
||||
const dachB = [[1160, 200], [1115, 200], [1070, 200], [1160, 130], [1115, 130], [1070, 130], [1025, 130],
|
||||
[1160, 60], [1115, 60], [1070, 60]].map(([x, y]) => [x - 45, y, 45, 70]);
|
||||
|
||||
const stehend = (eingang, x, y) => ({ eingang, flaeche: "veranda", x, y, w: 40, h: 70 });
|
||||
const carport = (eingang, nr, x, y) => ({ eingang, flaeche: "carport", nr, x, y });
|
||||
|
||||
return {
|
||||
flaechen: [
|
||||
{ id: "dach", name: "Dach", farbe: "#f7c000",
|
||||
umriss: ["560,80 720,80 720,300 1160,300 1160,370 560,370", "980,60 1160,60 1160,265 980,265"] },
|
||||
{ id: "veranda", name: "Veranda", farbe: "#f78d00", umriss: { x: 385, y: 35, w: 300, h: 520 } },
|
||||
{ id: "carport", name: "Carport", farbe: "#f75a1e", umriss: { x: 85, y: 495, w: 380, h: 240 } },
|
||||
],
|
||||
|
||||
wechselrichter: [
|
||||
{ nr: 0, name: "Gen24", modell: "Fronius Symo Gen24", eingaenge: [0, 1] },
|
||||
{ nr: 1, name: "Veranda UG2", modell: "Hoymiles (DTU-BI)", eingaenge: [2, 3] },
|
||||
{ nr: 2, name: "Veranda UG/OG1", modell: "Hoymiles, 4 Eingänge", eingaenge: [4, 5, 6, 7] },
|
||||
{ nr: 3, name: "Veranda OG2–4", modell: "Hoymiles, 6 Eingänge", eingaenge: [8, 9, 10, 11, 12, 13] },
|
||||
{ nr: 4, name: "Carport 1–6", modell: "Hoymiles HMS-2250-6T", eingaenge: [14, 15, 16, 17, 18, 19] },
|
||||
{ nr: 5, name: "Carport 7–12", modell: "Hoymiles HMS-2250-6T", eingaenge: [20, 21, 22, 23, 24, 25] },
|
||||
{ nr: 6, name: "Carport 13–16", modell: "Hoymiles HMS-1800-4T", eingaenge: [26, 27, 28, 29] },
|
||||
{ nr: 7, name: "Carport 17–20", modell: "Hoymiles HMS-1600-4T", eingaenge: [30, 31, 32, 33] },
|
||||
],
|
||||
|
||||
strings: [
|
||||
{ id: "A", name: "String A", flaeche: "dach", eingang: 0, farbe: "#f7c000", platten: dachA, schild: [640, 222] },
|
||||
{ id: "B", name: "String B", flaeche: "dach", eingang: 1, farbe: "#f7a000", platten: dachB, schild: [1070, 168] },
|
||||
],
|
||||
|
||||
platten: [
|
||||
stehend(7, 400, 50), stehend(6, 400, 120), stehend(3, 400, 200), stehend(2, 400, 270),
|
||||
stehend(5, 460, 50), stehend(4, 460, 120), stehend(9, 460, 200), stehend(8, 460, 270),
|
||||
stehend(11, 460, 350), stehend(10, 460, 420),
|
||||
{ eingang: 12, flaeche: "veranda", x: 500, y: 500 }, { eingang: 13, flaeche: "veranda", x: 600, y: 500 },
|
||||
|
||||
carport(14, 1, 100, 510), carport(15, 2, 100, 550), carport(16, 3, 100, 590), carport(17, 4, 100, 630),
|
||||
carport(21, 7, 170, 550), carport(20, 8, 170, 510), carport(19, 6, 170, 590), carport(18, 5, 170, 630),
|
||||
carport(25, 12, 240, 510), carport(24, 11, 240, 550), carport(23, 10, 240, 590), carport(22, 9, 240, 630),
|
||||
carport(29, 16, 310, 510), carport(28, 15, 310, 550), carport(27, 14, 310, 590), carport(26, 13, 310, 630),
|
||||
carport(32, 19, 380, 550), carport(31, 18, 380, 590), carport(30, 17, 380, 630),
|
||||
carport(33, 20, 200, 680),
|
||||
],
|
||||
|
||||
nord: { x: 1235, y: 45, winkel: 10 },
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,103 @@
|
||||
/* ==========================================================================
|
||||
Anzeige-Modus der Solar-Seite (index.php?action=anzeige) für ein festes
|
||||
Display, gebaut für den Raspberry Pi 3B+ mit 800 x 480.
|
||||
|
||||
Die Seite selbst ist die Solar-Seite: solarMQTT.js zeichnet Übersicht,
|
||||
Anlage, Speicher und Diagramme und öffnet die Bedienfenster. header.php
|
||||
lässt Kopfleiste und Menü weg und setzt stattdessen die schmale Leiste
|
||||
links; die Übersicht bleibt quer (energieflussStarten() in solarMQTT.js
|
||||
fragt nach der Klasse "anzeige" am body).
|
||||
|
||||
Hier steht nur, was ein Display ohne Bediener braucht:
|
||||
- Leiste: Übersicht, Anlage, Speicher, Diagramme
|
||||
- nach LEERLAUF ohne Berührung zurück zur Übersicht, ganz nach oben
|
||||
- Punkt in der Leiste: grün verbunden, gelb keine frischen Werte,
|
||||
rot getrennt
|
||||
- alle NEU_LADEN Stunden neu laden - ein Browser, der wochenlang läuft,
|
||||
sammelt sonst Speicher an, und Änderungen am Code kämen nie an
|
||||
========================================================================== */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const LEERLAUF = 2 * 60000;
|
||||
const NEU_LADEN = 6 * 3600000;
|
||||
const VERALTET = 30000;
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
let leerlaufUhr = null;
|
||||
let letzteNachricht = null, letzteZeit = 0;
|
||||
|
||||
function leisteSetzen(name) {
|
||||
document.querySelectorAll("[data-anzeige]").forEach(b =>
|
||||
b.setAttribute("aria-pressed", b.dataset.anzeige === name ? "true" : "false"));
|
||||
}
|
||||
|
||||
function karte() {
|
||||
return $("energieflussKarte");
|
||||
}
|
||||
|
||||
/** Eine Ansicht der Realtime-Karte zeigen - über ihren eigenen Umschalter. */
|
||||
function zeige(name) {
|
||||
if (name === "diagramme") {
|
||||
// Die nächste Spalte nach der Realtime-Karte - dazwischen steht ein <script>.
|
||||
let ziel = karte() && karte().closest("[class*=col-]").nextElementSibling;
|
||||
while (ziel && !ziel.matches("[class*=col-]")) ziel = ziel.nextElementSibling;
|
||||
if (ziel) ziel.scrollIntoView({ behavior: "instant", block: "start" });
|
||||
leisteSetzen("diagramme");
|
||||
return;
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: "instant" });
|
||||
const knopf = document.querySelector('[data-enf-ansicht="' + name + '"]');
|
||||
if (knopf) knopf.click();
|
||||
leisteSetzen(name);
|
||||
}
|
||||
|
||||
function wachHalten() {
|
||||
clearTimeout(leerlaufUhr);
|
||||
leerlaufUhr = setTimeout(() => {
|
||||
const offen = document.querySelector(".modal.show");
|
||||
if (offen && window.bootstrap) bootstrap.Modal.getOrCreateInstance(offen).hide();
|
||||
zeige("fluss");
|
||||
}, LEERLAUF);
|
||||
}
|
||||
|
||||
function zustand() {
|
||||
const nachricht = window.mqttData && mqttData["solarManager/P_Load"];
|
||||
if (nachricht && nachricht !== letzteNachricht) { letzteNachricht = nachricht; letzteZeit = Date.now(); }
|
||||
const verbunden = typeof solarMqttClient !== "undefined" && solarMqttClient && solarMqttClient.connected;
|
||||
const frisch = Date.now() - letzteZeit < VERALTET;
|
||||
const punkt = $("anzeigeVerbindung");
|
||||
const klasse = "anzeige-punkt" + (verbunden && frisch ? " gut" : verbunden ? " warn" : "");
|
||||
if (punkt.className !== klasse) {
|
||||
punkt.className = klasse;
|
||||
punkt.title = verbunden && frisch ? "verbunden" : verbunden ? "verbunden, aber keine frischen Werte" : "Verbindung getrennt";
|
||||
}
|
||||
const d = new Date();
|
||||
const uhr = String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
|
||||
if ($("anzeigeUhr").textContent !== uhr) $("anzeigeUhr").textContent = uhr;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
if (!document.body.classList.contains("anzeige")) return;
|
||||
|
||||
document.querySelectorAll("[data-anzeige]").forEach(b => b.addEventListener("click", () => zeige(b.dataset.anzeige)));
|
||||
// Klick auf Sonne oder Batterie wechselt über den Umschalter der Karte -
|
||||
// die Leiste zieht nach.
|
||||
document.querySelectorAll("[data-enf-ansicht]").forEach(b =>
|
||||
b.addEventListener("click", () => leisteSetzen(b.dataset.enfAnsicht)));
|
||||
window.addEventListener("scroll", () => {
|
||||
const k = karte();
|
||||
if (!k) return;
|
||||
const unten = k.getBoundingClientRect().bottom < window.innerHeight / 2;
|
||||
const aktiv = document.querySelector("[data-enf-ansicht].active");
|
||||
leisteSetzen(unten ? "diagramme" : aktiv ? aktiv.dataset.enfAnsicht : "fluss");
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener("pointerdown", wachHalten, { passive: true });
|
||||
wachHalten();
|
||||
setInterval(zustand, 1000);
|
||||
zustand();
|
||||
setTimeout(() => location.reload(), NEU_LADEN);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* Eine Auswahlliste fuer die ganze Anwendung.
|
||||
*
|
||||
* Die Liste, die der Browser zu einem <select> aufmacht, zeichnet er selbst -
|
||||
* bei Chrome hell auf grau, mitten in einer dunklen Seite. Gestalten laesst
|
||||
* sie sich nicht. Deshalb bekommt hier jedes <select> seinen eigenen Knopf;
|
||||
* das <select> bleibt stehen und haelt weiterhin den Wert, sodass jeder
|
||||
* vorhandene Zuhoerer (onchange, Formulare, dataset) unveraendert
|
||||
* funktioniert. Wer den Wert im Code setzt, ruft auswahlAktualisieren(select)
|
||||
* oder loest "change" aus - beides zieht die Beschriftung nach.
|
||||
*
|
||||
* Die aufgeklappte Liste haengt an <body> und liegt mit position:fixed ueber
|
||||
* der Seite. Das ist der zweite Grund fuer dieses Skript: eine Liste im Fluss
|
||||
* des Modals machte es hoeher, und der ganze Inhalt sprang.
|
||||
*
|
||||
* Neue <select> im Dokument werden von selbst uebernommen (MutationObserver),
|
||||
* auch die, die der Automatik-Editor bei jedem Zeichnen neu baut.
|
||||
*
|
||||
* Ausnahmen: <select multiple>, <select size="2"> und alles mit
|
||||
* data-auswahl="aus".
|
||||
*/
|
||||
|
||||
const AUSWAHL_SUCHE_AB = 9; // ab so vielen Eintraegen ein Suchfeld
|
||||
const AUSWAHL_MIN_BREITE = 190;
|
||||
const AUSWAHL_MAX_HOEHE = 300;
|
||||
|
||||
let auswahlOffen = null; // { select, knopf, panel, eintraege }
|
||||
|
||||
/** Die Beschriftung des Knopfes aus dem gewaehlten Eintrag nachziehen. */
|
||||
function auswahlAktualisieren(select) {
|
||||
const huelle = select.closest(".aw");
|
||||
if (!huelle) return;
|
||||
const gewaehlt = select.selectedOptions[0];
|
||||
const text = huelle.querySelector(".aw-wert");
|
||||
text.textContent = gewaehlt ? gewaehlt.textContent : "–";
|
||||
text.classList.toggle("aw-leer", !gewaehlt);
|
||||
huelle.querySelector(".aw-knopf").disabled = select.disabled;
|
||||
}
|
||||
|
||||
/** Ein <select> um seinen eigenen Knopf ergaenzen. */
|
||||
function auswahlAufsetzen(select) {
|
||||
if (select.dataset.aw || select.multiple || select.size > 1
|
||||
|| select.dataset.auswahl === "aus") {
|
||||
return;
|
||||
}
|
||||
select.dataset.aw = "1";
|
||||
|
||||
const huelle = document.createElement("span");
|
||||
huelle.className = "aw";
|
||||
const knopf = document.createElement("button");
|
||||
knopf.type = "button";
|
||||
// Die Klassen des <select> wandern mit: daran haengt, wie das Feld an
|
||||
// dieser Stelle aussehen soll (.feld-als-text im Editor, .kw-format in den
|
||||
// Einstellungen). Nur Bootstraps eigene Feldgestaltung bleibt weg - die
|
||||
// bringt ihren eigenen hellen Pfeil mit.
|
||||
const klassen = [...select.classList].filter(c => !c.startsWith("form-select") && !c.startsWith("form-control"));
|
||||
knopf.className = ["aw-knopf"].concat(klassen).join(" ");
|
||||
if (klassen.indexOf("feld-als-text") >= 0) huelle.classList.add("aw-text");
|
||||
knopf.setAttribute("aria-haspopup", "listbox");
|
||||
knopf.setAttribute("aria-expanded", "false");
|
||||
const zettel = select.id ? document.querySelector("label[for='" + CSS.escape(select.id) + "']") : null;
|
||||
const beschriftung = select.getAttribute("aria-label") || (zettel ? zettel.textContent.trim() : "");
|
||||
if (beschriftung) knopf.setAttribute("aria-label", beschriftung);
|
||||
knopf.innerHTML = "<span class='aw-wert'></span><i class='bi bi-chevron-down'></i>";
|
||||
|
||||
select.parentNode.insertBefore(huelle, select);
|
||||
huelle.appendChild(knopf);
|
||||
huelle.appendChild(select);
|
||||
auswahlAktualisieren(select);
|
||||
|
||||
select.addEventListener("change", () => auswahlAktualisieren(select));
|
||||
knopf.addEventListener("click", () => {
|
||||
if (auswahlOffen && auswahlOffen.select === select) auswahlSchliessen();
|
||||
else auswahlOeffnen(select, knopf);
|
||||
});
|
||||
knopf.addEventListener("keydown", e => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
auswahlOeffnen(select, knopf);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Alle noch nicht ergaenzten <select> unterhalb von wurzel uebernehmen. */
|
||||
function auswahlAlle(wurzel) {
|
||||
(wurzel || document).querySelectorAll("select:not([data-aw])").forEach(auswahlAufsetzen);
|
||||
}
|
||||
|
||||
function auswahlSchliessen(zurueck) {
|
||||
if (!auswahlOffen) return;
|
||||
const knopf = auswahlOffen.knopf;
|
||||
auswahlOffen.panel.remove();
|
||||
knopf.setAttribute("aria-expanded", "false");
|
||||
auswahlOffen = null;
|
||||
if (zurueck && document.body.contains(knopf)) knopf.focus();
|
||||
}
|
||||
|
||||
function auswahlOeffnen(select, knopf) {
|
||||
auswahlSchliessen();
|
||||
// Steckt der Knopf in einem ausgeblendeten Reiter, hat er keine Lage - die
|
||||
// Liste landete sonst in der Ecke oben links.
|
||||
if (!knopf.getBoundingClientRect().width) return;
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "aw-panel";
|
||||
if (select.options.length >= AUSWAHL_SUCHE_AB) {
|
||||
panel.innerHTML = "<input type='search' class='aw-suche' placeholder='Suchen …' aria-label='In der Liste suchen'>";
|
||||
}
|
||||
const liste = document.createElement("div");
|
||||
liste.className = "aw-liste";
|
||||
liste.setAttribute("role", "listbox");
|
||||
panel.appendChild(liste);
|
||||
|
||||
// Gruppen bleiben Gruppen: in den Einstellungen stehen die Geraete nach
|
||||
// Etagen sortiert, und ohne die Zwischenueberschrift waere die Liste eine
|
||||
// Wand aus Namen.
|
||||
const eintraege = [];
|
||||
const bauen = function (opt) {
|
||||
const k = document.createElement("button");
|
||||
k.type = "button";
|
||||
k.className = "aw-eintrag";
|
||||
k.setAttribute("role", "option");
|
||||
k.setAttribute("aria-selected", String(opt.selected));
|
||||
k.disabled = opt.disabled;
|
||||
k.textContent = opt.textContent;
|
||||
k.addEventListener("click", () => auswahlWaehlen(opt));
|
||||
liste.appendChild(k);
|
||||
eintraege.push({ knopf: k, option: opt });
|
||||
};
|
||||
[...select.children].forEach(kind => {
|
||||
if (kind.tagName === "OPTGROUP") {
|
||||
const titel = document.createElement("div");
|
||||
titel.className = "aw-gruppe";
|
||||
titel.textContent = kind.label;
|
||||
liste.appendChild(titel);
|
||||
[...kind.children].forEach(bauen);
|
||||
} else if (kind.tagName === "OPTION") {
|
||||
bauen(kind);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(panel);
|
||||
auswahlOffen = { select: select, knopf: knopf, panel: panel, eintraege: eintraege };
|
||||
knopf.setAttribute("aria-expanded", "true");
|
||||
auswahlStellen();
|
||||
|
||||
const gewaehlt = eintraege.findIndex(e => e.option.selected);
|
||||
if (gewaehlt >= 0) {
|
||||
eintraege[gewaehlt].knopf.classList.add("aktiv");
|
||||
eintraege[gewaehlt].knopf.scrollIntoView({ block: "center" });
|
||||
}
|
||||
|
||||
const suche = panel.querySelector(".aw-suche");
|
||||
if (suche) {
|
||||
suche.focus({ preventScroll: true });
|
||||
suche.addEventListener("input", () => {
|
||||
const text = suche.value.trim().toLowerCase();
|
||||
eintraege.forEach(e => { e.knopf.hidden = text !== "" && e.knopf.textContent.toLowerCase().indexOf(text) < 0; });
|
||||
// Ein Gruppentitel ohne sichtbare Eintraege darunter verschwindet mit.
|
||||
[...liste.querySelectorAll(".aw-gruppe")].forEach(titel => {
|
||||
let el = titel.nextElementSibling, sichtbar = false;
|
||||
while (el && !el.classList.contains("aw-gruppe")) {
|
||||
if (!el.hidden) { sichtbar = true; break; }
|
||||
el = el.nextElementSibling;
|
||||
}
|
||||
titel.hidden = !sichtbar;
|
||||
});
|
||||
auswahlStellen();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Liste an ihren Platz legen: unter den Knopf, sonst darueber. */
|
||||
function auswahlStellen() {
|
||||
if (!auswahlOffen) return;
|
||||
const knopf = auswahlOffen.knopf, panel = auswahlOffen.panel;
|
||||
const r = knopf.getBoundingClientRect();
|
||||
const breite = Math.min(Math.max(r.width, AUSWAHL_MIN_BREITE), window.innerWidth - 16);
|
||||
panel.style.width = breite + "px";
|
||||
const platzUnten = window.innerHeight - r.bottom - 12;
|
||||
const platzOben = r.top - 12;
|
||||
const unten = platzUnten >= Math.min(AUSWAHL_MAX_HOEHE, panel.scrollHeight) || platzUnten >= platzOben;
|
||||
panel.style.maxHeight = Math.min(AUSWAHL_MAX_HOEHE, unten ? platzUnten : platzOben) + "px";
|
||||
panel.style.left = Math.max(8, Math.min(r.left, window.innerWidth - breite - 8)) + "px";
|
||||
panel.style.top = unten ? (r.bottom + 4) + "px" : "";
|
||||
panel.style.bottom = unten ? "" : (window.innerHeight - r.top + 4) + "px";
|
||||
}
|
||||
|
||||
function auswahlWaehlen(option) {
|
||||
if (!auswahlOffen) return;
|
||||
const select = auswahlOffen.select;
|
||||
const knopf = auswahlOffen.knopf;
|
||||
auswahlSchliessen();
|
||||
if (option.disabled) return;
|
||||
if (select.value !== option.value) {
|
||||
select.value = option.value;
|
||||
auswahlAktualisieren(select);
|
||||
select.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
// Nach einem Neuzeichnen - der Editor baut seine Zeilen neu - gibt es den
|
||||
// Knopf vielleicht gar nicht mehr.
|
||||
if (document.body.contains(knopf)) knopf.focus();
|
||||
}
|
||||
|
||||
function auswahlSchritt(richtung) {
|
||||
const sichtbar = auswahlOffen.eintraege.filter(e => !e.knopf.hidden && !e.knopf.disabled);
|
||||
if (!sichtbar.length) return;
|
||||
const jetzt = sichtbar.findIndex(e => e.knopf.classList.contains("aktiv"));
|
||||
const naechst = jetzt < 0 ? (richtung > 0 ? 0 : sichtbar.length - 1)
|
||||
: (jetzt + richtung + sichtbar.length) % sichtbar.length;
|
||||
auswahlOffen.eintraege.forEach(e => e.knopf.classList.remove("aktiv"));
|
||||
sichtbar[naechst].knopf.classList.add("aktiv");
|
||||
sichtbar[naechst].knopf.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", e => {
|
||||
if (!auswahlOffen) return;
|
||||
if (e.target.closest(".aw-panel") || e.target.closest(".aw")) return;
|
||||
auswahlSchliessen();
|
||||
}, true);
|
||||
|
||||
document.addEventListener("keydown", e => {
|
||||
if (!auswahlOffen) return;
|
||||
if (e.key === "Escape") {
|
||||
// Ohne das Anhalten macht Bootstrap das Modal darunter gleich mit zu.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
auswahlSchliessen(true);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
auswahlSchritt(1);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
auswahlSchritt(-1);
|
||||
} else if (e.key === "Enter") {
|
||||
const aktiv = auswahlOffen.eintraege.find(x => x.knopf.classList.contains("aktiv") && !x.knopf.hidden);
|
||||
if (aktiv) {
|
||||
e.preventDefault();
|
||||
auswahlWaehlen(aktiv.option);
|
||||
}
|
||||
} else if (e.key === "Tab") {
|
||||
auswahlSchliessen();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Scrollt die Seite oder das Modal, wandert die Liste mit; ist der Knopf dabei
|
||||
// aus dem Bild, geht sie zu.
|
||||
window.addEventListener("scroll", () => {
|
||||
if (!auswahlOffen) return;
|
||||
const r = auswahlOffen.knopf.getBoundingClientRect();
|
||||
if (r.bottom < 0 || r.top > window.innerHeight) auswahlSchliessen();
|
||||
else auswahlStellen();
|
||||
}, true);
|
||||
window.addEventListener("resize", () => auswahlSchliessen());
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
auswahlAlle(document);
|
||||
// Was spaeter dazukommt - Modals, neu gezeichnete Editorzeilen - wird von
|
||||
// selbst uebernommen; die Aufrufer muessen daran nicht denken.
|
||||
new MutationObserver(aenderungen => {
|
||||
for (const a of aenderungen) {
|
||||
for (const knoten of a.addedNodes) {
|
||||
if (knoten.nodeType !== 1) continue;
|
||||
if (knoten.tagName === "SELECT") auswahlAufsetzen(knoten);
|
||||
else auswahlAlle(knoten);
|
||||
}
|
||||
}
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
/* ==========================================================================
|
||||
Bedienfenster Heizstab und Bewässerung - im selben Stil wie das
|
||||
Ladefenster (ladefenster.js), mit denselben Bausteinen aus solar.css.
|
||||
|
||||
heizfensterStarten(wurzel, url) Gerüst: ajax/heater.php
|
||||
wasserfensterStarten(wurzel, url) Gerüst: ajax/watering.php
|
||||
|
||||
Beide lesen jede Sekunde aus mqttData und schicken jede Änderung sofort.
|
||||
Was eben geschickt wurde, gilt HALTEN ms lang lokal, bis das Gerät den
|
||||
neuen Stand meldet. Der Takt endet, wenn das Fenster zugeht.
|
||||
========================================================================== */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const HALTEN = 8000;
|
||||
const S = " "; // schmales Leerzeichen vor Einheiten
|
||||
const komma = (z, n = 1) => Number(z).toFixed(n).replace(".", ",");
|
||||
const text = t => { const v = window.mqttData && mqttData[t]; return v === undefined ? undefined : String(v); };
|
||||
const zahl = t => { const v = Number(text(t)); return isFinite(v) ? v : null; };
|
||||
|
||||
/** Gemeinsames Gerüst: lokaler Stand, Meldungszeile, Takt bis zum Schließen. */
|
||||
function geruest(wurzel, meldungsFeld, grundText) {
|
||||
const lokal = {};
|
||||
const g = {
|
||||
merke(name, wert) { lokal[name] = { wert, bis: Date.now() + HALTEN }; },
|
||||
gilt(name, gemeldet) { return lokal[name] && lokal[name].bis > Date.now() ? lokal[name].wert : gemeldet; },
|
||||
meldung(inhalt, art) {
|
||||
const m = meldungsFeld();
|
||||
m.textContent = inhalt;
|
||||
m.className = "lade-meldung" + (art ? " " + art : "");
|
||||
clearTimeout(g.uhr);
|
||||
if (art === "gut") g.uhr = setTimeout(() => { m.textContent = grundText; m.className = "lade-meldung"; }, 2200);
|
||||
},
|
||||
takt(zeichnen) {
|
||||
zeichnen();
|
||||
const uhr = setInterval(zeichnen, 1000);
|
||||
const fenster = wurzel.closest(".modal");
|
||||
if (fenster) fenster.addEventListener("hidden.bs.modal", () => clearInterval(uhr), { once: true });
|
||||
},
|
||||
};
|
||||
return g;
|
||||
}
|
||||
|
||||
function setze(el, inhalt) {
|
||||
if (el && el.textContent !== inhalt) el.textContent = inhalt;
|
||||
}
|
||||
|
||||
/** Temperatur als Farbe, wie der Speicher in der Übersicht. */
|
||||
function tempFarbe(t) {
|
||||
const f = Math.max(0, Math.min(1, (t - 20) / 60));
|
||||
const kalt = [35, 137, 186], heiss = [186, 59, 35];
|
||||
return "rgb(" + kalt.map((k, i) => Math.round(k + (heiss[i] - k) * f)).join(",") + ")";
|
||||
}
|
||||
|
||||
/* ======================================================== Heizstab */
|
||||
window.heizfensterStarten = function (wurzel, url) {
|
||||
const $ = name => wurzel.querySelector('[data-heiz="' + name + '"]');
|
||||
const g = geruest(wurzel, () => $("meldung"), "Änderungen gelten sofort");
|
||||
let ziehen = false;
|
||||
// Der Heizstab meldet seine Betriebsart und die Leistung, die er gerade
|
||||
// zieht - nicht die, die eingestellt ist. Wer 0,1 kW fest einstellt und
|
||||
// dessen Puffer schon warm ist, sieht also 0 W; daraus liesse sich die
|
||||
// Einstellung nicht zurueckrechnen (vorher sprang die Anzeige dann auf
|
||||
// 3 kW). Die eigene Wahl bleibt deshalb stehen, solange das Fenster
|
||||
// offen ist.
|
||||
const gewaehlt = { modus: null, stufe: null };
|
||||
|
||||
async function senden(stufe) {
|
||||
g.meldung("wird geschickt …");
|
||||
try {
|
||||
const antwort = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: "setMode=" + stufe,
|
||||
});
|
||||
const inhalt = (await antwort.text()).trim();
|
||||
if (!antwort.ok || inhalt) throw new Error(inhalt || "HTTP " + antwort.status);
|
||||
g.meldung("✓ an den Heizstab geschickt", "gut");
|
||||
} catch (e) {
|
||||
g.meldung("Nicht übernommen: " + e.message, "fehler");
|
||||
}
|
||||
zeichnen();
|
||||
}
|
||||
|
||||
function stand() {
|
||||
const w = zahl("solarManager/pHeat") || 0;
|
||||
const gemeldet = text("solarManager/heatMode");
|
||||
const gemeldetFest = gemeldet !== undefined && gemeldet !== "eco";
|
||||
// Kurz nach dem Absenden meldet das Geraet noch den alten Stand -
|
||||
// solange gilt die eigene Wahl.
|
||||
const frisch = g.gilt("frisch", false) === true;
|
||||
let modus;
|
||||
if (frisch && gewaehlt.modus) {
|
||||
modus = gewaehlt.modus;
|
||||
} else if (!gemeldetFest) {
|
||||
// Automatik sagt das Geraet eindeutig - auch, wenn sie woanders
|
||||
// eingeschaltet wurde.
|
||||
modus = "auto";
|
||||
if (gewaehlt.modus && gewaehlt.modus !== "auto") gewaehlt.modus = null;
|
||||
} else if (gewaehlt.modus === "fest" || gewaehlt.modus === "aus") {
|
||||
// "fest" und "aus" sind fuer das Geraet dasselbe ("man"); welches von
|
||||
// beiden gemeint war, weiss nur, wer es gewaehlt hat.
|
||||
modus = gewaehlt.modus;
|
||||
} else {
|
||||
modus = w >= 20 ? "fest" : "aus";
|
||||
gewaehlt.modus = null;
|
||||
}
|
||||
const stufe = gewaehlt.stufe !== null
|
||||
? gewaehlt.stufe
|
||||
: Math.max(1, Math.min(60, Math.round(w / 100) || 30));
|
||||
return {
|
||||
w, modus, stufe,
|
||||
temps: ["t_buffT", "t_buffM", "t_buffB"].map(t => zahl("solarManager/" + t)),
|
||||
};
|
||||
}
|
||||
|
||||
function zeichnen() {
|
||||
const s = stand();
|
||||
const heizt = s.w >= 20;
|
||||
|
||||
// Speicher
|
||||
s.temps.forEach((t, i) => {
|
||||
const schicht = $("t" + i);
|
||||
if (t !== null) schicht.style.fill = tempFarbe(t);
|
||||
setze($("tt" + i), t !== null ? Math.round(t) + "°" : "–");
|
||||
});
|
||||
$("spirale").classList.toggle("an", heizt);
|
||||
const namen = ["oben", "Mitte", "unten"];
|
||||
setze($("temps"), s.temps.map((t, i) => t !== null ? namen[i] + " " + komma(t) : null).filter(Boolean).join(" · ") + S + "°C");
|
||||
|
||||
$("leistung").innerHTML = komma(s.w / 1000, 2) + S + "kW<small>" + (heizt ? "heizt" : "aus") + "</small>";
|
||||
const pille = $("pille");
|
||||
setze(pille, s.modus === "auto" ? "Automatik" : s.modus === "aus" ? "aus" : "fest");
|
||||
pille.className = "lade-pille" + (heizt ? " an" : "");
|
||||
|
||||
setze($("erklaerung"), s.modus === "auto"
|
||||
? "Heizt nur mit dem Überschuss der Anlage – ohne Sonne bleibt der Heizstab aus."
|
||||
: s.modus === "aus"
|
||||
? "Bleibt aus, auch wenn die Anlage Überschuss hat. Das Warmwasser macht dann allein die Therme."
|
||||
: "Heizt dauerhaft mit " + komma(s.stufe / 10) + S + "kW – reicht die Sonne nicht, kommt der Strom aus dem Netz.");
|
||||
|
||||
wurzel.querySelectorAll("[data-heiz-modus]").forEach(b => b.setAttribute("aria-checked", b.dataset.heizModus === s.modus ? "true" : "false"));
|
||||
$("fest").hidden = s.modus !== "fest";
|
||||
const regler = $("regler");
|
||||
if (!ziehen) regler.value = s.stufe;
|
||||
const wert = Number(regler.value);
|
||||
regler.style.setProperty("--wert", ((wert - 1) / 59 * 100).toFixed(1) + "%");
|
||||
$("reglerAusgabe").innerHTML = komma(wert / 10) + S + "kW";
|
||||
wurzel.querySelectorAll("[data-heiz-stufe]").forEach(c => c.setAttribute("aria-pressed", Number(c.dataset.heizStufe) === wert ? "true" : "false"));
|
||||
}
|
||||
|
||||
/** Eigene Wahl festhalten; "frisch" ueberbrueckt, bis das Geraet meldet. */
|
||||
function waehlen(modus, stufe) {
|
||||
gewaehlt.modus = modus;
|
||||
if (stufe !== undefined) gewaehlt.stufe = stufe;
|
||||
g.merke("frisch", true);
|
||||
}
|
||||
|
||||
wurzel.querySelectorAll("[data-heiz-modus]").forEach(b => b.addEventListener("click", () => {
|
||||
const modus = b.dataset.heizModus;
|
||||
waehlen(modus);
|
||||
senden(modus === "auto" ? 0 : modus === "aus" ? -1 : Number($("regler").value));
|
||||
}));
|
||||
$("regler").addEventListener("input", () => { ziehen = true; gewaehlt.stufe = Number($("regler").value); zeichnen(); });
|
||||
$("regler").addEventListener("change", () => {
|
||||
ziehen = false;
|
||||
waehlen("fest", Number($("regler").value));
|
||||
senden(Number($("regler").value));
|
||||
});
|
||||
wurzel.querySelectorAll("[data-heiz-stufe]").forEach(c => c.addEventListener("click", () => {
|
||||
const stufe = Number(c.dataset.heizStufe);
|
||||
waehlen("fest", stufe);
|
||||
senden(stufe);
|
||||
}));
|
||||
|
||||
g.takt(zeichnen);
|
||||
};
|
||||
|
||||
/* ===================================================== Bewässerung */
|
||||
window.wasserfensterStarten = function (wurzel, url) {
|
||||
const g = geruest(wurzel, () => wurzel.querySelector('[data-w="meldung"]'), "Befehle gehen sofort an die Ventilsteuerung");
|
||||
const bilder = window.ENERGIEFLUSS_ZONENBILDER || {};
|
||||
const dauer = {}; // Zone -> gewählte Minuten
|
||||
|
||||
wurzel.querySelectorAll("[data-bild]").forEach(b => {
|
||||
if (bilder[b.dataset.bild]) b.innerHTML = '<svg viewBox="0 0 24 24">' + bilder[b.dataset.bild] + "</svg>";
|
||||
});
|
||||
wurzel.querySelectorAll("[data-zone]").forEach(k => { dauer[k.dataset.zone] = Number(k.dataset.dauer); });
|
||||
|
||||
const zeitText = sek => sek < 60 ? Math.round(sek) + S + "s" : Math.ceil(sek / 60) + S + "min";
|
||||
const labelText = l => String(l).replace(/^(\d+)m$/, "$1" + S + "min").replace(/^(\d+)s$/, "$1" + S + "s");
|
||||
|
||||
async function senden(topic, payload) {
|
||||
g.meldung("wird geschickt …");
|
||||
try {
|
||||
const antwort = await fetch(url, {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ topic, payload: typeof payload === "string" ? payload : JSON.stringify(payload) }),
|
||||
});
|
||||
const d = await antwort.json().catch(() => ({}));
|
||||
if (!antwort.ok || !d.success) throw new Error(d.message || "HTTP " + antwort.status);
|
||||
g.meldung("✓ an die Ventilsteuerung geschickt", "gut");
|
||||
} catch (e) {
|
||||
g.meldung("Nicht übernommen: " + e.message, "fehler");
|
||||
}
|
||||
zeichnen();
|
||||
}
|
||||
|
||||
function zeichnen() {
|
||||
wurzel.querySelectorAll("[data-zone]").forEach(karte => {
|
||||
const key = karte.dataset.zone;
|
||||
const zone = wateringZones[key];
|
||||
if (!zone) return;
|
||||
const z = wateringZonenZustand(zone);
|
||||
const gemeldetLaeuft = z.state !== "aus" && z.state !== "--";
|
||||
const laeuft = g.gilt("laeuft-" + key, gemeldetLaeuft);
|
||||
const timer = wateringValue(wateringJson(zone.topic + "/Timers"), zone.valve);
|
||||
const rest = timer !== null ? Math.abs(wateringSeconds(timer)) : null;
|
||||
const $ = name => karte.querySelector('[data-w="' + name + '"]');
|
||||
|
||||
let zustand;
|
||||
if (laeuft && gemeldetLaeuft) zustand = (z.paused ? "pausiert" : "läuft") + (rest ? " · noch " + zeitText(rest) : "");
|
||||
else if (laeuft) zustand = "startet …";
|
||||
else if (z.bewaessert) zustand = "heute " + labelText(z.heute.label) + " gegossen";
|
||||
else if (z.state === "--") zustand = "Zustand unbekannt";
|
||||
else zustand = "heute noch nicht gegossen";
|
||||
setze($("zustand"), zustand);
|
||||
|
||||
const pille = $("pille");
|
||||
setze(pille, laeuft ? (z.paused ? "pausiert" : "läuft") : z.bewaessert ? "gegossen" : "");
|
||||
pille.className = "lade-pille" + (laeuft ? (z.paused ? " warn" : " an") : z.bewaessert ? " blau" : "");
|
||||
karte.classList.toggle("laeuft", laeuft);
|
||||
|
||||
const knopf = $("knopf");
|
||||
knopf.className = "lade-start wasser-knopf" + (laeuft ? " laeuft" : "");
|
||||
setze($("knopfText"), laeuft ? "Stoppen" : "Gießen · " + dauer[key] + S + "min");
|
||||
knopf.dataset.aktion = laeuft ? "stopp" : "start";
|
||||
|
||||
karte.querySelectorAll("[data-min]").forEach(c => c.setAttribute("aria-pressed", Number(c.dataset.min) === dauer[key] ? "true" : "false"));
|
||||
|
||||
const verlauf = wateringHistory(zone.topic, zone.valve);
|
||||
const vortag = wateringDayTotal(zone.topic, "LastWateringDay", "wateringDurationDaySecs", zone.valve, false);
|
||||
const teile = [];
|
||||
if (verlauf.dateTime !== "--") teile.push("zuletzt " + verlauf.dateTime + (verlauf.duration !== "--" ? " · " + labelText(verlauf.duration) : ""));
|
||||
if (vortag.label !== "--" && vortag.sekunden > 0) teile.push("letzter Gießtag " + labelText(vortag.label));
|
||||
setze($("info"), teile.join(" · "));
|
||||
});
|
||||
|
||||
wurzel.querySelectorAll("[data-ablauf]").forEach(c => {
|
||||
const modi = JSON.parse(c.dataset.ablauf);
|
||||
const aktiv = g.gilt("ablauf-" + c.textContent, Object.entries(modi).every(([t, m]) => wateringAutoActive(t, m)));
|
||||
c.setAttribute("aria-pressed", aktiv ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
wurzel.querySelectorAll("[data-zone]").forEach(karte => {
|
||||
const key = karte.dataset.zone;
|
||||
const zone = wateringZones[key];
|
||||
karte.querySelectorAll("[data-min]").forEach(c => c.addEventListener("click", () => {
|
||||
dauer[key] = Number(c.dataset.min);
|
||||
zeichnen();
|
||||
}));
|
||||
karte.querySelector('[data-w="knopf"]').addEventListener("click", e => {
|
||||
const start = e.currentTarget.dataset.aktion === "start";
|
||||
g.merke("laeuft-" + key, start);
|
||||
senden(zone.topic + "/set", start
|
||||
? { valve: zone.valve, set: "open", timer: dauer[key] }
|
||||
: { valve: zone.valve, set: "close" });
|
||||
});
|
||||
});
|
||||
|
||||
wurzel.querySelectorAll("[data-ablauf]").forEach(c => c.addEventListener("click", async () => {
|
||||
const modi = JSON.parse(c.dataset.ablauf);
|
||||
const aktiv = c.getAttribute("aria-pressed") === "true";
|
||||
g.merke("ablauf-" + c.textContent, !aktiv);
|
||||
for (const [topic, modus] of Object.entries(modi)) {
|
||||
await senden(topic + "/auto", aktiv ? "Stop" : modus);
|
||||
}
|
||||
}));
|
||||
|
||||
g.takt(zeichnen);
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Benachrichtigungen auf diesem Gerät ein- und ausschalten.
|
||||
*
|
||||
* Web Push braucht drei Dinge, und alle drei gibt es hier schon: eine
|
||||
* sichere Verbindung, einen Service Worker (sw.js im Wurzelverzeichnis) und
|
||||
* ein Schlüsselpaar (VAPID, liegt auf der NAS). Eine eigene App braucht es
|
||||
* nicht - unter iOS muss die Seite allerdings einmal über "Zum
|
||||
* Home-Bildschirm" abgelegt werden, sonst verweigert Safari die Erlaubnis.
|
||||
*
|
||||
* Das Abo gehört dem Browser, nicht dem Konto: Jedes Gerät meldet sich
|
||||
* einzeln an. Gespeichert wird es in homeMesh.push_abos (ajax/push.php),
|
||||
* verschickt vom Runner auf der NAS.
|
||||
*/
|
||||
|
||||
const PUSH_ENDPUNKT = "ajax/push.php";
|
||||
|
||||
let pushStand = { schluessel: "", abos: [] };
|
||||
|
||||
/** base64url aus der Datenbank in den Bytesalat, den der Browser will. */
|
||||
function pushSchluesselBytes(text) {
|
||||
const voll = (text + "=".repeat((4 - text.length % 4) % 4)).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const roh = atob(voll);
|
||||
return Uint8Array.from(roh, z => z.charCodeAt(0));
|
||||
}
|
||||
|
||||
/** Was der Browser über sich verrät, auf das Nötige gekürzt. */
|
||||
function pushBrowserName() {
|
||||
const ua = navigator.userAgent;
|
||||
const name = /Edg\//.test(ua) ? "Edge" : /OPR\//.test(ua) ? "Opera"
|
||||
: /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari"
|
||||
: /Firefox\//.test(ua) ? "Firefox" : "Browser";
|
||||
const os = /Android/.test(ua) ? "Android" : /iPhone|iPad/.test(ua) ? "iOS"
|
||||
: /Windows/.test(ua) ? "Windows" : /Mac OS/.test(ua) ? "Mac" : "";
|
||||
return os ? name + " · " + os : name;
|
||||
}
|
||||
|
||||
function pushMoeglich() {
|
||||
return "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||
}
|
||||
|
||||
async function pushAnfrage(action, body) {
|
||||
const antwort = await fetch(PUSH_ENDPUNKT + "?action=" + action, body ? {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" },
|
||||
body: JSON.stringify(body),
|
||||
} : { cache: "no-store" });
|
||||
const d = await antwort.json();
|
||||
if (d.error) throw new Error(d.error);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Das Abo dieses Browsers, oder null. */
|
||||
async function pushAbo() {
|
||||
if (!pushMoeglich()) return null;
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
return reg ? await reg.pushManager.getSubscription() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auf diesem Gerät anmelden.
|
||||
*
|
||||
* Die Reihenfolge ist wichtig: erst den Service Worker, dann die Erlaubnis,
|
||||
* dann das Abo. Fragt man zuerst nach der Erlaubnis und der Service Worker
|
||||
* scheitert danach, hat der Nutzer eine Erlaubnis vergeben, die zu nichts
|
||||
* führt - und der Browser fragt kein zweites Mal.
|
||||
*/
|
||||
async function pushAnmelden(name) {
|
||||
if (!pushMoeglich()) {
|
||||
throw new Error("Dieser Browser kann keine Benachrichtigungen empfangen.");
|
||||
}
|
||||
if (!pushStand.schluessel) {
|
||||
throw new Error("Auf der NAS fehlt der Schlüssel (push_vapid.json).");
|
||||
}
|
||||
const reg = await navigator.serviceWorker.register("sw.js");
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
const erlaubnis = await Notification.requestPermission();
|
||||
if (erlaubnis !== "granted") {
|
||||
throw new Error(erlaubnis === "denied"
|
||||
? "Benachrichtigungen sind für diese Seite blockiert – im Browser unter „Website-Einstellungen“ wieder erlauben."
|
||||
: "Ohne Erlaubnis geht es nicht.");
|
||||
}
|
||||
|
||||
const abo = await reg.pushManager.getSubscription()
|
||||
|| await reg.pushManager.subscribe({
|
||||
// Ohne diese Zusage weigern sich die Browser: Jede Nachricht muss den
|
||||
// Nutzer erreichen, stille Datenübertragung ist nicht erlaubt.
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: pushSchluesselBytes(pushStand.schluessel),
|
||||
});
|
||||
|
||||
const d = await pushAnfrage("anmelden", {
|
||||
abo: abo.toJSON(),
|
||||
name: name || pushBrowserName(),
|
||||
});
|
||||
pushStand.abos = d.abos;
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Auf diesem Gerät abmelden - Abo im Browser und Zeile in der Datenbank. */
|
||||
async function pushAbmelden() {
|
||||
const abo = await pushAbo();
|
||||
if (!abo) return;
|
||||
const endpoint = abo.endpoint;
|
||||
await abo.unsubscribe();
|
||||
const d = await pushAnfrage("abmelden", { endpoint: endpoint });
|
||||
pushStand.abos = d.abos;
|
||||
}
|
||||
|
||||
/** Ein fremdes Gerät aus der Liste werfen (Handy verkauft, Browser weg). */
|
||||
async function pushEntfernen(id) {
|
||||
const d = await pushAnfrage("entfernen", { id: id });
|
||||
pushStand.abos = d.abos;
|
||||
}
|
||||
|
||||
/** Probemeldung über den Runner an alle angemeldeten Geräte. */
|
||||
async function pushProbe() {
|
||||
await pushAnfrage("probe", { titel: "Probe", text: "Wenn das ankommt, stimmt der Weg." });
|
||||
}
|
||||
|
||||
async function pushStandLaden() {
|
||||
pushStand = await pushAnfrage("stand");
|
||||
return pushStand;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/* ==========================================================================
|
||||
Zustand der Bewässerungszonen - geteilt von der Solar-Seite (solarMQTT.js,
|
||||
dort zusätzlich das Modal) und der Vollbildanzeige (anzeige.js).
|
||||
|
||||
Liest aus dem globalen mqttData, das die jeweilige Seite füllt:
|
||||
Gartenwasser/<vorn|hinten>/Timers, /status, /WateringToday. Wer die
|
||||
Timers-Nachricht empfängt, trägt die Uhrzeit in wateringTimersLastSeen ein
|
||||
- daran hängt, ob eine Zone als laufend gilt.
|
||||
|
||||
Bis September 2026 stand das alles in solarMQTT.js.
|
||||
========================================================================== */
|
||||
|
||||
const wateringTimersLastSeen = {};
|
||||
// Eine Tabelle fuer alles: Ventil, Beschriftung und die Anzeige-Ids im
|
||||
// Realtime-SVG. Vorher standen dieselben Zuordnungen in drei getrennten
|
||||
// Tabellen.
|
||||
const wateringZones = {
|
||||
troughs: {
|
||||
topic: "Gartenwasser/vorn", valve: 1, label: "Tröge",
|
||||
statusId: "wateringTroughStatus", iconId: "wateringTroughIcon"
|
||||
},
|
||||
frontGarden: {
|
||||
topic: "Gartenwasser/vorn", valve: 6, label: "Vorn",
|
||||
statusId: "wateringFrontStatus", iconId: "wateringFrontIcon"
|
||||
},
|
||||
raisedBeds: {
|
||||
topic: "Gartenwasser/hinten", valve: 6, label: "Beete",
|
||||
statusId: "wateringBedsStatus", iconId: "wateringBedsIcon"
|
||||
}
|
||||
};
|
||||
// Feste Liste - der Sekundentakt soll sie nicht jedes Mal neu aufbauen.
|
||||
const wateringZoneList = Object.entries(wateringZones);
|
||||
|
||||
// Die Anzeige laeuft im Sekundentakt, die Nutzlasten aendern sich aber nur,
|
||||
// wenn eine neue MQTT-Nachricht kommt. messageReceived legt dabei jedes Mal
|
||||
// ein neues Buffer-Objekt ab - der Identitaetsvergleich reicht also als
|
||||
// Gueltigkeitspruefung, ein Invalidieren von Hand braucht es nicht.
|
||||
const wateringJsonCache = {};
|
||||
const wateringDecoder = new TextDecoder();
|
||||
|
||||
function wateringJson(topic) {
|
||||
const value = mqttData[topic];
|
||||
if (!value) return null;
|
||||
const cached = wateringJsonCache[topic];
|
||||
if (cached && cached.raw === value) return cached.parsed;
|
||||
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(typeof value === "string" ? value : wateringDecoder.decode(value));
|
||||
} catch (error) {
|
||||
parsed = null;
|
||||
}
|
||||
wateringJsonCache[topic] = { raw: value, parsed: parsed };
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function wateringHistory(topic, valve) {
|
||||
const data = wateringJson(topic + "/LastWatering");
|
||||
if (!data) return { dateTime: "--", duration: "--" };
|
||||
return {
|
||||
dateTime: data["wateringDateTime" + valve] || "--",
|
||||
duration: data["wateringDurationSecs" + valve] !== undefined
|
||||
? wateringTimeLabel(Number(data["wateringDurationSecs" + valve]))
|
||||
: "--"
|
||||
};
|
||||
}
|
||||
|
||||
// Tagesdatum im Format der Ventilsteuerung ("29.08.26").
|
||||
function wateringDayStamp(date) {
|
||||
const zwei = (zahl) => String(zahl).padStart(2, "0");
|
||||
return zwei(date.getDate()) + "." + zwei(date.getMonth() + 1) + "." + zwei(date.getFullYear() % 100);
|
||||
}
|
||||
|
||||
// Tagessummen der Ventilsteuerung: WateringToday fuer den laufenden Tag,
|
||||
// LastWateringDay fuer den letzten Tag, an dem ueberhaupt bewaessert wurde.
|
||||
// Beide Nutzlasten tragen den zugehoerigen Tag als "wateringDay" mit. Bei
|
||||
// WateringToday wird der geprueft - die Nachricht ist retained, nach einem
|
||||
// Tageswechsel ohne Bewaesserung stuende sonst die Summe von gestern als
|
||||
// "heute" in der Karte.
|
||||
function wateringDayTotal(topic, subTopic, feld, valve, mussHeuteSein) {
|
||||
const data = wateringJson(topic + "/" + subTopic);
|
||||
if (!data) return { label: "--", day: "", sekunden: null };
|
||||
const tag = data.wateringDay || "";
|
||||
const sekunden = data[feld + valve];
|
||||
if (sekunden === undefined) return { label: "--", day: tag, sekunden: null };
|
||||
if (mussHeuteSein && tag !== wateringDayStamp(new Date())) {
|
||||
return { label: "--", day: tag, sekunden: null };
|
||||
}
|
||||
return { label: wateringTimeLabel(Number(sekunden)), day: tag, sekunden: Number(sekunden) };
|
||||
}
|
||||
|
||||
function wateringAutoActive(topic, mode) {
|
||||
const timers = wateringJson(topic + "/Timers");
|
||||
return !!(timers && timers.auto === mode);
|
||||
}
|
||||
|
||||
function wateringValue(data, valve) {
|
||||
if (!data) return null;
|
||||
const candidates = [
|
||||
data.timers && data.timers[valve],
|
||||
data.timers && data.timers[String(valve)],
|
||||
data[valve],
|
||||
data[String(valve)],
|
||||
data.valves && data.valves[valve],
|
||||
data.valves && data.valves[String(valve)],
|
||||
data.status && data.status[valve],
|
||||
data.status && data.status[String(valve)],
|
||||
data.valveState && data.valveState[valve],
|
||||
data.valveState && data.valveState[String(valve)],
|
||||
data.setValveState && data.setValveState[valve],
|
||||
data.setValveState && data.setValveState[String(valve)]
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate !== undefined) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function wateringState(value) {
|
||||
if (value && typeof value === "object") {
|
||||
return value.state ?? value.status ?? value.valveState ?? value.set;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function wateringSeconds(value) {
|
||||
if (value && typeof value === "object") {
|
||||
value = value.remaining ?? value.timer ?? value.seconds ?? value.time;
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function wateringLabel(topic, valve) {
|
||||
const timers = wateringJson(topic + "/Timers");
|
||||
const status = wateringJson(topic + "/status");
|
||||
const lastSeen = wateringTimersLastSeen[topic + "/Timers"];
|
||||
if (!lastSeen || Date.now() - lastSeen > 3000) return "aus";
|
||||
const remaining = wateringValue(timers, valve);
|
||||
if (remaining !== null) {
|
||||
const seconds = wateringSeconds(remaining);
|
||||
if (seconds !== 0) return wateringTimeLabel(Math.abs(seconds));
|
||||
return "aus";
|
||||
}
|
||||
const state = wateringState(wateringValue(status, valve));
|
||||
if (state === true || state === 1 || state === "1" || state === "open" || state === "OPEN") return "an";
|
||||
if (state === false || state === 0 || state === "0" || state === "close" || state === "closed" || state === "CLOSED") return "aus";
|
||||
return "--";
|
||||
}
|
||||
|
||||
function wateringTimeLabel(seconds) {
|
||||
if (seconds < 60) return seconds + "s";
|
||||
return Math.ceil(seconds / 60) + "m";
|
||||
}
|
||||
|
||||
function wateringIsPaused(topic, valve) {
|
||||
const timers = wateringJson(topic + "/Timers");
|
||||
const remaining = wateringValue(timers, valve);
|
||||
return remaining !== null && wateringSeconds(remaining) < 0;
|
||||
}
|
||||
|
||||
function updateWateringIndicator(status, icon, name, state, paused, bewaessert) {
|
||||
// "Aus" bleibt stehen, solange die Zone heute noch kein Wasser hatte;
|
||||
// danach ersetzt das Haekchen (U+2713, ohne Emoji-Variationsselektor ->
|
||||
// einfarbig, folgt der Icon-Farbe) den Text, um Platz auf der textPath zu
|
||||
// sparen. Laeuft im Sekundentakt, aendert sich aber selten - ohne Pruefung
|
||||
// wuerde jede Sekunde derselbe Wert geschrieben und das Icon samt
|
||||
// Filter neu gezeichnet.
|
||||
const text = state === "aus" ? (bewaessert ? "✓" : "Aus") : state;
|
||||
if (status.textContent !== text) status.textContent = text;
|
||||
// Das Blau ("#3A6292") entspricht dem Wassertropfen-Icon (id="water").
|
||||
const color = state !== "aus" ? (paused ? "#ffc107" : "#20c997") : bewaessert ? "#3A6292" : "#6c757d";
|
||||
if (icon.getAttribute("color") !== color) {
|
||||
icon.setAttribute("color", color);
|
||||
icon.setAttribute("fill", color);
|
||||
icon.style.color = color;
|
||||
}
|
||||
const titel = name + ": " + (state === "aus" && bewaessert ? "Aus (heute bereits bewässert)" : state);
|
||||
const titelKnoten = icon.querySelector("title");
|
||||
if (titelKnoten.textContent !== titel) titelKnoten.textContent = titel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zustand einer Zone aus den MQTT-Werten.
|
||||
*
|
||||
* Die Regenautomatik laeuft zum Sonnenaufgang; wenn jemand auf die Anzeige
|
||||
* schaut, ist die Entscheidung laengst gefallen. Angezeigt wird deshalb nicht,
|
||||
* was geplant ist, sondern was passiert ist. Der Zaehler unterscheidet nicht,
|
||||
* wer ausgeloest hat - eine Bewaesserung von Hand zaehlt genauso.
|
||||
*/
|
||||
function wateringZonenZustand(zone) {
|
||||
const state = wateringLabel(zone.topic, zone.valve);
|
||||
const paused = wateringIsPaused(zone.topic, zone.valve);
|
||||
const heute = wateringDayTotal(zone.topic, "WateringToday", "wateringDurationTodaySecs", zone.valve, true);
|
||||
return { state, paused, heute, bewaessert: state === "aus" && heute.sekunden > 0 };
|
||||
}
|
||||
|
||||
/** Symbol und Text jeder Zone setzen; finde(id) liefert das Element. */
|
||||
function wateringSymboleSetzen(finde) {
|
||||
wateringZoneList.forEach(([, zone]) => {
|
||||
const status = finde(zone.statusId);
|
||||
const icon = finde(zone.iconId);
|
||||
if (!status || !icon) return;
|
||||
const z = wateringZonenZustand(zone);
|
||||
updateWateringIndicator(status, icon, zone.label, z.state, z.paused, z.bewaessert);
|
||||
});
|
||||
}
|
||||
@@ -73,75 +73,6 @@ async function getData(chart, url, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formular des Modals per POST abschicken.
|
||||
* Der Start/Stop-Knopf der Ladesteuerung schickt nur sich selbst.
|
||||
*/
|
||||
function submitFormAjax(event) {
|
||||
let xmlhttp = new XMLHttpRequest();
|
||||
|
||||
xmlhttp.onreadystatechange = function () {
|
||||
if (this.readyState === 4 && this.status === 200) {
|
||||
setTimeout(function () { modalEV.hide(); }, 700);
|
||||
if (xmlhttp.responseText) {
|
||||
alert(xmlhttp.responseText);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let post = "";
|
||||
if (event.currentTarget.id == "evStart/Stop") {
|
||||
post = "evStart/Stop=" + event.currentTarget.innerHTML;
|
||||
} else {
|
||||
const form = document.getElementById('modalEV').querySelector('form');
|
||||
for (const element of Array.from(form.elements)) {
|
||||
if (!element.name) continue;
|
||||
if (element.type == "radio" || element.type == "checkbox") {
|
||||
if (element.checked) {
|
||||
post += element.name + "=" + encodeURIComponent(element.value) + "&";
|
||||
}
|
||||
} else if (element.type == "button") {
|
||||
if (!element.classList.contains("accordion-button")) {
|
||||
post += element.name + "=" + encodeURIComponent(element.innerHTML) + "&";
|
||||
}
|
||||
} else {
|
||||
post += element.name + "=" + encodeURIComponent(element.value) + "&";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xmlhttp.open("POST", event.currentTarget.contentURL, true);
|
||||
xmlhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
|
||||
document.getElementById('modalEV').querySelector('.modal-body').innerHTML = loadingHTML("Änderungen werden übernommen...");
|
||||
xmlhttp.send(post);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schieberegler im Modal mit seiner Beschriftung verbinden.
|
||||
* label(wert) liefert den anzuzeigenden Text.
|
||||
*/
|
||||
function bindModalSlider(slider, span, label) {
|
||||
slider.oninput = function () {
|
||||
const min = Number(slider.min);
|
||||
const max = Number(slider.max);
|
||||
const percent = (Number(slider.value) - min) * 100 / (max - min);
|
||||
slider.style.setProperty("--background-size", `${percent}%`);
|
||||
|
||||
// Beschriftung mitlaufen lassen, an den Raendern etwas gebremst
|
||||
let margin = percent;
|
||||
if (margin < 10) {
|
||||
margin = margin - 0.4 * margin;
|
||||
} else if (margin < 85) {
|
||||
margin = margin - 4;
|
||||
} else {
|
||||
margin = margin - 4 - 0.5 * (margin - 85);
|
||||
}
|
||||
span.setAttribute('style', 'margin-left:' + margin + '%;');
|
||||
span.innerHTML = label(slider.value);
|
||||
};
|
||||
}
|
||||
|
||||
/** Meteoblue-Meteogramm einhängen, sofern die Seite einen Platz dafür hat. */
|
||||
// Mindestbreite, in der das Meteogramm von meteoblue geladen wird. Der
|
||||
// Anbieter legt sein Bild einmal beim Laden fest und zeichnet es bei einer
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/* ==========================================================================
|
||||
Einführung als Folien (restricted/einfuehrung.php).
|
||||
|
||||
Blättern mit den Knöpfen, den Pfeiltasten oder durch Wischen. Die Folie
|
||||
steht in der Adresse (#folie-7), damit man sie verlinken und nach einem
|
||||
Neuladen dort weitermachen kann. Eine Erklärung antippen lässt ihren Punkt
|
||||
im Bild aufleuchten - und umgekehrt.
|
||||
========================================================================== */
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const ef = document.getElementById("einfuehrung");
|
||||
if (!ef) return;
|
||||
|
||||
const folien = [...ef.querySelectorAll(".ef-folie")];
|
||||
const zurueck = ef.querySelector(".ef-zurueck");
|
||||
const weiter = ef.querySelector(".ef-weiter");
|
||||
const zaehler = ef.querySelector(".ef-zaehler");
|
||||
const balken = ef.querySelector(".ef-fortschritt span");
|
||||
const kapitelWahl = ef.querySelector(".ef-kapitel");
|
||||
const punkteNav = ef.querySelector(".ef-punkte-nav");
|
||||
let aktuell = 0;
|
||||
|
||||
// Die Punkte unten: einer je Folie, Teile durch eine Lücke getrennt.
|
||||
folien.forEach((f, i) => {
|
||||
const p = document.createElement("span");
|
||||
p.className = "ef-punkt" + (i > 0 && folien[i - 1].dataset.teil !== f.dataset.teil ? " neuer-teil" : "");
|
||||
p.dataset.ziel = i;
|
||||
punkteNav.appendChild(p);
|
||||
});
|
||||
punkteNav.removeAttribute("aria-hidden");
|
||||
|
||||
function zeige(nr, ohneAdresse) {
|
||||
nr = Math.max(0, Math.min(folien.length - 1, nr));
|
||||
const richtung = nr >= aktuell ? "vor" : "rueck";
|
||||
folien.forEach((f, i) => {
|
||||
f.hidden = i !== nr;
|
||||
f.classList.remove("rein-vor", "rein-rueck");
|
||||
});
|
||||
const f = folien[nr];
|
||||
void f.offsetWidth; // Animation neu starten
|
||||
f.classList.add("rein-" + richtung);
|
||||
aktuell = nr;
|
||||
|
||||
zaehler.textContent = (nr + 1) + " / " + folien.length;
|
||||
balken.style.width = ((nr + 1) / folien.length * 100).toFixed(1) + "%";
|
||||
zurueck.disabled = nr === 0;
|
||||
weiter.innerHTML = nr === folien.length - 1
|
||||
? 'Von vorn<i class="bi bi-arrow-counterclockwise ms-1"></i>'
|
||||
: 'Weiter<i class="bi bi-arrow-right ms-1"></i>';
|
||||
ef.querySelectorAll(".ef-teil").forEach(t => t.classList.toggle("aktiv", t.dataset.teil === f.dataset.teil));
|
||||
punkteNav.querySelectorAll(".ef-punkt").forEach((p, i) => p.classList.toggle("aktiv", i === nr));
|
||||
|
||||
// Das Kapitel dieser Folie in der Auswahl: die letzte Kapitel-Folie <= nr.
|
||||
let kap = null;
|
||||
[...kapitelWahl.options].forEach(o => { if (Number(o.value) <= nr) kap = o.value; });
|
||||
if (kap !== null) kapitelWahl.value = kap;
|
||||
|
||||
if (!ohneAdresse) history.replaceState(null, "", "#folie-" + (nr + 1));
|
||||
// Auf dem Handy nach oben, damit die neue Folie mit dem Titel beginnt.
|
||||
const oben = ef.getBoundingClientRect().top;
|
||||
if (oben < 0) ef.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
}
|
||||
|
||||
zurueck.addEventListener("click", () => zeige(aktuell - 1));
|
||||
weiter.addEventListener("click", () => zeige(aktuell === folien.length - 1 ? 0 : aktuell + 1));
|
||||
kapitelWahl.addEventListener("change", () => zeige(Number(kapitelWahl.value)));
|
||||
ef.querySelectorAll(".ef-teil").forEach(t => t.addEventListener("click", () => {
|
||||
zeige(folien.findIndex(f => f.dataset.teil === t.dataset.teil));
|
||||
}));
|
||||
punkteNav.addEventListener("click", e => {
|
||||
const p = e.target.closest(".ef-punkt");
|
||||
if (p) zeige(Number(p.dataset.ziel));
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", e => {
|
||||
if (e.target.closest("input, select, textarea")) return;
|
||||
if (e.key === "ArrowRight" || e.key === "PageDown") { zeige(aktuell + 1); e.preventDefault(); }
|
||||
if (e.key === "ArrowLeft" || e.key === "PageUp") { zeige(aktuell - 1); e.preventDefault(); }
|
||||
if (e.key === "Home") zeige(0);
|
||||
if (e.key === "End") zeige(folien.length - 1);
|
||||
});
|
||||
|
||||
// Wischen: waagerecht deutlich, senkrecht wenig - sonst ist es Scrollen.
|
||||
let start = null;
|
||||
ef.querySelector(".ef-buehne").addEventListener("touchstart", e => {
|
||||
start = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
}, { passive: true });
|
||||
ef.querySelector(".ef-buehne").addEventListener("touchend", e => {
|
||||
if (!start) return;
|
||||
const dx = e.changedTouches[0].clientX - start.x;
|
||||
const dy = e.changedTouches[0].clientY - start.y;
|
||||
start = null;
|
||||
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) zeige(aktuell + (dx < 0 ? 1 : -1));
|
||||
}, { passive: true });
|
||||
|
||||
// Erklärung und Punkt im Bild gehören zusammen: das eine hebt das andere hervor.
|
||||
function hervorheben(folie, nr, an) {
|
||||
folie.querySelectorAll('[data-marke="' + nr + '"]').forEach(el => el.classList.toggle("hell", an));
|
||||
}
|
||||
folien.forEach(folie => {
|
||||
folie.addEventListener("mouseover", e => {
|
||||
const el = e.target.closest("[data-marke]");
|
||||
if (el) hervorheben(folie, el.dataset.marke, true);
|
||||
});
|
||||
folie.addEventListener("mouseout", e => {
|
||||
const el = e.target.closest("[data-marke]");
|
||||
if (el) hervorheben(folie, el.dataset.marke, false);
|
||||
});
|
||||
folie.addEventListener("click", e => {
|
||||
const el = e.target.closest("[data-marke]");
|
||||
if (!el) return;
|
||||
const nr = el.dataset.marke;
|
||||
folie.querySelectorAll("[data-marke]").forEach(x => x.classList.remove("hell"));
|
||||
hervorheben(folie, nr, true);
|
||||
// Auf dem Handy liegt das Bild über dem Text: zum Punkt scrollen.
|
||||
const punkt = folie.querySelector('.ef-marke[data-marke="' + nr + '"]');
|
||||
if (punkt && el.tagName === "LI") {
|
||||
const r = punkt.getBoundingClientRect();
|
||||
if (r.top < 60 || r.bottom > window.innerHeight) punkt.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Ein Link auf eine Folie in einem schon offenen Tab ändert nur den Anker -
|
||||
// die Seite lädt dann nicht neu, also hier nachziehen.
|
||||
window.addEventListener("hashchange", () => {
|
||||
const t = /^#folie-(\d+)$/.exec(location.hash);
|
||||
if (t && Number(t[1]) - 1 !== aktuell) zeige(Number(t[1]) - 1, true);
|
||||
});
|
||||
|
||||
const treffer = /^#folie-(\d+)$/.exec(location.hash);
|
||||
zeige(treffer ? Number(treffer[1]) - 1 : 0, !treffer);
|
||||
});
|
||||
@@ -0,0 +1,674 @@
|
||||
/* ==========================================================================
|
||||
Energiefluss-Übersicht: zeichnet den Katalog (energieflussKatalog.js) als
|
||||
animiertes SVG und hält es mit den MQTT-Werten aktuell.
|
||||
|
||||
const bild = new Energiefluss(behaelter, {
|
||||
einstellungen, // Abweichungen aus homeMesh.energiefluss
|
||||
bearbeiten: false, // true = Einstellungsvorschau, Kreise ziehbar
|
||||
onKlick(knoten), // Kreis mit klick-Ziel angetippt
|
||||
onVerschoben(id, x, y), // nur beim Bearbeiten
|
||||
onGezeichnet(), // nach jedem (Neu-)Zeichnen
|
||||
hochkantUnter: 720, // optional: px Breite, darunter hochkant (0 = nie)
|
||||
});
|
||||
bild.aktualisieren(mqttData);
|
||||
|
||||
Der Renderer kennt keinen Kreis beim Namen. Alles, was einen Kreis
|
||||
ausmacht, steht im Katalog; hier steht nur, wie Kreise, Flüsse, Symbole
|
||||
und Plaketten grundsätzlich aussehen.
|
||||
|
||||
Zwei Ansichten: quer (Lage aus dem Katalog) und hochkant für schmale
|
||||
Schirme. Hochkant sind x und y vertauscht - aus "Quellen links, Verbraucher
|
||||
rechts" wird "Quellen oben, Verbraucher unten". So braucht es keine zweite
|
||||
Lage je Kreis, und eine eigene Lage aus den Einstellungen gilt in beiden.
|
||||
|
||||
Zur Leistung: die Flüsse laufen über stroke-dashoffset in groben Stufen
|
||||
(steps), und alle Animationen stehen still, solange die Karte nicht zu
|
||||
sehen ist. Werte werden nur geschrieben, wenn sie sich geändert haben.
|
||||
========================================================================== */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
/**
|
||||
* Wie voll der Ring bei dieser Leistung ist (0..1).
|
||||
*
|
||||
* Nicht linear: bei 15 kW Vollausschlag waeren 300 W ein Strich von zwei
|
||||
* Grad - man saehe nicht, ob das Erdgeschoss 50 W oder 500 W zieht, und
|
||||
* genau in diesem Bereich steht die Anlage die meiste Zeit. Die gestauchte
|
||||
* Skala gibt dem unteren Zehntel knapp ein Drittel des Rings und laesst den
|
||||
* vollen Ausschlag trotzdem genau bei "skala".
|
||||
*/
|
||||
function ringAnteil(w, skala) {
|
||||
if (!(skala > 0)) return 0;
|
||||
const teil = Math.max(0, Math.min(1, Math.abs(w) / skala));
|
||||
return Math.log10(1 + 9 * teil);
|
||||
}
|
||||
|
||||
const RADIUS = 78;
|
||||
const RADIUS_GROSS = 92;
|
||||
const RAND = 60; // Luft um die äußersten Kreise
|
||||
const HOCHKANT_UNTER = 720; // px Breite, ab der hochkant gezeichnet wird
|
||||
|
||||
/* ------------------------------------------------------------------------
|
||||
Symbole: 24er Raster, Mitte 0/0, Linien in currentColor. Teile mit
|
||||
Klasse enf-s-* werden über CSS animiert oder über den Zustand gesetzt.
|
||||
------------------------------------------------------------------------ */
|
||||
const SYMBOLE = {
|
||||
sonne: () => `
|
||||
<g class="enf-s-strahlen">
|
||||
${[0, 45, 90, 135, 180, 225, 270, 315].map(w =>
|
||||
`<path d="M0-8.2V-11" transform="rotate(${w})"/>`).join("")}
|
||||
</g>
|
||||
<circle r="4.6" class="enf-s-kern"/>`,
|
||||
|
||||
netz: () => `
|
||||
<path d="M-5 11.5L-1.6-10.5h3.2L5 11.5"/>
|
||||
<path d="M-9.5-5.5h19M-7-1h14"/>
|
||||
<path d="M-8.5-5.5v2.4M8.5-5.5v2.4M-6-1v2.4M6-1v2.4"/>
|
||||
<path d="M-3.3 3.5l6.8 8M3.3 3.5l-6.8 8" class="enf-s-fein"/>`,
|
||||
|
||||
akku: () => `
|
||||
<rect x="-6.5" y="-8.5" width="13" height="20" rx="2.4"/>
|
||||
<path d="M-2.6-11h5.2"/>
|
||||
<rect x="-4" y="-6" width="8" height="15" rx="1" class="enf-s-stand"/>
|
||||
<path d="M1.2-4.2l-4 5.6h3l-1.4 4.6 4.2-6h-3z" class="enf-s-blitz"/>`,
|
||||
|
||||
wechselrichter: () => `
|
||||
<rect x="-10.5" y="-10.5" width="21" height="21" rx="3.5"/>
|
||||
<path d="M-10.5 10.5l21-21" class="enf-s-fein"/>
|
||||
<path d="M-7.5-5.2h5M-7.5-2.4h5"/>
|
||||
<path d="M1.8 4.2c1-1.7 2.1-1.7 3.1 0s2.1 1.7 3.1 0" class="enf-s-welle"/>`,
|
||||
|
||||
verbrauch: () => `
|
||||
<path d="M-11-1.5L0-10.5l11 9"/>
|
||||
<path d="M-8-4v14.5h16V-4"/>
|
||||
<path d="M1.3-3.8l-4.4 6.2h3.3l-1.6 5 4.6-6.6H-.1z" class="enf-s-blitz enf-s-an"/>`,
|
||||
|
||||
etageOben: () => etage(-4.5, 1.5),
|
||||
etageMitte: () => etage(1.5, 7.5),
|
||||
etageUnten: () => etage(7.5, 11.5),
|
||||
|
||||
wallbox: () => `
|
||||
<rect x="-10.5" y="-10.5" width="11" height="21" rx="2.2"/>
|
||||
<rect x="-8" y="-7.5" width="6" height="4" rx=".8" class="enf-s-fein"/>
|
||||
<path d="M-6.4 1.2l-2 3.5h2.2l-1 3.2 2.9-4.2h-2.2z" class="enf-s-blitz"/>
|
||||
<path d="M.5 2h2.8a2.3 2.3 0 0 1 2.3 2.3v3.9a1.9 1.9 0 0 0 3.8 0V-.5" class="enf-s-kabel"/>
|
||||
<path d="M7.2-4.6h3.6v2.3a1.8 1.8 0 0 1-3.6 0z"/>
|
||||
<path d="M8.2-4.6v-2M9.8-4.6v-2"/>`,
|
||||
|
||||
speicher: () => `
|
||||
<defs><clipPath id="{id}-tank"><rect x="-6.8" y="-10.8" width="13.6" height="19.6" rx="5.2"/></clipPath></defs>
|
||||
<g clip-path="url(#{id}-tank)" class="enf-s-temps">
|
||||
<rect x="-8" y="-12" width="16" height="7.6" class="enf-s-t0"/>
|
||||
<rect x="-8" y="-4.4" width="16" height="6.6" class="enf-s-t1"/>
|
||||
<rect x="-8" y="2.2" width="16" height="7.6" class="enf-s-t2"/>
|
||||
</g>
|
||||
<rect x="-7" y="-11" width="14" height="20" rx="5.4"/>
|
||||
<path d="M-3.4 5.6l1.1-1.8 1.2 1.8 1.1-1.8 1.2 1.8 1.1-1.8" class="enf-s-spirale"/>
|
||||
<path d="M-4 9v2.5M4 9v2.5"/>`,
|
||||
|
||||
tropfen: () => `
|
||||
<path d="M0-11c4.6 5.5 7 9 7 12.6a7 7 0 0 1-14 0C-7-2-4.6-5.5 0-11z"/>
|
||||
<path d="M-3.6 3.4c1.2 1 2.4 1 3.6 0s2.4-1 3.6 0" class="enf-s-welle"/>`,
|
||||
};
|
||||
|
||||
/* Kleine Symbole für Zeilen in einem Kreis (Bewässerungszonen): 24er
|
||||
Raster, Ecke oben links, Linien in currentColor. */
|
||||
const ZONENBILDER = {
|
||||
// Pflanzkübel mit Pflanze - ein Trog auf der Terrasse
|
||||
kuebel: `
|
||||
<path d="M12 12V7.2"/>
|
||||
<path d="M12 9.6c-2.6.1-4.3-1.5-4.4-4.2 2.6-.1 4.3 1.5 4.4 4.2z"/>
|
||||
<path d="M12 8.4c.1-2.7 1.8-4.3 4.4-4.2-.1 2.7-1.8 4.3-4.4 4.2z"/>
|
||||
<rect x="4.6" y="12" width="14.8" height="2.8" rx=".8"/>
|
||||
<path d="M6.2 14.8 7.6 21h8.8l1.4-6.2"/>`,
|
||||
// Baum im Gras - der normale Garten
|
||||
garten: `
|
||||
<path d="M12 3.6a4.3 4.3 0 0 0-4.2 5.3 3.4 3.4 0 0 0 1.3 6.5h5.8a3.4 3.4 0 0 0 1.3-6.5A4.3 4.3 0 0 0 12 3.6z"/>
|
||||
<path d="M12 15.4V20.6M12 18l2-1.8"/>
|
||||
<path d="M3.5 20.6h17M6 20.6l-.7-1.9M7.6 20.6l.3-2.1M17.2 20.6l.6-2M18.6 20.6l.9-1.6"/>`,
|
||||
// Hochbeet: Holzkasten auf Füßen, oben drei Pflanzen
|
||||
hochbeet: `
|
||||
<path d="M8 11V8.6"/>
|
||||
<path d="M8 9.4c-1.5 0-2.4-.9-2.4-2.4 1.5 0 2.4.9 2.4 2.4z"/>
|
||||
<path d="M12 11V6.4"/>
|
||||
<path d="M12 8.6c-1.6 0-2.6-1-2.6-2.6 1.6 0 2.6 1 2.6 2.6z"/>
|
||||
<path d="M12 7.6c0-1.6 1-2.6 2.6-2.6 0 1.6-1 2.6-2.6 2.6z"/>
|
||||
<path d="M16 11V8.6"/>
|
||||
<path d="M16 9.4c0-1.5.9-2.4 2.4-2.4 0 1.5-.9 2.4-2.4 2.4z"/>
|
||||
<rect x="3.5" y="11" width="17" height="8.4" rx=".8"/>
|
||||
<path d="M3.5 13.8h17M3.5 16.6h17" stroke-width="1.1"/>
|
||||
<path d="M5.4 19.4v1.9M18.6 19.4v1.9"/>`,
|
||||
};
|
||||
|
||||
function etage(von, bis) {
|
||||
return `
|
||||
<rect x="-8" y="${von}" width="16" height="${bis - von}" class="enf-s-ebene"/>
|
||||
<path d="M-11.5-3L0-11l11.5 8"/>
|
||||
<path d="M-8-5v12.5h16V-5"/>
|
||||
<path d="M-8 1.5h16" class="enf-s-fein"/>
|
||||
<path d="M-12 7.5h24"/>
|
||||
<path d="M-8 7.5v4h16v-4" class="enf-s-fein enf-s-keller"/>`;
|
||||
}
|
||||
|
||||
/* Plaketten am Kreis: Zeichen aus Bootstrap Icons (liegt ohnehin im Dashboard). */
|
||||
const ZEICHEN = {
|
||||
// Codepunkte als Escape: die Zeichen selbst liegen im privaten Bereich
|
||||
// (U+F90C sogar auf einem CJK-Kompatibilitaetszeichen) und sind im Editor
|
||||
// unsichtbar oder werden beim Kopieren leicht verfaelscht.
|
||||
eco: { glyph: "\uF90C", farbe: "#6cbe58" }, // bi-leaf
|
||||
uhr: { glyph: "\uF291", farbe: "#e8a33a" }, // bi-clock-fill
|
||||
manuell: { glyph: "\uF56B", farbe: "#c9a227" }, // bi-sliders
|
||||
stecker: { glyph: "\uF4F6", farbe: "#8fb8de" }, // bi-plug-fill
|
||||
schloss: { glyph: "\uF47A", farbe: "#b9c0c8" }, // bi-lock-fill
|
||||
heizung: { glyph: "\uF7F6", farbe: "#e5624f" }, // bi-fire
|
||||
tank: { glyph: "\uF83D", farbe: "#b9c0c8" }, // bi-fuel-pump-fill
|
||||
warnung: { glyph: "\uF33A", farbe: "#e5624f" }, // bi-exclamation-triangle-fill
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------
|
||||
Hilfen
|
||||
------------------------------------------------------------------------ */
|
||||
function el(name, attr, eltern) {
|
||||
const e = document.createElementNS(NS, name);
|
||||
if (attr) for (const k in attr) e.setAttribute(k, attr[k]);
|
||||
if (eltern) eltern.appendChild(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
function leistungText(w) {
|
||||
const a = Math.abs(w);
|
||||
if (a < 1000) return Math.round(a) + " W";
|
||||
return (a / 1000).toFixed(a < 10000 ? 2 : 1).replace(".", ",") + " kW";
|
||||
}
|
||||
|
||||
/** Liest mqttData: Werte kommen als Buffer, Zahl und Text wie gehabt. */
|
||||
function leser(daten) {
|
||||
const cache = {};
|
||||
return {
|
||||
text(topic) {
|
||||
const v = daten[topic];
|
||||
return v === undefined ? undefined : String(v);
|
||||
},
|
||||
zahl(topic) {
|
||||
const v = Number(String(daten[topic]));
|
||||
return isFinite(v) ? v : 0;
|
||||
},
|
||||
json(topic) {
|
||||
if (topic in cache) return cache[topic];
|
||||
try { cache[topic] = JSON.parse(String(daten[topic])); } catch (e) { cache[topic] = null; }
|
||||
return cache[topic];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Farbe zwischen kalt und heiß, wie früher assignColor() fürs Pufferbild. */
|
||||
function tempFarbe(t) {
|
||||
const f = Math.max(0, Math.min(1, (t - 20) / 60));
|
||||
const kalt = [35, 137, 186], heiss = [186, 59, 35];
|
||||
return "rgb(" + kalt.map((k, i) => Math.round(k + (heiss[i] - k) * f)).join(",") + ")";
|
||||
}
|
||||
|
||||
let laufendeNummer = 0;
|
||||
|
||||
/* ------------------------------------------------------------------------
|
||||
Die Übersicht
|
||||
------------------------------------------------------------------------ */
|
||||
class Energiefluss {
|
||||
constructor(behaelter, opt = {}) {
|
||||
this.behaelter = behaelter;
|
||||
this.opt = opt;
|
||||
this.bearbeiten = !!opt.bearbeiten;
|
||||
this.nummer = ++laufendeNummer;
|
||||
this.letzte = null;
|
||||
this.setzeEinstellungen(opt.einstellungen || {});
|
||||
|
||||
// Hochkant oder quer - neu zeichnen nur, wenn die Ansicht wechselt.
|
||||
this.beobachter = new ResizeObserver(() => {
|
||||
const hoch = this.hochkantGewuenscht();
|
||||
if (hoch !== this.hochkant) this.zeichnen();
|
||||
});
|
||||
this.beobachter.observe(behaelter);
|
||||
|
||||
// Animationen anhalten, solange niemand hinsieht.
|
||||
this.sichtbar = true;
|
||||
this.sicht = new IntersectionObserver(eintraege => {
|
||||
this.sichtbar = eintraege[0].isIntersecting;
|
||||
this.pausieren();
|
||||
});
|
||||
this.sicht.observe(behaelter);
|
||||
document.addEventListener("visibilitychange", () => this.pausieren());
|
||||
}
|
||||
|
||||
/** Katalog und Abweichungen zu einer Liste zusammenführen. */
|
||||
setzeEinstellungen(einstellungen) {
|
||||
this.einstellungen = einstellungen;
|
||||
this.knoten = ENERGIEFLUSS_KATALOG.knoten.map(k => {
|
||||
const e = einstellungen[k.id] || {};
|
||||
return Object.assign({}, k, {
|
||||
aktiv: k.pflicht || e.aktiv !== false,
|
||||
anzeigeName: e.name || k.name,
|
||||
lage: [e.x ?? k.x, e.y ?? k.y],
|
||||
// Hochkant wird sonst nur x und y getauscht. Wo das die Kreise
|
||||
// ungluecklich stellt, nennt der Katalog eine eigene Lage.
|
||||
hochLage: k.hochX === undefined ? null : [k.hochX, k.hochY],
|
||||
optionenWirksam: Object.assign({}, k.optionen || {}, e.optionen || {}),
|
||||
});
|
||||
});
|
||||
this.nachId = Object.fromEntries(this.knoten.map(k => [k.id, k]));
|
||||
// Wirksamer Elternkreis: der nächste eingeschaltete in der Kette.
|
||||
for (const k of this.knoten) {
|
||||
let e = k.eltern ? this.nachId[k.eltern] : null;
|
||||
while (e && !e.aktiv) e = e.eltern ? this.nachId[e.eltern] : null;
|
||||
k.elternWirksam = e;
|
||||
let tiefe = 0;
|
||||
for (let p = e; p; p = p.elternWirksam) tiefe++;
|
||||
k.tiefe = tiefe;
|
||||
}
|
||||
}
|
||||
|
||||
/** Antippbar: hat ein Ziel und wird nicht gerade bearbeitet. */
|
||||
klickbar(k) {
|
||||
return !!k.klick && !this.bearbeiten;
|
||||
}
|
||||
|
||||
hochkantGewuenscht() {
|
||||
const grenze = this.opt.hochkantUnter ?? HOCHKANT_UNTER;
|
||||
return !this.bearbeiten && this.behaelter.clientWidth > 0 && this.behaelter.clientWidth < grenze;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lage eines Kreises in der aktuellen Ansicht.
|
||||
*
|
||||
* Hochkant werden x und y getauscht - aus einem breiten Bild wird ein
|
||||
* hohes. Ein Kreis mit hochX/hochY im Katalog steht dort stattdessen,
|
||||
* wo der Katalog ihn hinstellt; verschieben im Bearbeitungsmodus (den es
|
||||
* nur quer gibt) laesst diese Lage unberuehrt.
|
||||
*/
|
||||
punkt(k) {
|
||||
if (this.hochkant) return k.hochLage || [k.lage[1], k.lage[0]];
|
||||
return [k.lage[0], k.lage[1]];
|
||||
}
|
||||
|
||||
radius(k) {
|
||||
return k.gross ? RADIUS_GROSS : RADIUS;
|
||||
}
|
||||
|
||||
sichtbareKnoten() {
|
||||
return this.knoten.filter(k => k.aktiv || this.bearbeiten);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- Zeichnen */
|
||||
zeichnen() {
|
||||
this.hochkant = this.hochkantGewuenscht();
|
||||
this.behaelter.innerHTML = "";
|
||||
const svg = this.svg = el("svg", {
|
||||
class: "enf" + (this.bearbeiten ? " enf-bearbeiten" : "") + (this.hochkant ? " enf-hoch" : ""),
|
||||
role: "img", "aria-label": "Energiefluss",
|
||||
}, this.behaelter);
|
||||
this.ebeneFluss = el("g", { class: "enf-fluesse" }, svg);
|
||||
this.ebeneKnoten = el("g", { class: "enf-knotenebene" }, svg);
|
||||
|
||||
this.elemente = {};
|
||||
for (const k of this.sichtbareKnoten()) this.knotenZeichnen(k);
|
||||
for (const k of this.sichtbareKnoten()) if (k.aktiv && k.elternWirksam) this.flussZeichnen(k);
|
||||
this.rahmenSetzen();
|
||||
this.pausieren();
|
||||
|
||||
if (this.letzte) {
|
||||
const d = this.letzte;
|
||||
this.letzte = null;
|
||||
this.aktualisieren(d);
|
||||
}
|
||||
if (this.opt.onGezeichnet) this.opt.onGezeichnet();
|
||||
}
|
||||
|
||||
rahmenSetzen() {
|
||||
const ks = this.sichtbareKnoten();
|
||||
if (!ks.length) return;
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const k of ks) {
|
||||
const [x, y] = this.punkt(k), r = this.radius(k);
|
||||
x0 = Math.min(x0, x - r - 20); x1 = Math.max(x1, x + r + 20);
|
||||
y0 = Math.min(y0, y - r - 20); y1 = Math.max(y1, y + r + 56); // Name unter dem Kreis
|
||||
}
|
||||
this.svg.setAttribute("viewBox",
|
||||
[x0 - RAND / 2, y0 - RAND / 2, x1 - x0 + RAND, y1 - y0 + RAND].map(Math.round).join(" "));
|
||||
}
|
||||
|
||||
knotenZeichnen(k) {
|
||||
const [x, y] = this.punkt(k);
|
||||
const r = this.radius(k);
|
||||
const id = "enf" + this.nummer + "-" + k.id;
|
||||
const g = el("g", {
|
||||
class: "enf-knoten" + (k.aktiv ? "" : " enf-aus") + (this.klickbar(k) ? " enf-klickbar" : ""),
|
||||
transform: `translate(${x} ${y})`,
|
||||
style: `--farbe:${k.farbe};--verz:${k.tiefe * 110}ms`,
|
||||
"data-knoten": k.id,
|
||||
}, this.ebeneKnoten);
|
||||
|
||||
const koerper = el("g", { class: "enf-koerper" }, g);
|
||||
el("circle", { r: r + 17, class: "enf-hof" }, koerper);
|
||||
el("circle", { r, class: "enf-spur" }, koerper);
|
||||
const ring = el("g", { class: "enf-ring", transform: "rotate(-90)" }, koerper);
|
||||
el("circle", { r: r - 11, class: "enf-scheibe" }, koerper);
|
||||
|
||||
// Füllstand (Batterie, Auto): Welle, die mit dem Stand steigt.
|
||||
let fuellung = null;
|
||||
if (k.symbol === "akku" || k.symbol === "wallbox") {
|
||||
el("clipPath", { id: id + "-innen" }, el("defs", null, koerper))
|
||||
.appendChild(el("circle", { r: r - 11 }));
|
||||
const clip = el("g", { "clip-path": `url(#${id}-innen)` }, koerper);
|
||||
// Die Welle ist in Bögen der Breite ri gezeichnet, eine Periode sind
|
||||
// zwei davon. Die CSS-Animation schiebt um genau 2 * --r - stimmen
|
||||
// beide nicht überein, springt die Welle am Ende jeder Runde.
|
||||
const ri = r - 11;
|
||||
fuellung = el("g", { class: "enf-fuellung", style: `--stand:0;--r:${ri}` }, clip);
|
||||
const b = ri * 4;
|
||||
const bogen = `M${-b} 0 q${ri / 2} -9 ${ri} 0` + ` t${ri} 0`.repeat(7);
|
||||
el("path", { class: "enf-welle", d: bogen + ` V${ri * 2} H${-b} Z` }, fuellung);
|
||||
// Dieselbe Linie noch einmal als Kante: die Flaeche allein ist so
|
||||
// blass, dass man den Ladestand suchen muss. Die Kante zeichnet die
|
||||
// Wasserlinie scharf nach.
|
||||
el("path", { class: "enf-welle-kante", d: bogen }, fuellung);
|
||||
}
|
||||
|
||||
const symbol = el("g", {
|
||||
class: "enf-symbol enf-sym-" + k.symbol,
|
||||
transform: `translate(0 ${-r * 0.43}) scale(${k.gross ? 1.95 : 1.75})`,
|
||||
}, koerper);
|
||||
symbol.innerHTML = SYMBOLE[k.symbol]().replace(/\{id\}/g, id);
|
||||
|
||||
// Die kleine Zeile sitzt dicht unter dem Wert: weiter unten wird die
|
||||
// Scheibe schmal, und "89 % entlädt" stößt an den Rand.
|
||||
const wert = el("text", { class: "enf-wert", y: r * 0.2 + 6 }, koerper);
|
||||
const neben = el("text", { class: "enf-neben", y: r * 0.2 + 26 }, koerper);
|
||||
const name = el("text", { class: "enf-name", y: r + 46 }, g);
|
||||
name.textContent = k.anzeigeName;
|
||||
|
||||
const zeichen = el("g", { class: "enf-zeichen" }, koerper);
|
||||
|
||||
if (this.klickbar(k)) {
|
||||
const t = el("g", { class: "enf-tipp", transform: `translate(${r * 0.72} ${-r * 0.72})` }, koerper);
|
||||
el("circle", { r: 15 }, t);
|
||||
el("path", { d: "M-3.5-6l6 6-6 6" }, t);
|
||||
g.setAttribute("tabindex", "0");
|
||||
g.setAttribute("role", "button");
|
||||
g.setAttribute("aria-label", k.anzeigeName + " – Details");
|
||||
const los = () => this.opt.onKlick && this.opt.onKlick(k);
|
||||
g.addEventListener("click", los);
|
||||
g.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); los(); }
|
||||
});
|
||||
}
|
||||
|
||||
// Bewässerung: Zonen als Zeilen im Kreis. Die Ids gehören solarMQTT.js
|
||||
// und werden nur außerhalb der Einstellungsvorschau vergeben.
|
||||
if (k.zonen) {
|
||||
wert.remove(); neben.remove();
|
||||
symbol.setAttribute("transform", `translate(0 ${-r * 0.5}) scale(1.4)`);
|
||||
k.zonen.forEach((z, i) => {
|
||||
const zy = -r * 0.06 + i * 20;
|
||||
// Eigenes Symbol (ZONENBILDER) oder ein Zeichen aus Bootstrap Icons.
|
||||
// updateWateringIndicator() färbt es über color und schreibt den
|
||||
// Tooltip in das <title>.
|
||||
let glyph;
|
||||
if (z.bild && ZONENBILDER[z.bild]) {
|
||||
glyph = el("g", { class: "enf-zone-bild", transform: `translate(${(-r * 0.52 - 9.6).toFixed(1)} ${(zy - 9.6).toFixed(1)}) scale(.8)` }, koerper);
|
||||
glyph.innerHTML = ZONENBILDER[z.bild];
|
||||
} else {
|
||||
glyph = el("text", { class: "enf-zone-glyph", x: -r * 0.52, y: zy }, koerper);
|
||||
glyph.textContent = z.zeichen || "";
|
||||
}
|
||||
el("title", null, glyph).textContent = z.name;
|
||||
const nm = el("text", { class: "enf-zone-name", x: -r * 0.36, y: zy }, koerper);
|
||||
nm.textContent = z.name;
|
||||
const st = el("text", { class: "enf-zone-status", x: r * 0.6, y: zy }, koerper);
|
||||
st.textContent = "Aus";
|
||||
if (!this.bearbeiten) { glyph.id = z.symbol; st.id = z.status; }
|
||||
});
|
||||
}
|
||||
|
||||
if (this.bearbeiten) this.ziehbarMachen(g, k);
|
||||
|
||||
this.elemente[k.id] = { g, ring, wert, neben, zeichen, symbol, fuellung, fluss: null, segmente: [] };
|
||||
}
|
||||
|
||||
flussPfad(k) {
|
||||
const e = k.elternWirksam;
|
||||
const a = this.punkt(e), b = this.punkt(k);
|
||||
// Knick: quer vom Elternkreis senkrecht los, dann waagerecht in den Kreis
|
||||
// (Ecke x vom Eltern, y vom Kind); hochkant genau umgekehrt.
|
||||
let c;
|
||||
if (Math.abs(a[0] - b[0]) < 30 || Math.abs(a[1] - b[1]) < 30) {
|
||||
c = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
} else {
|
||||
c = this.hochkant ? [b[0], a[1]] : [a[0], b[1]];
|
||||
}
|
||||
const rand = (p, q, r) => {
|
||||
const dx = q[0] - p[0], dy = q[1] - p[1], l = Math.hypot(dx, dy) || 1;
|
||||
return [p[0] + dx / l * (r + 20), p[1] + dy / l * (r + 20)];
|
||||
};
|
||||
const s = rand(a, c, this.radius(e)), z = rand(b, c, this.radius(k));
|
||||
return `M${s[0].toFixed(1)} ${s[1].toFixed(1)} Q${c[0]} ${c[1]} ${z[0].toFixed(1)} ${z[1].toFixed(1)}`;
|
||||
}
|
||||
|
||||
flussZeichnen(k) {
|
||||
const d = this.flussPfad(k);
|
||||
const g = el("g", { class: "enf-fluss enf-still", style: `--farbe:${k.farbe};--verz:${k.tiefe * 110 + 150}ms` }, this.ebeneFluss);
|
||||
// Das Bett gibt es nur beim Bearbeiten: dort zeigt es, was mit was
|
||||
// verbunden ist, waehrend man die Kreise schiebt. In der Ansicht
|
||||
// erzaehlen die laufenden Punkte den Weg, und wo nichts fliesst, soll
|
||||
// auch keine Linie stehen.
|
||||
const bett = this.bearbeiten ? el("path", { d, class: "enf-bett", pathLength: "1" }, g) : null;
|
||||
const hof = el("path", { d, class: "enf-strom-hof" }, g);
|
||||
const strom = el("path", { d, class: "enf-strom" }, g);
|
||||
this.elemente[k.id].fluss = { g, bett, hof, strom };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ Aktualisieren */
|
||||
aktualisieren(daten) {
|
||||
this.letzte = daten;
|
||||
if (!this.svg) return;
|
||||
const m = leser(daten);
|
||||
for (const k of this.sichtbareKnoten()) {
|
||||
const e = this.elemente[k.id];
|
||||
if (!e) continue;
|
||||
let z;
|
||||
try {
|
||||
z = k.lesen(m, k.optionenWirksam) || {};
|
||||
} catch (fehler) {
|
||||
console.warn("Energiefluss: " + k.id, fehler);
|
||||
continue;
|
||||
}
|
||||
this.knotenSetzen(k, e, z);
|
||||
if (e.fluss) this.flussSetzen(e.fluss, z.fluss || 0);
|
||||
}
|
||||
}
|
||||
|
||||
knotenSetzen(k, e, z) {
|
||||
// Werte
|
||||
const wert = z.wert !== undefined ? z.wert : leistungText(z.leistung || 0);
|
||||
if (e.wert.isConnected && e.wert.textContent !== wert) e.wert.textContent = wert;
|
||||
const neben = z.neben || "";
|
||||
if (e.neben.isConnected && e.neben.textContent !== neben) e.neben.textContent = neben;
|
||||
e.g.classList.toggle("enf-ruhe", !!z.ruhe && k.aktiv);
|
||||
|
||||
// Ring: Segmente in Hundertsteln des Umfangs
|
||||
let segmente = z.segmente || (z.leistung !== undefined ? [{ w: Math.abs(z.leistung), farbe: k.farbe }] : []);
|
||||
segmente = this.segmenteZusammenfassen(segmente);
|
||||
const skala = k.skala || 15000;
|
||||
while (e.segmente.length < segmente.length) {
|
||||
e.segmente.push(el("circle", { r: this.radius(k), class: "enf-bogen", pathLength: "100" }, e.ring));
|
||||
}
|
||||
// Gerechnet wird ueber die Summe, nicht je Segment: die gestauchte
|
||||
// Skala ist nicht additiv, sonst reichten drei Viertel-Segmente weiter
|
||||
// als ein volles.
|
||||
let start = 0, summe = 0;
|
||||
e.segmente.forEach((kreis, i) => {
|
||||
const s = segmente[i];
|
||||
let laenge = 0;
|
||||
if (s) {
|
||||
const vorher = ringAnteil(summe, skala) * 100;
|
||||
summe += Math.max(0, s.w);
|
||||
laenge = Math.max(0, Math.min(100 - start, ringAnteil(summe, skala) * 100 - vorher));
|
||||
}
|
||||
if (laenge < 0.4) laenge = 0;
|
||||
const luecke = laenge > 0 && start > 0 ? 0.6 : 0;
|
||||
const a = (laenge - luecke).toFixed(2), off = (-(start + luecke)).toFixed(2);
|
||||
const schluessel = a + "|" + off + "|" + (s ? s.farbe : "");
|
||||
if (kreis._stand !== schluessel) {
|
||||
kreis._stand = schluessel;
|
||||
kreis.style.strokeDasharray = `${Math.max(0, a)} 100`;
|
||||
kreis.style.strokeDashoffset = off;
|
||||
if (s) kreis.style.stroke = s.farbe;
|
||||
}
|
||||
start += laenge;
|
||||
});
|
||||
|
||||
// Zustand des Symbols
|
||||
const zu = z.zustand || {};
|
||||
e.g.classList.toggle("enf-an", !!zu.an);
|
||||
e.g.classList.toggle("enf-laden", !!zu.laden);
|
||||
// Ohne gemeldeten Stand bleibt die Fuellung ganz weg: sonst stuende
|
||||
// bei einer Wallbox ohne Auto eine Wasserlinie am unteren Rand.
|
||||
e.g.classList.toggle("enf-stand", zu.stand !== undefined && isFinite(zu.stand));
|
||||
if (zu.stand !== undefined && isFinite(zu.stand)) {
|
||||
const s = Math.max(0, Math.min(1, zu.stand)).toFixed(3);
|
||||
if (e.g.style.getPropertyValue("--stand") !== s) {
|
||||
e.g.style.setProperty("--stand", s);
|
||||
if (e.fuellung) e.fuellung.style.setProperty("--stand", s);
|
||||
}
|
||||
}
|
||||
if (zu.temps) {
|
||||
zu.temps.forEach((t, i) => {
|
||||
const r = e.symbol.querySelector(".enf-s-t" + i);
|
||||
const f = tempFarbe(t);
|
||||
if (r && r._f !== f) { r._f = f; r.style.fill = f; }
|
||||
});
|
||||
}
|
||||
|
||||
// Plaketten
|
||||
const zeichen = z.zeichen || [];
|
||||
const sig = zeichen.map(x => x.art + ":" + (x.text || "") + ":" + (x.titel || "")).join("|");
|
||||
if (e.zeichen._sig !== sig) {
|
||||
e.zeichen._sig = sig;
|
||||
this.zeichenSetzen(k, e.zeichen, zeichen);
|
||||
}
|
||||
}
|
||||
|
||||
/** Anteile ausgeschalteter Kreise fallen ins letzte Segment. */
|
||||
segmenteZusammenfassen(segmente) {
|
||||
const aus = segmente.filter(s => s.knoten && this.nachId[s.knoten] && !this.nachId[s.knoten].aktiv);
|
||||
if (!aus.length) return segmente;
|
||||
const rest = segmente.filter(s => !aus.includes(s)).map(s => Object.assign({}, s));
|
||||
if (rest.length) rest[rest.length - 1].w += aus.reduce((a, s) => a + Math.max(0, s.w), 0);
|
||||
return rest;
|
||||
}
|
||||
|
||||
zeichenSetzen(k, gruppe, zeichen) {
|
||||
gruppe.innerHTML = "";
|
||||
const r = this.radius(k);
|
||||
zeichen.forEach((z, i) => {
|
||||
const art = ZEICHEN[z.art];
|
||||
if (!art) return;
|
||||
// Außen am Ring rechts unten, eine Plakette je 36 Grad - so bleiben
|
||||
// Wert und Zeile darunter frei.
|
||||
const winkel = (88 + i * 36) * Math.PI / 180;
|
||||
const px = Math.sin(winkel) * (r + 12), py = -Math.cos(winkel) * (r + 12);
|
||||
const p = el("g", { class: "enf-plakette", transform: `translate(${px.toFixed(1)} ${py.toFixed(1)})`, style: `--pfarbe:${art.farbe};--i:${i}` }, gruppe);
|
||||
if (z.titel) el("title", null, p).textContent = z.titel;
|
||||
if (z.text) {
|
||||
const breite = 26 + z.text.length * 10;
|
||||
el("rect", { x: -16, y: -16, width: breite, height: 32, rx: 16 }, p);
|
||||
el("text", { class: "enf-plakette-text", x: 12, y: 1 }, p).textContent = z.text;
|
||||
} else {
|
||||
el("circle", { r: 16 }, p);
|
||||
}
|
||||
el("text", { class: "enf-plakette-glyph", x: 0, y: 1 }, p).textContent = art.glyph;
|
||||
});
|
||||
}
|
||||
|
||||
flussSetzen(f, w) {
|
||||
const a = Math.abs(w);
|
||||
const still = a < 15;
|
||||
// Die Punktgroesse soll die Leistung erzaehlen: von 2,4 (ein paar
|
||||
// hundert Watt) bis 13 (Volllast). Vorher lag alles ab 3 kW dicht
|
||||
// beieinander.
|
||||
const breite = still ? 0 : 2.4 + 11 * (1 - Math.exp(-a / 5000));
|
||||
// Schneller bei mehr Leistung: 2,6 s je Abschnitt bei wenig, 0,7 s bei viel.
|
||||
// Gesetzt wird nicht die Dauer der CSS-Animation - eine neue Dauer
|
||||
// rechnet die Position neu, und die Punkte springen -, sondern ihre
|
||||
// Abspielgeschwindigkeit; die behält die Position.
|
||||
const tempo = still ? 1 : 1 / (0.7 + 1.9 * Math.exp(-a / 3000));
|
||||
const sig = still ? "still" : breite.toFixed(1) + "|" + (w < 0);
|
||||
if (f._sig !== sig) {
|
||||
f._sig = sig;
|
||||
f.g.classList.toggle("enf-still", still);
|
||||
f.g.classList.toggle("enf-rueck", w < 0);
|
||||
f.g.style.setProperty("--breite", breite.toFixed(1));
|
||||
}
|
||||
if (still) return;
|
||||
// Nach dem Wechsel aus "still" entsteht die Animation erst beim nächsten
|
||||
// Stilabgleich - deshalb im nächsten Bild.
|
||||
requestAnimationFrame(() => {
|
||||
for (const pfad of [f.strom, f.hof]) {
|
||||
for (const anim of pfad.getAnimations()) {
|
||||
if (Math.abs(anim.playbackRate - tempo) < 0.02) continue;
|
||||
if (anim.updatePlaybackRate) anim.updatePlaybackRate(tempo);
|
||||
else anim.playbackRate = tempo;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pausieren() {
|
||||
if (!this.svg) return;
|
||||
this.svg.classList.toggle("enf-pause", !this.sichtbar || document.hidden);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Bearbeiten */
|
||||
ziehbarMachen(g, k) {
|
||||
g.classList.add("enf-ziehbar");
|
||||
g.addEventListener("pointerdown", ev => {
|
||||
ev.preventDefault();
|
||||
g.setPointerCapture(ev.pointerId);
|
||||
const pt = this.svg.createSVGPoint();
|
||||
const svgPunkt = e2 => {
|
||||
pt.x = e2.clientX; pt.y = e2.clientY;
|
||||
return pt.matrixTransform(this.svg.getScreenCTM().inverse());
|
||||
};
|
||||
const start = svgPunkt(ev);
|
||||
const [x0, y0] = k.lage;
|
||||
g.classList.add("enf-zieht");
|
||||
const bewegen = e2 => {
|
||||
const p = svgPunkt(e2);
|
||||
k.lage = [Math.round((x0 + p.x - start.x) / 10) * 10, Math.round((y0 + p.y - start.y) / 10) * 10];
|
||||
g.setAttribute("transform", `translate(${k.lage[0]} ${k.lage[1]})`);
|
||||
for (const kind of this.knoten) {
|
||||
const f = this.elemente[kind.id] && this.elemente[kind.id].fluss;
|
||||
if (f && (kind === k || kind.elternWirksam === k)) {
|
||||
const d = this.flussPfad(kind);
|
||||
if (f.bett) f.bett.setAttribute("d", d);
|
||||
f.hof.setAttribute("d", d); f.strom.setAttribute("d", d);
|
||||
}
|
||||
}
|
||||
};
|
||||
const ende = () => {
|
||||
g.removeEventListener("pointermove", bewegen);
|
||||
g.classList.remove("enf-zieht");
|
||||
this.rahmenSetzen();
|
||||
if (this.opt.onVerschoben) this.opt.onVerschoben(k.id, k.lage[0], k.lage[1]);
|
||||
};
|
||||
g.addEventListener("pointermove", bewegen);
|
||||
g.addEventListener("pointerup", ende, { once: true });
|
||||
g.addEventListener("pointercancel", ende, { once: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.Energiefluss = Energiefluss;
|
||||
// Auch die Bedienfenster (bedienfenster.js) zeigen die Zonen mit diesen Symbolen.
|
||||
window.ENERGIEFLUSS_ZONENBILDER = ZONENBILDER;
|
||||
})();
|
||||
@@ -0,0 +1,279 @@
|
||||
/* ==========================================================================
|
||||
Reiter "Übersicht": die Energiefluss-Übersicht an dieses Haus anpassen.
|
||||
|
||||
Links die Übersicht selbst im Bearbeitungsmodus (energiefluss.js) mit
|
||||
Beispielwerten, rechts je Kreis Schalter und Name, beim PV-Kreis die
|
||||
Flächen. Verschoben wird in der Vorschau. Gespeichert wird der ganze Stand
|
||||
auf einmal (restricted/energiefluss.php hält davon nur die Abweichungen).
|
||||
|
||||
Nutzt textSchuetzen() aus settings.js.
|
||||
========================================================================== */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Beispielwerte: ein sonniger Nachmittag, Auto lädt im EG, Heizstab läuft.
|
||||
const BEISPIEL = {
|
||||
"solarManager/P_PV": "8400", "solarManager/P_PVn": JSON.stringify([2600, 1300, 130, 130, 140, 140, 130, 130, 135, 135, 130, 130, 120, 120,
|
||||
155, 150, 152, 158, 150, 149, 150, 154, 151, 150, 153, 155, 150, 92, 150, 152, 151, 150, 149, 0]),
|
||||
"solarManager/P_Grid": "-900", "solarManager/i_l1evu": "-1.3", "solarManager/i_l2evu": "-1.4", "solarManager/i_l3evu": "-1.2",
|
||||
"solarManager/P_Akku": "-2100", "solarManager/SOC": "64", "solarManager/crgMaxPct": "100",
|
||||
"solarManager/autarky": "100", "solarManager/eff": "96",
|
||||
"solarManager/P_Load": "-5400", "solarManager/og": "-350", "solarManager/eg": "3720", "solarManager/ug": "270",
|
||||
"solarManager/pHeat": "1000", "solarManager/t_buffT": "61", "solarManager/t_buffM": "48", "solarManager/t_buffB": "35",
|
||||
"solarManager/heatMode": "eco", "solarManager/heatOG": "1", "solarManager/heatEG": "0",
|
||||
"solarManager/evPower": "3.2", "solarManager/evSOC": "78", "solarManager/evFuel": "0", "solarManager/evMode": "Eco",
|
||||
"solarManager/evPlug": "1", "solarManager/evLock": "1", "solarManager/carRemChrg": "95",
|
||||
"solarManager/evPowerOG": "0", "solarManager/evPlugOG": "0", "solarManager/evModeOG": "Eco",
|
||||
};
|
||||
|
||||
let bild = null;
|
||||
let gespeichert = {}; // Stand der Datenbank
|
||||
let stand = {}; // Stand der Maske
|
||||
let geladen = false;
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const katalog = () => ENERGIEFLUSS_KATALOG.knoten;
|
||||
const kopie = o => JSON.parse(JSON.stringify(o));
|
||||
|
||||
function hinweis(text, klasse) {
|
||||
const ziel = $("energieflussHinweis");
|
||||
if (!ziel) return;
|
||||
ziel.className = "small " + (klasse || "text-body-secondary");
|
||||
ziel.textContent = text;
|
||||
}
|
||||
|
||||
function geaendert() {
|
||||
const sauber = s => JSON.stringify(Object.keys(s).sort().map(k => [k, s[k]]));
|
||||
const anders = sauber(normalisiert(stand)) !== sauber(normalisiert(gespeichert));
|
||||
hinweis(anders ? "Nicht gespeichert." : "", anders ? "text-warning" : "");
|
||||
}
|
||||
|
||||
/** Nur echte Abweichungen, in fester Form - zum Vergleichen und Schicken. */
|
||||
function normalisiert(s) {
|
||||
const aus = {};
|
||||
for (const k of katalog()) {
|
||||
const e = s[k.id] || {};
|
||||
const eintrag = {
|
||||
aktiv: k.pflicht ? true : e.aktiv !== false,
|
||||
name: (e.name || "").trim() || null,
|
||||
x: e.x ?? null, y: e.y ?? null,
|
||||
optionen: e.optionen || null,
|
||||
};
|
||||
if (eintrag.x === k.x && eintrag.y === k.y) { eintrag.x = null; eintrag.y = null; }
|
||||
if (!eintrag.aktiv || eintrag.name || eintrag.x !== null || eintrag.optionen) aus[k.id] = eintrag;
|
||||
}
|
||||
return aus;
|
||||
}
|
||||
|
||||
function vorschauZeichnen() {
|
||||
if (!bild) {
|
||||
bild = new Energiefluss($("energieflussVorschau"), {
|
||||
bearbeiten: true,
|
||||
einstellungen: stand,
|
||||
onVerschoben(id, x, y) {
|
||||
stand[id] = Object.assign({}, stand[id], { x, y });
|
||||
listeZeichnen();
|
||||
geaendert();
|
||||
},
|
||||
});
|
||||
}
|
||||
bild.setzeEinstellungen(normalisiert(stand));
|
||||
bild.zeichnen();
|
||||
bild.aktualisieren(BEISPIEL);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Liste */
|
||||
function listeZeichnen() {
|
||||
const ziel = $("energieflussListe");
|
||||
const wirksam = bild ? bild.nachId : {};
|
||||
ziel.innerHTML = katalog().map(k => {
|
||||
const e = stand[k.id] || {};
|
||||
const aktiv = k.pflicht || e.aktiv !== false;
|
||||
const w = wirksam[k.id];
|
||||
const eltern = w && w.elternWirksam;
|
||||
const elternName = eltern ? (stand[eltern.id] && stand[eltern.id].name) || eltern.titel || eltern.name : null;
|
||||
const verschoben = e.x != null && (e.x !== k.x || e.y !== k.y);
|
||||
const flaechen = k.id === "pv" && aktiv ? flaechenZeichnen(k, e) : "";
|
||||
return `
|
||||
<div class="enf-e-zeile${aktiv ? "" : " enf-e-aus"}" data-id="${k.id}">
|
||||
<div class="form-check form-switch m-0" title="${k.pflicht ? "Gehört immer dazu" : "Ein- oder ausschalten"}">
|
||||
<input class="form-check-input" type="checkbox" id="enf-aktiv-${k.id}" data-feld="aktiv"
|
||||
${aktiv ? "checked" : ""} ${k.pflicht ? "disabled" : ""}>
|
||||
</div>
|
||||
<label class="enf-e-name" for="enf-name-${k.id}">
|
||||
<span class="enf-e-punkt" style="--farbe:${k.farbe}"></span>
|
||||
<input class="form-control form-control-sm" id="enf-name-${k.id}" data-feld="name" maxlength="40"
|
||||
placeholder="${textSchuetzen(k.name)}" title="${textSchuetzen(k.titel || k.name)}" value="${textSchuetzen(e.name || "")}" ${aktiv ? "" : "disabled"}>
|
||||
</label>
|
||||
<span class="enf-e-eltern small text-body-secondary">${elternName ? "an " + textSchuetzen(elternName) : k.eltern ? "" : "frei"}</span>
|
||||
<button type="button" class="btn btn-sm btn-link text-body-secondary p-0" data-aktion="lage"
|
||||
title="Lage aus dem Katalog" ${verschoben ? "" : "hidden"}><i class="bi bi-arrow-counterclockwise"></i></button>
|
||||
${flaechen}
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function flaechenZeichnen(k, e) {
|
||||
const liste = (e.optionen && e.optionen.flaechen) || k.optionen.flaechen;
|
||||
const eigen = !!(e.optionen && e.optionen.flaechen);
|
||||
return `
|
||||
<div class="enf-e-flaechen">
|
||||
<div class="small text-body-secondary mb-1">
|
||||
Flächen im Sonnenring – welche Einträge von <code>P_PVn</code> dazugehören
|
||||
${eigen ? `<button type="button" class="btn btn-sm btn-link p-0 ms-2" data-aktion="flaechen-zurueck">Katalog</button>` : ""}
|
||||
</div>
|
||||
${liste.map((f, i) => `
|
||||
<div class="enf-e-flaeche" data-index="${i}">
|
||||
<input type="color" class="form-control form-control-sm form-control-color" data-flaeche="farbe" value="${f.farbe}" title="Farbe">
|
||||
<input class="form-control form-control-sm" data-flaeche="name" value="${textSchuetzen(f.name)}" placeholder="Name" maxlength="20">
|
||||
<input class="form-control form-control-sm" type="number" min="0" max="99" data-flaeche="von" value="${f.von}" title="erster Eintrag">
|
||||
<input class="form-control form-control-sm" type="number" min="0" max="99" data-flaeche="bis" value="${f.bis}" title="letzter Eintrag">
|
||||
<button type="button" class="btn btn-sm btn-link text-body-secondary p-0" data-aktion="flaeche-weg" title="Fläche entfernen"><i class="bi bi-x-lg"></i></button>
|
||||
</div>`).join("")}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" data-aktion="flaeche-neu"><i class="bi bi-plus-lg me-1"></i>Fläche</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function flaechenLesen(zeile) {
|
||||
return [...zeile.querySelectorAll(".enf-e-flaeche")].map(r => ({
|
||||
name: r.querySelector('[data-flaeche="name"]').value.trim(),
|
||||
von: Math.max(0, parseInt(r.querySelector('[data-flaeche="von"]').value, 10) || 0),
|
||||
bis: Math.max(0, parseInt(r.querySelector('[data-flaeche="bis"]').value, 10) || 0),
|
||||
farbe: r.querySelector('[data-flaeche="farbe"]').value,
|
||||
}));
|
||||
}
|
||||
|
||||
function flaechenSetzen(id, flaechen) {
|
||||
const e = stand[id] = Object.assign({}, stand[id]);
|
||||
e.optionen = Object.assign({}, e.optionen, { flaechen });
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- Ereignisse */
|
||||
function binden() {
|
||||
const liste = $("energieflussListe");
|
||||
|
||||
liste.addEventListener("change", ev => {
|
||||
const zeile = ev.target.closest(".enf-e-zeile");
|
||||
if (!zeile) return;
|
||||
const id = zeile.dataset.id;
|
||||
if (ev.target.dataset.feld === "aktiv") {
|
||||
stand[id] = Object.assign({}, stand[id], { aktiv: ev.target.checked });
|
||||
vorschauZeichnen();
|
||||
listeZeichnen();
|
||||
} else if (ev.target.dataset.flaeche) {
|
||||
flaechenSetzen(id, flaechenLesen(zeile));
|
||||
vorschauZeichnen();
|
||||
}
|
||||
geaendert();
|
||||
});
|
||||
|
||||
liste.addEventListener("input", ev => {
|
||||
const zeile = ev.target.closest(".enf-e-zeile");
|
||||
if (!zeile || ev.target.dataset.feld !== "name") return;
|
||||
stand[zeile.dataset.id] = Object.assign({}, stand[zeile.dataset.id], { name: ev.target.value });
|
||||
clearTimeout(binden._uhr);
|
||||
binden._uhr = setTimeout(vorschauZeichnen, 300);
|
||||
geaendert();
|
||||
});
|
||||
|
||||
liste.addEventListener("click", ev => {
|
||||
const knopf = ev.target.closest("[data-aktion]");
|
||||
if (!knopf) return;
|
||||
const zeile = knopf.closest(".enf-e-zeile");
|
||||
const id = zeile.dataset.id;
|
||||
switch (knopf.dataset.aktion) {
|
||||
case "lage":
|
||||
stand[id] = Object.assign({}, stand[id], { x: null, y: null });
|
||||
break;
|
||||
case "flaeche-neu": {
|
||||
const f = flaechenLesen(zeile);
|
||||
const bis = f.length ? f[f.length - 1].bis + 1 : 0;
|
||||
f.push({ name: "Fläche " + (f.length + 1), von: bis, bis, farbe: "#f7c000" });
|
||||
flaechenSetzen(id, f);
|
||||
break;
|
||||
}
|
||||
case "flaeche-weg": {
|
||||
const f = flaechenLesen(zeile);
|
||||
f.splice(Number(knopf.closest(".enf-e-flaeche").dataset.index), 1);
|
||||
flaechenSetzen(id, f);
|
||||
break;
|
||||
}
|
||||
case "flaechen-zurueck":
|
||||
if (stand[id] && stand[id].optionen) {
|
||||
const o = Object.assign({}, stand[id].optionen);
|
||||
delete o.flaechen;
|
||||
stand[id].optionen = Object.keys(o).length ? o : null;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
vorschauZeichnen();
|
||||
listeZeichnen();
|
||||
geaendert();
|
||||
});
|
||||
|
||||
$("energieflussSpeichern").addEventListener("click", speichern);
|
||||
$("energieflussNeuLaden").addEventListener("click", () => {
|
||||
stand = kopie(gespeichert);
|
||||
vorschauZeichnen(); listeZeichnen(); geaendert();
|
||||
});
|
||||
$("energieflussZuruecksetzen").addEventListener("click", () => {
|
||||
stand = {};
|
||||
vorschauZeichnen(); listeZeichnen(); geaendert();
|
||||
});
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
const knopf = $("energieflussSpeichern");
|
||||
knopf.disabled = true;
|
||||
try {
|
||||
const n = normalisiert(stand);
|
||||
const knoten = Object.keys(n).map(id => Object.assign({ id }, n[id]));
|
||||
const antwort = await fetch("ajax/settings.php?action=energiefluss-save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ knoten }),
|
||||
});
|
||||
const d = await antwort.json();
|
||||
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||||
gespeichert = d.einstellungen || {};
|
||||
stand = kopie(gespeichert);
|
||||
vorschauZeichnen(); listeZeichnen();
|
||||
hinweis("Gespeichert – die Solar-Seite zeigt es beim nächsten Laden.", "text-success");
|
||||
} catch (e) {
|
||||
hinweis(e.message, "text-danger");
|
||||
} finally {
|
||||
knopf.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function laden() {
|
||||
if (geladen) return;
|
||||
geladen = true;
|
||||
try {
|
||||
const antwort = await fetch("ajax/settings.php?action=energiefluss", { cache: "no-store" });
|
||||
const d = await antwort.json();
|
||||
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||||
gespeichert = d.einstellungen || {};
|
||||
stand = kopie(gespeichert);
|
||||
binden();
|
||||
vorschauZeichnen();
|
||||
listeZeichnen();
|
||||
} catch (e) {
|
||||
geladen = false;
|
||||
$("energieflussListe").innerHTML = '<div class="alert alert-danger mb-0">Übersicht nicht abrufbar: '
|
||||
+ textSchuetzen(e.message) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
if (!$("energieflussVorschau") || typeof Energiefluss === "undefined") return;
|
||||
// Erst zeichnen, wenn der Reiter offen ist - verborgen hat die Vorschau keine Größe.
|
||||
const knopf = $("einstellung-energiefluss-tab");
|
||||
if (knopf) knopf.addEventListener("shown.bs.tab", laden);
|
||||
if ($("einstellung-energiefluss").classList.contains("active")) laden();
|
||||
// Ein Anker in der Adresse öffnet den Reiter erst nach DOMContentLoaded.
|
||||
setTimeout(() => { if ($("einstellung-energiefluss").classList.contains("active")) laden(); }, 0);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,319 @@
|
||||
/* ==========================================================================
|
||||
Katalog der Energiefluss-Übersicht (Solar-Seite, Karte "Realtime").
|
||||
|
||||
Jeder Kreis steht hier genau einmal: wie er heißt, wie er aussieht, woran
|
||||
er hängt, wo er steht und woher seine Werte kommen. Gezeichnet wird er von
|
||||
js/solar/energiefluss.js, ohne dass der Renderer einen Kreis beim Namen
|
||||
kennt. Ein neuer Kreis ist also ein neuer Eintrag hier - sonst nichts.
|
||||
|
||||
Was ein Haus davon abweichend einstellt (Kreis aus, eigener Name, eigene
|
||||
Lage, Optionen), steht in homeMesh.energiefluss und wird unter
|
||||
Einstellungen -> Übersicht gepflegt.
|
||||
|
||||
Felder eines Eintrags:
|
||||
id feste Kennung, steht in der Datenbank
|
||||
name Beschriftung unter dem Kreis
|
||||
titel Bezeichnung in den Einstellungen, falls name dort missverständlich ist
|
||||
symbol Schlüssel in ENERGIEFLUSS_SYMBOLE (energiefluss.js)
|
||||
farbe Hauptfarbe: Ring, Symbol, Fluss
|
||||
eltern Kennung des Kreises, mit dem er über einen Fluss verbunden
|
||||
ist; null = frei stehend. Ist der Elternkreis aus, hängt der
|
||||
Kreis am nächsten eingeschalteten darüber.
|
||||
pflicht lässt sich nicht ausschalten (die Mitte des Bildes)
|
||||
gross größerer Kreis
|
||||
x, y Mitte in der Queransicht (Zeichenfläche etwa 1400 x 850)
|
||||
hochX, hochY Mitte in der Hochkant-Ansicht, falls das blosse Tauschen
|
||||
von x und y den Kreis dort ungünstig stellt (optional)
|
||||
skala Leistung in W, bei der der Ring voll ist. Dazwischen ist die
|
||||
Skala gestaucht (ringAnteil() in energiefluss.js): kleine
|
||||
Leistungen bekommen mehr Ring als ihr Anteil
|
||||
klick {ansicht: "anlage"} oder {modal: "heater"} - Ziel beim Antippen
|
||||
optionen Vorgabe für die Optionen, die in den Einstellungen änderbar sind
|
||||
zonen nur Bewässerung: die Anzeigen, die solarMQTT.js beschreibt;
|
||||
je Zone bild (ZONENBILDER in energiefluss.js) oder zeichen
|
||||
lesen(m, opt) liefert den Zustand aus den MQTT-Werten:
|
||||
leistung W, bestimmt den Hauptwert und den Ring
|
||||
wert Text statt der Leistung (optional)
|
||||
neben kleine Zeile unter dem Wert
|
||||
segmente [{w, farbe, knoten?}] statt eines einzigen Rings; knoten =
|
||||
der Anteil gehört zu diesem Kreis und fällt, ist der aus, in
|
||||
das letzte Segment
|
||||
fluss W vom Elternkreis zu diesem hin; negativ = in Gegenrichtung
|
||||
ruhe Kreis blass zeichnen (nichts los)
|
||||
zeichen kleine Plaketten: [{art, text?, titel?}] - art siehe
|
||||
ENERGIEFLUSS_ZEICHEN in energiefluss.js
|
||||
zustand was das Symbol braucht: {an, stand, laden, temps}
|
||||
|
||||
Vorzeichen wie im Manager: P_Grid > 0 Bezug, P_Akku > 0 Entladen,
|
||||
P_Load und og negativ.
|
||||
========================================================================== */
|
||||
|
||||
const ENERGIEFLUSS_KATALOG = (function () {
|
||||
"use strict";
|
||||
|
||||
const kw = w => (Math.abs(w) / 1000).toFixed(1).replace(".", ",");
|
||||
|
||||
/** Flächen des Sonnenrings aus anlageKatalog.js - fehlt der, feste Vorgabe. */
|
||||
function flaechenAusAnlage() {
|
||||
if (typeof ANLAGE_KATALOG === "undefined") {
|
||||
return [
|
||||
{ name: "Dach", von: 0, bis: 1, farbe: "#f7c000" },
|
||||
{ name: "Veranda", von: 2, bis: 13, farbe: "#f78d00" },
|
||||
{ name: "Carport", von: 14, bis: 33, farbe: "#f75a1e" },
|
||||
];
|
||||
}
|
||||
return ANLAGE_KATALOG.flaechen.map(f => {
|
||||
const eingaenge = [
|
||||
...ANLAGE_KATALOG.platten.filter(p => p.flaeche === f.id).map(p => p.eingang),
|
||||
...ANLAGE_KATALOG.strings.filter(s => s.flaeche === f.id).map(s => s.eingang),
|
||||
];
|
||||
return { name: f.name, von: Math.min(...eingaenge), bis: Math.max(...eingaenge), farbe: f.farbe };
|
||||
}).filter(f => isFinite(f.von));
|
||||
}
|
||||
|
||||
const wallboxZeichen = (modus, stecker, gesperrt) => {
|
||||
const z = [];
|
||||
if (modus === "Eco") z.push({ art: "eco", titel: "Eco-Modus" });
|
||||
else if (modus === "Next Trip") z.push({ art: "uhr", titel: "Nächste Fahrt" });
|
||||
else if (modus !== undefined) z.push({ art: "manuell", titel: "Standard-Modus" });
|
||||
if (stecker) z.push({ art: "stecker", titel: "Fahrzeug angesteckt" });
|
||||
if (gesperrt) z.push({ art: "schloss", titel: "Fahrzeug verriegelt" });
|
||||
return z;
|
||||
};
|
||||
|
||||
const knoten = [
|
||||
// ---------------------------------------------------------------- Quellen
|
||||
{
|
||||
id: "pv", name: "Sonne", symbol: "sonne", farbe: "#f7c000",
|
||||
eltern: "wr", x: 180, y: 130, skala: 15000,
|
||||
klick: { ansicht: "anlage" },
|
||||
// Welche Einträge von solarManager/P_PVn zu welcher Fläche gehören.
|
||||
// Vorgabe aus dem Anlagenkatalog, damit die Zuordnung nur einmal
|
||||
// gepflegt wird; in den Einstellungen überschreibbar.
|
||||
optionen: { flaechen: flaechenAusAnlage() },
|
||||
lesen(m, opt) {
|
||||
const pvn = m.json("solarManager/P_PVn") || [];
|
||||
const pv = m.zahl("solarManager/P_PV");
|
||||
const flaechen = (opt.flaechen || []).map(f => {
|
||||
let w = 0;
|
||||
for (let i = f.von; i <= f.bis && i < pvn.length; i++) w += Number(pvn[i]) || 0;
|
||||
return { w, farbe: f.farbe, name: f.name };
|
||||
});
|
||||
return {
|
||||
leistung: pv,
|
||||
segmente: flaechen.length ? flaechen : null,
|
||||
neben: flaechen.map(f => kw(f.w)).join(" / "),
|
||||
fluss: -pv,
|
||||
ruhe: pv < 20,
|
||||
zustand: { an: pv >= 20, stand: Math.min(1, pv / 10000) },
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "netz", name: "Netz", symbol: "netz", farbe: "#9a9aab",
|
||||
eltern: "wr", x: 100, y: 380, skala: 15000,
|
||||
lesen(m) {
|
||||
const netz = m.zahl("solarManager/P_Grid");
|
||||
const a = n => (Math.round(Math.abs(m.zahl("solarManager/i_" + n + "evu")) * 10) / 10).toString().replace(".", ",");
|
||||
return {
|
||||
leistung: Math.abs(netz),
|
||||
neben: a("l1") + " / " + a("l2") + " / " + a("l3") + " A",
|
||||
fluss: -netz,
|
||||
ruhe: Math.abs(netz) < 30,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "akku", name: "Batterie", symbol: "akku", farbe: "#6cbe58",
|
||||
eltern: "wr", x: 180, y: 630, skala: 15000,
|
||||
klick: { ansicht: "speicher" },
|
||||
lesen(m) {
|
||||
const p = m.zahl("solarManager/P_Akku");
|
||||
const soc = m.zahl("solarManager/SOC");
|
||||
const grenze = m.zahl("solarManager/crgMaxPct");
|
||||
const laden = p < -20;
|
||||
const zeichen = [];
|
||||
if (laden && grenze < 100) {
|
||||
zeichen.push({ art: "uhr", text: Math.round(grenze) + " %", titel: "Ladeleistung begrenzt" });
|
||||
}
|
||||
return {
|
||||
leistung: Math.abs(p),
|
||||
neben: Math.round(soc) + " %" + (laden ? " lädt" : p > 20 ? " entlädt" : ""),
|
||||
fluss: -p,
|
||||
ruhe: Math.abs(p) < 20,
|
||||
zeichen,
|
||||
zustand: { stand: soc / 100, laden, an: Math.abs(p) >= 20 },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------ Mitte
|
||||
{
|
||||
id: "wr", name: "Autarkie", titel: "Wechselrichter (Autarkie)", symbol: "wechselrichter", farbe: "#b9c0c8",
|
||||
eltern: null, pflicht: true, gross: true, x: 430, y: 380, skala: 100,
|
||||
lesen(m) {
|
||||
const aut = m.zahl("solarManager/autarky");
|
||||
return {
|
||||
leistung: aut,
|
||||
wert: Math.round(aut) + " %",
|
||||
neben: "Wirkungsgrad " + Math.round(m.zahl("solarManager/eff")) + " %",
|
||||
segmente: [{ w: aut, farbe: "#6cbe58" }],
|
||||
zustand: { an: m.zahl("solarManager/P_PV") >= 20 },
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "verbrauch", name: "Verbrauch", symbol: "verbrauch", farbe: "#71afcd",
|
||||
eltern: "wr", pflicht: true, gross: true, x: 770, y: 380, skala: 15000,
|
||||
lesen(m) {
|
||||
const last = -m.zahl("solarManager/P_Load");
|
||||
const og = -m.zahl("solarManager/og");
|
||||
const eg = m.zahl("solarManager/eg");
|
||||
const ug = m.zahl("solarManager/ug");
|
||||
const heiz = m.zahl("solarManager/pHeat");
|
||||
// "Gemein" ist alles außer den Etagen - der Heizstab hängt mit daran.
|
||||
// Im Text bleibt er drin, im Ring hat er sein eigenes Segment und wird
|
||||
// deshalb vom Allgemeinstrom abgezogen; sonst zählte er doppelt.
|
||||
const gemein = Math.max(0, last - ug - eg - og);
|
||||
const allgemein = Math.max(0, gemein - heiz);
|
||||
return {
|
||||
leistung: last,
|
||||
neben: "Gemein " + kw(gemein) + " kW",
|
||||
segmente: [
|
||||
{ w: og, farbe: "#70ccbb", knoten: "og" },
|
||||
{ w: eg, farbe: "#70cc9c", knoten: "eg" },
|
||||
{ w: ug, farbe: "#83a55b", knoten: "ug" },
|
||||
{ w: heiz, farbe: "#c2614e", knoten: "heizstab" },
|
||||
{ w: allgemein, farbe: "#71afcd" },
|
||||
],
|
||||
fluss: last,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// --------------------------------------------------------------- Etagen
|
||||
{
|
||||
id: "og", name: "Obergeschoss", symbol: "etageOben", farbe: "#70ccbb",
|
||||
eltern: "verbrauch", x: 920, y: 70, skala: 15000,
|
||||
lesen(m) {
|
||||
const w = -m.zahl("solarManager/og");
|
||||
return {
|
||||
leistung: w, fluss: w, ruhe: w < 20,
|
||||
zeichen: m.zahl("solarManager/heatOG") ? [{ art: "heizung", titel: "Heizung an" }] : [],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "eg", name: "Erdgeschoss", symbol: "etageMitte", farbe: "#70cc9c",
|
||||
eltern: "verbrauch", x: 920, y: 670, skala: 15000,
|
||||
lesen(m) {
|
||||
const w = m.zahl("solarManager/eg");
|
||||
const wb = m.zahl("solarManager/evPower") * 1000;
|
||||
return {
|
||||
leistung: w, fluss: w, ruhe: w < 20,
|
||||
segmente: [
|
||||
{ w: Math.max(0, w - wb), farbe: "#70cc9c" },
|
||||
{ w: Math.min(w, wb), farbe: "#60afbf", knoten: "wallboxEG" },
|
||||
],
|
||||
zeichen: m.zahl("solarManager/heatEG") ? [{ art: "heizung", titel: "Heizung an" }] : [],
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ug", name: "Untergeschoss", symbol: "etageUnten", farbe: "#83a55b",
|
||||
// Hochkant steht das UG dort, wo quer der Heizstab sitzt, und umgekehrt:
|
||||
// so stehen die drei Geschosse untereinander.
|
||||
eltern: "verbrauch", x: 600, y: 670, hochX: 380, hochY: 1120, skala: 15000,
|
||||
lesen(m) {
|
||||
const w = m.zahl("solarManager/ug");
|
||||
return { leistung: w, fluss: w, ruhe: w < 20 };
|
||||
},
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------- Verbraucher
|
||||
{
|
||||
id: "heizstab", name: "Heizstab", symbol: "speicher", farbe: "#c2614e",
|
||||
eltern: "verbrauch", x: 1120, y: 380, hochX: 670, hochY: 600, skala: 6000,
|
||||
klick: { modal: "heater" },
|
||||
lesen(m) {
|
||||
const w = m.zahl("solarManager/pHeat");
|
||||
const oben = m.zahl("solarManager/t_buffT");
|
||||
const mitte = m.zahl("solarManager/t_buffM");
|
||||
const unten = m.zahl("solarManager/t_buffB");
|
||||
const eco = m.text("solarManager/heatMode") === "eco";
|
||||
const grad = t => (Math.round(t * 10) / 10).toString().replace(".", ",");
|
||||
return {
|
||||
leistung: w, fluss: w, ruhe: w < 20,
|
||||
neben: grad(oben) + " / " + grad(mitte) + " °C",
|
||||
zeichen: [eco ? { art: "eco", titel: "Automatik (Überschuss)" }
|
||||
: { art: "manuell", titel: w >= 20 ? "Feste Leistung" : "Aus" }],
|
||||
zustand: { an: w >= 20, temps: [oben, mitte, unten] },
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "wallboxOG", name: "Wallbox OG", symbol: "wallbox", farbe: "#4b71a6",
|
||||
eltern: "og", x: 1220, y: 70, skala: 11000,
|
||||
klick: { modal: "CarOG" },
|
||||
lesen(m) {
|
||||
const w = m.zahl("solarManager/evPowerOG") * 1000;
|
||||
const stecker = m.zahl("solarManager/evPlugOG") > 0;
|
||||
return {
|
||||
leistung: w, fluss: w, ruhe: w < 50 && !stecker,
|
||||
neben: w >= 50 ? "lädt" : stecker ? "angesteckt" : "kein Auto",
|
||||
zeichen: wallboxZeichen(m.text("solarManager/evModeOG"), stecker, false),
|
||||
zustand: { an: w >= 50 },
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "wallboxEG", name: "Wallbox EG", symbol: "wallbox", farbe: "#60afbf",
|
||||
eltern: "eg", x: 1220, y: 670, skala: 11000,
|
||||
klick: { modal: "CarEG" },
|
||||
lesen(m) {
|
||||
const w = m.zahl("solarManager/evPower") * 1000;
|
||||
const soc = m.zahl("solarManager/evSOC");
|
||||
const tank = m.zahl("solarManager/evFuel");
|
||||
// solarManager/evPlug kommt als 1/0; aeltere Manager schickten den
|
||||
// Text der Wallbox ("no car", "charging", ...).
|
||||
const roh = m.text("solarManager/evPlug");
|
||||
const stecker = isNaN(Number(roh)) ? (roh !== undefined && roh !== "no car") : Number(roh) > 0;
|
||||
let neben = Math.round(soc) + " %";
|
||||
// Restladezeit in Minuten laut Fahrzeug (gatherSkodaData.py). Das
|
||||
// frühere carRemChrg schickt der Manager nicht mehr.
|
||||
const rest = m.zahl("solarManager/evRemChrgTime");
|
||||
if (w >= 50 && rest > 0) {
|
||||
const fertig = new Date(Date.now() + rest * 60000);
|
||||
neben = "fertig " + String(fertig.getHours()).padStart(2, "0") + ":" + String(fertig.getMinutes()).padStart(2, "0");
|
||||
}
|
||||
const zeichen = wallboxZeichen(m.text("solarManager/evMode"), stecker, m.zahl("solarManager/evLock") > 0);
|
||||
if (tank > 0) zeichen.push({ art: "tank", text: Math.round(tank) + " %", titel: "Tank" });
|
||||
return {
|
||||
leistung: w, fluss: w, ruhe: w < 50 && !stecker,
|
||||
neben, zeichen,
|
||||
zustand: { an: w >= 50, stand: soc / 100 },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------- frei
|
||||
{
|
||||
id: "bewaesserung", name: "Bewässerung", symbol: "tropfen", farbe: "#4b8fd6",
|
||||
eltern: null, x: 550, y: 70,
|
||||
klick: { modal: "watering" },
|
||||
// Ids wie in wateringZones (solarMQTT.js) - dort werden Farbe, Text und
|
||||
// Tooltip gesetzt, hier nur die Stellen dafür angelegt.
|
||||
zonen: [
|
||||
// bild: Symbol aus ZONENBILDER (energiefluss.js); zeichen: Zeichen aus Bootstrap Icons
|
||||
{ name: "Tröge", bild: "kuebel", status: "wateringTroughStatus", symbol: "wateringTroughIcon" },
|
||||
{ name: "Vorn", bild: "garten", status: "wateringFrontStatus", symbol: "wateringFrontIcon" },
|
||||
{ name: "Beete", bild: "hochbeet", status: "wateringBedsStatus", symbol: "wateringBedsIcon" },
|
||||
],
|
||||
lesen() {
|
||||
return { wert: "" };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return { knoten };
|
||||
})();
|
||||
@@ -0,0 +1,534 @@
|
||||
/* ==========================================================================
|
||||
Reiter "Grundriss": Etagen, Räume und wo ihre Kacheln sitzen.
|
||||
|
||||
Vorher stand das alles in restricted/rooms.php, und eine Kachel setzte man,
|
||||
indem man in tools/kachelpositionen.php die Stelle ablas und die Zahlen in
|
||||
die Datei schrieb. Hier geschieht beides am Plan selbst: Kacheln werden
|
||||
gezogen, ein Raum ohne Kachel mit "Platzieren" und einem Klick gesetzt.
|
||||
Von dort übernommen sind Raster, Geisterkachel und die Umrechnung: gezeigt
|
||||
wird auf die Mitte, gespeichert die linke obere Ecke.
|
||||
|
||||
Geprüft wird nichts, was der Server nicht auch prüft
|
||||
(restricted/grundriss.php). Jede Aktion antwortet mit dem ganzen neuen
|
||||
Stand; die Maske zeichnet ihn einfach neu.
|
||||
|
||||
Nutzt textSchuetzen() aus settings.js - beide Dateien stehen auf derselben
|
||||
Seite, settings.js zuerst.
|
||||
========================================================================== */
|
||||
|
||||
let grundrissModell = null; // Antwort von ?action=grundriss
|
||||
const grundrissZustand = {
|
||||
etage: null, // gewählte Etage (code) oder "" für "neue Etage"
|
||||
raum: null, // gewählter Raum (nr), 0 = neuer Raum, null = keiner
|
||||
platzieren: null, // nr des Raums, der gerade auf den Plan gesetzt wird
|
||||
ziehen: null, // { nr, dx, dy, x, y, bewegt } während eines Zugs
|
||||
};
|
||||
|
||||
/* --- Ableitungen --------------------------------------------------------- */
|
||||
|
||||
function grEtage(code) {
|
||||
return (grundrissModell.etagen || []).find(e => e.code === code) || null;
|
||||
}
|
||||
function grRaum(nr) {
|
||||
return (grundrissModell.raeume || []).find(r => r.nr === nr) || null;
|
||||
}
|
||||
function grRaeumeDerEtage(code) {
|
||||
return (grundrissModell.raeume || []).filter(r => r.floor === code);
|
||||
}
|
||||
function grMass() {
|
||||
return grundrissModell.mass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lange Namen brechen am letzten Leerzeichen um - wie auf der Startseite
|
||||
* (restricted/home.php), damit die Kachel hier genauso breit wirkt wie dort.
|
||||
*/
|
||||
function grNamenszeilen(name) {
|
||||
const luecke = name.lastIndexOf(" ");
|
||||
return name.length > 8 && luecke > 0 ? [name.slice(0, luecke), name.slice(luecke + 1)] : [name];
|
||||
}
|
||||
|
||||
/** Zeigerposition in Koordinaten der Zeichenfläche. */
|
||||
function grPunkt(svg, ereignis) {
|
||||
const p = svg.createSVGPoint();
|
||||
p.x = ereignis.clientX;
|
||||
p.y = ereignis.clientY;
|
||||
return p.matrixTransform(svg.getScreenCTM().inverse());
|
||||
}
|
||||
|
||||
/** Linke obere Ecke auf die Zeichenfläche begrenzen - wie der Server. */
|
||||
function grBegrenzt(x, y) {
|
||||
const m = grMass();
|
||||
return {
|
||||
x: Math.round(Math.max(0, Math.min(m.planBreit - m.kachelBreit, x))),
|
||||
y: Math.round(Math.max(0, Math.min(m.planHoch - m.kachelHoch, y))),
|
||||
};
|
||||
}
|
||||
|
||||
/* --- Zeichnen ------------------------------------------------------------ */
|
||||
|
||||
function zeichneGrundriss() {
|
||||
const ziel = document.getElementById("grundrissFlaeche");
|
||||
if (!ziel || !grundrissModell) return;
|
||||
|
||||
const etagen = grundrissModell.etagen || [];
|
||||
if (grundrissZustand.etage === null || (grundrissZustand.etage !== "" && !grEtage(grundrissZustand.etage))) {
|
||||
grundrissZustand.etage = etagen.length ? (grundrissModell.standard || etagen[0].code) : "";
|
||||
}
|
||||
const code = grundrissZustand.etage;
|
||||
const etage = grEtage(code);
|
||||
|
||||
let html = '<div class="gr-etagen">';
|
||||
etagen.forEach(e => {
|
||||
html += '<button type="button" class="btn btn-sm gr-etage' + (e.code === code ? " aktiv" : "") + '"'
|
||||
+ ' data-code="' + textSchuetzen(e.code) + '" title="' + textSchuetzen(e.label) + '">'
|
||||
+ '<span class="gr-etage-code">' + textSchuetzen(e.code) + "</span>"
|
||||
+ '<span class="gr-etage-name">' + textSchuetzen(e.label) + "</span>"
|
||||
+ (e.standard ? '<i class="bi bi-star-fill gr-etage-std" title="Standard-Etage"></i>' : "")
|
||||
+ "</button>";
|
||||
});
|
||||
html += '<button type="button" class="btn btn-sm btn-outline-secondary gr-etage-neu' + (code === "" ? " active" : "") + '">'
|
||||
+ '<i class="bi bi-plus-lg me-1"></i>Etage</button></div>';
|
||||
|
||||
if (!etagen.length && code === "") {
|
||||
html += '<p class="small text-body-secondary mb-3">Noch keine Etage angelegt. Eine Etage '
|
||||
+ "braucht ein Kürzel (so steht sie im Menü und in den Etagenschaltern), einen Namen "
|
||||
+ "und später ein Grundrissbild.</p>";
|
||||
}
|
||||
|
||||
html += '<div class="row g-3">'
|
||||
+ '<div class="col-lg-8">' + (etage ? grPlan(etage) : grPlanLeer()) + "</div>"
|
||||
+ '<div class="col-lg-4">' + grEtageFormular(etage)
|
||||
+ (etage ? grRaumListe(etage) + grRaumFormular(etage) : "")
|
||||
+ "</div></div>"
|
||||
+ '<div class="gr-stand small mt-2"></div>';
|
||||
|
||||
ziel.innerHTML = html;
|
||||
}
|
||||
|
||||
function grPlanLeer() {
|
||||
return '<div class="gr-plan-huelle gr-plan-leer"><span>Erst die Etage speichern, dann '
|
||||
+ "lässt sich ihr Grundriss hochladen und bestücken.</span></div>";
|
||||
}
|
||||
|
||||
/** Der Plan einer Etage: Bild, Raster, Kacheln, Geisterkachel. */
|
||||
function grPlan(etage) {
|
||||
const m = grMass();
|
||||
const raeume = grRaeumeDerEtage(etage.code).filter(r => r.x !== null);
|
||||
let svg = '<div class="gr-plan-huelle' + (grundrissZustand.platzieren ? " platzieren" : "") + '">'
|
||||
+ '<svg class="gr-plan" viewBox="0 0 ' + m.planBreit + " " + m.planHoch + '">'
|
||||
+ '<defs><pattern id="grRaster" width="10" height="10" patternUnits="userSpaceOnUse">'
|
||||
+ '<path d="M10 0H0V10" fill="none" stroke="#ffffff1c" stroke-width=".4"/></pattern></defs>';
|
||||
if (etage.bild) {
|
||||
// Dieselbe Einpassung wie auf der Startseite: das Bild füllt die
|
||||
// Zeichenfläche, ohne verzerrt zu werden (preserveAspectRatio "meet").
|
||||
svg += '<image href="' + textSchuetzen(etage.bild) + '" x="0" y="0" width="' + m.planBreit
|
||||
+ '" height="' + m.planHoch + '"/>';
|
||||
} else {
|
||||
svg += '<text x="' + m.planBreit / 2 + '" y="' + m.planHoch / 2 + '" class="gr-ohne-bild">'
|
||||
+ "Noch kein Grundrissbild</text>";
|
||||
}
|
||||
svg += '<rect width="' + m.planBreit + '" height="' + m.planHoch + '" fill="url(#grRaster)" pointer-events="none"/>';
|
||||
|
||||
raeume.forEach(r => {
|
||||
const zeilen = grNamenszeilen(r.name);
|
||||
const ys = zeilen.length === 2 ? [9, 15.5] : [11.5];
|
||||
svg += '<g class="gr-kachel' + (r.nr === grundrissZustand.raum ? " gewaehlt" : "") + '"'
|
||||
+ ' data-nr="' + r.nr + '" transform="translate(' + r.x + "," + r.y + ')">'
|
||||
+ '<rect width="' + m.kachelBreit + '" height="' + m.kachelHoch + '" rx="5"/>'
|
||||
+ zeilen.map((z, i) => '<text x="' + m.kachelBreit / 2 + '" y="' + ys[i] + '">'
|
||||
+ textSchuetzen(z) + "</text>").join("")
|
||||
+ "</g>";
|
||||
});
|
||||
|
||||
svg += '<g class="gr-geist" style="display:none"><rect width="' + m.kachelBreit + '" height="'
|
||||
+ m.kachelHoch + '" rx="5"/></g></svg></div>';
|
||||
|
||||
const hinweis = grundrissZustand.platzieren
|
||||
? "Auf die Stelle klicken, an der die Mitte der Kachel „"
|
||||
+ textSchuetzen((grRaum(grundrissZustand.platzieren) || {}).name || "") + "“ liegen soll. Esc bricht ab."
|
||||
: "Kacheln lassen sich ziehen; ein Klick wählt den Raum. Die Zeichenfläche ist "
|
||||
+ m.planBreit + " × " + m.planHoch + ", eine Kachel " + m.kachelBreit + " × " + m.kachelHoch + ".";
|
||||
return svg + '<p class="small text-body-secondary mt-1 mb-0 gr-plan-hinweis">' + hinweis + "</p>";
|
||||
}
|
||||
|
||||
/** Formular der gewählten Etage - oder einer neuen. */
|
||||
function grEtageFormular(etage) {
|
||||
const neu = !etage;
|
||||
const etagen = grundrissModell.etagen || [];
|
||||
const i = neu ? -1 : etagen.findIndex(e => e.code === etage.code);
|
||||
let html = '<div class="gr-block" data-block="etage">'
|
||||
+ '<div class="gr-block-titel">' + (neu ? "Neue Etage" : "Etage") + "</div>"
|
||||
+ '<div class="row g-2">'
|
||||
+ '<div class="col-4"><label class="form-label small mb-0">Kürzel</label>'
|
||||
+ '<input type="text" class="form-control form-control-sm" data-feld="code" maxlength="4"'
|
||||
+ ' value="' + (neu ? "" : textSchuetzen(etage.code)) + '"' + (neu ? ' placeholder="EG"' : " readonly") + ' autocomplete="off"></div>'
|
||||
+ '<div class="col-8"><label class="form-label small mb-0">Name</label>'
|
||||
+ '<input type="text" class="form-control form-control-sm" data-feld="label" maxlength="40"'
|
||||
+ ' value="' + (neu ? "" : textSchuetzen(etage.label)) + '" placeholder="Erdgeschoss"></div>'
|
||||
+ "</div>"
|
||||
+ '<div class="form-check mt-2"><input class="form-check-input" type="checkbox" id="grStandard" data-feld="standard"'
|
||||
+ (!neu && etage.standard ? " checked" : "") + '>'
|
||||
+ '<label class="form-check-label small" for="grStandard">Öffnet, wenn keine Etage gewählt ist</label></div>'
|
||||
+ '<div class="gr-knoepfe">'
|
||||
+ '<button type="button" class="btn btn-primary btn-sm gr-etage-speichern">Speichern</button>';
|
||||
if (!neu) {
|
||||
html += '<button type="button" class="btn btn-outline-secondary btn-sm gr-etage-links" title="Nach vorn"'
|
||||
+ (i <= 0 ? " disabled" : "") + '><i class="bi bi-arrow-left"></i></button>'
|
||||
+ '<button type="button" class="btn btn-outline-secondary btn-sm gr-etage-rechts" title="Nach hinten"'
|
||||
+ (i >= etagen.length - 1 ? " disabled" : "") + '><i class="bi bi-arrow-right"></i></button>'
|
||||
+ '<label class="btn btn-outline-secondary btn-sm mb-0" title="PNG, JPEG oder WebP">'
|
||||
+ '<i class="bi bi-image me-1"></i>' + (etage.bild ? "Bild tauschen" : "Bild hochladen")
|
||||
+ '<input type="file" class="d-none gr-bild" accept="image/png,image/jpeg,image/webp"></label>'
|
||||
+ '<button type="button" class="btn btn-sm gr-etage-weg" title="Etage löschen"><i class="bi bi-trash3"></i></button>';
|
||||
}
|
||||
html += "</div>";
|
||||
if (!neu && etage.automatiken.length) {
|
||||
html += '<p class="small text-body-secondary mb-0 mt-1">' + etage.automatiken.length
|
||||
+ " Automatik(en) stehen unter dieser Etage.</p>";
|
||||
}
|
||||
return html + "</div>";
|
||||
}
|
||||
|
||||
/** Die Räume der Etage, mit und ohne Kachel. */
|
||||
function grRaumListe(etage) {
|
||||
const raeume = grRaeumeDerEtage(etage.code);
|
||||
let html = '<div class="gr-block" data-block="raeume"><div class="gr-block-titel">Räume</div>';
|
||||
if (!raeume.length) {
|
||||
html += '<p class="small text-body-secondary mb-2">Noch keine Räume auf dieser Etage.</p>';
|
||||
}
|
||||
html += '<div class="gr-raeume">';
|
||||
raeume.forEach(r => {
|
||||
const aktiv = r.nr === grundrissZustand.raum;
|
||||
html += '<div class="gr-raum' + (aktiv ? " gewaehlt" : "") + '" data-nr="' + r.nr + '">'
|
||||
+ '<button type="button" class="gr-raum-name">' + textSchuetzen(r.name)
|
||||
+ (r.thermostat ? ' <i class="bi bi-thermometer-half text-body-secondary" title="' + textSchuetzen(r.thermostat) + '"></i>' : "")
|
||||
+ "</button>"
|
||||
+ (r.x !== null
|
||||
? '<span class="gr-raum-stand" title="Kachel bei ' + r.x + " / " + r.y + '"><i class="bi bi-geo-alt-fill"></i></span>'
|
||||
: '<button type="button" class="btn btn-outline-secondary btn-sm gr-platzieren'
|
||||
+ (grundrissZustand.platzieren === r.nr ? " active" : "") + '">Platzieren</button>')
|
||||
+ "</div>";
|
||||
});
|
||||
html += '</div><button type="button" class="btn btn-sm btn-outline-secondary mt-2 gr-raum-neu'
|
||||
+ (grundrissZustand.raum === 0 ? " active" : "") + '"><i class="bi bi-plus-lg me-1"></i>Raum</button></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/** Formular des gewählten Raums, sofern einer gewählt ist. */
|
||||
function grRaumFormular(etage) {
|
||||
const nr = grundrissZustand.raum;
|
||||
if (nr === null) return "";
|
||||
const r = nr === 0 ? { nr: 0, floor: etage.code, name: "", kuerzel: "", thermostat: "", x: null, geraete: 0 } : grRaum(nr);
|
||||
if (!r) return "";
|
||||
|
||||
const etagenAuswahl = (grundrissModell.etagen || []).map(e =>
|
||||
'<option value="' + textSchuetzen(e.code) + '"' + (e.code === r.floor ? " selected" : "") + ">"
|
||||
+ textSchuetzen(e.code + " · " + e.label) + "</option>").join("");
|
||||
const zweige = '<datalist id="grZweige">' + (grundrissModell.zweige || []).map(z =>
|
||||
'<option value="' + textSchuetzen(z) + '">').join("") + "</datalist>";
|
||||
|
||||
let html = '<div class="gr-block" data-block="raum">'
|
||||
+ '<div class="gr-block-titel">' + (nr === 0 ? "Neuer Raum" : "Raum") + "</div>"
|
||||
+ '<label class="form-label small mb-0">Name</label>'
|
||||
+ '<input type="text" class="form-control form-control-sm" data-feld="name" maxlength="40" value="'
|
||||
+ textSchuetzen(r.name) + '" placeholder="Wohnzimmer">'
|
||||
+ '<div class="row g-2 mt-0">'
|
||||
+ '<div class="col-6"><label class="form-label small mb-0">Etage</label>'
|
||||
+ '<select class="form-select form-select-sm" data-feld="floor">' + etagenAuswahl + "</select></div>"
|
||||
+ '<div class="col-6"><label class="form-label small mb-0" title="Teil der Element-Ids auf der Startseite. Leer lassen: entsteht aus dem Namen.">Kürzel</label>'
|
||||
+ '<input type="text" class="form-control form-control-sm" data-feld="kuerzel" maxlength="40" value="'
|
||||
+ textSchuetzen(r.kuerzel) + '" placeholder="aus dem Namen" autocomplete="off"></div></div>'
|
||||
+ '<label class="form-label small mb-0 mt-2">Thermostat-Zweig</label>'
|
||||
+ '<input type="text" class="form-control form-control-sm" data-feld="thermostat" list="grZweige" value="'
|
||||
+ textSchuetzen(r.thermostat || "") + '" placeholder="leer = kein Thermostat" autocomplete="off">' + zweige
|
||||
+ '<div class="form-text small">Unter diesem MQTT-Zweig meldet das Thermostat, z. B. Raumtemp/EG/Bad. '
|
||||
+ "Er trägt Heizsymbol, Temperaturregler und die Vorgabewerte der Kachel.</div>"
|
||||
+ '<div class="gr-knoepfe">'
|
||||
+ '<button type="button" class="btn btn-primary btn-sm gr-raum-speichern">Speichern</button>';
|
||||
if (nr !== 0) {
|
||||
if (r.x !== null) {
|
||||
html += '<button type="button" class="btn btn-outline-secondary btn-sm gr-kachelwerte" title="Werte dieser Kachel bearbeiten">'
|
||||
+ '<i class="bi bi-grid-1x2 me-1"></i>Werte</button>'
|
||||
+ '<button type="button" class="btn btn-outline-secondary btn-sm gr-vom-plan">Vom Plan</button>';
|
||||
}
|
||||
html += '<button type="button" class="btn btn-sm gr-raum-weg" title="Raum löschen"><i class="bi bi-trash3"></i></button>';
|
||||
}
|
||||
html += "</div>";
|
||||
if (nr !== 0 && r.geraete) {
|
||||
html += '<p class="small text-body-secondary mb-0 mt-1">' + r.geraete + " Gerät(e) zugeordnet.</p>";
|
||||
}
|
||||
return html + "</div>";
|
||||
}
|
||||
|
||||
/* --- Sprechen mit dem Endpunkt ------------------------------------------- */
|
||||
|
||||
function grStand(text, klasse) {
|
||||
const ziel = document.querySelector("#grundrissFlaeche .gr-stand");
|
||||
if (!ziel) return;
|
||||
ziel.className = "gr-stand small mt-2 " + (klasse || "text-body-secondary");
|
||||
ziel.textContent = text;
|
||||
clearTimeout(grStand._uhr);
|
||||
if (klasse !== "text-danger") {
|
||||
grStand._uhr = setTimeout(() => { if (ziel.textContent === text) ziel.textContent = ""; }, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Aktion schicken, den neuen Stand übernehmen, neu zeichnen.
|
||||
*
|
||||
* Danach wissen die anderen Reiter nicht mehr, was gilt: neue Räume fehlen in
|
||||
* der Kachelliste und in der Raumauswahl der Geräte. Sie laden beim nächsten
|
||||
* Öffnen nach (grundrissGeaendert, siehe settings.js).
|
||||
*/
|
||||
async function grSenden(action, koerper, meldung) {
|
||||
try {
|
||||
const optionen = koerper instanceof FormData
|
||||
? { method: "POST", body: koerper }
|
||||
: { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper) };
|
||||
const antwort = await fetch("ajax/settings.php?action=" + action, optionen);
|
||||
const d = await antwort.json();
|
||||
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||||
grundrissModell = d.stand;
|
||||
window.grundrissGeaendert = true;
|
||||
zeichneGrundriss();
|
||||
if (meldung) grStand(meldung, "text-success");
|
||||
return d;
|
||||
} catch (e) {
|
||||
grStand(e.message, "text-danger");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function grundrissHolen() {
|
||||
const ziel = document.getElementById("grundrissFlaeche");
|
||||
if (!ziel) return;
|
||||
try {
|
||||
const antwort = await fetch("ajax/settings.php?action=grundriss", { cache: "no-store" });
|
||||
const d = await antwort.json();
|
||||
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||||
grundrissModell = d;
|
||||
zeichneGrundriss();
|
||||
} catch (e) {
|
||||
ziel.innerHTML = '<div class="alert alert-danger mb-0">Grundriss nicht abrufbar: '
|
||||
+ textSchuetzen(e.message) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Felder eines Blocks als Objekt. */
|
||||
function grFelder(block) {
|
||||
const werte = {};
|
||||
block.querySelectorAll("[data-feld]").forEach(f => {
|
||||
werte[f.dataset.feld] = f.type === "checkbox" ? f.checked : f.value;
|
||||
});
|
||||
return werte;
|
||||
}
|
||||
|
||||
/* --- Bedienung ----------------------------------------------------------- */
|
||||
|
||||
function grundrissBinden() {
|
||||
const flaeche = document.getElementById("grundrissFlaeche");
|
||||
if (!flaeche) return;
|
||||
|
||||
flaeche.addEventListener("click", async function (ereignis) {
|
||||
if (!grundrissModell) return;
|
||||
const t = ereignis.target;
|
||||
|
||||
const etagenKnopf = t.closest(".gr-etage");
|
||||
if (etagenKnopf) {
|
||||
grundrissZustand.etage = etagenKnopf.dataset.code;
|
||||
grundrissZustand.raum = null;
|
||||
grundrissZustand.platzieren = null;
|
||||
zeichneGrundriss();
|
||||
return;
|
||||
}
|
||||
if (t.closest(".gr-etage-neu")) {
|
||||
grundrissZustand.etage = "";
|
||||
grundrissZustand.raum = null;
|
||||
grundrissZustand.platzieren = null;
|
||||
zeichneGrundriss();
|
||||
return;
|
||||
}
|
||||
if (t.closest(".gr-etage-speichern")) {
|
||||
const werte = grFelder(t.closest('[data-block="etage"]'));
|
||||
const neu = grundrissZustand.etage === "";
|
||||
const d = await grSenden("etage-speichern",
|
||||
{ code: werte.code, label: werte.label, standard: werte.standard, neu: neu }, "Etage gespeichert.");
|
||||
if (d && neu) {
|
||||
grundrissZustand.etage = String(werte.code).trim().toUpperCase();
|
||||
zeichneGrundriss();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (t.closest(".gr-etage-links") || t.closest(".gr-etage-rechts")) {
|
||||
const codes = grundrissModell.etagen.map(e => e.code);
|
||||
const i = codes.indexOf(grundrissZustand.etage);
|
||||
const j = i + (t.closest(".gr-etage-links") ? -1 : 1);
|
||||
if (i < 0 || j < 0 || j >= codes.length) return;
|
||||
[codes[i], codes[j]] = [codes[j], codes[i]];
|
||||
grSenden("etagen-reihenfolge", { codes: codes }, "Reihenfolge gespeichert.");
|
||||
return;
|
||||
}
|
||||
if (t.closest(".gr-etage-weg")) {
|
||||
const e = grEtage(grundrissZustand.etage);
|
||||
if (!e || !confirm("Etage „" + e.label + "“ löschen?")) return;
|
||||
const d = await grSenden("etage-loeschen", { code: e.code }, "Etage gelöscht.");
|
||||
if (d) {
|
||||
grundrissZustand.etage = null;
|
||||
zeichneGrundriss();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const raumZeile = t.closest(".gr-raum");
|
||||
if (t.closest(".gr-platzieren") && raumZeile) {
|
||||
const nr = Number(raumZeile.dataset.nr);
|
||||
grundrissZustand.platzieren = grundrissZustand.platzieren === nr ? null : nr;
|
||||
grundrissZustand.raum = nr;
|
||||
zeichneGrundriss();
|
||||
return;
|
||||
}
|
||||
if (t.closest(".gr-raum-name") && raumZeile) {
|
||||
const nr = Number(raumZeile.dataset.nr);
|
||||
grundrissZustand.raum = grundrissZustand.raum === nr ? null : nr;
|
||||
grundrissZustand.platzieren = null;
|
||||
zeichneGrundriss();
|
||||
return;
|
||||
}
|
||||
if (t.closest(".gr-raum-neu")) {
|
||||
grundrissZustand.raum = 0;
|
||||
grundrissZustand.platzieren = null;
|
||||
zeichneGrundriss();
|
||||
const feld = flaeche.querySelector('[data-block="raum"] [data-feld="name"]');
|
||||
if (feld) feld.focus();
|
||||
return;
|
||||
}
|
||||
if (t.closest(".gr-raum-speichern")) {
|
||||
const werte = grFelder(t.closest('[data-block="raum"]'));
|
||||
werte.nr = grundrissZustand.raum || 0;
|
||||
const d = await grSenden("raum-speichern", werte, "Raum gespeichert.");
|
||||
if (d) {
|
||||
grundrissZustand.raum = d.nr;
|
||||
// Hat der Raum die Etage gewechselt, geht der Blick mit.
|
||||
const r = grRaum(d.nr);
|
||||
if (r && r.floor !== grundrissZustand.etage) grundrissZustand.etage = r.floor;
|
||||
zeichneGrundriss();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (t.closest(".gr-vom-plan")) {
|
||||
grSenden("raum-position", { nr: grundrissZustand.raum, x: null, y: null }, "Kachel vom Plan genommen.");
|
||||
return;
|
||||
}
|
||||
if (t.closest(".gr-raum-weg")) {
|
||||
const r = grRaum(grundrissZustand.raum);
|
||||
if (!r) return;
|
||||
const frage = "Raum „" + r.name + "“ löschen?"
|
||||
+ (r.geraete ? "\n\n" + r.geraete + " Gerät(e) stehen danach ohne Raum da." : "");
|
||||
if (!confirm(frage)) return;
|
||||
const d = await grSenden("raum-loeschen", { nr: r.nr }, "Raum gelöscht.");
|
||||
if (d) {
|
||||
grundrissZustand.raum = null;
|
||||
zeichneGrundriss();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (t.closest(".gr-kachelwerte")) {
|
||||
const r = grRaum(grundrissZustand.raum);
|
||||
if (r && typeof kachelOeffnen === "function") kachelOeffnen(r.floor, r.kuerzel);
|
||||
}
|
||||
});
|
||||
|
||||
// Das Bild geht los, sobald eine Datei gewählt ist - ein zweiter Klick auf
|
||||
// "Speichern" wäre hier nur eine Gelegenheit, es zu vergessen.
|
||||
flaeche.addEventListener("change", function (ereignis) {
|
||||
const feld = ereignis.target.closest(".gr-bild");
|
||||
if (!feld || !feld.files.length) return;
|
||||
const formular = new FormData();
|
||||
formular.append("code", grundrissZustand.etage);
|
||||
formular.append("bild", feld.files[0]);
|
||||
grStand("Bild wird hochgeladen …");
|
||||
grSenden("etage-bild", formular, "Grundriss übernommen.");
|
||||
});
|
||||
|
||||
/* Ziehen und Platzieren. Pointer-Ereignisse, damit es mit Maus, Stift und
|
||||
Finger gleich geht; die Kachel folgt dem Zeiger, gespeichert wird beim
|
||||
Loslassen. */
|
||||
flaeche.addEventListener("pointerdown", function (ereignis) {
|
||||
const kachel = ereignis.target.closest(".gr-kachel");
|
||||
const svg = ereignis.target.closest(".gr-plan");
|
||||
if (!kachel || !svg || grundrissZustand.platzieren) return;
|
||||
const r = grRaum(Number(kachel.dataset.nr));
|
||||
if (!r) return;
|
||||
const p = grPunkt(svg, ereignis);
|
||||
grundrissZustand.ziehen = { nr: r.nr, dx: p.x - r.x, dy: p.y - r.y, x: r.x, y: r.y, bewegt: false, kachel: kachel, svg: svg };
|
||||
kachel.setPointerCapture(ereignis.pointerId);
|
||||
kachel.classList.add("zieht");
|
||||
ereignis.preventDefault();
|
||||
});
|
||||
|
||||
flaeche.addEventListener("pointermove", function (ereignis) {
|
||||
const z = grundrissZustand.ziehen;
|
||||
if (z) {
|
||||
const p = grPunkt(z.svg, ereignis);
|
||||
const pos = grBegrenzt(p.x - z.dx, p.y - z.dy);
|
||||
if (pos.x !== z.x || pos.y !== z.y) z.bewegt = true;
|
||||
z.x = pos.x;
|
||||
z.y = pos.y;
|
||||
z.kachel.setAttribute("transform", "translate(" + pos.x + "," + pos.y + ")");
|
||||
grStand("x " + pos.x + " · y " + pos.y);
|
||||
return;
|
||||
}
|
||||
if (grundrissZustand.platzieren) {
|
||||
const svg = ereignis.target.closest(".gr-plan");
|
||||
const geist = flaeche.querySelector(".gr-geist");
|
||||
if (!svg || !geist) return;
|
||||
const m = grMass();
|
||||
const p = grPunkt(svg, ereignis);
|
||||
const pos = grBegrenzt(p.x - m.kachelBreit / 2, p.y - m.kachelHoch / 2);
|
||||
geist.style.display = "";
|
||||
geist.setAttribute("transform", "translate(" + pos.x + "," + pos.y + ")");
|
||||
}
|
||||
});
|
||||
|
||||
flaeche.addEventListener("pointerup", async function (ereignis) {
|
||||
const z = grundrissZustand.ziehen;
|
||||
if (z) {
|
||||
grundrissZustand.ziehen = null;
|
||||
z.kachel.classList.remove("zieht");
|
||||
if (!z.bewegt) {
|
||||
// Nur angetippt: den Raum wählen statt ihn zu verschieben.
|
||||
grundrissZustand.raum = grundrissZustand.raum === z.nr ? null : z.nr;
|
||||
zeichneGrundriss();
|
||||
return;
|
||||
}
|
||||
grSenden("raum-position", { nr: z.nr, x: z.x, y: z.y }, "Kachel gesetzt.");
|
||||
return;
|
||||
}
|
||||
if (grundrissZustand.platzieren) {
|
||||
const svg = ereignis.target.closest(".gr-plan");
|
||||
if (!svg) return;
|
||||
const m = grMass();
|
||||
const p = grPunkt(svg, ereignis);
|
||||
const pos = grBegrenzt(p.x - m.kachelBreit / 2, p.y - m.kachelHoch / 2);
|
||||
const nr = grundrissZustand.platzieren;
|
||||
grundrissZustand.platzieren = null;
|
||||
grSenden("raum-position", { nr: nr, x: pos.x, y: pos.y }, "Kachel gesetzt.");
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (ereignis) {
|
||||
if (ereignis.key === "Escape" && grundrissZustand.platzieren) {
|
||||
grundrissZustand.platzieren = null;
|
||||
zeichneGrundriss();
|
||||
}
|
||||
});
|
||||
|
||||
const neuLaden = document.getElementById("grundrissNeuLaden");
|
||||
if (neuLaden) neuLaden.addEventListener("click", grundrissHolen);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
grundrissBinden();
|
||||
grundrissHolen();
|
||||
});
|
||||
@@ -156,8 +156,12 @@ const homeSVG = {
|
||||
// Solarseite fuer dieselben Zahlen benutzt - eine eigene Regel hier
|
||||
// haette bedeutet, dass dieselbe Wallbox an zwei Stellen verschieden
|
||||
// gerundet dasteht.
|
||||
if (wert.format === "leistung") {
|
||||
const zahl = Number(text);
|
||||
//
|
||||
// "leistung_kw" ist dasselbe fuer Quellen, die schon Kilowatt melden -
|
||||
// die Wallboxen im SolarManager. Umgerechnet wird hier in Watt, damit
|
||||
// es eine Regel fuer beide bleibt.
|
||||
if (wert.format === "leistung" || wert.format === "leistung_kw") {
|
||||
const zahl = Number(text) * (wert.format === "leistung_kw" ? 1000 : 1);
|
||||
if (text === "" || isNaN(zahl)) return null;
|
||||
// Ersetzt wird nur das Trennzeichen: auf der Kachel steht ein
|
||||
// schmales Leerzeichen, im Rest der Anwendung ein gewoehnliches.
|
||||
@@ -176,6 +180,7 @@ const homeSVG = {
|
||||
// mitsamt den Werten, die auf der Kachel stehen sollen. Jeder Raum
|
||||
// bekommt die gleiche Behandlung - Raeume und ihre Werte kommen dort
|
||||
// dazu, nicht hier.
|
||||
if (typeof raumfensterMqtt === "function") raumfensterMqtt();
|
||||
homeRooms.forEach(function (room) {
|
||||
const el = (part) => document.getElementById(room.floor + "_" + part);
|
||||
|
||||
@@ -188,7 +193,7 @@ const homeSVG = {
|
||||
// Heizsymbol und Pufferbetrieb gibt es nur, wo ein Thermostat
|
||||
// haengt - anderswo sind die Elemente gar nicht gezeichnet.
|
||||
if (!room.heizung) return;
|
||||
const values = mqttData["Raumtemp"]?.[room.floor]?.[room.mqtt];
|
||||
const values = mqttZweig(room.thermostat);
|
||||
if (!values) return;
|
||||
if (typeof (values["Heating"]) != "undefined")
|
||||
el(room.id + "_heater").setAttribute("display", values["Heating"] == "false" ? "none" : "");
|
||||
@@ -199,7 +204,9 @@ const homeSVG = {
|
||||
|
||||
}
|
||||
|
||||
var currentFloor = "OG";
|
||||
// Die Etage, mit der die Seite oeffnet - standardEtage() in rooms.php, von
|
||||
// home.php in die Seite geschrieben. Stand frueher fest auf "OG".
|
||||
var currentFloor = (typeof homeStandardEtage !== "undefined") ? homeStandardEtage : "";
|
||||
|
||||
function addClass(el, classNameToAdd){
|
||||
el.className += ' ' + classNameToAdd;
|
||||
@@ -270,8 +277,23 @@ function switchFloor(floor){
|
||||
|
||||
var mqttData = {};
|
||||
|
||||
function openHeaterSettings(heater){
|
||||
openRoomView(heater);
|
||||
/**
|
||||
* Ein Zweig des Nachrichtenbaums nach seinem Topic, z.B. "Raumtemp/EG/Bad".
|
||||
*
|
||||
* Frueher stand hier fest mqttData["Raumtemp"][Etage][Raum] - das setzte
|
||||
* voraus, dass jedes Thermostat unter Raumtemp/<Etage>/<Raumname> meldet.
|
||||
* Jetzt steht der Zweig am Raum (rooms.thermostat).
|
||||
*/
|
||||
function mqttZweig(topic) {
|
||||
if (!topic) return undefined;
|
||||
return String(topic).split("/").filter(t => t !== "")
|
||||
.reduce((knoten, teil) => knoten?.[teil], mqttData);
|
||||
}
|
||||
|
||||
/** Ein Raum aus homeRooms nach seiner festen Nummer. */
|
||||
function raumNachNummer(nr) {
|
||||
return (typeof homeRooms !== "undefined" ? homeRooms : [])
|
||||
.find(r => r.nr === Number(nr));
|
||||
}
|
||||
|
||||
document.addEventListener('readystatechange', function () {
|
||||
@@ -288,13 +310,25 @@ function switchFloor(floor){
|
||||
* oeffnen - vorher kam nur der Temperaturregler, die Rollladen im selben
|
||||
* Zimmer waren von hier gar nicht erreichbar.
|
||||
*
|
||||
* Welche Geraete dazugehoeren, sagt die Raumzuordnung (actors.room).
|
||||
* Welche Geraete dazugehoeren, sagt die Raumzuordnung (actors.room_id).
|
||||
* `raum` ist die feste Raumnummer (rooms.id).
|
||||
*/
|
||||
function openRoomView(raum) {
|
||||
const teile = raum.toString().split("_");
|
||||
document.getElementById("modal-title").innerHTML =
|
||||
teile[0] + " · " + teile.slice(1).join(" ")
|
||||
.replace("ue", "ü").replace("ae", "ä").replace("oe", "ö");
|
||||
const eintrag = raumNachNummer(raum);
|
||||
// Der Name kommt jetzt aus der Datenbank und darf Umlaute tragen. Die
|
||||
// Ersetzung bleibt fuer die alten Namen ("Kueche"), bis sie jemand
|
||||
// unter Einstellungen -> Grundriss umbenennt.
|
||||
const titel = eintrag
|
||||
? eintrag.mqtt.replace("ue", "ü").replace("ae", "ä").replace("oe", "ö")
|
||||
: "Raum";
|
||||
const modalTitel = document.getElementById("modal-title");
|
||||
modalTitel.textContent = titel;
|
||||
if (eintrag) {
|
||||
const etage = document.createElement("span");
|
||||
etage.className = "rf-etage";
|
||||
etage.textContent = eintrag.floor;
|
||||
modalTitel.append(" ", etage);
|
||||
}
|
||||
|
||||
// Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne
|
||||
// Zuhoerer stellt sicher, dass nicht der Handler eines vorher
|
||||
@@ -319,414 +353,8 @@ function switchFloor(floor){
|
||||
.then(a => a.text())
|
||||
.then(html => {
|
||||
koerper.innerHTML = html;
|
||||
|
||||
const slider = document.getElementById("modal-slider");
|
||||
if (slider) {
|
||||
bindModalSlider(slider, document.getElementById("modal-slider-label"),
|
||||
wert => wert + " °C");
|
||||
const stand = mqttData["Raumtemp"]?.[teile[0]]?.[teile[1]]?.["Set Temp[degC]"];
|
||||
slider.value = typeof stand !== "undefined" ? stand : 10;
|
||||
// Nur "input": das zeichnet die Anzeige, schickt aber nichts.
|
||||
slider.dispatchEvent(new Event("input"));
|
||||
// "change" kommt beim Loslassen - wie bei den Rollladenreglern.
|
||||
slider.addEventListener("change", () => sendeSolltemperatur(raum));
|
||||
}
|
||||
|
||||
raumBedienungAktivieren(koerper);
|
||||
// Jede Jalousie im Raum einmal an der Box nachfragen. Der Wert im
|
||||
// Modal kommt aus actor_states und ist damit hoechstens so frisch
|
||||
// wie der letzte Poll des Runners - und wer das Modal waehrend einer
|
||||
// Fahrt aufmacht, saehe sonst den Stand von davor und nie wieder
|
||||
// etwas anderes: gezeichnet wird nur beim Oeffnen.
|
||||
koerper.querySelectorAll(".raum-geraet").forEach(k => jalousieVerfolgen(k, null));
|
||||
raumMesswerteAktualisieren(teile[0], teile[1]);
|
||||
// Verdrahten, zeichnen, Jalousien nachfragen: js/solar/raumfenster.js.
|
||||
raumfensterStarten(koerper.querySelector("#roomView") || koerper, raum, eintrag?.thermostat);
|
||||
})
|
||||
.catch(fehler => { koerper.innerHTML = "Konnte den Raum nicht laden: " + fehler.message; });
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Bedienelemente des Raum-Modals mit ihren Kommandos verbinden.
|
||||
*
|
||||
* Welches Geraet welches Element bekommt, entscheidet der Server
|
||||
* (restricted/roomControls.php); hier haengen nur die Zuhoerer an den
|
||||
* data-Attributen, die er dabei setzt.
|
||||
*/
|
||||
function raumBedienungAktivieren(koerper) {
|
||||
koerper.querySelectorAll("button[data-cmd]").forEach(k => {
|
||||
k.addEventListener("click", () => schalte(k));
|
||||
});
|
||||
|
||||
// Schieberegler: waehrend des Ziehens nur die Anzeige, geschickt wird
|
||||
// erst beim Loslassen. Sonst bekaeme eine Jalousie bei jedem Pixel ein
|
||||
// neues Ziel und kaeme aus dem Anfahren nicht mehr heraus.
|
||||
koerper.querySelectorAll(".raum-jalousie").forEach(lamellenZeichnen);
|
||||
koerper.querySelectorAll(".raum-regler").forEach(regler => {
|
||||
const schieber = regler.querySelector("input[type=range]");
|
||||
reglerAnzeigen(regler);
|
||||
schieber.addEventListener("input", () => reglerAnzeigen(regler));
|
||||
schieber.addEventListener("change", () => {
|
||||
const params = {};
|
||||
params[regler.dataset.param] = schieber.value;
|
||||
// Der Hoehenregler nimmt die Neigung mit: eine Hoehenfahrt rastet die
|
||||
// Lamellen um, und der Server faehrt sie nur dann wieder an, wenn der
|
||||
// Befehl ein Neigungsziel dabei hat (Kugelschreiber-Mechanik, siehe
|
||||
// sendeTahoma() in restricted/commands.php). Welcher Parameter das
|
||||
// ist, sagt der Server ueber data-mit-param.
|
||||
if (regler.dataset.mitParam) {
|
||||
const daneben = regler.closest(".raum-jalousie")
|
||||
.querySelector("[data-rolle='" + regler.dataset.mitRolle + "'] input[type=range]");
|
||||
if (daneben) params[regler.dataset.mitParam] = daneben.value;
|
||||
}
|
||||
raumKommando(Number(regler.dataset.cmd), params, regler);
|
||||
});
|
||||
});
|
||||
|
||||
// Kippschalter: An und Aus sind zwei getrennte Kommandos. Kennt das
|
||||
// Geraet nur "umschalten", tut es auch das - dann stimmt die Stellung
|
||||
// hinterher, weil wir sie selbst gesetzt haben.
|
||||
koerper.querySelectorAll(".raum-schalter").forEach(schalter => {
|
||||
const kasten = schalter.querySelector("input[type=checkbox]");
|
||||
const beschriftung = schalter.querySelector("label");
|
||||
kasten.addEventListener("change", () => {
|
||||
const ziel = kasten.checked ? schalter.dataset.ein : schalter.dataset.aus;
|
||||
const cmd = ziel || schalter.dataset.um;
|
||||
beschriftung.textContent = kasten.checked ? "An" : "Aus";
|
||||
if (!cmd) return;
|
||||
raumKommando(Number(cmd), {}, schalter);
|
||||
});
|
||||
});
|
||||
|
||||
// Farbe: der Browser bringt seinen Farbwaehler mit, das Geraet will drei
|
||||
// Zahlen 0-255. Zerlegt wird deshalb erst hier.
|
||||
koerper.querySelectorAll(".raum-farbe").forEach(feld => {
|
||||
const eingabe = feld.querySelector("input[type=color]");
|
||||
eingabe.addEventListener("change", () => {
|
||||
const hex = eingabe.value;
|
||||
const params = {};
|
||||
params[feld.dataset.red] = parseInt(hex.substr(1, 2), 16);
|
||||
params[feld.dataset.green] = parseInt(hex.substr(3, 2), 16);
|
||||
params[feld.dataset.blue] = parseInt(hex.substr(5, 2), 16);
|
||||
raumKommando(Number(feld.dataset.cmd), params, feld);
|
||||
});
|
||||
});
|
||||
|
||||
// Auswahllisten, die ohne zusaetzlichen Knopf schalten - Effekt, Preset.
|
||||
koerper.querySelectorAll("select.raum-sofort").forEach(auswahl => {
|
||||
auswahl.addEventListener("change", () => {
|
||||
const params = {};
|
||||
params[auswahl.dataset.param] = auswahl.value;
|
||||
raumKommando(Number(auswahl.dataset.cmd), params, auswahl);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzeige, gefuellte Bahn und Lamellenbild eines Reglers nachziehen.
|
||||
*
|
||||
* Was 0 % und 100 % bedeuten, steht bei den senkrechten Reglern als Wort
|
||||
* an ihren Enden ("oben"/"unten", "auf"/"zu"); eine Zahl steht dort nicht
|
||||
* mehr - das Bild daneben zeigt beim Ziehen mehr als sie.
|
||||
*/
|
||||
function reglerAnzeigen(regler) {
|
||||
const schieber = regler.querySelector("input[type=range]");
|
||||
const anzeige = regler.querySelector(".raum-regler-wert");
|
||||
const wert = Number(schieber.value);
|
||||
|
||||
if (anzeige) {
|
||||
anzeige.textContent = wert + (schieber.dataset.einheit || "");
|
||||
}
|
||||
|
||||
// Der gefuellte Anteil der Bahn - dieselbe Eigenschaft, aus der auch
|
||||
// bindModalSlider() in common.js den Temperaturregler faerbt.
|
||||
const min = Number(schieber.min);
|
||||
const max = Number(schieber.max);
|
||||
schieber.style.setProperty("--background-size",
|
||||
((wert - min) * 100 / (max - min)) + "%");
|
||||
|
||||
lamellenZeichnen(regler.closest(".raum-jalousie"));
|
||||
}
|
||||
|
||||
/** Der Stand eines der beiden Regler einer Jalousie, als Anteil 0..1. */
|
||||
function reglerAnteil(jalousie, rolle, ersatz) {
|
||||
const schieber = jalousie.querySelector("[data-rolle=" + rolle + "] input[type=range]");
|
||||
if (!schieber) return ersatz;
|
||||
const min = Number(schieber.min);
|
||||
return (Number(schieber.value) - min) / (Number(schieber.max) - min);
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Fenster zeichnen, wie die Jalousie davor gerade steht.
|
||||
*
|
||||
* Zwei Zahlen in einem Bild. Die Hoehe schneidet ab - gezeichnet wird nur
|
||||
* der Teil des Fensters, den die Jalousie bedeckt, mit der Endschiene an
|
||||
* seiner Unterkante. Die Neigung bestimmt, wie dick eine Lamelle von vorn
|
||||
* erscheint: w * sin(Winkel). Bei "auf" sieht man nur ihre Kante und viel
|
||||
* Luft dazwischen, bei "zu" deckt sie den Abstand zur naechsten
|
||||
* vollstaendig ab - genau das, was das Licht abhaelt. Von Mitte zu Mitte
|
||||
* sind es 10 Einheiten, die Lamelle ist 11 tief; ab etwa 65 % schliesst
|
||||
* das Bild, so wie es die Jalousie auch tut.
|
||||
*
|
||||
* Ein Rollladen ohne Neigung bekommt volle Lamellenhoehe - er ist ja ein
|
||||
* geschlossener Panzer.
|
||||
*/
|
||||
function lamellenZeichnen(jalousie) {
|
||||
if (!jalousie) return;
|
||||
const bild = jalousie.querySelector(".raum-lamellen");
|
||||
if (!bild) return;
|
||||
|
||||
const hoch = bild.viewBox.baseVal.height;
|
||||
const abdeckung = hoch * reglerAnteil(jalousie, "position", 1);
|
||||
const kippung = reglerAnteil(jalousie, "neigung", 1);
|
||||
const dicke = Math.max(1.6, 11 * Math.sin(kippung * Math.PI / 2));
|
||||
|
||||
bild.querySelector(".raum-abdeckung").setAttribute("height", abdeckung);
|
||||
bild.querySelectorAll(".raum-lamelle").forEach(lamelle => {
|
||||
const mitte = Number(lamelle.dataset.mitte);
|
||||
lamelle.setAttribute("height", dicke);
|
||||
lamelle.setAttribute("y", mitte - dicke / 2);
|
||||
});
|
||||
// Die Endschiene sitzt auf der Unterkante und bleibt im Bild, auch wenn
|
||||
// die Jalousie ganz oben steht.
|
||||
const schiene = bild.querySelector(".raum-endschiene");
|
||||
const dick = schiene.height.baseVal.value;
|
||||
schiene.setAttribute("y", Math.max(0, Math.min(hoch - dick, abdeckung - dick)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Ist-Werte im Heizungsteil aus dem laufenden MQTT-Strom fuellen.
|
||||
*
|
||||
* Der Browser hoert ohnehin schon auf Raumtemp/#, und ein Wert aus der
|
||||
* Datenbank waere neben dem Regler nur ein Stand von vorhin.
|
||||
*/
|
||||
function raumMesswerteAktualisieren(etage, raum) {
|
||||
const werte = mqttData["Raumtemp"]?.[etage]?.[raum];
|
||||
if (!werte) return;
|
||||
document.querySelectorAll("#roomView [data-mqtt]").forEach(feld => {
|
||||
const wert = werte[feld.dataset.mqtt];
|
||||
if (typeof wert !== "undefined") feld.textContent = wert;
|
||||
});
|
||||
const heizt = document.querySelector("#roomView [data-mqtt-heizung]");
|
||||
if (heizt) heizt.classList.toggle("d-none", String(werte["Heating"]) !== "true");
|
||||
}
|
||||
|
||||
/** Ein Kommando des angeklickten Knopfes ausfuehren. */
|
||||
function schalte(knopf) {
|
||||
const zeile = knopf.closest("[data-cmdrow]");
|
||||
const params = {};
|
||||
if (zeile) {
|
||||
zeile.querySelectorAll("[data-param]").forEach(f => { params[f.dataset.param] = f.value; });
|
||||
}
|
||||
raumKommando(Number(knopf.dataset.cmd), params, knopf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Kommando schicken und die Karte so lange sperren.
|
||||
*
|
||||
* Ein "Zu" an einer Jalousie dauert ueber eine Minute (die Mechanik will
|
||||
* zweimal geschwenkt werden, siehe restricted/commands.php). Ohne sichtbare
|
||||
* Sperre tippt man in der Zeit dreimal nach.
|
||||
*/
|
||||
function raumKommando(cmdId, params, element) {
|
||||
const karte = element.closest(".raum-geraet") || element;
|
||||
karte.classList.add("raum-warte");
|
||||
// Die Verfolgung laeuft neben dem Auftrag her, nicht nach ihm: ein "Zu"
|
||||
// faehrt herunter, wartet und neigt danach - die Antwort kaeme also erst,
|
||||
// wenn die Jalousie schon steht. Beides nebeneinander geht, seit
|
||||
// ajax/room.php die Sitzung freigibt. Der Auftrag geht mit, weil nur er
|
||||
// weiss, wann das Manoever zu Ende ist.
|
||||
const auftrag = raumRequest("command", { command_id: cmdId, params: params });
|
||||
jalousieVerfolgen(karte, auftrag);
|
||||
auftrag.then(antwort => {
|
||||
karte.classList.remove("raum-warte");
|
||||
raumMeldung(antwort);
|
||||
});
|
||||
}
|
||||
|
||||
// Takt und Grenzen der Verfolgung. Dieselben Zahlen wie die Konstanten
|
||||
// JALOUSIE_* in restricted/commands.php: die Box meldet core:MovingState
|
||||
// traege, kurz nach dem Kommando steht dort noch "faehrt nicht" - deshalb
|
||||
// der Vorlauf, in dem ein Stillstand nichts bedeutet.
|
||||
const JALOUSIE_TAKT_MS = 2000;
|
||||
const JALOUSIE_VORLAUF_MS = 8000;
|
||||
const JALOUSIE_MAX_MS = 120000;
|
||||
|
||||
// Wie lange nach dem Ende des Auftrags noch nachgesehen wird. Eine
|
||||
// Neigungsfahrt setzt core:MovingState nie (siehe warteAufJalousie() in
|
||||
// restricted/commands.php), dauert aber gemessen bis zu fuenfzehn Sekunden
|
||||
// von Anschlag zu Anschlag - ohne diesen Nachlauf stuende der Wert von
|
||||
// vor dem letzten Schwenk in der Anzeige.
|
||||
const JALOUSIE_NACHLAUF_MS = 14000;
|
||||
|
||||
/**
|
||||
* Nach einem Fahrbefehl abwarten, bis die Jalousie steht, und dann ihren
|
||||
* wirklichen Stand zeigen.
|
||||
*
|
||||
* actor_states schreibt sonst nur der Runner fort, und der fragt die
|
||||
* Tahoma-Box alle fuenf Minuten ab - so lange zeigte das Modal nach einem
|
||||
* Fahrbefehl den Stand von davor. Hier wird genau dieses eine Geraet
|
||||
* gefragt; ajax/room.php haelt den frischen Wert gleich in der Tabelle
|
||||
* fest, damit auch das naechste Oeffnen und der Automatik-Editor ihn haben.
|
||||
*
|
||||
* Waehrend der Fahrt wird bewusst nichts angezeigt. An der Anlage
|
||||
* gemessen: die Hoehe (core:ClosureState) steht die ganze Fahrt ueber auf
|
||||
* dem Wert von vor dem Kommando und springt erst am Ende um - wer sie
|
||||
* anzeigte, holte den Regler vom eingestellten Ziel auf den alten Stand
|
||||
* zurueck, nur um ihn zwanzig Sekunden spaeter wieder vorzuschieben. Die
|
||||
* Neigung meldet zwar Zwischenwerte, aber ausgerechnet die der
|
||||
* Kugelschreiber-Mechanik: bei "Neigung 80" liefe der Regler 62 -> 28 ->
|
||||
* 38 -> 79, ueber einen Umweg, den niemand angefordert hat.
|
||||
*/
|
||||
function jalousieVerfolgen(karte, auftrag) {
|
||||
if (!karte || !karte.querySelector(".raum-jalousie")) return;
|
||||
const actor = Number(karte.dataset.actor);
|
||||
if (!actor) return;
|
||||
// Ohne Auftrag - beim Oeffnen des Modals - gibt es keine Traegheit
|
||||
// abzuwarten und nichts nachlaufen zu lassen: was die Box beim ersten
|
||||
// Blick meldet, gilt. Faehrt sie gerade, wird bis zum Stillstand
|
||||
// weitergesehen.
|
||||
const vorlaufNoetig = !!auftrag;
|
||||
|
||||
// Ein zweiter Fahrbefehl schiebt nur das Ende hinaus. Zwei Schleifen
|
||||
// wuerden dieselbe Box doppelt so oft fragen, ohne mehr zu zeigen.
|
||||
karte.dataset.jalousieBis = String(Date.now() + JALOUSIE_MAX_MS);
|
||||
if (karte.dataset.jalousieLaeuft) return;
|
||||
karte.dataset.jalousieLaeuft = "1";
|
||||
|
||||
const beginn = Date.now();
|
||||
let gefahren = false;
|
||||
// Der zuletzt im Stillstand gelesene Stand. "core:MovingState ist false"
|
||||
// heisst noch nicht "steht": die Box meldet das Ende der Fahrt, bevor die
|
||||
// Werte darauf nachgezogen haben. Fertig ist es erst, wenn zwei Abfragen
|
||||
// hintereinander dasselbe sagen.
|
||||
let letzterStand = null;
|
||||
// Solange der Auftrag laeuft, ist nichts entschieden: die
|
||||
// Kugelschreiber-Mechanik schickt zwei Befehle mit einer Wartezeit
|
||||
// dazwischen, und zwischen ihnen steht die Jalousie still. Wer da
|
||||
// aufhoert, schreibt die Zwischenstellung fest - gemessen "Neigung 0",
|
||||
// wo 62 gemeint war.
|
||||
let ruheAb = 0;
|
||||
if (auftrag) {
|
||||
ruheAb = Infinity;
|
||||
Promise.resolve(auftrag).then(
|
||||
function () { ruheAb = Date.now() + JALOUSIE_NACHLAUF_MS; },
|
||||
function () { ruheAb = Date.now(); }
|
||||
);
|
||||
}
|
||||
|
||||
function aufhoeren() {
|
||||
delete karte.dataset.jalousieLaeuft;
|
||||
delete karte.dataset.jalousieBis;
|
||||
}
|
||||
|
||||
function runde() {
|
||||
raumRequest("jalousie", { actor: actor }).then(stand => {
|
||||
// Ein Fehler soll auffallen. Ohne die Meldung endet die Schleife
|
||||
// still, und die Anzeige bleibt einfach stehen - nicht zu
|
||||
// unterscheiden von "die Verfolgung gibt es gar nicht".
|
||||
if (stand && stand.error) raumMeldung(stand);
|
||||
// Modal zu, Box stumm, Geraet unbekannt - in jedem Fall aufhoeren
|
||||
// statt zwei Minuten lang ins Leere zu fragen.
|
||||
if (!stand || stand.error || !document.body.contains(karte)) {
|
||||
aufhoeren();
|
||||
return;
|
||||
}
|
||||
const abgelesen = stand.faehrt
|
||||
? null
|
||||
: String(stand.position) + "/" + String(stand.neigung);
|
||||
const steht = abgelesen !== null && abgelesen === letzterStand;
|
||||
letzterStand = abgelesen;
|
||||
|
||||
if (stand.faehrt) {
|
||||
gefahren = true;
|
||||
} else if (Date.now() >= ruheAb) {
|
||||
// Erst wenn der Auftrag durch ist. Zwischen den beiden Befehlen der
|
||||
// Kugelschreiber-Mechanik steht die Jalousie still und meldet
|
||||
// "Neigung 0" - anzeigen hiesse, den Umweg vorzufuehren.
|
||||
jalousieAnzeigen(karte, stand);
|
||||
}
|
||||
if (Date.now() > Number(karte.dataset.jalousieBis)) {
|
||||
aufhoeren();
|
||||
return;
|
||||
}
|
||||
// Steht sie still, ist die Fahrt vorbei - aber erst, wenn wir sie
|
||||
// haben fahren sehen oder der Vorlauf um ist. Eine letzte Runde
|
||||
// danach holt den Wert, den die Box beim Anhalten setzt.
|
||||
// Beim blossen Aufmachen eines Raums, in dem sich nichts geruehrt
|
||||
// hat, gibt es nichts abzuwarten - dann bleibt es bei einer Abfrage
|
||||
// je Jalousie.
|
||||
if (!vorlaufNoetig && !gefahren && !stand.faehrt) {
|
||||
aufhoeren();
|
||||
return;
|
||||
}
|
||||
const fertig = steht
|
||||
&& Date.now() >= ruheAb
|
||||
&& (gefahren || !vorlaufNoetig
|
||||
|| Date.now() - beginn >= JALOUSIE_VORLAUF_MS);
|
||||
if (fertig) {
|
||||
aufhoeren();
|
||||
return;
|
||||
}
|
||||
setTimeout(() => runde(false), JALOUSIE_TAKT_MS);
|
||||
});
|
||||
}
|
||||
// Beim Oeffnen sofort nachsehen, nach einem Kommando erst im Takt: da
|
||||
// stuende die Box ohnehin noch auf dem alten Wert.
|
||||
setTimeout(runde, auftrag ? JALOUSIE_TAKT_MS : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die gemeldete Stellung in die beiden Regler und das Bild uebernehmen.
|
||||
* Aufgerufen nur im Stillstand, siehe jalousieVerfolgen().
|
||||
*/
|
||||
function jalousieAnzeigen(karte, stand) {
|
||||
[["position", stand.position], ["neigung", stand.neigung]].forEach(paar => {
|
||||
const wert = paar[1];
|
||||
if (wert === null || typeof wert === "undefined") return;
|
||||
const regler = karte.querySelector(".raum-regler[data-rolle='" + paar[0] + "']");
|
||||
if (!regler) return;
|
||||
const schieber = regler.querySelector("input[type=range]");
|
||||
// Wer gerade zieht, bestimmt. Sonst spraenge ihm der Schieber unter
|
||||
// dem Daumen weg, waehrend die Jalousie noch das alte Ziel anfaehrt.
|
||||
if (document.activeElement === schieber) return;
|
||||
schieber.value = wert;
|
||||
reglerAnzeigen(regler);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Die neue Solltemperatur schicken.
|
||||
*
|
||||
* Das Modal bleibt danach offen: frueher schloss es sich, weil der
|
||||
* Speichern-Knopf das Ende der Bedienung war. Jetzt ist das Verschieben
|
||||
* des Reglers die Bedienung, und wer dreimal nachjustiert, will nicht
|
||||
* dreimal neu aufmachen.
|
||||
*/
|
||||
function sendeSolltemperatur(raum) {
|
||||
const slider = document.getElementById("modal-slider");
|
||||
if (!slider) return;
|
||||
const karte = slider.closest(".raum-geraet");
|
||||
if (karte) karte.classList.add("raum-warte");
|
||||
raumRequest("temp", { room: raum, temp: Number(slider.value) })
|
||||
.then(antwort => {
|
||||
if (karte) karte.classList.remove("raum-warte");
|
||||
raumMeldung(antwort);
|
||||
});
|
||||
}
|
||||
|
||||
function raumRequest(action, body) {
|
||||
return fetch("./ajax/room.php?action=" + action, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(a => a.json())
|
||||
.catch(fehler => ({ error: fehler.message }));
|
||||
}
|
||||
|
||||
/** Rueckmeldung im Modal statt als alert - man schaltet oft mehrmals. */
|
||||
function raumMeldung(antwort) {
|
||||
const ziel = document.getElementById("roomFeedback");
|
||||
if (!ziel) return;
|
||||
ziel.className = "small mt-2 " + (antwort.error ? "text-danger" : "text-body-secondary");
|
||||
ziel.textContent = antwort.error ? antwort.error : "✓ " + antwort.ok;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/* ==========================================================================
|
||||
Ladefenster der Wallboxen (Gerüst: ajax/carForm.php, Schreiben:
|
||||
ajax/carSteuerung.php).
|
||||
|
||||
ladefensterStarten(wurzel, url) nach dem Laden des Fensters
|
||||
|
||||
Liest jede Sekunde aus mqttData (von der Seite gefüllt) und schreibt jede
|
||||
Änderung sofort. Was eben geändert wurde, gilt HALTEN ms lang lokal - so
|
||||
springt die Anzeige nicht auf den alten Stand zurück, bevor die Wallbox
|
||||
den neuen meldet.
|
||||
|
||||
Freigabe (frc) wird gelesen, nicht geraten: 0 = der Modus entscheidet,
|
||||
1 = gesperrt, 2 = lädt sofort. Der große Knopf bietet jeweils den
|
||||
sinnvollen nächsten Schritt an.
|
||||
========================================================================== */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const HALTEN = 8000;
|
||||
|
||||
// Was die Wallboxen melden und wie es heißt. Geschrieben wird mit denselben
|
||||
// Schlüsseln über restricted/wallboxen.php.
|
||||
const WALLBOXEN = {
|
||||
eg: { // Wattpilot, über wattpilot_bruecke.py im SolarManager
|
||||
modus: "wattpilot/properties/lmo/state", modi: { Default: "sofort", Awattar: "sonne", AutomaticStop: "plan", 3: "sofort", 4: "sonne", 5: "plan" },
|
||||
ampere: "wattpilot/properties/amp/state",
|
||||
energie: "wattpilot/properties/fte/state", zeit: "wattpilot/properties/ftt/state",
|
||||
frc: "wattpilot/properties/frc/state", frcWerte: { Neutral: 0, Off: 1, On: 2, 0: 0, 1: 1, 2: 2 },
|
||||
auto: "wattpilot/properties/car/state", autoWerte: { Idle: "kein", Charging: "laedt", WaitCar: "wartet", Complete: "fertig" }, // Complete = Ladevorgang beendet, nicht Akku voll
|
||||
leistungKW: "solarManager/evPower", soc: "solarManager/evSOC", restMin: "solarManager/evRemChrgTime",
|
||||
},
|
||||
og: { // go-e Charger, direkt am Broker
|
||||
modus: "go-eCharger/270003/lmo", modi: { 3: "sofort", 4: "sonne", 5: "plan" },
|
||||
ampere: "go-eCharger/270003/amp",
|
||||
energie: "go-eCharger/270003/ate", zeit: "go-eCharger/270003/att",
|
||||
frc: "go-eCharger/270003/frc", frcWerte: { 0: 0, 1: 1, 2: 2 },
|
||||
auto: "go-eCharger/270003/car", autoWerte: { 1: "kein", 2: "laedt", 3: "wartet", 4: "fertig" },
|
||||
leistungKW: "solarManager/evPowerOG", soc: null, restMin: null,
|
||||
},
|
||||
};
|
||||
const KW_JE_A = { 6: 4.1, 10: 6.9, 16: 11 }; // dreiphasig, 230 V
|
||||
|
||||
const komma = (z, n = 1) => Number(z).toFixed(n).replace(".", ",");
|
||||
const hhmm = d => String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
|
||||
const text = t => { const v = window.mqttData && mqttData[t]; return v === undefined ? undefined : String(v); };
|
||||
const zahl = t => { const v = Number(text(t)); return isFinite(v) ? v : null; };
|
||||
|
||||
function sekundenZuZeit(s) {
|
||||
if (s === null) return null;
|
||||
const h = Math.floor(s / 3600) % 24, m = Math.floor(s % 3600 / 60);
|
||||
return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0");
|
||||
}
|
||||
/** "07:00" -> "heute 07:00" oder "morgen 07:00", je nachdem, was als nächstes kommt. */
|
||||
function naechstes(zeit) {
|
||||
const [h, m] = zeit.split(":").map(Number);
|
||||
const jetzt = new Date(), ziel = new Date();
|
||||
ziel.setHours(h, m, 0, 0);
|
||||
return (ziel > jetzt ? "heute " : "morgen ") + zeit;
|
||||
}
|
||||
function inStunden(h) {
|
||||
const d = new Date(Date.now() + h * 3600000);
|
||||
return (d.getDate() !== new Date().getDate() ? "morgen " : "um ") + hhmm(d);
|
||||
}
|
||||
|
||||
window.ladefensterStarten = function (wurzel, url) {
|
||||
const box = WALLBOXEN[wurzel.dataset.wallbox];
|
||||
if (!box) return;
|
||||
const whProProzent = Number(wurzel.dataset.whProProzent);
|
||||
const $ = name => wurzel.querySelector('[data-lade="' + name + '"]');
|
||||
const lokal = {}; // name -> {wert, bis}
|
||||
let ziehen = false;
|
||||
|
||||
const merke = (name, wert) => { lokal[name] = { wert, bis: Date.now() + HALTEN }; };
|
||||
const gilt = (name, gemeldet) => (lokal[name] && lokal[name].bis > Date.now()) ? lokal[name].wert : gemeldet;
|
||||
|
||||
function meldung(textInhalt, art) {
|
||||
const m = $("meldung");
|
||||
m.textContent = textInhalt;
|
||||
m.className = "lade-meldung" + (art ? " " + art : "");
|
||||
clearTimeout(meldung.uhr);
|
||||
if (art === "gut") meldung.uhr = setTimeout(() => { m.textContent = "Änderungen gelten sofort"; m.className = "lade-meldung"; }, 2200);
|
||||
}
|
||||
|
||||
async function senden(daten) {
|
||||
meldung("wird geschickt …");
|
||||
try {
|
||||
const antwort = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(daten) });
|
||||
const d = await antwort.json().catch(() => ({}));
|
||||
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||||
meldung("✓ an die Wallbox geschickt", "gut");
|
||||
} catch (e) {
|
||||
meldung("Nicht übernommen: " + e.message, "fehler");
|
||||
}
|
||||
zeichnen();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ Stand aus MQTT */
|
||||
function stand() {
|
||||
const soc = box.soc ? zahl(box.soc) : null;
|
||||
const energieWh = zahl(box.energie);
|
||||
const zeit = gilt("zeit", sekundenZuZeit(zahl(box.zeit)) || "07:00");
|
||||
// Ziel: mit Ladestand als Prozent des Akkus, ohne als zusätzliche Prozent.
|
||||
const gemeldetZiel = energieWh === null ? 80
|
||||
: soc !== null ? Math.min(100, Math.round((soc + energieWh / whProProzent) / 5) * 5)
|
||||
: Math.min(100, Math.max(5, Math.round(energieWh / whProProzent / 5) * 5));
|
||||
return {
|
||||
soc,
|
||||
modus: gilt("modus", box.modi[text(box.modus)] || null),
|
||||
ampere: gilt("ampere", zahl(box.ampere)),
|
||||
frc: gilt("frc", box.frcWerte[text(box.frc)] ?? 0),
|
||||
auto: box.autoWerte[text(box.auto)] || null,
|
||||
kw: zahl(box.leistungKW) || 0,
|
||||
restMin: box.restMin ? zahl(box.restMin) : null,
|
||||
ziel: gilt("ziel", gemeldetZiel),
|
||||
zeit,
|
||||
netz: zahl("solarManager/P_Grid") || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ Zeichnen */
|
||||
function zeichnen() {
|
||||
const s = stand();
|
||||
const laedt = s.kw >= 0.2 || s.auto === "laedt";
|
||||
const mitSoc = s.soc !== null;
|
||||
|
||||
// Ring
|
||||
const ist = mitSoc ? s.soc : 0;
|
||||
const ziel = s.modus === "plan" ? s.ziel : mitSoc ? 100 : 0;
|
||||
const zielBogen = mitSoc ? Math.max(0, ziel - ist) : (s.modus === "plan" ? s.ziel : 0);
|
||||
const istBogen = wurzel.querySelector(".lade-ist");
|
||||
istBogen.style.strokeDasharray = ist + " 100";
|
||||
// Ein Bogen der Länge 0 zeigt mit runden Enden einen Punkt.
|
||||
istBogen.style.opacity = ist > 0 ? "1" : "0";
|
||||
const zb = wurzel.querySelector(".lade-ziel");
|
||||
zb.style.strokeDasharray = zielBogen + " 100";
|
||||
zb.style.strokeDashoffset = -ist;
|
||||
setze("soc", mitSoc ? Math.round(s.soc) + " %" : (Number(wurzel.dataset.akkuKwh) ? komma(Number(wurzel.dataset.akkuKwh)) + " kWh" : "–"));
|
||||
setze("ringUnten", s.modus === "plan" ? (mitSoc ? "Ziel " + s.ziel + " %" : "+ " + s.ziel + " %") : mitSoc ? "Ladestand" : "Akku");
|
||||
|
||||
// Zustand
|
||||
const zustand = laedt ? "lädt" : s.frc === 1 ? "gestoppt" : s.auto === "fertig" ? "beendet" : s.auto === "kein" ? "kein Auto" : "wartet";
|
||||
$("leistung").innerHTML = komma(s.kw) + " kW<small>" + zustand + "</small>";
|
||||
const pille = $("stecker");
|
||||
const pilleText = s.auto === "kein" ? "kein Auto" : s.auto === "laedt" ? "lädt" : s.auto === "fertig" ? "beendet" : s.auto ? "angesteckt" : "";
|
||||
setze("stecker", pilleText);
|
||||
pille.className = "lade-pille" + (s.auto && s.auto !== "kein" ? " an" : "");
|
||||
const anteil = laedt && s.kw > 0 ? Math.max(0, Math.min(1, 1 - Math.max(0, s.netz) / (s.kw * 1000))) : null;
|
||||
setze("quelle", anteil !== null ? Math.round(anteil * 100) + " % aus Sonnenstrom" : "");
|
||||
let fertig = "";
|
||||
if (laedt && s.restMin > 0) fertig = "fertig " + inStunden(s.restMin / 60);
|
||||
else if (s.modus === "plan") fertig = "Ziel bis " + naechstes(s.zeit);
|
||||
setze("fertig", fertig);
|
||||
|
||||
// Knopf
|
||||
const knopf = $("start");
|
||||
let aktion, beschriftung, klasse = "";
|
||||
if (laedt) { aktion = 1; beschriftung = "Laden stoppen"; klasse = "laeuft"; }
|
||||
else if (s.frc === 1) { aktion = 0; beschriftung = "Ladung freigeben"; klasse = "gesperrt"; }
|
||||
else if (s.frc === 2) { aktion = 0; beschriftung = "Zurück zur Automatik"; }
|
||||
else if (s.auto === "kein") { aktion = 2; beschriftung = "Kein Auto angesteckt"; klasse = "aus"; }
|
||||
else { aktion = 2; beschriftung = "Jetzt laden"; }
|
||||
knopf.dataset.aktion = aktion;
|
||||
knopf.disabled = klasse === "aus";
|
||||
knopf.className = "lade-start " + klasse;
|
||||
setze("startText", beschriftung);
|
||||
|
||||
// Erklärung
|
||||
const kwMax = KW_JE_A[s.ampere] || (s.ampere ? s.ampere * 0.69 : 11);
|
||||
let erkl = {
|
||||
sofort: "Lädt mit bis zu " + komma(kwMax) + " kW – reicht die Sonne nicht, kommt der Rest aus dem Netz.",
|
||||
sonne: "Lädt nur mit Überschuss. Scheint zu wenig Sonne, pausiert die Ladung.",
|
||||
plan: "Lädt bis " + naechstes(s.zeit) + (mitSoc ? " auf " + s.ziel + " %" : " " + s.ziel + " % nach") + " – bevorzugt mit Sonnenstrom, nötigenfalls aus dem Netz.",
|
||||
}[s.modus] || "";
|
||||
if (s.frc === 1) erkl = "Gestoppt: die Wallbox lädt erst wieder nach „Ladung freigeben“ – dann entscheidet der Modus.";
|
||||
if (s.frc === 2) erkl = "Lädt sofort, unabhängig vom Modus. „Zurück zur Automatik“ überlässt es wieder dem Modus.";
|
||||
setze("erklaerung", erkl);
|
||||
|
||||
// Modus, Strom
|
||||
wurzel.querySelectorAll("[data-modus]").forEach(b => b.setAttribute("aria-checked", b.dataset.modus === s.modus ? "true" : "false"));
|
||||
wurzel.querySelectorAll("[data-ampere]").forEach(b => b.setAttribute("aria-checked", Number(b.dataset.ampere) === s.ampere ? "true" : "false"));
|
||||
|
||||
// Planung
|
||||
const planung = $("planung");
|
||||
planung.hidden = s.modus !== "plan";
|
||||
if (!planung.hidden) {
|
||||
const regler = $("ziel");
|
||||
if (!ziehen) regler.value = s.ziel;
|
||||
const wert = Number(regler.value);
|
||||
regler.style.setProperty("--ist", (mitSoc ? s.soc : 0) + "%");
|
||||
regler.style.setProperty("--wert", wert + "%");
|
||||
setze("zielTitel", mitSoc ? "Ladestand bei Abfahrt" : "Zusätzlich laden");
|
||||
setze("markeMitte", mitSoc ? "jetzt " + Math.round(s.soc) + " %" : "");
|
||||
const plusWh = mitSoc ? Math.max(0, wert - s.soc) * whProProzent : wert * whProProzent;
|
||||
$("zielAusgabe").innerHTML = wert + " %<small>" + (plusWh > 0 ? "+ " + komma(plusWh / 1000) + " kWh" : "schon erreicht") + "</small>";
|
||||
setze("abfahrtAusgabe", naechstes(s.zeit));
|
||||
let bekannt = false;
|
||||
wurzel.querySelectorAll("[data-zeit]").forEach(c => {
|
||||
const an = c.dataset.zeit === s.zeit;
|
||||
bekannt = bekannt || an;
|
||||
c.setAttribute("aria-pressed", an ? "true" : "false");
|
||||
});
|
||||
$("chipEigen").setAttribute("aria-pressed", bekannt ? "false" : "true");
|
||||
setze("chipEigenText", bekannt ? "andere" : s.zeit);
|
||||
const eigen = $("zeitEigen");
|
||||
if (document.activeElement !== eigen) eigen.value = s.zeit;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function setze(name, inhalt) {
|
||||
const e = $(name);
|
||||
if (e && e.textContent !== inhalt) e.textContent = inhalt;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ Aktionen */
|
||||
function planSchicken(s) {
|
||||
const ziel = Number($("ziel").value);
|
||||
let wh = s.soc !== null ? Math.max(0, ziel - s.soc) * whProProzent : ziel * whProProzent;
|
||||
wh = Math.max(100, Math.round(wh));
|
||||
merke("modus", "plan");
|
||||
merke("ziel", ziel);
|
||||
merke("zeit", s.zeit);
|
||||
senden({ aktion: "plan", wh, zeit: s.zeit });
|
||||
}
|
||||
|
||||
$("start").addEventListener("click", () => {
|
||||
const wert = Number($("start").dataset.aktion);
|
||||
merke("frc", wert);
|
||||
senden({ aktion: "frc", wert });
|
||||
});
|
||||
wurzel.querySelectorAll("[data-modus]").forEach(b => b.addEventListener("click", () => {
|
||||
const modus = b.dataset.modus;
|
||||
if (modus === "plan") { merke("modus", "plan"); planSchicken(stand()); return; }
|
||||
merke("modus", modus);
|
||||
senden({ aktion: "modus", modus });
|
||||
}));
|
||||
wurzel.querySelectorAll("[data-ampere]").forEach(b => b.addEventListener("click", () => {
|
||||
const ampere = Number(b.dataset.ampere);
|
||||
merke("ampere", ampere);
|
||||
senden({ aktion: "strom", ampere });
|
||||
}));
|
||||
$("ziel").addEventListener("input", () => { ziehen = true; merke("ziel", Number($("ziel").value)); zeichnen(); });
|
||||
$("ziel").addEventListener("change", () => { ziehen = false; planSchicken(stand()); });
|
||||
wurzel.querySelectorAll("[data-zeit]").forEach(c => c.addEventListener("click", () => {
|
||||
merke("zeit", c.dataset.zeit);
|
||||
planSchicken(stand());
|
||||
}));
|
||||
$("chipEigen").addEventListener("click", e => {
|
||||
const feld = $("zeitEigen");
|
||||
if (e.target === feld) return;
|
||||
// showPicker öffnet den Wähler des Browsers; ältere Browser bekommen
|
||||
// wenigstens den Fokus und damit die Eingabe per Tastatur.
|
||||
try { feld.showPicker(); } catch (fehler) { feld.focus(); }
|
||||
});
|
||||
$("zeitEigen").addEventListener("change", e => {
|
||||
if (!/^\d\d:\d\d$/.test(e.target.value)) return;
|
||||
merke("zeit", e.target.value);
|
||||
planSchicken(stand());
|
||||
});
|
||||
|
||||
zeichnen();
|
||||
const takt = setInterval(zeichnen, 1000);
|
||||
const fenster = wurzel.closest(".modal");
|
||||
if (fenster) fenster.addEventListener("hidden.bs.modal", () => clearInterval(takt), { once: true });
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,702 @@
|
||||
/* ==========================================================================
|
||||
Meteogramm: Wetter über die nächsten Tage, im Stil des Dashboards.
|
||||
|
||||
const m = new Meteogramm(behaelter, { kopf: element });
|
||||
m.zeichnen();
|
||||
m.aktualisieren(mqttData); // nur die Live-Leiste, nicht das Bild
|
||||
|
||||
Die Daten kommen aus ajax/meteogramm.php, was gezeigt wird steht in
|
||||
js/solar/meteogrammKatalog.js. Diese Datei kennt kein Feld und keine Spur
|
||||
beim Namen - sie liest nur, was der Katalog deklariert. Wer eine Spur
|
||||
hinzufügen will, ändert hier nichts.
|
||||
|
||||
Ein einziges SVG über alle Felder. Dadurch gibt es genau eine Zeitachse,
|
||||
und Nachtstreifen, Tagesgrenzen, Jetzt-Linie und Zeiger laufen ohne
|
||||
Kunstgriff durch alle Felder hindurch. Jedes Feld ist ein <g> mit eigener
|
||||
Y-Abbildung; X(t) ist gemeinsam.
|
||||
|
||||
Das viewBox ist so breit wie der Behälter (nicht fest), damit eine
|
||||
Nutzereinheit ein CSS-Pixel bleibt und die Beschriftung am Handy nicht
|
||||
mitschrumpft - dieselbe Wahl wie in speicher.js.
|
||||
========================================================================== */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const TAGE = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
||||
const NACHLADEN_MS = 10 * 60000;
|
||||
const ALT_S = 2 * 3600; // ab hier gilt der Stand als alt
|
||||
|
||||
const komma = (z, n = 1) => Number(z).toFixed(n).replace(".", ",");
|
||||
const uhrzeit = d => String(d.getHours()).padStart(2, "0") + ":"
|
||||
+ String(d.getMinutes()).padStart(2, "0");
|
||||
const schutz = s => String(s).replace(/[&<>"]/g, c =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
|
||||
/** Wie in den übrigen Modulen: mqtt.js liefert Buffer, keine Zahlen. */
|
||||
function leser(daten) {
|
||||
return {
|
||||
da(t) { return daten[t] !== undefined; },
|
||||
zahl(t) { const v = Number(String(daten[t])); return isFinite(v) ? v : 0; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Achse auf runde Werte bringen.
|
||||
*
|
||||
* Ohne das stuenden an der Temperaturachse Werte wie 0,8 und 13,4 - die
|
||||
* Zahl ist dann zwar richtig, aber niemand liest eine Kurve gegen 13,4 ab.
|
||||
* Gesucht wird die groesste "huebsche" Schrittweite (1, 2, 2,5, 5, 10 mal
|
||||
* einer Zehnerpotenz), die noch hoechstens n Stufen braucht.
|
||||
*/
|
||||
function schoeneAchse(min, max, n) {
|
||||
const spanne = Math.max(max - min, 1e-6);
|
||||
const roh = spanne / n;
|
||||
const potenz = Math.pow(10, Math.floor(Math.log10(roh)));
|
||||
const schritt = [1, 2, 2.5, 5, 10].map(f => f * potenz).find(s => s >= roh) || 10 * potenz;
|
||||
return {
|
||||
min: Math.floor(min / schritt) * schritt,
|
||||
max: Math.ceil(max / schritt) * schritt,
|
||||
schritt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mitternacht des Tages, in dem t liegt - als Unix-Sekunden. */
|
||||
function tagesBeginn(t) {
|
||||
const d = new Date(t * 1000);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return Math.round(d.getTime() / 1000);
|
||||
}
|
||||
|
||||
class Meteogramm {
|
||||
|
||||
constructor(behaelter, opt = {}) {
|
||||
this.behaelter = behaelter;
|
||||
this.kopf = opt.kopf || null;
|
||||
this.katalog = opt.katalog || METEOGRAMM_KATALOG;
|
||||
const vorgabe = this.katalog.zeitraeume.find(z => z.vorgabe);
|
||||
// Am Handy starten zwei Tage statt fünf: auf 358 nutzbaren Pixeln
|
||||
// blieben für fünf Tage drei Pixel je Stunde, und ein Wolkenband aus
|
||||
// Dreipixelstreifen sagt nichts mehr.
|
||||
const eng = window.innerWidth < this.katalog.umbruch;
|
||||
this.tage = opt.tage
|
||||
|| (eng ? this.katalog.zeitraumSchmal : (vorgabe ? vorgabe.tage : 5));
|
||||
this.stand = null; // Antwort des Endpunkts
|
||||
this.live = null; // Werte der eigenen Station aus MQTT
|
||||
this.zuletzt = {};
|
||||
this.sichtbar = false;
|
||||
|
||||
new ResizeObserver(() => this.tafelZeichnen()).observe(behaelter);
|
||||
new IntersectionObserver(e => {
|
||||
const vorher = this.sichtbar;
|
||||
this.sichtbar = e[0].isIntersecting;
|
||||
this.behaelter.classList.toggle("mtg-pause", !this.sichtbar);
|
||||
// Erst beim Hinsehen laden: vorher kostet der Abruf nur.
|
||||
if (this.sichtbar && !vorher && !this.stand) this.holen();
|
||||
}).observe(behaelter);
|
||||
setInterval(() => {
|
||||
if (this.sichtbar && !document.hidden) this.holen();
|
||||
}, NACHLADEN_MS);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- Zeichnen */
|
||||
|
||||
zeichnen() {
|
||||
this.behaelter.innerHTML = `
|
||||
<div class="mtg">
|
||||
<div class="mtg-meldung" hidden></div>
|
||||
<div class="mtg-jetzt"></div>
|
||||
<div class="mtg-bild"><div class="mtg-tipp" hidden></div></div>
|
||||
<div class="mtg-unten">
|
||||
<div class="mtg-legende"></div>
|
||||
<div class="mtg-fuss"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
const $ = s => this.behaelter.querySelector(s);
|
||||
this.dom = {
|
||||
meldung: $(".mtg-meldung"), jetzt: $(".mtg-jetzt"), bild: $(".mtg-bild"),
|
||||
tipp: $(".mtg-tipp"), legende: $(".mtg-legende"), fuss: $(".mtg-fuss"),
|
||||
};
|
||||
|
||||
// Die Zeitraumknöpfe gehören in die Kartenkopfzeile, nicht ins Bild -
|
||||
// deshalb hängt sie das Modul dort ein, wenn die Seite einen Platz
|
||||
// dafür anbietet.
|
||||
if (this.kopf) {
|
||||
this.kopf.innerHTML =
|
||||
`<div class="mtg-zeitraum" role="group" aria-label="Zeitraum">`
|
||||
+ this.katalog.zeitraeume.map(z =>
|
||||
`<button type="button" data-mtg-tage="${z.tage}"`
|
||||
+ `${z.tage === this.tage ? ' class="aktiv"' : ""}>${z.name}</button>`).join("")
|
||||
+ `</div>`;
|
||||
this.kopf.addEventListener("click", ev => {
|
||||
const b = ev.target.closest("[data-mtg-tage]");
|
||||
if (!b) return;
|
||||
this.tage = Number(b.dataset.mtgTage);
|
||||
this.kopf.querySelectorAll("[data-mtg-tage]").forEach(x =>
|
||||
x.classList.toggle("aktiv", x === b));
|
||||
this.holen();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.stand) this.anzeigen();
|
||||
else if (this.sichtbar) this.holen();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Daten */
|
||||
|
||||
async holen() {
|
||||
try {
|
||||
const antwort = await fetch(
|
||||
"ajax/meteogramm.php?tage=" + this.tage + "&rueck=" + this.katalog.rueckblick,
|
||||
{ cache: "no-store" });
|
||||
const d = await antwort.json();
|
||||
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||||
this.stand = d;
|
||||
this.zuletzt = {};
|
||||
if (this.dom) this.anzeigen();
|
||||
} catch (e) {
|
||||
if (this.dom) {
|
||||
this.dom.bild.innerHTML =
|
||||
'<p class="mtg-leer">Vorhersage nicht abrufbar: ' + schutz(e.message) + "</p>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Nur die Live-Leiste. Das Bild bleibt stehen - es zeigt Stunden. */
|
||||
aktualisieren(daten) {
|
||||
const m = leser(daten);
|
||||
if (!m.da("weatherStation/tempAmb")) return;
|
||||
this.live = {
|
||||
temp: m.zahl("weatherStation/tempAmb"),
|
||||
feuchte: m.zahl("weatherStation/hum"),
|
||||
druck: m.zahl("weatherStation/qff"),
|
||||
wind: m.zahl("weatherStation/avgWindspeed"),
|
||||
boe: m.zahl("weatherStation/maxgust"),
|
||||
richtung: m.zahl("weatherStation/windDeg"),
|
||||
};
|
||||
if (this.dom) this.liveZeigen();
|
||||
}
|
||||
|
||||
anzeigen() {
|
||||
this.liveZeigen();
|
||||
this.tafelZeichnen();
|
||||
this.legendeZeigen();
|
||||
this.fussZeigen();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Live-Leiste */
|
||||
|
||||
liveZeigen() {
|
||||
const s = this.live;
|
||||
if (!s) return;
|
||||
const k = [
|
||||
{ z: "", w: komma(s.temp, 1) + " °C", n: "Temperatur" }, // bi-thermometer-half
|
||||
{ z: "", w: Math.round(s.feuchte) + " %", n: "Feuchte" }, // bi-droplet-fill
|
||||
{ z: "", w: komma(s.wind, 1) + " km/h", n: "Wind" }, // bi-wind
|
||||
{ z: "", w: komma(s.boe, 1) + " km/h", n: "Böen" }, // bi-wind
|
||||
{ z: "", w: komma(s.druck, 1) + " hPa", n: "Druck" }, // bi-speedometer2
|
||||
];
|
||||
const html = '<span class="mtg-jetzt-titel">gerade</span>' + k.map(x =>
|
||||
`<span class="mtg-wert" title="${x.n}"><i>${x.z}</i>${schutz(x.w)}</span>`).join("");
|
||||
if (this.zuletzt.live === html) return;
|
||||
this.zuletzt.live = html;
|
||||
this.dom.jetzt.innerHTML = html;
|
||||
}
|
||||
|
||||
fussZeigen() {
|
||||
const d = this.stand;
|
||||
if (!d) return;
|
||||
const alt = d.jetzt - d.stand > ALT_S;
|
||||
const text = d.stand
|
||||
? "Stand " + uhrzeit(new Date(d.stand * 1000)) + " · Open-Meteo"
|
||||
: "kein Stand";
|
||||
if (this.zuletzt.fuss !== text + alt) {
|
||||
this.zuletzt.fuss = text + alt;
|
||||
this.dom.fuss.textContent = text;
|
||||
this.dom.fuss.classList.toggle("mtg-alt", alt);
|
||||
}
|
||||
const meldung = alt
|
||||
? "Die Vorhersage ist seit über zwei Stunden nicht aufgefrischt worden."
|
||||
: "";
|
||||
this.dom.meldung.textContent = meldung;
|
||||
this.dom.meldung.hidden = !meldung;
|
||||
}
|
||||
|
||||
legendeZeigen() {
|
||||
const teile = [];
|
||||
this.katalog.felder.forEach(f => {
|
||||
(f.baender || []).forEach(b =>
|
||||
teile.push(`<span><i class="mtg-band-probe" style="background:${b.farbe}"></i>${b.name}</span>`));
|
||||
f.spuren.filter(s => s.legende).forEach(s =>
|
||||
teile.push(`<span><i style="background:${s.farbe}"></i>${schutz(s.name)}</span>`));
|
||||
});
|
||||
const html = teile.join("");
|
||||
if (this.zuletzt.legende === html) return;
|
||||
this.zuletzt.legende = html;
|
||||
this.dom.legende.innerHTML = html;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Tafel */
|
||||
|
||||
tafelZeichnen() {
|
||||
const d = this.stand;
|
||||
if (!d || !this.dom) return;
|
||||
const kat = this.katalog;
|
||||
const B = Math.max(320, Math.round(this.dom.bild.clientWidth) || 320);
|
||||
const schmal = B < kat.umbruch;
|
||||
const sig = [B, schmal, this.tage, d.stand, d.jetzt].join("|");
|
||||
if (this.zuletzt.tafel === sig) return;
|
||||
this.zuletzt.tafel = sig;
|
||||
|
||||
if (!d.stunden.length) {
|
||||
this.dom.bild.innerHTML = '<p class="mtg-leer">Noch keine Vorhersage vorhanden.</p>'
|
||||
+ '<div class="mtg-tipp" hidden></div>';
|
||||
this.dom.tipp = this.dom.bild.querySelector(".mtg-tipp");
|
||||
return;
|
||||
}
|
||||
|
||||
const hoehen = kat.hoehen[schmal ? "schmal" : "breit"];
|
||||
const L = schmal ? 30 : 36, R = schmal ? 30 : 38;
|
||||
// Name und Einheit stehen in der Luecke ueber dem Feld, nicht darin -
|
||||
// sonst liegen sie unter den Wettersymbolen und den Windpfeilen, die
|
||||
// beide am oberen Rand ihres Feldes sitzen.
|
||||
const O = 15, U = schmal ? 26 : 24, LUECKE = 16;
|
||||
const felder = kat.felder.map(f => ({ f, h: hoehen[f.hoehe] }));
|
||||
let y = O;
|
||||
felder.forEach(e => { e.oben = y; y += e.h + LUECKE; });
|
||||
const H = y - LUECKE + U;
|
||||
|
||||
const von = d.von, bis = d.bis;
|
||||
const X = t => L + (t - von) / (bis - von) * (B - L - R);
|
||||
const breiteStunde = (B - L - R) / ((bis - von) / 3600);
|
||||
|
||||
const S = kat.spalten;
|
||||
const wert = (zeile, name) => zeile[S[name]];
|
||||
|
||||
// --- Achsen je Feld und Seite ---
|
||||
felder.forEach(e => {
|
||||
e.achsen = {};
|
||||
["links", "rechts"].forEach(seite => {
|
||||
const cfg = e.f[seite];
|
||||
if (!cfg) return;
|
||||
let min = cfg.min, max = cfg.max;
|
||||
if (min === undefined || max === undefined) {
|
||||
const werte = [];
|
||||
e.f.spuren.filter(s => s.achse === seite).forEach(s =>
|
||||
d.stunden.forEach(z => {
|
||||
const v = wert(z, s.spalte);
|
||||
if (v !== null && v !== undefined) werte.push(v);
|
||||
}));
|
||||
if (!werte.length) werte.push(0, 1);
|
||||
if (min === undefined) min = Math.min(...werte);
|
||||
if (max === undefined) max = Math.max(...werte);
|
||||
// Etwas Luft, sonst klebt die Kurve am Rand.
|
||||
const luft = (max - min) * 0.12 || 1;
|
||||
if (cfg.min === undefined) min -= luft;
|
||||
if (cfg.max === undefined) max += luft;
|
||||
}
|
||||
if (cfg.minSpanne && max - min < cfg.minSpanne) {
|
||||
const mitte = (max + min) / 2;
|
||||
min = cfg.min !== undefined ? cfg.min : mitte - cfg.minSpanne / 2;
|
||||
max = min + cfg.minSpanne;
|
||||
}
|
||||
// Feste Grenzen bleiben fest - eine Achse 0..1000 W/m2 soll nicht
|
||||
// auf 0..1000 "gerundet" werden und dabei wandern.
|
||||
let schritt = null;
|
||||
if (cfg.min === undefined || cfg.max === undefined) {
|
||||
const rund = schoeneAchse(min, max, cfg.gitter || 4);
|
||||
if (cfg.min === undefined) min = rund.min;
|
||||
if (cfg.max === undefined) max = rund.max;
|
||||
schritt = rund.schritt;
|
||||
}
|
||||
e.achsen[seite] = { min, max, schritt, einheit: cfg.einheit, cfg };
|
||||
});
|
||||
e.Y = (seite, v) => {
|
||||
const a = e.achsen[seite];
|
||||
return e.oben + (a.max - v) / (a.max - a.min) * e.h;
|
||||
};
|
||||
});
|
||||
|
||||
const id = "mtg" + Math.random().toString(36).slice(2, 8);
|
||||
let svg = `<svg class="mtg-tafel" viewBox="0 0 ${B} ${H}" width="${B}" height="${H}" `
|
||||
+ `role="img" aria-label="Wetter der nächsten ${this.tage} Tage">`;
|
||||
|
||||
// --- Nacht und Vergangenheit ---
|
||||
const nachtStuecke = this.naechte(d);
|
||||
felder.filter(e => e.f.nacht).forEach(e => {
|
||||
nachtStuecke.forEach(([a, b]) => {
|
||||
const x1 = Math.max(L, X(a)), x2 = Math.min(B - R, X(b));
|
||||
if (x2 > x1) {
|
||||
svg += `<rect class="mtg-nacht" x="${x1.toFixed(1)}" y="${e.oben}" `
|
||||
+ `width="${(x2 - x1).toFixed(1)}" height="${e.h}"/>`;
|
||||
}
|
||||
});
|
||||
});
|
||||
if (d.jetzt > von) {
|
||||
const x2 = Math.min(B - R, X(d.jetzt));
|
||||
svg += `<rect class="mtg-vergangen" x="${L}" y="${O}" `
|
||||
+ `width="${(x2 - L).toFixed(1)}" height="${H - O - U}"/>`;
|
||||
}
|
||||
|
||||
// --- Tagesgrenzen durch alle Felder ---
|
||||
for (let t = tagesBeginn(von) + 86400; t < bis; t += 86400) {
|
||||
const x = X(t).toFixed(1);
|
||||
svg += `<line class="mtg-tag" x1="${x}" x2="${x}" y1="${O}" y2="${H - U}"/>`;
|
||||
}
|
||||
|
||||
// --- Felder ---
|
||||
felder.forEach(e => {
|
||||
svg += this.feldZeichnen(e, { d, X, L, R, B, id, breiteStunde, wert, schmal });
|
||||
});
|
||||
|
||||
// --- Jetzt-Linie, Zeitachse, Zeiger, Fangfläche ---
|
||||
const xJetzt = X(d.jetzt);
|
||||
if (xJetzt > L && xJetzt < B - R) {
|
||||
svg += `<line class="mtg-jetztlinie" x1="${xJetzt.toFixed(1)}" x2="${xJetzt.toFixed(1)}" `
|
||||
+ `y1="${O}" y2="${H - U}"/>`;
|
||||
}
|
||||
svg += this.zeitachse({ d, X, L, R, B, H, U, schmal });
|
||||
svg += `<line class="mtg-zeiger" x1="-10" x2="-10" y1="${O}" y2="${H - U}"/>`;
|
||||
svg += `<rect class="mtg-fang" x="${L}" y="0" width="${B - L - R}" height="${H}"/>`;
|
||||
svg += `</svg><div class="mtg-tipp" hidden></div>`;
|
||||
|
||||
this.dom.bild.innerHTML = svg;
|
||||
this.dom.tipp = this.dom.bild.querySelector(".mtg-tipp");
|
||||
this.zeigerVerdrahten({ d, X, L, R, B, felder, wert });
|
||||
}
|
||||
|
||||
/** Nachtabschnitte als [von, bis] aus den Sonnenzeiten der Tage. */
|
||||
naechte(d) {
|
||||
const T = this.katalog.tagSpalten;
|
||||
const stuecke = [];
|
||||
const tage = d.tage;
|
||||
if (!tage.length) return stuecke;
|
||||
// Vor dem ersten Sonnenaufgang ist Nacht.
|
||||
stuecke.push([d.von, tage[0][T.auf]]);
|
||||
tage.forEach((tag, i) => {
|
||||
const naechster = tage[i + 1];
|
||||
stuecke.push([tag[T.unter], naechster ? naechster[T.auf] : d.bis]);
|
||||
});
|
||||
return stuecke;
|
||||
}
|
||||
|
||||
/** Ein Feld: Gitter, Achsen, Bänder, Spuren, Symbolleisten. */
|
||||
feldZeichnen(e, o) {
|
||||
const { d, X, L, R, B, id, breiteStunde, wert, schmal } = o;
|
||||
const f = e.f;
|
||||
const S = this.katalog.spalten;
|
||||
let svg = `<g class="mtg-feld" data-feld="${f.id}">`;
|
||||
|
||||
// Gitter und linke Beschriftung
|
||||
const links = e.achsen.links;
|
||||
if (links) {
|
||||
const schritt = links.schritt || (links.max - links.min) / (links.cfg.gitter || 4);
|
||||
for (let v = links.min; v <= links.max + 1e-6; v += schritt) {
|
||||
const y = e.Y("links", v).toFixed(1);
|
||||
svg += `<line class="mtg-gitter" x1="${L}" x2="${B - R}" y1="${y}" y2="${y}"/>`;
|
||||
svg += `<text class="mtg-achse" x="${L - 4}" y="${+y + 3}" text-anchor="end">`
|
||||
+ `${this.achsenZahl(v)}</text>`;
|
||||
}
|
||||
}
|
||||
// Rechte Beschriftung nur, wo sie etwas bedeutet.
|
||||
const rechts = e.achsen.rechts;
|
||||
if (rechts && rechts.cfg.ticks) {
|
||||
rechts.cfg.ticks.forEach(v => {
|
||||
svg += `<text class="mtg-achse" x="${B - R + 4}" y="${e.Y("rechts", v) + 3}">`
|
||||
+ `${this.achsenZahl(v)}</text>`;
|
||||
});
|
||||
}
|
||||
|
||||
// Name samt Einheit in die Luecke darueber, als EIN Text. Getrennt
|
||||
// standen "mm/h" und "NIEDERSCHLAG UND BEWOELKUNG" dicht nebeneinander
|
||||
// und lasen sich wie ein Wort.
|
||||
const titel = f.name + (links ? " · " + links.einheit : "");
|
||||
svg += `<text class="mtg-name" x="${L}" y="${e.oben - 5}">${schutz(titel)}</text>`;
|
||||
if (rechts && rechts.cfg.ticks) {
|
||||
svg += `<text class="mtg-einheit" x="${B - R + 4}" y="${e.oben - 5}">`
|
||||
+ `${schutz(rechts.einheit)}</text>`;
|
||||
}
|
||||
|
||||
// Bänder (Bewölkung): je Schicht EIN Rechteck mit einem waagerechten
|
||||
// Farbverlauf, dessen Haltepunkte die Stundenwerte sind.
|
||||
//
|
||||
// Ein Rechteck je Stunde wäre naheliegender und sah aus wie
|
||||
// Fernsehrauschen: zweihundert harte Kanten nebeneinander. Der Verlauf
|
||||
// zeichnet dieselben Zahlen, blendet aber zwischen ihnen über - und
|
||||
// Bewölkung ist ohnehin nichts, was zur vollen Stunde umspringt.
|
||||
(f.baender || []).forEach(band => {
|
||||
band.schichten.forEach((sch, si) => {
|
||||
const y1 = e.Y(band.achse, sch.bis), y2 = e.Y(band.achse, sch.von);
|
||||
const stops = d.stunden.map(z => {
|
||||
const p = wert(z, sch.spalte);
|
||||
const o = p === null || p === undefined ? 0 : p / 100 * band.maxDeckung;
|
||||
const an = ((z[S.t] - d.von) / (d.bis - d.von) * 100).toFixed(2);
|
||||
return `<stop offset="${an}%" stop-color="${band.farbe}" `
|
||||
+ `stop-opacity="${o.toFixed(3)}"/>`;
|
||||
}).join("");
|
||||
if (!stops) return;
|
||||
const gid = `${id}-${f.id}-${si}`;
|
||||
svg += `<defs><linearGradient id="${gid}" x1="0" x2="1" y1="0" y2="0">`
|
||||
+ stops + `</linearGradient></defs>`;
|
||||
svg += `<rect class="mtg-wolke" x="${L}" y="${y1.toFixed(1)}" `
|
||||
+ `width="${(B - R - L).toFixed(1)}" height="${(y2 - y1).toFixed(1)}" `
|
||||
+ `fill="url(#${gid})"/>`;
|
||||
});
|
||||
});
|
||||
|
||||
// Spuren
|
||||
f.spuren.forEach((spur, i) => {
|
||||
const punkte = this.punkte(d, spur, wert);
|
||||
if (!punkte.length) return;
|
||||
if (spur.art === "balken") {
|
||||
const grund = e.Y(spur.achse, Math.max(e.achsen[spur.achse].min, 0));
|
||||
punkte.forEach(([t, v]) => {
|
||||
if (!v) return;
|
||||
const y = e.Y(spur.achse, v);
|
||||
svg += `<rect class="mtg-balken" x="${X(t).toFixed(1)}" y="${y.toFixed(1)}" `
|
||||
+ `width="${Math.max(0.8, breiteStunde * 0.86).toFixed(2)}" `
|
||||
+ `height="${Math.max(0.6, grund - y).toFixed(1)}" fill="${spur.farbe}"/>`;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pfad = this.pfad(punkte, X, v => e.Y(spur.achse, v));
|
||||
if (spur.art === "flaeche") {
|
||||
const grund = e.Y(spur.achse, Math.max(e.achsen[spur.achse].min, 0));
|
||||
// Senkrechter Verlauf statt einer gleichmäßigen Füllung: oben, wo
|
||||
// die Spitzen stehen, kräftig, nach unten auslaufend. Flach
|
||||
// gefüllt sah die Strahlung über der dunklen Fläche olivgrün aus.
|
||||
const gid = `${id}-${f.id}-${spur.id}`;
|
||||
const deck = spur.deckkraft || 0.2;
|
||||
svg += `<defs><linearGradient id="${gid}" x1="0" x2="0" y1="0" y2="1">`
|
||||
+ `<stop offset="0%" stop-color="${spur.farbe}" stop-opacity="${(deck * 1.9).toFixed(3)}"/>`
|
||||
+ `<stop offset="100%" stop-color="${spur.farbe}" stop-opacity="${(deck * 0.25).toFixed(3)}"/>`
|
||||
+ `</linearGradient></defs>`;
|
||||
svg += `<path class="mtg-flaeche" d="${pfad} L${X(punkte[punkte.length - 1][0]).toFixed(1)} `
|
||||
+ `${grund.toFixed(1)} L${X(punkte[0][0]).toFixed(1)} ${grund.toFixed(1)} Z" `
|
||||
+ `fill="url(#${gid})"/>`;
|
||||
return;
|
||||
}
|
||||
svg += `<path class="mtg-linie" d="${pfad}" stroke="${spur.farbe}" `
|
||||
+ `stroke-width="${spur.breit || 2}"`
|
||||
+ (spur.strich ? ` stroke-dasharray="${spur.strich}"` : "")
|
||||
+ ` style="--i:${i}"/>`;
|
||||
if (spur.beschriftung === "minmax") {
|
||||
svg += this.minMax(d, spur, punkte, X, v => e.Y(spur.achse, v), { links: L, rechts: B - R });
|
||||
}
|
||||
});
|
||||
|
||||
// Wettersymbole
|
||||
if (f.symbole) {
|
||||
const schritt = this.schritt(f.symbole, breiteStunde, schmal);
|
||||
d.stunden.forEach((z, i) => {
|
||||
if (i % schritt) return;
|
||||
const eintrag = this.katalog.wetter[wert(z, "code")];
|
||||
if (!eintrag) return;
|
||||
const glyph = (!z[S.t] || wert(z, "code") === null) ? null
|
||||
: (this.istNacht(d, z[S.t]) && eintrag.nacht ? eintrag.nacht : eintrag.glyph);
|
||||
if (!glyph) return;
|
||||
svg += `<text class="mtg-wetter" x="${(X(z[S.t]) + breiteStunde / 2).toFixed(1)}" `
|
||||
+ `y="${e.oben + 15}" text-anchor="middle">${glyph}</text>`;
|
||||
});
|
||||
}
|
||||
|
||||
// Windpfeile
|
||||
if (f.pfeile) {
|
||||
const schritt = this.schritt(f.pfeile, breiteStunde, schmal);
|
||||
const laenge = this.katalog.windPfeil.laenge;
|
||||
d.stunden.forEach((z, i) => {
|
||||
if (i % schritt) return;
|
||||
const grad = wert(z, f.pfeile.spalte);
|
||||
if (grad === null || grad === undefined) return;
|
||||
const v = wert(z, f.pfeile.staerke) || 0;
|
||||
const x = X(z[S.t]) + breiteStunde / 2, y = e.oben + 12;
|
||||
// +180: die Richtung sagt, woher der Wind kommt - der Pfeil fliegt
|
||||
// mit ihm. Dieselbe Rechnung wie updateWeatherCards() in common.js.
|
||||
svg += `<g class="mtg-pfeil${v < this.katalog.windPfeil.schwach ? " mtg-schwach" : ""}" `
|
||||
+ `transform="translate(${x.toFixed(1)} ${y}) rotate(${(grad + 180).toFixed(0)})">`
|
||||
+ `<path d="M0 ${-laenge} L0 ${laenge} M0 ${laenge} L${-3} ${laenge - 4} `
|
||||
+ `M0 ${laenge} L3 ${laenge - 4}"/></g>`;
|
||||
});
|
||||
}
|
||||
|
||||
return svg + "</g>";
|
||||
}
|
||||
|
||||
/** Wie viele Stunden übersprungen werden, damit nichts überlappt. */
|
||||
schritt(cfg, breiteStunde, schmal) {
|
||||
let s = cfg.alle || 3;
|
||||
while (s * breiteStunde < cfg.mindestAbstand && s < 24) s *= 2;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Die Werte einer Spur, gefiltert nach gemessen/vorhergesagt. */
|
||||
punkte(d, spur, wert) {
|
||||
const S = this.katalog.spalten;
|
||||
const aus = [];
|
||||
d.stunden.forEach((z, i) => {
|
||||
const v = wert(z, spur.spalte);
|
||||
if (v === null || v === undefined) return;
|
||||
const gemessen = !!z[S.gemessen];
|
||||
if (spur.nurGemessen && !gemessen) return;
|
||||
// Die Vorhersagespur nimmt die letzte gemessene Stunde mit, sonst
|
||||
// klafft am Jetzt-Strich eine Lücke zwischen beiden Linien.
|
||||
if (spur.nurVorhersage && gemessen) {
|
||||
const naechste = d.stunden[i + 1];
|
||||
if (!naechste || naechste[S.gemessen]) return;
|
||||
}
|
||||
aus.push([z[S.t], spur.skala ? spur.skala(v) : v]);
|
||||
});
|
||||
return aus;
|
||||
}
|
||||
|
||||
/** Pfad mit Lückenerkennung: über zwei Stunden Abstand wird nicht verbunden. */
|
||||
pfad(punkte, X, Y) {
|
||||
let d = "", zug = [];
|
||||
const zu = () => {
|
||||
if (zug.length) d += "M" + zug.map(p => p[0].toFixed(1) + " " + p[1].toFixed(1)).join("L");
|
||||
zug = [];
|
||||
};
|
||||
punkte.forEach((p, i) => {
|
||||
if (i && p[0] - punkte[i - 1][0] > 2 * 3600) zu();
|
||||
zug.push([X(p[0]), Y(p[1])]);
|
||||
});
|
||||
zu();
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Tagesminimum und -maximum an die Kurve schreiben. */
|
||||
minMax(d, spur, punkte, X, Y, grenze) {
|
||||
let svg = "";
|
||||
const nachTag = new Map();
|
||||
punkte.forEach(([t, v]) => {
|
||||
const tag = tagesBeginn(t);
|
||||
const e = nachTag.get(tag);
|
||||
if (!e) nachTag.set(tag, { min: [t, v], max: [t, v] });
|
||||
else {
|
||||
if (v < e.min[1]) e.min = [t, v];
|
||||
if (v > e.max[1]) e.max = [t, v];
|
||||
}
|
||||
});
|
||||
nachTag.forEach(e => {
|
||||
[[e.max, -7], [e.min, 13]].forEach(([p, dy]) => {
|
||||
// Am Rand nach innen ruecken: ein Tagesmaximum in der letzten
|
||||
// Stunde stand sonst halb ausserhalb des Bildes.
|
||||
const x = Math.min(grenze.rechts - 8, Math.max(grenze.links + 8, X(p[0])));
|
||||
svg += `<text class="mtg-marke" x="${x.toFixed(1)}" `
|
||||
+ `y="${(Y(p[1]) + dy).toFixed(1)}" text-anchor="middle" `
|
||||
+ `fill="${spur.farbe}">${Math.round(p[1])}</text>`;
|
||||
});
|
||||
});
|
||||
return svg;
|
||||
}
|
||||
|
||||
istNacht(d, t) {
|
||||
const T = this.katalog.tagSpalten;
|
||||
const tag = d.tage.find(x => t >= x[T.t] && t < x[T.t] + 86400);
|
||||
if (!tag) return false;
|
||||
return t < tag[T.auf] || t >= tag[T.unter];
|
||||
}
|
||||
|
||||
achsenZahl(v) {
|
||||
return Math.abs(v) >= 10 || Number.isInteger(v) ? String(Math.round(v)) : komma(v, 1);
|
||||
}
|
||||
|
||||
/** Der Streifen unten: Wochentag je Tag, bei zwei Tagen auch Stunden. */
|
||||
zeitachse(o) {
|
||||
const { d, X, L, R, B, H, U, schmal } = o;
|
||||
let svg = "";
|
||||
const stundenTicks = this.tage <= 2;
|
||||
for (let t = tagesBeginn(d.von); t < d.bis; t += 86400) {
|
||||
const a = Math.max(d.von, t), b = Math.min(d.bis, t + 86400);
|
||||
if (b <= a) continue;
|
||||
const mitte = (X(a) + X(b)) / 2;
|
||||
const tag = new Date(t * 1000);
|
||||
const text = TAGE[tag.getDay()] + (schmal ? "" : " " + tag.getDate() + ".");
|
||||
svg += `<text class="mtg-tagname" x="${mitte.toFixed(1)}" y="${H - U + 12}" `
|
||||
+ `text-anchor="middle">${text}</text>`;
|
||||
if (stundenTicks) {
|
||||
[6, 12, 18].forEach(h => {
|
||||
const x = X(t + h * 3600);
|
||||
if (x < L || x > B - R) return;
|
||||
svg += `<text class="mtg-stunde" x="${x.toFixed(1)}" y="${H - U + 22}" `
|
||||
+ `text-anchor="middle">${String(h).padStart(2, "0")}</text>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Zeiger */
|
||||
|
||||
zeigerVerdrahten(o) {
|
||||
const { d, X, L, R, B, felder, wert } = o;
|
||||
const S = this.katalog.spalten;
|
||||
const svg = this.dom.bild.querySelector("svg");
|
||||
const zeiger = svg && svg.querySelector(".mtg-zeiger");
|
||||
const tipp = this.dom.tipp;
|
||||
if (!svg || !zeiger || !tipp) return;
|
||||
|
||||
const verstecken = () => {
|
||||
tipp.hidden = true;
|
||||
zeiger.setAttribute("x1", -10);
|
||||
zeiger.setAttribute("x2", -10);
|
||||
};
|
||||
|
||||
const zeigen = ev => {
|
||||
const r = svg.getBoundingClientRect();
|
||||
const x = (ev.clientX - r.left) / r.width * B;
|
||||
const t = d.von + (x - L) / (B - L - R) * (d.bis - d.von);
|
||||
let best = null;
|
||||
d.stunden.forEach(z => {
|
||||
if (!best || Math.abs(z[S.t] - t) < Math.abs(best[S.t] - t)) best = z;
|
||||
});
|
||||
if (!best || Math.abs(best[S.t] - t) > 2 * 3600) return verstecken();
|
||||
|
||||
const tx = X(best[S.t]);
|
||||
zeiger.setAttribute("x1", tx);
|
||||
zeiger.setAttribute("x2", tx);
|
||||
|
||||
const zeit = new Date(best[S.t] * 1000);
|
||||
const eintrag = this.katalog.wetter[wert(best, "code")];
|
||||
let html = `<b>${TAGE[zeit.getDay()]} ${zeit.getDate()}.${zeit.getMonth() + 1}., `
|
||||
+ `${uhrzeit(zeit)}</b>`
|
||||
+ `<em class="mtg-tipp-art">${best[S.gemessen] ? "gemessen" : "Vorhersage"}</em>`;
|
||||
if (eintrag) {
|
||||
html += `<span class="mtg-tipp-wetter"><i>${eintrag.glyph}</i>${schutz(eintrag.name)}</span>`;
|
||||
}
|
||||
this.katalog.felder.forEach(f => {
|
||||
f.spuren.forEach(spur => {
|
||||
if (spur.nurGemessen && !best[S.gemessen]) return;
|
||||
if (spur.nurVorhersage && best[S.gemessen]) return;
|
||||
const v = wert(best, spur.spalte);
|
||||
if (v === null || v === undefined) return;
|
||||
html += `<span><i style="background:${spur.farbe}"></i>${schutz(spur.name)}`
|
||||
+ `<em>${schutz(spur.format(v))}</em></span>`;
|
||||
});
|
||||
(f.baender || []).forEach(band => {
|
||||
const teile = band.schichten
|
||||
.map(s => [s.name, wert(best, s.spalte)])
|
||||
.filter(([, v]) => v !== null && v !== undefined);
|
||||
if (!teile.length) return;
|
||||
html += `<span><i class="mtg-band-probe" style="background:${band.farbe}"></i>`
|
||||
+ `${schutz(band.name)}<em>`
|
||||
+ teile.map(([n, v]) => n + " " + Math.round(v) + " %").join(" · ")
|
||||
+ `</em></span>`;
|
||||
});
|
||||
});
|
||||
tipp.innerHTML = html;
|
||||
tipp.hidden = false;
|
||||
const links = tx / B * r.width;
|
||||
tipp.style.left = Math.min(r.width - tipp.offsetWidth - 4,
|
||||
Math.max(4, links + 12)) + "px";
|
||||
};
|
||||
|
||||
svg.addEventListener("pointermove", zeigen);
|
||||
svg.addEventListener("pointerdown", zeigen);
|
||||
svg.addEventListener("pointerleave", verstecken);
|
||||
}
|
||||
}
|
||||
|
||||
window.Meteogramm = Meteogramm;
|
||||
})();
|
||||
@@ -0,0 +1,271 @@
|
||||
/* ==========================================================================
|
||||
Was das Meteogramm zeigt.
|
||||
|
||||
Hier steht alles, was man ändern will, und nichts, was man zum Zeichnen
|
||||
wissen muss. js/solar/meteogramm.js kennt kein Feld, keine Spur und keine
|
||||
Farbe beim Namen - es liest nur, was hier deklariert ist. Dasselbe Muster
|
||||
wie anlageKatalog.js und energieflussKatalog.js.
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
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. Hinten anhängen ist wichtig: die Stellen der bestehenden
|
||||
Spuren bleiben dadurch gültig.
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
Das Vokabular
|
||||
|
||||
Ein FELD ist ein waagerechter Streifen mit eigener senkrechter Achse. Alle
|
||||
Felder teilen sich die Zeitachse.
|
||||
|
||||
id Kennung
|
||||
name Überschrift links oben im Feld
|
||||
hoehe Schlüssel in HOEHEN (breit/schmal)
|
||||
links { einheit, min?, max?, minSpanne?, gitter? } - linke Achse
|
||||
rechts dasselbe für die rechte Achse, oder fehlt
|
||||
nacht true = Tag/Nacht-Schattierung in diesem Feld zeichnen
|
||||
symbole { alle, mindestAbstand } für die Wettersymbolleiste, oder fehlt
|
||||
pfeile { spalte, alle, mindestAbstand } für die Windpfeile, oder fehlt
|
||||
baender Liste von BÄNDERN, vor den Spuren gezeichnet
|
||||
spuren Liste von SPUREN, in Zeichenreihenfolge (erste zuunterst)
|
||||
|
||||
Eine SPUR ist eine Reihe von Werten über die Zeit.
|
||||
|
||||
id, name Kennung; der Name steht in Legende und Tooltip
|
||||
spalte Schlüssel in SPALTEN
|
||||
achse "links" oder "rechts"
|
||||
art "linie" | "flaeche" | "balken"
|
||||
farbe Datenfarben gehören hierher, nicht ins Stylesheet
|
||||
breit Strichstärke bei "linie"
|
||||
strich z.B. "4 3" für gestrichelt
|
||||
nurGemessen true = nur Stunden mit gemessen = 1
|
||||
nurVorhersage true = nur Stunden ohne
|
||||
beschriftung "minmax" = Tagesminimum und -maximum an die Kurve schreiben
|
||||
format Wert -> Text für den Tooltip
|
||||
legende true = in der Legendenzeile zeigen
|
||||
|
||||
Ein BAND füllt eine Fläche mit veränderlicher Deckkraft - so wird das
|
||||
Höhenprofil der Bewölkung nachgebaut, das Open-Meteo nicht liefert.
|
||||
|
||||
schichten je { spalte, von, bis, name } in Einheiten der rechten Achse
|
||||
maxDeckung Deckkraft bei 100 %
|
||||
========================================================================== */
|
||||
|
||||
const METEOGRAMM_KATALOG = (function () {
|
||||
"use strict";
|
||||
|
||||
const komma = (z, n = 1) => Number(z).toFixed(n).replace(".", ",");
|
||||
const ganz = z => String(Math.round(z));
|
||||
|
||||
/* Die Spaltenlage einer Stundenzeile aus ajax/meteogramm.php. Steht dort
|
||||
im Kopfkommentar noch einmal - beide Listen müssen zueinander passen. */
|
||||
const SPALTEN = {
|
||||
t: 0, gemessen: 1, temp: 2, regen: 3, schnee: 4, wind: 5, boe: 6,
|
||||
richtung: 7, strahlung: 8, wtief: 9, wmittel: 10, whoch: 11,
|
||||
code: 12, feuchte: 13, druck: 14, regenWkt: 15,
|
||||
};
|
||||
|
||||
/* Eine Tageszeile. */
|
||||
const TAGSPALTEN = { t: 0, tmin: 1, tmax: 2, code: 3, auf: 4, unter: 5 };
|
||||
|
||||
/* Feldhöhen in Nutzereinheiten (= CSS-Pixel). Unter UMBRUCH gilt "schmal".
|
||||
Zusammen mit Achsenstreifen und Rändern ergibt "schmal" rund 345 px -
|
||||
das eingebettete meteoblue-Bild brauchte 500. */
|
||||
const HOEHEN = {
|
||||
breit: { temp: 150, regen: 130, wind: 115 },
|
||||
schmal: { temp: 120, regen: 100, wind: 92 },
|
||||
};
|
||||
const UMBRUCH = 520;
|
||||
|
||||
/* --------------------------------------------------------- Wettersymbole
|
||||
|
||||
WMO-Code -> Glyphe der Bootstrap-Icons, wie das ZEICHEN-Register in
|
||||
energiefluss.js. Die achtundzwanzig Codes fallen auf zwölf Symbole
|
||||
zusammen; wo es eine Nachtvariante gibt, steht sie daneben.
|
||||
|
||||
Die Codepunkte stammen aus css/bootstrap-icons.min.css. Der Kommentar
|
||||
dahinter nennt das Icon - ohne ihn wäre die Zeile nicht zu lesen. */
|
||||
const WETTER = {
|
||||
0: { glyph: "", nacht: "", name: "klar" }, // bi-sun-fill / bi-moon-stars-fill
|
||||
1: { glyph: "", nacht: "", name: "überwiegend klar" }, // dito
|
||||
2: { glyph: "", nacht: "", name: "teils bewölkt" }, // bi-cloud-sun-fill / bi-cloud-moon-fill
|
||||
3: { glyph: "", name: "bedeckt" }, // bi-clouds-fill
|
||||
45: { glyph: "", name: "Nebel" }, // bi-cloud-fog2-fill
|
||||
48: { glyph: "", name: "Reifnebel" }, // bi-cloud-fog2-fill
|
||||
51: { glyph: "", name: "leichter Nieselregen" }, // bi-cloud-drizzle-fill
|
||||
53: { glyph: "", name: "Nieselregen" }, // bi-cloud-drizzle-fill
|
||||
55: { glyph: "", name: "starker Nieselregen" }, // bi-cloud-drizzle-fill
|
||||
56: { glyph: "", name: "gefrierender Nieselregen" }, // bi-cloud-sleet-fill
|
||||
57: { glyph: "", name: "starker gefrierender Nieselregen" }, // bi-cloud-sleet-fill
|
||||
61: { glyph: "", name: "leichter Regen" }, // bi-cloud-rain-fill
|
||||
63: { glyph: "", name: "Regen" }, // bi-cloud-rain-fill
|
||||
65: { glyph: "", name: "starker Regen" }, // bi-cloud-rain-heavy-fill
|
||||
66: { glyph: "", name: "gefrierender Regen" }, // bi-cloud-sleet-fill
|
||||
67: { glyph: "", name: "starker gefrierender Regen" }, // bi-cloud-sleet-fill
|
||||
71: { glyph: "", name: "leichter Schneefall" }, // bi-cloud-snow-fill
|
||||
73: { glyph: "", name: "Schneefall" }, // bi-cloud-snow-fill
|
||||
75: { glyph: "", name: "starker Schneefall" }, // bi-cloud-snow-fill
|
||||
77: { glyph: "", name: "Schneegriesel" }, // bi-snow3
|
||||
80: { glyph: "", name: "leichte Regenschauer" }, // bi-cloud-rain-fill
|
||||
81: { glyph: "", name: "Regenschauer" }, // bi-cloud-rain-fill
|
||||
82: { glyph: "", name: "heftige Regenschauer" }, // bi-cloud-rain-heavy-fill
|
||||
85: { glyph: "", name: "leichte Schneeschauer" }, // bi-cloud-snow-fill
|
||||
86: { glyph: "", name: "Schneeschauer" }, // bi-cloud-snow-fill
|
||||
95: { glyph: "", name: "Gewitter" }, // bi-cloud-lightning-fill
|
||||
96: { glyph: "", name: "Gewitter mit Hagel" }, // bi-cloud-lightning-rain-fill
|
||||
99: { glyph: "", name: "schweres Gewitter mit Hagel" }, // bi-cloud-lightning-rain-fill
|
||||
};
|
||||
|
||||
/* Die Farben der Anzeige. Strukturfarben stehen im Stylesheet, Datenfarben
|
||||
hier - so wie bei energieflussKatalog.js. */
|
||||
const FARBE = {
|
||||
temp: "#e5624f",
|
||||
tempMess: "#f0907f",
|
||||
strahlung: "#f0b429",
|
||||
regen: "#4f8fb8",
|
||||
schnee: "#a8cfe8",
|
||||
wind: "#7fb3d5",
|
||||
boe: "#e8a33a",
|
||||
wolken: "#c9d0d6",
|
||||
};
|
||||
|
||||
return {
|
||||
spalten: SPALTEN,
|
||||
tagSpalten: TAGSPALTEN,
|
||||
hoehen: HOEHEN,
|
||||
umbruch: UMBRUCH,
|
||||
wetter: WETTER,
|
||||
farbe: FARBE,
|
||||
|
||||
/* Voreinstellung ist fünf Tage wie beim Vorbild; am Handy zwei, weil dort
|
||||
fünf Tage drei Pixel je Stunde bedeuten. */
|
||||
zeitraeume: [
|
||||
{ tage: 2, name: "2 Tage" },
|
||||
{ tage: 5, name: "5 Tage", vorgabe: true },
|
||||
{ tage: 7, name: "7 Tage" },
|
||||
],
|
||||
zeitraumSchmal: 2,
|
||||
|
||||
/* Wie weit der Rückblick reicht, in Stunden. Die eigene Station misst
|
||||
alle fünf Minuten; der Endpunkt mittelt auf Stunden. */
|
||||
rueckblick: 24,
|
||||
|
||||
/* Der Windpfeil zeigt, wohin der Wind weht. Die meteorologische Richtung
|
||||
sagt, woher er kommt - deshalb dreht der Renderer um 180 Grad, genau
|
||||
wie updateWeatherCards() in common.js. */
|
||||
windPfeil: {
|
||||
laenge: 9,
|
||||
// Unter dieser Stärke verblasst der Pfeil: bei Windstille zeigt eine
|
||||
// Richtung ohnehin nichts an.
|
||||
schwach: 5,
|
||||
},
|
||||
|
||||
felder: [
|
||||
{
|
||||
id: "temp",
|
||||
name: "Temperatur",
|
||||
hoehe: "temp",
|
||||
nacht: true,
|
||||
links: { einheit: "°C", minSpanne: 8, gitter: 4 },
|
||||
// Die Strahlung teilt sich das Feld mit der Temperatur statt ein
|
||||
// eigenes zu bekommen: beide folgen demselben Tagesrhythmus und
|
||||
// derselben Tag/Nacht-Schattierung, und ein viertes Feld machte das
|
||||
// Meteogramm am Handy höher als das externe - also genau das
|
||||
// Gegenteil dessen, was es soll. Die Achse ist fest 0..1000, damit
|
||||
// ein trüber Tag flach bleibt und nicht auf volle Höhe gestreckt wird.
|
||||
rechts: { einheit: "W/m²", min: 0, max: 1000, gitter: 0 },
|
||||
symbole: { alle: 3, mindestAbstand: 26 },
|
||||
spuren: [
|
||||
{
|
||||
id: "strahlung", name: "Strahlung", spalte: "strahlung",
|
||||
achse: "rechts", art: "flaeche", farbe: FARBE.strahlung,
|
||||
deckkraft: 0.16, legende: true,
|
||||
format: w => ganz(w) + " W/m²",
|
||||
},
|
||||
{
|
||||
id: "temp", name: "Temperatur", spalte: "temp",
|
||||
achse: "links", art: "linie", farbe: FARBE.temp, breit: 2.4,
|
||||
beschriftung: "minmax", legende: true, nurVorhersage: true,
|
||||
format: w => komma(w, 1) + " °C",
|
||||
},
|
||||
{
|
||||
// Dieselbe Spalte, aber nur die gemessenen Stunden: links vom
|
||||
// Jetzt-Strich steht, was die eigene Station wirklich gesehen
|
||||
// hat. Zwei Spuren statt einer, damit die Farbe wechseln kann,
|
||||
// ohne dass der Renderer von "gemessen" wissen muss.
|
||||
id: "tempMess", name: "gemessen", spalte: "temp",
|
||||
achse: "links", art: "linie", farbe: FARBE.tempMess, breit: 2.4,
|
||||
nurGemessen: true, legende: true,
|
||||
format: w => komma(w, 1) + " °C",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "regen",
|
||||
name: "Niederschlag und Bewölkung",
|
||||
hoehe: "regen",
|
||||
nacht: true,
|
||||
// minSpanne 2 mm: ohne sie füllte ein Nieselregen von 0,2 mm das
|
||||
// ganze Feld und sähe aus wie ein Wolkenbruch.
|
||||
links: { einheit: "mm/h", min: 0, minSpanne: 2, gitter: 3 },
|
||||
// Die rechte Achse ist die Höhe in Kilometern - sie gehört zum
|
||||
// Wolkenband, nicht zu einer Spur.
|
||||
rechts: { einheit: "km", min: 0, max: 14, ticks: [0, 2, 7, 14] },
|
||||
baender: [
|
||||
{
|
||||
id: "wolken", name: "Bewölkung", achse: "rechts",
|
||||
farbe: FARBE.wolken, maxDeckung: 0.42,
|
||||
// Open-Meteo kennt drei Schichten statt eines Profils. Die
|
||||
// Grenzen sind die üblichen meteorologischen: tiefe Wolken bis
|
||||
// 2 km, mittelhohe bis 7 km, hohe darüber.
|
||||
schichten: [
|
||||
{ spalte: "wtief", von: 0, bis: 2, name: "tief" },
|
||||
{ spalte: "wmittel", von: 2, bis: 7, name: "mittel" },
|
||||
{ spalte: "whoch", von: 7, bis: 14, name: "hoch" },
|
||||
],
|
||||
},
|
||||
],
|
||||
spuren: [
|
||||
{
|
||||
id: "regen", name: "Niederschlag", spalte: "regen",
|
||||
achse: "links", art: "balken", farbe: FARBE.regen, legende: true,
|
||||
format: w => komma(w, 1) + " mm/h",
|
||||
},
|
||||
{
|
||||
id: "schnee", name: "Schnee", spalte: "schnee",
|
||||
achse: "links", art: "balken", farbe: FARBE.schnee, legende: true,
|
||||
// Schnee kommt in cm, die Achse rechnet in mm Wasser. Die
|
||||
// Faustregel ist ein Zentimeter Neuschnee je Millimeter Wasser.
|
||||
skala: w => w * 1,
|
||||
format: w => komma(w, 1) + " cm",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "wind",
|
||||
name: "Wind",
|
||||
hoehe: "wind",
|
||||
links: { einheit: "km/h", min: 0, minSpanne: 20, gitter: 3 },
|
||||
pfeile: { spalte: "richtung", staerke: "wind", alle: 3, mindestAbstand: 16 },
|
||||
spuren: [
|
||||
{
|
||||
id: "boe", name: "Böen", spalte: "boe",
|
||||
achse: "links", art: "linie", farbe: FARBE.boe, breit: 1.6,
|
||||
strich: "4 3", legende: true,
|
||||
format: w => komma(w, 1) + " km/h",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,684 @@
|
||||
/*
|
||||
* Raum-Modal: Beschattung, Heizung, Licht und Schalter eines Raums.
|
||||
*
|
||||
* Das Markup kommt fertig aus ajax/room.php (Bedienformen in
|
||||
* restricted/roomControls.php); hier haengen die Zuhoerer an den
|
||||
* data-Attributen, und hier wird gezeichnet, was sich beim Bedienen aendert:
|
||||
* Zustand der Reiter, Behang-Bild, Heizring, Lampen.
|
||||
*
|
||||
* Wie im Lade-, Heiz- und Bewaesserungsfenster gilt jede Wahl sofort - es
|
||||
* gibt keinen Speichern-Knopf.
|
||||
*
|
||||
* Aufgerufen von openRoomView() in js/solar/homeMQTT.js; die Ist-Werte der
|
||||
* Heizung kommen ueber raumfensterMqtt() aus dem laufenden MQTT-Strom.
|
||||
*/
|
||||
|
||||
const RF_S = " "; // schmales Leerzeichen vor Einheiten
|
||||
let raumfenster = null; // {wurzel, raum, thermostat}
|
||||
|
||||
function raumfensterStarten(wurzel, raum, thermostat) {
|
||||
raumfenster = { wurzel: wurzel, raum: raum, thermostat: thermostat };
|
||||
|
||||
wurzel.querySelectorAll("[data-rf-reiter]").forEach(k => k.addEventListener("click", () => rfReiter(wurzel, k)));
|
||||
wurzel.querySelectorAll(".rf-behang").forEach(rfBehangStarten);
|
||||
wurzel.querySelectorAll(".rf-alle [data-alle]").forEach(k => k.addEventListener("click", () => rfAlle(k)));
|
||||
wurzel.querySelectorAll(".rf-lampe").forEach(rfLampeStarten);
|
||||
wurzel.querySelectorAll(".rf-schalter").forEach(k => k.addEventListener("click", () => rfSchalten(k, k)));
|
||||
// Mehr-Menue und "Sonstiges": Kommandos so, wie sie in der Datenbank stehen.
|
||||
wurzel.querySelectorAll("button[data-cmd]").forEach(k => k.addEventListener("click", () => rfSchalte(k)));
|
||||
const heiz = wurzel.querySelector("[data-rf-heiz-teil]");
|
||||
if (heiz) rfHeizungStarten(heiz);
|
||||
|
||||
rfStatus();
|
||||
raumfensterMqtt();
|
||||
// Jede Jalousie im Raum einmal an der Box nachfragen. Der Wert im Modal
|
||||
// kommt aus actor_states und ist damit hoechstens so frisch wie der letzte
|
||||
// Poll des Runners - und wer das Modal waehrend einer Fahrt aufmacht, saehe
|
||||
// sonst den Stand von davor.
|
||||
wurzel.querySelectorAll(".rf-behang").forEach(k => rfJalousieVerfolgen(k, null));
|
||||
}
|
||||
|
||||
/* --- Reiter ---------------------------------------------------------------- */
|
||||
|
||||
function rfReiter(wurzel, knopf) {
|
||||
wurzel.querySelectorAll("[data-rf-reiter]").forEach(k => k.setAttribute("aria-selected", String(k === knopf)));
|
||||
wurzel.querySelectorAll("[data-rf-flaeche]").forEach(f => { f.hidden = f.dataset.rfFlaeche !== knopf.dataset.rfReiter; });
|
||||
}
|
||||
|
||||
/** Die Zeile unter jedem Reiter: was in dieser Gruppe gerade los ist. */
|
||||
function rfStatus() {
|
||||
if (!raumfenster) return;
|
||||
const w = raumfenster.wurzel;
|
||||
const setze = (form, text) => { const el = w.querySelector("[data-rf-status='" + form + "']"); if (el) el.textContent = text; };
|
||||
|
||||
const behaenge = [...w.querySelectorAll(".rf-behang")];
|
||||
const bekannt = behaenge.filter(k => k.dataset.pos !== "");
|
||||
const zu = bekannt.filter(k => Number(k.dataset.pos) >= 95).length;
|
||||
setze("beschattung", !bekannt.length ? behaenge.length + " Geräte"
|
||||
: zu === 0 ? "alle offen" : zu === bekannt.length ? "alle zu" : zu + " von " + bekannt.length + " zu");
|
||||
|
||||
const an = w.querySelectorAll(".rf-lampe[data-an='1']").length;
|
||||
setze("licht", an ? an + " an" : "alles aus");
|
||||
const schalter = w.querySelectorAll(".rf-schalter[aria-pressed='true']").length;
|
||||
setze("schalter", schalter ? schalter + " an" : "alles aus");
|
||||
const zahl = (sel, wort) => { const n = w.querySelectorAll(sel).length; return n + " " + wort; };
|
||||
setze("sonstiges", zahl("[data-rf-flaeche='sonstiges'] .rf-karte", "Geräte"));
|
||||
setze("messwerte", zahl("[data-rf-flaeche='messwerte'] .rf-karte", "Geräte"));
|
||||
// "heizung" setzt rfHeizungZeichnen() mit der Ist-Temperatur.
|
||||
}
|
||||
|
||||
/* --- Kommandos --------------------------------------------------------------- */
|
||||
|
||||
function rfRequest(action, body) {
|
||||
return fetch("./ajax/room.php?action=" + action, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(a => a.json())
|
||||
.catch(fehler => ({ error: fehler.message }));
|
||||
}
|
||||
|
||||
/** Rueckmeldung unten im Fenster; nach ein paar Sekunden wieder der Hinweis. */
|
||||
function rfMeldung(antwort) {
|
||||
const ziel = document.getElementById("roomFeedback");
|
||||
if (!ziel) return;
|
||||
clearTimeout(rfMeldung.takt);
|
||||
ziel.classList.toggle("fehler", !!antwort.error);
|
||||
ziel.classList.toggle("gut", !antwort.error);
|
||||
ziel.textContent = antwort.error ? antwort.error : "✓ " + antwort.ok;
|
||||
rfMeldung.takt = setTimeout(() => {
|
||||
ziel.classList.remove("fehler", "gut");
|
||||
ziel.textContent = "Änderungen gelten sofort";
|
||||
}, antwort.error ? 8000 : 4000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Kommando schicken. Eine Jalousie zeigt dabei "fährt…" und wird bis zum
|
||||
* Stillstand verfolgt; alles andere ist kurz gesperrt, damit man nicht
|
||||
* nachtippt.
|
||||
*/
|
||||
function rfKommando(cmdId, params, element) {
|
||||
const karte = element.closest(".raum-geraet") || element;
|
||||
const behang = karte.classList.contains("rf-behang");
|
||||
if (behang) {
|
||||
karte.dataset.faehrt = "1";
|
||||
rfBehangZeichnen(karte);
|
||||
} else {
|
||||
karte.classList.add("raum-warte");
|
||||
}
|
||||
const auftrag = rfRequest("command", { command_id: cmdId, params: params });
|
||||
if (behang) rfJalousieVerfolgen(karte, auftrag);
|
||||
return auftrag.then(antwort => {
|
||||
karte.classList.remove("raum-warte");
|
||||
if (antwort.error && behang) {
|
||||
delete karte.dataset.faehrt;
|
||||
rfBehangZeichnen(karte);
|
||||
}
|
||||
rfMeldung(antwort);
|
||||
return antwort;
|
||||
});
|
||||
}
|
||||
|
||||
/** Ein Knopf mit data-cmd; Parameter stehen, wenn noetig, in seiner Zeile. */
|
||||
function rfSchalte(knopf) {
|
||||
const zeile = knopf.closest("[data-cmdrow]");
|
||||
const params = {};
|
||||
if (zeile) zeile.querySelectorAll("[data-param]").forEach(f => { params[f.dataset.param] = f.value; });
|
||||
rfKommando(Number(knopf.dataset.cmd), params, knopf);
|
||||
}
|
||||
|
||||
/* --- Beschattung -------------------------------------------------------------- */
|
||||
|
||||
function rfLamellenWort(n) {
|
||||
n = Number(n);
|
||||
return n <= 12 ? "offen" : n >= 88 ? "zu" : Math.abs(n - 50) <= 12 ? "halb" : n + RF_S + "%";
|
||||
}
|
||||
|
||||
function rfStandText(pos, neig) {
|
||||
if (pos === "") return "";
|
||||
const p = Number(pos);
|
||||
let text = p <= 2 ? "offen" : p >= 98 ? "zu" : p + RF_S + "% zu";
|
||||
if (neig !== "" && p > 2) text += " · Lamellen " + rfLamellenWort(neig);
|
||||
return text;
|
||||
}
|
||||
|
||||
function rfBehangZeichnen(karte) {
|
||||
const d = karte.dataset;
|
||||
const pos = d.pos, neig = d.neig;
|
||||
const teil = karte.querySelector(".rf-behang-teil");
|
||||
if (teil) teil.style.height = (pos === "" ? 0 : Number(pos)) + "%";
|
||||
const lamellen = karte.querySelector(".rf-lamellen");
|
||||
if (lamellen) lamellen.style.setProperty("--kipp", 0.2 + 0.8 * (neig === "" ? 0 : Number(neig)) / 100);
|
||||
|
||||
const faehrt = d.faehrt === "1";
|
||||
karte.classList.toggle("rf-bewegt", faehrt);
|
||||
const pille = karte.querySelector("[data-rf-stand]");
|
||||
if (pille) {
|
||||
pille.textContent = faehrt ? "fährt…" : rfStandText(pos, neig);
|
||||
pille.classList.toggle("faehrt", faehrt);
|
||||
}
|
||||
|
||||
const aufMy = d.myPos !== "" && pos !== "" && Math.abs(Number(pos) - Number(d.myPos)) <= 2
|
||||
&& (d.myNeig === "" || neig === "" || Math.abs(Number(neig) - Number(d.myNeig)) <= 2);
|
||||
const myKnopf = karte.querySelector("[data-rf-fahrt='my']");
|
||||
if (myKnopf) myKnopf.classList.toggle("aktiv", aufMy && !faehrt);
|
||||
|
||||
karte.querySelectorAll("[data-rf-pos]").forEach(k =>
|
||||
k.setAttribute("aria-checked", String(pos !== "" && !aufMy && Math.abs(Number(k.dataset.rfPos) - Number(pos)) <= 2)));
|
||||
karte.querySelectorAll("[data-rf-neig]").forEach(k =>
|
||||
k.setAttribute("aria-checked", String(neig !== "" && Math.abs(Number(k.dataset.rfNeig) - Number(neig)) <= 2)));
|
||||
|
||||
const my = karte.querySelector("[data-rf-my]");
|
||||
if (my) {
|
||||
const myText = (p, n) => p + RF_S + "%" + (n !== "" && n !== null && typeof n !== "undefined" ? " · Lamellen " + rfLamellenWort(n) : "");
|
||||
if (d.myFrage === "1") {
|
||||
my.className = "rf-my-zeile frage";
|
||||
my.innerHTML = "<span>my von <b>" + myText(d.myPos, d.myNeig) + "</b> auf <b>" + myText(pos, neig) + "</b> ändern?</span>"
|
||||
+ "<span class='rf-my-knoepfe'><button type='button' class='rf-chip ja' data-rf-my-tun='ja'>Speichern</button>"
|
||||
+ "<button type='button' class='rf-chip' data-rf-my-tun='nein'>Abbrechen</button></span>";
|
||||
} else {
|
||||
my.className = "rf-my-zeile";
|
||||
my.innerHTML = "<i class='bi bi-star-fill'></i><span>my-Position " + myText(d.myPos, d.myNeig) + "</span>"
|
||||
+ (aufMy || faehrt || pos === "" || d.mySpeichert === "1" ? ""
|
||||
: "<button type='button' class='rf-link' data-rf-my-tun='frage'>aktuellen Stand als my speichern</button>")
|
||||
+ (d.mySpeichert === "1" ? "<span class='rf-link'>wird gespeichert…</span>" : "");
|
||||
}
|
||||
}
|
||||
rfStatus();
|
||||
}
|
||||
|
||||
function rfBehangStarten(karte) {
|
||||
const d = karte.dataset;
|
||||
rfBehangZeichnen(karte);
|
||||
|
||||
karte.querySelectorAll("[data-rf-fahrt]").forEach(k => k.addEventListener("click", () => {
|
||||
const cmd = d[k.dataset.rfFahrt];
|
||||
if (!cmd) return;
|
||||
rfKommando(Number(cmd), {}, karte);
|
||||
}));
|
||||
|
||||
const hoehe = function (wert) {
|
||||
const params = {};
|
||||
params[d.hoeheParam] = wert;
|
||||
// Die Hoehe nimmt die Neigung mit: eine Hoehenfahrt rastet die Lamellen
|
||||
// um, und der Server faehrt sie nur dann wieder an, wenn der Befehl ein
|
||||
// Neigungsziel dabei hat (siehe sendeTahoma() in restricted/commands.php).
|
||||
if (d.hoeheMit) params[d.hoeheMit] = d.neig === "" ? 0 : Number(d.neig);
|
||||
d.pos = String(wert);
|
||||
rfKommando(Number(d.hoeheCmd), params, karte);
|
||||
};
|
||||
karte.querySelectorAll("[data-rf-pos]").forEach(k => k.addEventListener("click", () => hoehe(Number(k.dataset.rfPos))));
|
||||
karte.querySelectorAll("[data-rf-neig]").forEach(k => k.addEventListener("click", () => {
|
||||
const params = {};
|
||||
params[d.neigParam] = Number(k.dataset.rfNeig);
|
||||
d.neig = k.dataset.rfNeig;
|
||||
rfKommando(Number(d.neigCmd), params, karte);
|
||||
}));
|
||||
|
||||
// Im Fensterbild ziehen: die Hoehe fein einstellen. Waehrend des Ziehens
|
||||
// nur das Bild, geschickt wird beim Loslassen - sonst bekaeme die Jalousie
|
||||
// bei jedem Pixel ein neues Ziel.
|
||||
const fenster = karte.querySelector("[data-rf-zieh]");
|
||||
if (fenster && d.hoeheCmd) {
|
||||
let zug = null;
|
||||
const wert = e => {
|
||||
const r = fenster.querySelector(".rf-glas").getBoundingClientRect();
|
||||
return Math.max(0, Math.min(100, (e.clientY - r.top) / r.height * 100));
|
||||
};
|
||||
fenster.addEventListener("pointerdown", e => {
|
||||
zug = e.pointerId;
|
||||
d.zieht = "1";
|
||||
fenster.setPointerCapture(e.pointerId);
|
||||
karte.querySelector(".rf-behang-teil").style.height = wert(e) + "%";
|
||||
e.preventDefault();
|
||||
});
|
||||
fenster.addEventListener("pointermove", e => {
|
||||
if (zug !== e.pointerId) return;
|
||||
const w = wert(e);
|
||||
karte.querySelector(".rf-behang-teil").style.height = w + "%";
|
||||
const pille = karte.querySelector("[data-rf-stand]");
|
||||
if (pille) pille.textContent = rfStandText(String(Math.round(w)), d.neig);
|
||||
});
|
||||
const ende = e => {
|
||||
if (zug !== e.pointerId) return;
|
||||
zug = null;
|
||||
delete d.zieht;
|
||||
hoehe(Math.round(wert(e) / 5) * 5);
|
||||
};
|
||||
fenster.addEventListener("pointerup", ende);
|
||||
fenster.addEventListener("pointercancel", () => { zug = null; delete d.zieht; rfBehangZeichnen(karte); });
|
||||
}
|
||||
|
||||
// my-Position speichern: erst nachfragen, dann schreibt der Server den
|
||||
// Stand, den die Box meldet, in den Motor.
|
||||
karte.addEventListener("click", e => {
|
||||
const k = e.target.closest("[data-rf-my-tun]");
|
||||
if (!k) return;
|
||||
const tun = k.dataset.rfMyTun;
|
||||
if (tun === "frage") d.myFrage = "1";
|
||||
if (tun === "nein") delete d.myFrage;
|
||||
if (tun === "ja") {
|
||||
delete d.myFrage;
|
||||
d.mySpeichert = "1";
|
||||
rfRequest("my", { actor: Number(d.actor) }).then(antwort => {
|
||||
delete d.mySpeichert;
|
||||
if (!antwort.error) {
|
||||
d.myPos = String(antwort.myPosition);
|
||||
if (antwort.myNeigung !== null && typeof antwort.myNeigung !== "undefined") d.myNeig = String(antwort.myNeigung);
|
||||
}
|
||||
rfMeldung(antwort);
|
||||
rfBehangZeichnen(karte);
|
||||
});
|
||||
}
|
||||
rfBehangZeichnen(karte);
|
||||
});
|
||||
}
|
||||
|
||||
/** "Alle im Raum": jedem Behang sein eigenes Kommando. */
|
||||
function rfAlle(knopf) {
|
||||
const was = knopf.dataset.alle;
|
||||
knopf.closest("[data-rf-flaeche]").querySelectorAll(".rf-behang").forEach(karte => {
|
||||
const d = karte.dataset;
|
||||
if (was === "sicht") {
|
||||
if (!d.hoeheMit) return;
|
||||
const params = {};
|
||||
params[d.hoeheParam] = 100;
|
||||
params[d.hoeheMit] = 50;
|
||||
d.pos = "100";
|
||||
d.neig = "50";
|
||||
rfKommando(Number(d.hoeheCmd), params, karte);
|
||||
return;
|
||||
}
|
||||
if (d[was]) rfKommando(Number(d[was]), {}, karte);
|
||||
});
|
||||
}
|
||||
|
||||
// Takt und Grenzen der Verfolgung. Dieselben Zahlen wie die Konstanten
|
||||
// JALOUSIE_* in restricted/commands.php: die Box meldet core:MovingState
|
||||
// traege, kurz nach dem Kommando steht dort noch "faehrt nicht" - deshalb
|
||||
// der Vorlauf, in dem ein Stillstand nichts bedeutet.
|
||||
const RF_JALOUSIE_TAKT_MS = 2000;
|
||||
const RF_JALOUSIE_VORLAUF_MS = 8000;
|
||||
const RF_JALOUSIE_MAX_MS = 120000;
|
||||
|
||||
// Wie lange nach dem Ende des Auftrags noch nachgesehen wird. Eine
|
||||
// Neigungsfahrt setzt core:MovingState nie (siehe warteAufJalousie() in
|
||||
// restricted/commands.php), dauert aber gemessen bis zu fuenfzehn Sekunden
|
||||
// von Anschlag zu Anschlag - ohne diesen Nachlauf stuende der Wert von vor
|
||||
// dem letzten Schwenk in der Anzeige.
|
||||
const RF_JALOUSIE_NACHLAUF_MS = 14000;
|
||||
|
||||
/**
|
||||
* Nach einem Fahrbefehl abwarten, bis die Jalousie steht, und dann ihren
|
||||
* wirklichen Stand zeigen.
|
||||
*
|
||||
* actor_states schreibt sonst nur der Runner fort, und der fragt die
|
||||
* Tahoma-Box alle fuenf Minuten ab. Hier wird genau dieses eine Geraet
|
||||
* gefragt; ajax/room.php haelt den frischen Wert gleich in der Tabelle fest.
|
||||
*
|
||||
* Waehrend der Fahrt wird kein Zwischenstand angezeigt. An der Anlage
|
||||
* gemessen: die Hoehe (core:ClosureState) steht die ganze Fahrt ueber auf dem
|
||||
* Wert von vor dem Kommando und springt erst am Ende um, und die Neigung
|
||||
* meldet ausgerechnet die Umwege der Kugelschreiber-Mechanik.
|
||||
*/
|
||||
function rfJalousieVerfolgen(karte, auftrag) {
|
||||
const actor = Number(karte.dataset.actor);
|
||||
if (!actor) return;
|
||||
// Ohne Auftrag - beim Oeffnen des Modals - gibt es keine Traegheit
|
||||
// abzuwarten und nichts nachlaufen zu lassen.
|
||||
const vorlaufNoetig = !!auftrag;
|
||||
|
||||
// Ein zweiter Fahrbefehl schiebt nur das Ende hinaus. Zwei Schleifen
|
||||
// wuerden dieselbe Box doppelt so oft fragen, ohne mehr zu zeigen.
|
||||
karte.dataset.jalousieBis = String(Date.now() + RF_JALOUSIE_MAX_MS);
|
||||
if (karte.dataset.jalousieLaeuft) return;
|
||||
karte.dataset.jalousieLaeuft = "1";
|
||||
|
||||
const beginn = Date.now();
|
||||
let gefahren = false;
|
||||
// Fertig ist es erst, wenn zwei Abfragen im Stillstand dasselbe sagen: die
|
||||
// Box meldet das Ende der Fahrt, bevor die Werte nachgezogen haben.
|
||||
let letzterStand = null;
|
||||
// Solange der Auftrag laeuft, ist nichts entschieden: die
|
||||
// Kugelschreiber-Mechanik schickt zwei Befehle mit einer Wartezeit
|
||||
// dazwischen, und zwischen ihnen steht die Jalousie still.
|
||||
let ruheAb = 0;
|
||||
if (auftrag) {
|
||||
ruheAb = Infinity;
|
||||
Promise.resolve(auftrag).then(
|
||||
function () { ruheAb = Date.now() + RF_JALOUSIE_NACHLAUF_MS; },
|
||||
function () { ruheAb = Date.now(); }
|
||||
);
|
||||
}
|
||||
|
||||
function aufhoeren() {
|
||||
delete karte.dataset.jalousieLaeuft;
|
||||
delete karte.dataset.jalousieBis;
|
||||
delete karte.dataset.faehrt;
|
||||
rfBehangZeichnen(karte);
|
||||
}
|
||||
|
||||
function runde() {
|
||||
rfRequest("jalousie", { actor: actor }).then(stand => {
|
||||
// Ein Fehler soll auffallen, sonst bliebe die Anzeige still stehen.
|
||||
if (stand && stand.error) rfMeldung(stand);
|
||||
if (!stand || stand.error || !document.body.contains(karte)) {
|
||||
aufhoeren();
|
||||
return;
|
||||
}
|
||||
const abgelesen = stand.faehrt ? null : String(stand.position) + "/" + String(stand.neigung);
|
||||
const steht = abgelesen !== null && abgelesen === letzterStand;
|
||||
letzterStand = abgelesen;
|
||||
|
||||
if (stand.faehrt) {
|
||||
gefahren = true;
|
||||
} else if (Date.now() >= ruheAb) {
|
||||
rfJalousieAnzeigen(karte, stand);
|
||||
}
|
||||
if (Date.now() > Number(karte.dataset.jalousieBis)) {
|
||||
aufhoeren();
|
||||
return;
|
||||
}
|
||||
// Beim blossen Aufmachen eines Raums, in dem sich nichts geruehrt hat,
|
||||
// bleibt es bei einer Abfrage je Jalousie.
|
||||
if (!vorlaufNoetig && !gefahren && !stand.faehrt) {
|
||||
aufhoeren();
|
||||
return;
|
||||
}
|
||||
if (!vorlaufNoetig && stand.faehrt) {
|
||||
karte.dataset.faehrt = "1";
|
||||
rfBehangZeichnen(karte);
|
||||
}
|
||||
const fertig = steht && Date.now() >= ruheAb
|
||||
&& (gefahren || !vorlaufNoetig || Date.now() - beginn >= RF_JALOUSIE_VORLAUF_MS);
|
||||
if (fertig) {
|
||||
aufhoeren();
|
||||
return;
|
||||
}
|
||||
setTimeout(runde, RF_JALOUSIE_TAKT_MS);
|
||||
});
|
||||
}
|
||||
// Beim Oeffnen sofort nachsehen, nach einem Kommando erst im Takt: da
|
||||
// stuende die Box ohnehin noch auf dem alten Wert.
|
||||
setTimeout(runde, auftrag ? RF_JALOUSIE_TAKT_MS : 0);
|
||||
}
|
||||
|
||||
/** Die gemeldete Stellung uebernehmen - nur im Stillstand, siehe oben. */
|
||||
function rfJalousieAnzeigen(karte, stand) {
|
||||
const d = karte.dataset;
|
||||
// Wer gerade im Bild zieht, bestimmt.
|
||||
if (d.zieht) return;
|
||||
const setze = (feld, wert) => { if (wert !== null && typeof wert !== "undefined") d[feld] = String(wert); };
|
||||
setze("pos", stand.position);
|
||||
if (d.neigCmd) setze("neig", stand.neigung);
|
||||
if (d.myPos !== undefined && karte.querySelector("[data-rf-my]")) {
|
||||
setze("myPos", stand.myPosition);
|
||||
if (d.neigCmd) setze("myNeig", stand.myNeigung);
|
||||
}
|
||||
rfBehangZeichnen(karte);
|
||||
}
|
||||
|
||||
/* --- Heizung ------------------------------------------------------------------ */
|
||||
|
||||
const RF_RING_R = 70;
|
||||
const RF_RING_UMFANG = 2 * Math.PI * RF_RING_R;
|
||||
const RF_RING_BOGEN = RF_RING_UMFANG * 270 / 360;
|
||||
const RF_HEIZ_HALTEN_MS = 8000; // so lange gilt die eigene Wahl vor dem MQTT-Stand
|
||||
|
||||
function rfHeizungStarten(teil) {
|
||||
const min = Number(teil.dataset.min), max = Number(teil.dataset.max);
|
||||
const zustand = { soll: null, eigen: 0, senden: null };
|
||||
teil._rf = zustand;
|
||||
teil.querySelector(".rf-ring-spur").setAttribute("stroke-dasharray", RF_RING_BOGEN + " " + RF_RING_UMFANG);
|
||||
teil.querySelector(".rf-ring-griff").setAttribute("stroke-dasharray", RF_RING_BOGEN + " " + RF_RING_UMFANG);
|
||||
|
||||
const waehlen = function (wert, sofort) {
|
||||
wert = Math.max(min, Math.min(max, Math.round(wert)));
|
||||
zustand.soll = wert;
|
||||
zustand.eigen = Date.now();
|
||||
rfHeizungZeichnen(teil);
|
||||
clearTimeout(zustand.senden);
|
||||
// Mehrmals - oder + tippen ergibt einen Befehl, nicht fuenf.
|
||||
zustand.senden = setTimeout(() => {
|
||||
teil.classList.add("raum-warte");
|
||||
rfRequest("temp", { room: raumfenster.raum, temp: wert }).then(antwort => {
|
||||
teil.classList.remove("raum-warte");
|
||||
rfMeldung(antwort);
|
||||
});
|
||||
}, sofort ? 0 : 700);
|
||||
};
|
||||
|
||||
teil.querySelectorAll("[data-rf-schritt]").forEach(k => k.addEventListener("click", () => {
|
||||
if (zustand.soll === null) return;
|
||||
waehlen(zustand.soll + Number(k.dataset.rfSchritt), false);
|
||||
}));
|
||||
teil.querySelectorAll("[data-rf-soll]").forEach(k => k.addEventListener("click", () => waehlen(Number(k.dataset.rfSoll), true)));
|
||||
|
||||
// Den Soll-Punkt auf dem Ring ziehen oder auf den Ring tippen. Waehrend des
|
||||
// Ziehens wird nur gezeichnet, geschickt wird beim Loslassen.
|
||||
const svg = teil.querySelector(".rf-ring svg");
|
||||
const ausZeiger = e => {
|
||||
const r = svg.getBoundingClientRect();
|
||||
const grad = Math.atan2(e.clientY - (r.top + r.height / 2), e.clientX - (r.left + r.width / 2)) * 180 / Math.PI;
|
||||
let a = (grad - 135 + 720) % 360;
|
||||
if (a > 270) a = a > 315 ? 0 : 270;
|
||||
return min + a / 270 * (max - min);
|
||||
};
|
||||
let zug = null;
|
||||
svg.addEventListener("pointerdown", e => {
|
||||
if (!e.target.closest("[data-rf-ringzug]")) return;
|
||||
zug = e.pointerId;
|
||||
svg.setPointerCapture(e.pointerId);
|
||||
teil.classList.add("zieht");
|
||||
zustand.soll = Math.round(ausZeiger(e));
|
||||
zustand.eigen = Date.now();
|
||||
rfHeizungZeichnen(teil);
|
||||
e.preventDefault();
|
||||
});
|
||||
svg.addEventListener("pointermove", e => {
|
||||
if (zug !== e.pointerId) return;
|
||||
zustand.soll = Math.round(ausZeiger(e));
|
||||
zustand.eigen = Date.now();
|
||||
rfHeizungZeichnen(teil);
|
||||
});
|
||||
svg.addEventListener("pointerup", e => {
|
||||
if (zug !== e.pointerId) return;
|
||||
zug = null;
|
||||
teil.classList.remove("zieht");
|
||||
waehlen(zustand.soll, true);
|
||||
});
|
||||
svg.addEventListener("pointercancel", () => { zug = null; teil.classList.remove("zieht"); });
|
||||
const punkt = teil.querySelector(".rf-ring-soll");
|
||||
punkt.addEventListener("keydown", e => {
|
||||
const schritt = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1 }[e.key];
|
||||
if (!schritt || zustand.soll === null) return;
|
||||
e.preventDefault();
|
||||
waehlen(zustand.soll + schritt, false);
|
||||
});
|
||||
}
|
||||
|
||||
/** Ist, Soll und Hinweis in den Heizungsteil schreiben. */
|
||||
function rfHeizungZeichnen(teil) {
|
||||
const z = teil._rf;
|
||||
const werte = (raumfenster && typeof mqttZweig === "function") ? mqttZweig(raumfenster.thermostat) : null;
|
||||
const zahl = v => (v === null || v === "" || typeof v === "undefined" || isNaN(Number(v))) ? null : Number(v);
|
||||
const ist = werte ? zahl(werte["Temp[degC]"]) : null;
|
||||
const gemeldet = werte ? zahl(werte["Set Temp[degC]"]) : null;
|
||||
// Die eigene Wahl gilt, bis das Thermostat sie bestaetigt hat.
|
||||
if (gemeldet !== null && (z.soll === null || Date.now() - z.eigen > RF_HEIZ_HALTEN_MS)) z.soll = gemeldet;
|
||||
const soll = z.soll;
|
||||
|
||||
const min = Number(teil.dataset.min), max = Number(teil.dataset.max);
|
||||
const anteil = v => Math.max(0, Math.min(1, (v - min) / (max - min)));
|
||||
const komma = (v, stellen) => v.toFixed(stellen).replace(".", ",");
|
||||
const feld = name => teil.querySelector("[data-rf-heiz='" + name + "']");
|
||||
|
||||
teil.querySelector(".rf-ring-ist").setAttribute("stroke-dasharray",
|
||||
(ist === null ? 0 : RF_RING_BOGEN * anteil(ist)) + " " + RF_RING_UMFANG);
|
||||
const punkt = teil.querySelector(".rf-ring-soll");
|
||||
punkt.style.transform = "rotate(" + (135 + 270 * anteil(soll === null ? min : soll)) + "deg)";
|
||||
punkt.style.visibility = soll === null ? "hidden" : "";
|
||||
if (soll !== null) punkt.setAttribute("aria-valuenow", soll);
|
||||
|
||||
feld("ist").innerHTML = ist === null ? "–" : komma(ist, 1) + "<small>" + RF_S + "°C</small>";
|
||||
feld("soll").textContent = "Soll " + (soll === null ? "–" : komma(soll, 0) + RF_S + "°C");
|
||||
const chip = soll === null ? null : teil.querySelector("[data-rf-soll='" + soll + "']");
|
||||
feld("wert").innerHTML = soll === null ? "–"
|
||||
: soll + RF_S + "°C" + "<small>" + (chip ? chip.firstChild.textContent.trim() : " ") + "</small>";
|
||||
teil.querySelectorAll("[data-rf-soll]").forEach(k => k.setAttribute("aria-pressed", String(k === chip)));
|
||||
|
||||
const heizt = werte && String(werte["Heating"]) === "true";
|
||||
const hinweis = feld("hinweis");
|
||||
let text = "";
|
||||
if (ist !== null && soll !== null) {
|
||||
if (heizt) text = ist < soll ? "Heizt – noch " + komma(soll - ist, 1) + RF_S + "°C bis zum Soll." : "Heizt gerade.";
|
||||
else if (ist > soll + 0.2) text = "Heizt nicht – der Raum ist " + komma(ist - soll, 1) + RF_S + "°C wärmer als das Soll.";
|
||||
else text = "Heizt nicht – das Soll ist erreicht.";
|
||||
}
|
||||
hinweis.hidden = !text;
|
||||
hinweis.classList.toggle("an", !!heizt);
|
||||
hinweis.querySelector("span").textContent = text;
|
||||
|
||||
const feuchte = werte ? zahl(werte["rHum[%]"]) : null;
|
||||
feld("feuchte").textContent = feuchte === null ? "–" : Math.round(feuchte) + RF_S + "% Luftfeuchte";
|
||||
feld("puffer").hidden = !(werte && werte["mode"] == "Overheating");
|
||||
|
||||
const status = raumfenster && raumfenster.wurzel.querySelector("[data-rf-status='heizung']");
|
||||
if (status) status.textContent = ist === null ? "–" : komma(ist, 1) + RF_S + "°C" + (heizt ? " · heizt" : "");
|
||||
}
|
||||
|
||||
/** Aufgerufen bei jeder MQTT-Nachricht (homeSVG.updateValuesMQTT). */
|
||||
function raumfensterMqtt() {
|
||||
if (!raumfenster || !document.body.contains(raumfenster.wurzel)) return;
|
||||
const teil = raumfenster.wurzel.querySelector("[data-rf-heiz-teil]");
|
||||
if (teil && !teil.classList.contains("zieht")) rfHeizungZeichnen(teil);
|
||||
}
|
||||
|
||||
/* --- Licht und Schalter --------------------------------------------------------- */
|
||||
|
||||
/** An/Aus schicken - kennt das Geraet nur "umschalten", tut es auch das. */
|
||||
function rfSchalten(element, karte) {
|
||||
const an = karte.classList.contains("rf-schalter") ? karte.getAttribute("aria-pressed") !== "true" : karte.dataset.an !== "1";
|
||||
const cmd = (an ? karte.dataset.ein : karte.dataset.aus) || karte.dataset.um;
|
||||
if (!cmd) return;
|
||||
if (karte.classList.contains("rf-schalter")) {
|
||||
karte.setAttribute("aria-pressed", String(an));
|
||||
karte.querySelector(".rf-st").textContent = an ? "an" : "aus";
|
||||
} else {
|
||||
karte.dataset.an = an ? "1" : "";
|
||||
rfLampeZeichnen(karte);
|
||||
}
|
||||
rfStatus();
|
||||
rfKommando(Number(cmd), {}, element);
|
||||
}
|
||||
|
||||
function rfLampeZeichnen(karte) {
|
||||
const an = karte.dataset.an === "1";
|
||||
karte.classList.toggle("aus", !an);
|
||||
karte.querySelector("[data-rf-birne]").setAttribute("aria-pressed", String(an));
|
||||
const hell = karte.querySelector(".rf-hell");
|
||||
let text = an ? "an" : "aus";
|
||||
if (hell) {
|
||||
const min = Number(hell.min), max = Number(hell.max);
|
||||
const prozent = Math.round((Number(hell.value) - min) / (max - min) * 100);
|
||||
hell.style.setProperty("--wert", prozent + "%");
|
||||
if (an) text += " · " + prozent + RF_S + "%";
|
||||
}
|
||||
karte.querySelector("[data-rf-lampe-stand]").textContent = text;
|
||||
}
|
||||
|
||||
function rfLampeStarten(karte) {
|
||||
rfLampeZeichnen(karte);
|
||||
karte.querySelector("[data-rf-birne]").addEventListener("click", e => rfSchalten(e.currentTarget, karte));
|
||||
|
||||
const hell = karte.querySelector(".rf-hell");
|
||||
if (hell) {
|
||||
hell.addEventListener("input", () => rfLampeZeichnen(karte));
|
||||
hell.addEventListener("change", () => {
|
||||
const params = {};
|
||||
params[hell.dataset.param] = hell.value;
|
||||
rfKommando(Number(hell.dataset.cmd), params, hell);
|
||||
});
|
||||
}
|
||||
|
||||
// Farbe: das Geraet will drei Zahlen 0-255, zerlegt wird deshalb hier.
|
||||
const farben = karte.querySelector(".rf-farben");
|
||||
if (farben) {
|
||||
const setzen = hex => {
|
||||
karte.style.setProperty("--farbe", hex);
|
||||
farben.querySelectorAll("[data-farbe]").forEach(k => k.setAttribute("aria-pressed", String(k.dataset.farbe === hex)));
|
||||
const params = {};
|
||||
params[farben.dataset.red] = parseInt(hex.substr(1, 2), 16);
|
||||
params[farben.dataset.green] = parseInt(hex.substr(3, 2), 16);
|
||||
params[farben.dataset.blue] = parseInt(hex.substr(5, 2), 16);
|
||||
rfKommando(Number(farben.dataset.cmd), params, farben);
|
||||
};
|
||||
farben.querySelectorAll("[data-farbe]").forEach(k => k.addEventListener("click", () => setzen(k.dataset.farbe)));
|
||||
const eigen = farben.querySelector("input[type=color]");
|
||||
eigen.addEventListener("input", () => karte.style.setProperty("--farbe", eigen.value));
|
||||
eigen.addEventListener("change", () => setzen(eigen.value));
|
||||
}
|
||||
|
||||
// Preset, Effekt und andere Listen: wenige Eintraege als Chips, lange
|
||||
// Listen als <select>, aus dem js/solar/auswahl.js die eigene Liste macht.
|
||||
karte.querySelectorAll(".rf-liste").forEach(liste => {
|
||||
liste.querySelectorAll("[data-wert]").forEach(k => k.addEventListener("click", () => {
|
||||
rfListeSetzen(liste, k.dataset.wert);
|
||||
rfListeSenden(liste, k.dataset.wert);
|
||||
}));
|
||||
const auswahl = liste.querySelector("select");
|
||||
if (auswahl) auswahl.addEventListener("change", () => rfListeSenden(liste, auswahl.value));
|
||||
});
|
||||
|
||||
if (karte.dataset.wled) rfWledLesen(karte);
|
||||
}
|
||||
|
||||
/** Den gewaehlten Eintrag an das Geraet schicken. */
|
||||
function rfListeSenden(liste, wert) {
|
||||
const params = {};
|
||||
params[liste.dataset.param] = wert;
|
||||
rfKommando(Number(liste.dataset.cmd), params, liste);
|
||||
}
|
||||
|
||||
/** Einen Eintrag einer Liste als gewaehlt zeigen; null = keiner. */
|
||||
function rfListeSetzen(liste, wert) {
|
||||
liste.querySelectorAll("[data-wert]").forEach(k => {
|
||||
k.setAttribute("aria-pressed", String(wert !== null && k.dataset.wert === String(wert)));
|
||||
});
|
||||
const auswahl = liste.querySelector("select");
|
||||
if (auswahl && wert !== null) {
|
||||
auswahl.value = String(wert);
|
||||
// Die Beschriftung des eigenen Knopfes haengt am <select>, nicht am DOM
|
||||
// darunter - sie muss also mitgezogen werden.
|
||||
if (typeof auswahlAktualisieren === "function") auswahlAktualisieren(auswahl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Den wirklichen Zustand einer WLED-Lampe holen. In der Datenbank stehen nur
|
||||
* an/aus, Helligkeit und Farbe vom letzten Poll; welcher Effekt und welches
|
||||
* Preset laufen, weiss nur die Lampe selbst.
|
||||
*/
|
||||
function rfWledLesen(karte) {
|
||||
rfRequest("wled", { actor: Number(karte.dataset.actor) }).then(stand => {
|
||||
if (!stand || stand.error || !document.body.contains(karte)) return;
|
||||
karte.dataset.an = stand.an ? "1" : "";
|
||||
const hell = karte.querySelector(".rf-hell");
|
||||
if (hell && stand.bri !== null && document.activeElement !== hell) hell.value = stand.bri;
|
||||
if (stand.farbe && (stand.farbe[0] || stand.farbe[1] || stand.farbe[2])) {
|
||||
const hex = "#" + stand.farbe.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("");
|
||||
karte.style.setProperty("--farbe", hex);
|
||||
const eigen = karte.querySelector(".rf-farbe-eigen input");
|
||||
if (eigen) eigen.value = hex;
|
||||
karte.querySelectorAll("[data-farbe]").forEach(k => k.setAttribute("aria-pressed", String(k.dataset.farbe === hex)));
|
||||
}
|
||||
karte.querySelectorAll(".rf-liste").forEach(liste => {
|
||||
const wert = stand[liste.dataset.feld];
|
||||
// WLED meldet ps = -1, wenn kein Preset aktiv ist.
|
||||
if (typeof wert !== "undefined") rfListeSetzen(liste, wert === null || wert < 0 ? null : wert);
|
||||
});
|
||||
rfLampeZeichnen(karte);
|
||||
rfStatus();
|
||||
});
|
||||
}
|
||||
@@ -316,7 +316,7 @@ function kachelQuelle(wert) {
|
||||
* und aus drei Kuerzeln wuerden drei Zeilen.
|
||||
*/
|
||||
function kachelWertKurz(wert, kachel) {
|
||||
const einheit = wert.format === "leistung" ? "W/kW" : (wert.einheit || "");
|
||||
const einheit = istLeistung(wert) ? "W/kW" : (wert.einheit || "");
|
||||
const treffer = wert.wechselrichter ? null : katalogTreffer(wert);
|
||||
let text;
|
||||
|
||||
@@ -353,10 +353,9 @@ function zeichneKacheln() {
|
||||
if (!ziel || !kachelModell) return;
|
||||
|
||||
if (!kachelModell.tabelleDa) {
|
||||
ziel.innerHTML = '<div class="alert alert-warning mb-0">Die Tabelle '
|
||||
+ '<code>kachelWerte</code> fehlt noch. Sie steht in '
|
||||
+ '<code>solarLog_kacheln.sql</code> – bis dahin zeigen alle Kacheln '
|
||||
+ 'ihre Vorgabe, und Speichern ist nicht möglich.</div>';
|
||||
ziel.innerHTML = '<div class="alert alert-warning mb-0">Etagen und Räume '
|
||||
+ 'lassen sich nicht lesen – die Datenbank <code>homeMesh</code> ist nicht '
|
||||
+ 'erreichbar, oder <code>homeMesh_grundriss.sql</code> fehlt noch.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -499,7 +498,7 @@ function kachelWertZeile(wert, i, kachel) {
|
||||
// Ohne Treffer beim ersten Geraet des eigenen Raums beginnen, sonst beim
|
||||
// ersten ueberhaupt - ein neuer Wert soll nicht im Nachbargeschoss starten.
|
||||
const ersterEigener = katalog.findIndex(
|
||||
(g) => kachel && g.floorKey === kachel.floor && g.raumKey === kachel.name);
|
||||
(g) => kachel && g.raumKey === kachel.nr);
|
||||
const gi = treffer ? treffer.gi : Math.max(0, ersterEigener);
|
||||
const wi = treffer ? treffer.wi : -1;
|
||||
|
||||
@@ -513,7 +512,9 @@ function kachelWertZeile(wert, i, kachel) {
|
||||
* in der gefilterten Liste - sonst zeigte die Auswahl nach dem Umsortieren
|
||||
* auf ein anderes Geraet.
|
||||
*/
|
||||
const eigen = (g) => kachel && g.floorKey === kachel.floor && g.raumKey === kachel.name;
|
||||
// Ueber die feste Raumnummer, nicht ueber den Namen - "Bad" gibt es auf
|
||||
// drei Etagen, und ein umbenannter Raum bliebe sonst ohne eigene Geraete.
|
||||
const eigen = (g) => kachel && g.raumKey === kachel.nr;
|
||||
const option = (g, index) => '<option value="' + index + '"'
|
||||
+ (index === gi ? " selected" : "") + ">"
|
||||
+ textSchuetzen(g.label + (g.raum && !eigen(g) ? " – " + g.raum : "")) + "</option>";
|
||||
@@ -568,20 +569,29 @@ function kachelWertZeile(wert, i, kachel) {
|
||||
|
||||
+ '<div class="kw-einheit"><input type="text" class="form-control form-control-sm"'
|
||||
+ ' data-feld="einheit" placeholder="Einheit" maxlength="8"'
|
||||
+ (wert.format === "leistung" ? " disabled" : "")
|
||||
+ (istLeistung(wert) ? " disabled" : "")
|
||||
+ ' value="' + textSchuetzen(wert.einheit || "") + '"></div>'
|
||||
|
||||
+ '<div class="kw-stellen"><input type="number" class="form-control form-control-sm"'
|
||||
+ ' data-feld="stellen" min="0" max="3" placeholder="0"'
|
||||
+ (wert.format === "leistung" ? " disabled" : "")
|
||||
+ (istLeistung(wert) ? " disabled" : "")
|
||||
+ ' value="' + (wert.stellen === undefined ? "" : wert.stellen) + '"></div>'
|
||||
|
||||
+ '<div class="kw-symbol">' + kachelSymbolFeld(wert.icon || "") + "</div>"
|
||||
|
||||
+ '<div class="kw-schalter">'
|
||||
+ '<label title="Leistung: skaliert selbst zwischen W und kW">'
|
||||
+ '<input type="checkbox" class="form-check-input" data-feld="format"'
|
||||
+ (wert.format === "leistung" ? " checked" : "") + "> kW</label>"
|
||||
// Leistung: die Anzeige skaliert selbst zwischen W und kW. Gewählt wird
|
||||
// nur, in welcher Einheit die Quelle meldet - die Wallboxen schicken kW,
|
||||
// die Zähler W. Früher ein Häkchen, das stillschweigend Watt annahm.
|
||||
+ '<select class="form-select form-select-sm kw-format" data-feld="format"'
|
||||
+ ' title="Leistung: in welcher Einheit die Quelle meldet. Angezeigt wird'
|
||||
+ ' dann selbst in W oder kW.">'
|
||||
+ '<option value=""' + (istLeistung(wert) ? "" : " selected") + '>Einheit</option>'
|
||||
+ '<option value="leistung"' + (wert.format === "leistung" ? " selected" : "")
|
||||
+ '>Leistung aus W</option>'
|
||||
+ '<option value="leistung_kw"' + (wert.format === "leistung_kw" ? " selected" : "")
|
||||
+ '>Leistung aus kW</option>'
|
||||
+ "</select>"
|
||||
+ '<label title="Vorzeichen umdrehen - für Zähler, die verkehrt herum'
|
||||
+ ' eingebaut sind">'
|
||||
+ '<input type="checkbox" class="form-check-input" data-feld="negativ"'
|
||||
@@ -594,6 +604,11 @@ function kachelWertZeile(wert, i, kachel) {
|
||||
+ "</div></div>";
|
||||
}
|
||||
|
||||
/** Skaliert der Wert selbst zwischen W und kW? Egal, aus welcher Einheit. */
|
||||
function istLeistung(wert) {
|
||||
return wert.format === "leistung" || wert.format === "leistung_kw";
|
||||
}
|
||||
|
||||
/** Fremden Text sicher ins Markup bringen - Topics kommen aus der Datenbank. */
|
||||
function textSchuetzen(s) {
|
||||
const d = document.createElement("div");
|
||||
@@ -639,8 +654,9 @@ function kachelWertLesen(feldZeile) {
|
||||
const pfad = (hol("pfad") || "").trim();
|
||||
if (pfad) wert.pfad = pfad;
|
||||
}
|
||||
if (hol("format")) {
|
||||
wert.format = "leistung";
|
||||
const format = hol("format");
|
||||
if (format === "leistung" || format === "leistung_kw") {
|
||||
wert.format = format;
|
||||
} else {
|
||||
const einheit = (hol("einheit") || "").trim();
|
||||
if (einheit) wert.einheit = einheit;
|
||||
@@ -796,11 +812,55 @@ function kachelnBinden() {
|
||||
if (neuLaden) neuLaden.addEventListener("click", kachelnHolen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Kachel von aussen aufschlagen - der Knopf "Werte" im Reiter Grundriss.
|
||||
*
|
||||
* Wechselt in diesen Reiter, laedt nach, falls sich der Grundriss seit dem
|
||||
* letzten Laden geaendert hat (eine eben gesetzte Kachel stuende sonst nicht
|
||||
* in der Liste), und oeffnet die Zeile.
|
||||
*/
|
||||
async function kachelOeffnen(floor, room) {
|
||||
const knopf = document.getElementById("einstellung-kacheln-tab");
|
||||
if (knopf && typeof bootstrap !== "undefined") new bootstrap.Tab(knopf).show();
|
||||
if (window.grundrissGeaendert || !kachelModell) {
|
||||
window.grundrissGeaendert = false;
|
||||
await kachelnHolen();
|
||||
}
|
||||
const k = kachelModell && kachelSuchen(floor, room);
|
||||
if (!k) return;
|
||||
kachelModell.kacheln.forEach(a => { a._offen = false; });
|
||||
k._offen = true;
|
||||
if (!k._entwurf) k._entwurf = JSON.parse(JSON.stringify(k.werte));
|
||||
zeichneKacheln();
|
||||
const zeile = document.querySelector('.kachel-zeile[data-floor="' + k.floor + '"][data-room="' + k.room + '"]');
|
||||
if (zeile) zeile.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// Das Merken des offenen Reiters macht reiterMerken() in common.js - fuer
|
||||
// jede Leiste der Seite, nicht nur fuer diese.
|
||||
kachelnBinden();
|
||||
kachelnHolen();
|
||||
|
||||
// Nach einer Aenderung im Reiter Grundriss wissen Kacheln und Geraete nicht
|
||||
// mehr, was gilt: neue Raeume fehlen, umbenannte heissen noch wie vorher.
|
||||
// Nachgeladen wird beim naechsten Oeffnen - nicht sofort, sonst liefe bei
|
||||
// jedem gezogenen Kachelchen eine Anfrage fuer zwei Reiter mit.
|
||||
const kachelReiter = document.getElementById("einstellung-kacheln-tab");
|
||||
if (kachelReiter) kachelReiter.addEventListener("shown.bs.tab", function () {
|
||||
if (window.grundrissGeaendert) {
|
||||
window.grundrissGeaendert = false;
|
||||
window.geraeteVeraltet = true;
|
||||
kachelnHolen();
|
||||
}
|
||||
});
|
||||
const geraeteReiter = document.getElementById("einstellung-geraete-tab");
|
||||
if (geraeteReiter) geraeteReiter.addEventListener("shown.bs.tab", function () {
|
||||
if (window.grundrissGeaendert || window.geraeteVeraltet) {
|
||||
window.geraeteVeraltet = false;
|
||||
if (typeof geraeteHolen === "function") geraeteHolen();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1130,3 +1190,381 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
geraeteBinden();
|
||||
geraeteHolen();
|
||||
});
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
Fahrzeug: die Zugangsschlüssel der MyŠkoda-Anbindung
|
||||
--------------------------------------------------------------------------
|
||||
Sie laufen ab. Bis hierher hieß der Tausch: per SSH auf die NAS, skoda.conf
|
||||
im Editor öffnen, nicht vertippen. Das gehört in die Einstellungen wie die
|
||||
Preise und die Geräte.
|
||||
|
||||
Was hier nie ankommt, sind die Schlüssel selbst. Die Seite steht im
|
||||
Heimnetz ohne Anmeldung offen, und ein solcher Schlüssel steuert das
|
||||
Fahrzeug von überall auf der Welt - Laden, Klima, Standort. Zu sehen sind
|
||||
nur die letzten vier Zeichen, gerade genug, um zwei auseinanderzuhalten.
|
||||
Geschrieben wird deshalb auch nur in eine Richtung: ein neuer Wert geht
|
||||
hinein, ein alter kommt nie heraus.
|
||||
|
||||
Der zweite und dritte Schlüssel beschleunigen nichts: das Kontingent der
|
||||
API gilt gemessen fürs ganze Konto. Sie sind für den Fall da, dass einer
|
||||
abläuft oder widerrufen wird - dann fragt der Abruf mit dem nächsten
|
||||
weiter, statt stillzustehen. Deshalb steht unter den Feldern, was gerade
|
||||
gilt, und nicht, was ein weiterer Schlüssel brächte.
|
||||
========================================================================== */
|
||||
|
||||
let skodaModell = null; // was ?action=skoda-keys liefert, ohne Werte
|
||||
|
||||
/* --- Zeichnen ------------------------------------------------------------ */
|
||||
|
||||
function zeichneSkoda() {
|
||||
const ziel = document.getElementById("skodaListe");
|
||||
if (!ziel || !skodaModell) return;
|
||||
|
||||
if (!skodaModell.da) {
|
||||
ziel.innerHTML = '<div class="alert alert-warning mb-0">'
|
||||
+ "Die Datei <code>" + textSchuetzen(skodaModell.datei) + "</code> gibt es nicht. "
|
||||
+ "Sie gehört zum SolarManager und wird dort angelegt – ohne sie läuft auch "
|
||||
+ "der Abruf nicht.</div>";
|
||||
return;
|
||||
}
|
||||
|
||||
const zeilen = skodaModell.felder.map(skodaZeile).join("");
|
||||
ziel.innerHTML =
|
||||
'<div class="skoda-felder">' + zeilen + "</div>"
|
||||
+ (skodaModell.schreibbar ? "" :
|
||||
'<div class="alert alert-warning mt-3 mb-0 py-2 small">'
|
||||
+ "Der Webserver darf <code>" + textSchuetzen(skodaModell.datei)
|
||||
+ "</code> nicht schreiben. Eintragen lässt sich hier nichts.</div>")
|
||||
+ '<div class="d-flex flex-wrap align-items-center gap-3 mt-3">'
|
||||
+ '<button type="button" class="btn btn-primary" id="skodaSpeichern"'
|
||||
+ (skodaModell.schreibbar ? "" : " disabled") + ">Eintragen</button>"
|
||||
+ '<span class="skoda-stand small text-body-secondary"></span>'
|
||||
+ "</div>"
|
||||
+ skodaBilanz();
|
||||
}
|
||||
|
||||
/** Eine Zeile: was der Schlüssel tut, ob er da ist, und ein Feld dafür. */
|
||||
function skodaZeile(f) {
|
||||
const kennung = "skodaFeld-" + f.feld;
|
||||
const stand = f.gesetzt
|
||||
? '<span class="badge text-bg-success">hinterlegt</span>'
|
||||
+ ' <span class="text-body-secondary">endet auf ' + textSchuetzen(f.maske) + "</span>"
|
||||
: '<span class="badge text-bg-secondary">'
|
||||
+ (f.pflicht ? "fehlt" : "nicht hinterlegt") + "</span>";
|
||||
|
||||
// Entfernen nur, wo es etwas zu entfernen gibt und wo es ohne geht. Der
|
||||
// erste Schlüssel lässt sich ersetzen, aber nicht abschaffen - ohne ihn
|
||||
// steht der Abruf.
|
||||
const weg = (f.gesetzt && !f.pflicht)
|
||||
? '<button type="button" class="btn btn-outline-danger btn-sm skoda-weg"'
|
||||
+ ' data-feld="' + textSchuetzen(f.feld) + '" title="Diesen Schlüssel entfernen">'
|
||||
+ '<i class="bi bi-trash"></i></button>'
|
||||
: "";
|
||||
|
||||
return '<div class="border-top py-3 skoda-feld" data-feld="' + textSchuetzen(f.feld) + '">'
|
||||
+ '<div class="d-flex flex-wrap align-items-baseline gap-2">'
|
||||
+ '<label class="fw-semibold mb-0" for="' + kennung + '">'
|
||||
+ textSchuetzen(f.titel) + "</label>"
|
||||
+ stand
|
||||
+ "</div>"
|
||||
+ '<p class="text-body-secondary small mb-2">' + textSchuetzen(f.zweck) + "</p>"
|
||||
+ '<div class="input-group input-group-sm">'
|
||||
+ '<input type="password" class="form-control" id="' + kennung + '"'
|
||||
+ ' autocomplete="off" spellcheck="false"'
|
||||
+ ' placeholder="' + (f.gesetzt ? "neuer Schlüssel – leer lassen, um ihn zu behalten"
|
||||
: "Schlüssel einfügen") + '">'
|
||||
+ '<button type="button" class="btn btn-outline-secondary skoda-zeigen"'
|
||||
+ ' title="Anzeigen"><i class="bi bi-eye"></i></button>'
|
||||
+ weg
|
||||
+ "</div>"
|
||||
+ "</div>";
|
||||
}
|
||||
|
||||
/** Was die hinterlegten Schlüssel einbringen - die Zahlen dahinter. */
|
||||
function skodaBilanz() {
|
||||
const m = skodaModell;
|
||||
const teile = [];
|
||||
teile.push(m.abruf + (m.abruf === 1 ? " Schlüssel" : " Schlüssel im Wechsel")
|
||||
+ " für den Abruf – beim Laden und während der Fahrt alle "
|
||||
+ m.takt + " Sekunden");
|
||||
teile.push(m.befehle + " Befehle je Stunde");
|
||||
if (m.ablauf) {
|
||||
teile.push("nächster Ablauf am " + datumLesbar(String(m.ablauf).slice(0, 10)));
|
||||
}
|
||||
return '<p class="text-body-secondary small mt-3 mb-0">'
|
||||
+ textSchuetzen(teile.join(" · ")) + "<br>"
|
||||
+ "Ein neuer Schlüssel gilt sofort; der Abruf liest die Datei von selbst neu."
|
||||
+ "</p>";
|
||||
}
|
||||
|
||||
function skodaStand(text, klasse) {
|
||||
const ziel = document.querySelector("#skodaListe .skoda-stand");
|
||||
if (!ziel) return;
|
||||
ziel.className = "skoda-stand small " + (klasse || "text-body-secondary");
|
||||
ziel.textContent = text;
|
||||
clearTimeout(skodaStand._uhr);
|
||||
if (klasse !== "text-danger") {
|
||||
skodaStand._uhr = setTimeout(function () {
|
||||
if (ziel.textContent === text) ziel.textContent = "";
|
||||
}, 8000);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Sprechen mit dem Endpunkt ------------------------------------------- */
|
||||
|
||||
async function skodaFrage(action, koerper) {
|
||||
const antwort = await fetch("ajax/settings.php?action=" + action, koerper ? {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(koerper),
|
||||
} : { cache: "no-store" });
|
||||
const d = await antwort.json();
|
||||
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||||
return d;
|
||||
}
|
||||
|
||||
async function skodaHolen() {
|
||||
const ziel = document.getElementById("skodaListe");
|
||||
if (!ziel) return;
|
||||
try {
|
||||
skodaModell = await skodaFrage("skoda-keys");
|
||||
zeichneSkoda();
|
||||
} catch (e) {
|
||||
ziel.innerHTML = '<div class="alert alert-danger mb-0">Schlüssel nicht abrufbar: '
|
||||
+ textSchuetzen(e.message) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alles Eingetragene auf einmal senden.
|
||||
*
|
||||
* Leere Felder bedeuten "so lassen" - sonst löschte ein Speichern ohne
|
||||
* Eingabe alles, was man nicht noch einmal eintippt.
|
||||
*
|
||||
* Das kann dauern: der Server probiert jeden neuen Schlüssel einmal an der
|
||||
* API aus, bevor er ihn hinschreibt. Deshalb der Knopf mit Wartezustand.
|
||||
*/
|
||||
async function skodaSenden() {
|
||||
const knopf = document.getElementById("skodaSpeichern");
|
||||
const felder = {};
|
||||
document.querySelectorAll("#skodaListe .skoda-feld").forEach(function (zeile) {
|
||||
const feld = zeile.dataset.feld;
|
||||
const wert = zeile.querySelector("input").value.trim();
|
||||
if (wert) felder[feld] = wert;
|
||||
});
|
||||
if (!Object.keys(felder).length) {
|
||||
skodaStand("Kein Schlüssel eingetragen.", "text-warning");
|
||||
return;
|
||||
}
|
||||
|
||||
if (knopf) knopf.disabled = true;
|
||||
skodaStand("Wird an der Škoda-API ausprobiert …");
|
||||
try {
|
||||
const d = await skodaFrage("skoda-keys-save", { felder: felder });
|
||||
skodaModell = d.stand;
|
||||
zeichneSkoda();
|
||||
skodaStand((d.meldungen || []).join(" ") || "Eingetragen.", "text-success");
|
||||
} catch (e) {
|
||||
skodaStand(e.message, "text-danger");
|
||||
if (knopf) knopf.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen Schlüssel entfernen.
|
||||
*
|
||||
* Mit Rückfrage: er steht danach nirgends mehr, und ein neuer kommt nur aus
|
||||
* der MyŠkoda-App.
|
||||
*/
|
||||
async function skodaLoeschen(feld) {
|
||||
if (!window.confirm(feld + " entfernen?\n\nDer Schlüssel verschwindet aus der "
|
||||
+ "Konfiguration. Zurückholen lässt er sich nur über die MyŠkoda-App.")) {
|
||||
return;
|
||||
}
|
||||
skodaStand("Wird entfernt …");
|
||||
try {
|
||||
const d = await skodaFrage("skoda-keys-save", { loeschen: [feld] });
|
||||
skodaModell = d.stand;
|
||||
zeichneSkoda();
|
||||
skodaStand(feld + " entfernt.", "text-success");
|
||||
} catch (e) {
|
||||
skodaStand(e.message, "text-danger");
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Bedienung ----------------------------------------------------------- */
|
||||
|
||||
function skodaBinden() {
|
||||
const liste = document.getElementById("skodaListe");
|
||||
if (!liste) return;
|
||||
|
||||
liste.addEventListener("click", function (ereignis) {
|
||||
const zeigen = ereignis.target.closest(".skoda-zeigen");
|
||||
if (zeigen) {
|
||||
// Ein eingefügter Schlüssel ist nicht zu merken, nur zu vergleichen -
|
||||
// wer ihn nicht sehen kann, sucht einen Tippfehler im Dunkeln.
|
||||
const feld = zeigen.closest(".input-group").querySelector("input");
|
||||
feld.type = feld.type === "password" ? "text" : "password";
|
||||
zeigen.querySelector("i").className =
|
||||
feld.type === "password" ? "bi bi-eye" : "bi bi-eye-slash";
|
||||
return;
|
||||
}
|
||||
const weg = ereignis.target.closest(".skoda-weg");
|
||||
if (weg) {
|
||||
skodaLoeschen(weg.dataset.feld);
|
||||
return;
|
||||
}
|
||||
if (ereignis.target.closest("#skodaSpeichern")) {
|
||||
skodaSenden();
|
||||
}
|
||||
});
|
||||
|
||||
// Enter im Feld soll dasselbe tun wie der Knopf daneben.
|
||||
liste.addEventListener("keydown", function (ereignis) {
|
||||
if (ereignis.key === "Enter" && ereignis.target.matches(".skoda-feld input")) {
|
||||
ereignis.preventDefault();
|
||||
skodaSenden();
|
||||
}
|
||||
});
|
||||
|
||||
const neuLaden = document.getElementById("skodaNeuLaden");
|
||||
if (neuLaden) neuLaden.addEventListener("click", skodaHolen);
|
||||
}
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
Reiter "Meldungen": Web-Push-Abos dieses und der anderen Geräte.
|
||||
|
||||
Das Abo gehört dem Browser. Deshalb steht hier zweierlei nebeneinander:
|
||||
dieses Gerät (an- und abmelden) und die Liste aller angemeldeten Geräte
|
||||
(entfernen, wenn eines weg ist). Verschickt wird nichts von hier - die
|
||||
Probe geht über MQTT an den Runner, genau wie eine Automatik.
|
||||
========================================================================== */
|
||||
|
||||
let pushDiesesGeraet = null; // Abo dieses Browsers, oder null
|
||||
|
||||
async function pushZeichnen() {
|
||||
const ziel = document.getElementById("pushListe");
|
||||
if (!ziel) return;
|
||||
|
||||
if (!pushMoeglich()) {
|
||||
ziel.innerHTML = '<div class="alert alert-secondary mb-0">Dieser Browser kann keine '
|
||||
+ "Benachrichtigungen empfangen. Auf dem iPhone geht es erst, wenn die Seite über "
|
||||
+ "„Teilen ▸ Zum Home-Bildschirm“ abgelegt und von dort geöffnet wird.</div>";
|
||||
return;
|
||||
}
|
||||
if (!pushStand.schluessel) {
|
||||
ziel.innerHTML = '<div class="alert alert-warning mb-0">Auf der NAS fehlt das '
|
||||
+ "Schlüsselpaar. Einmalig <code>python3 vapid_erzeugen.py</code> im SolarManager "
|
||||
+ "ausführen, dann diese Seite neu laden.</div>";
|
||||
return;
|
||||
}
|
||||
|
||||
const an = !!pushDiesesGeraet;
|
||||
const blockiert = Notification.permission === "denied";
|
||||
const zeilen = pushStand.abos.map(a => {
|
||||
const stand = a.fehler
|
||||
? '<span class="text-danger">' + textSchuetzen(a.fehler) + "</span>"
|
||||
: a.zuletzt ? "zuletzt zugestellt " + textSchuetzen(a.zuletzt)
|
||||
: "noch nichts zugestellt";
|
||||
return '<div class="push-abo border-top py-2 d-flex align-items-center gap-2 flex-wrap">'
|
||||
+ '<i class="bi bi-phone text-body-secondary"></i>'
|
||||
+ "<b>" + textSchuetzen(a.name || "Gerät") + "</b>"
|
||||
+ '<span class="small text-body-secondary">' + textSchuetzen(a.browser) + "</span>"
|
||||
+ '<span class="small text-body-secondary ms-auto">' + stand + "</span>"
|
||||
+ '<button type="button" class="btn btn-outline-danger btn-sm push-weg" data-id="' + a.id
|
||||
+ '" title="Dieses Gerät entfernen"><i class="bi bi-trash3"></i></button></div>';
|
||||
}).join("");
|
||||
|
||||
ziel.innerHTML =
|
||||
'<div class="d-flex flex-wrap align-items-center gap-2 mb-3">'
|
||||
+ '<button type="button" class="btn ' + (an ? "btn-outline-secondary" : "btn-primary")
|
||||
+ '" id="pushSchalter"' + (blockiert && !an ? " disabled" : "") + ">"
|
||||
+ '<i class="bi ' + (an ? "bi-bell-slash" : "bi-bell") + ' me-1"></i>'
|
||||
+ (an ? "Auf diesem Gerät abmelden" : "Auf diesem Gerät anmelden") + "</button>"
|
||||
+ '<button type="button" class="btn btn-outline-primary" id="pushProbe"'
|
||||
+ (pushStand.abos.length ? "" : " disabled") + '><i class="bi bi-send me-1"></i>Probe schicken</button>'
|
||||
+ '<span class="small" id="pushMeldung"></span>'
|
||||
+ "</div>"
|
||||
+ (blockiert && !an
|
||||
? '<div class="alert alert-warning py-2 small">Der Browser hat Benachrichtigungen für '
|
||||
+ "diese Seite blockiert. Das lässt sich nur in seinen eigenen Website-Einstellungen "
|
||||
+ "wieder erlauben.</div>"
|
||||
: "")
|
||||
+ (zeilen
|
||||
? '<div class="push-abos">' + zeilen + "</div>"
|
||||
: '<p class="text-secondary small mb-0">Noch kein Gerät angemeldet.</p>');
|
||||
|
||||
document.getElementById("pushSchalter").addEventListener("click", pushSchalten);
|
||||
document.getElementById("pushProbe").addEventListener("click", pushProbeSchicken);
|
||||
ziel.querySelectorAll(".push-weg").forEach(k => k.addEventListener("click", async () => {
|
||||
pushMeldung("wird entfernt …");
|
||||
try {
|
||||
await pushEntfernen(Number(k.dataset.id));
|
||||
pushDiesesGeraet = await pushAbo();
|
||||
await pushZeichnen();
|
||||
} catch (fehler) {
|
||||
pushMeldung(fehler.message, "text-danger");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function pushMeldung(text, klasse) {
|
||||
const el = document.getElementById("pushMeldung");
|
||||
if (!el) return;
|
||||
el.className = "small " + (klasse || "text-body-secondary");
|
||||
el.textContent = text || "";
|
||||
}
|
||||
|
||||
async function pushSchalten() {
|
||||
const an = !!pushDiesesGeraet;
|
||||
pushMeldung(an ? "wird abgemeldet …" : "wird angemeldet …");
|
||||
try {
|
||||
if (an) await pushAbmelden();
|
||||
else await pushAnmelden();
|
||||
pushDiesesGeraet = await pushAbo();
|
||||
await pushZeichnen();
|
||||
pushMeldung(an ? "Abgemeldet." : "Angemeldet – die Probe kommt jetzt hier an.",
|
||||
an ? "text-body-secondary" : "text-success");
|
||||
} catch (fehler) {
|
||||
pushMeldung(fehler.message, "text-danger");
|
||||
}
|
||||
}
|
||||
|
||||
async function pushProbeSchicken() {
|
||||
pushMeldung("Probe unterwegs …");
|
||||
try {
|
||||
await pushProbe();
|
||||
pushMeldung("Probe geschickt – sie geht über den Runner auf der NAS.", "text-success");
|
||||
} catch (fehler) {
|
||||
pushMeldung(fehler.message, "text-danger");
|
||||
}
|
||||
}
|
||||
|
||||
async function pushLaden() {
|
||||
try {
|
||||
await pushStandLaden();
|
||||
pushDiesesGeraet = await pushAbo();
|
||||
} catch (fehler) {
|
||||
const ziel = document.getElementById("pushListe");
|
||||
if (ziel) ziel.innerHTML = '<div class="alert alert-danger mb-0">' + textSchuetzen(fehler.message) + "</div>";
|
||||
return;
|
||||
}
|
||||
await pushZeichnen();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
skodaBinden();
|
||||
skodaHolen();
|
||||
});
|
||||
|
||||
function pushBinden() {
|
||||
const neu = document.getElementById("pushNeuLaden");
|
||||
if (neu) neu.addEventListener("click", pushLaden);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
pushBinden();
|
||||
pushLaden();
|
||||
});
|
||||
|
||||
@@ -632,6 +632,7 @@ function zeichneLive() {
|
||||
zeichneAbzeichen(w);
|
||||
zeichneFahrt(w);
|
||||
zeichneLadequelle(w);
|
||||
zeichneLadefenster(w);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Diagramme */
|
||||
@@ -649,7 +650,10 @@ function baueKurve() {
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
// "nearest" statt "index": Wallbox-Verlauf und Fahrzeugmeldungen haben
|
||||
// verschiedene Zeitpunkte, der n-te Wert der einen Reihe gehört nicht zum
|
||||
// n-ten der anderen.
|
||||
interaction: { mode: "nearest", axis: "x", intersect: false },
|
||||
scales: {
|
||||
// 'timestack' ist die Zeitachse, die diese Seite mitbringt
|
||||
// (js/chartjs-scale-timestack.min.js). Chart.js' eingebautes 'time'
|
||||
@@ -1151,6 +1155,38 @@ async function ladeKurve() {
|
||||
// Date-Objekten - die nimmt sie stillschweigend nicht an und zeigt
|
||||
// stattdessen den heutigen Tag.
|
||||
const zeit = (p) => new Date(String(p.datetime).replace(" ", "T")).getTime();
|
||||
|
||||
// Mit Wallbox-Verlauf (skoda_ladepunkte): die Leistung im Minutentakt,
|
||||
// der Ladestand dazwischen nach der geflossenen Energie aufgefüllt, und
|
||||
// die echten Meldungen des Fahrzeugs als Punkte darauf - damit sichtbar
|
||||
// bleibt, was gemessen und was abgeleitet ist. Das Fahrzeug meldet beim
|
||||
// Laden mitunter nur einmal in der Stunde; eine Linie durch diese Punkte
|
||||
// allein erfände den Verlauf dazwischen.
|
||||
if (d.verlauf && d.verlauf.length) {
|
||||
const imBild = (m) => zeit(m) >= zeit(d.verlauf[0]) - 600000
|
||||
&& zeit(m) <= zeit(d.verlauf[d.verlauf.length - 1]) + 600000;
|
||||
const meldungen = (d.meldungen || []).filter(imBild);
|
||||
kurveChart.data.datasets = [
|
||||
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
|
||||
borderWidth: 2, pointRadius: 0, fill: "origin", tension: 0,
|
||||
data: d.verlauf.map(p => ({ x: zeit(p), y: +p.kw })) },
|
||||
{ label: "Ladestand, aufgefüllt", yAxisID: "y1", borderColor: "#f0b429", borderWidth: 2,
|
||||
borderDash: [5, 4], pointRadius: 0, tension: 0, spanGaps: false,
|
||||
data: d.verlauf.map(p => ({ x: zeit(p), y: p.soc === null ? null : +p.soc })) },
|
||||
{ label: "Ladestand laut Fahrzeug", yAxisID: "y1", showLine: false,
|
||||
borderColor: "#f0b429", backgroundColor: "#f0b429", pointRadius: 4, pointHoverRadius: 6,
|
||||
data: meldungen.map(m => ({ x: zeit(m), y: +m.soc })) },
|
||||
{ label: "Leistung laut Fahrzeug", yAxisID: "y", showLine: false,
|
||||
borderColor: "#7aa7c7", backgroundColor: "#7aa7c7", pointRadius: 3, pointStyle: "rectRot",
|
||||
data: meldungen.filter(m => m.chg_kw !== null && m.chg_state === "CHARGING")
|
||||
.map(m => ({ x: zeit(m), y: +m.chg_kw })) }
|
||||
];
|
||||
kurveChart.update();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ohne Wallbox-Verlauf - Ladungen von vor der Aufzeichnung - wie bisher
|
||||
// aus den Zeilen der Fahrzeugtabelle.
|
||||
kurveChart.data.datasets = [
|
||||
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
|
||||
borderWidth: 2, pointRadius: 0, fill: "origin", tension: .25,
|
||||
@@ -1413,6 +1449,10 @@ function koppleSteuerung() {
|
||||
|
||||
/** Das Modal mit den Befehlen eines Bereichs füllen und zeigen. */
|
||||
function zeigeSteuerung(bereich) {
|
||||
if (bereich === "laden") {
|
||||
zeigeLadefenster();
|
||||
return;
|
||||
}
|
||||
const b = STEUERUNG[bereich];
|
||||
if (!b || !steuerModal) return;
|
||||
|
||||
@@ -1495,6 +1535,185 @@ function verdrahteBefehle(bereich) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Ladefenster */
|
||||
|
||||
/*
|
||||
* Laden im Stil der Bedienfenster der Solarseite (.lade in css/solar.css):
|
||||
* links der Ring mit Ladestand und Ladeziel und der eine grosse Knopf zum
|
||||
* Starten oder Stoppen, rechts die Wahl des Ladeziels.
|
||||
*
|
||||
* Jeder Befehl kostet eine der vier Anfragen je Stunde (skodaCmd.php). Das
|
||||
* Ladeziel wird deshalb nicht bei jedem Antippen geschickt, sondern erst,
|
||||
* wenn eine Weile keine andere Stufe mehr gewaehlt wurde. Bis das Fahrzeug
|
||||
* das neue Ziel meldet - das dauert bis zum naechsten Abruf -, steht die
|
||||
* eigene Wahl im Fenster und ist als "wird übernommen" gekennzeichnet.
|
||||
*/
|
||||
const LADEZIELE = [50, 60, 70, 80, 90, 100];
|
||||
const LADEZIEL_WARTEN_MS = 1500;
|
||||
const LADEZIEL_HALTEN_MS = 15 * 60 * 1000;
|
||||
let ladezielWunsch = null; // { prozent, seit, gesendet }
|
||||
let ladezielUhr = null;
|
||||
|
||||
function zeigeLadefenster() {
|
||||
if (!steuerModal) return;
|
||||
document.getElementById("modal-title").textContent = "Laden";
|
||||
const speichern = document.getElementById("modalSaveBtn");
|
||||
if (speichern) speichern.style.display = "none";
|
||||
|
||||
const S = "\u2009";
|
||||
document.getElementById("modal-body").innerHTML =
|
||||
'<div class="lade skoda-lade" id="skodaLadefenster">'
|
||||
+ '<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-sl="soc">–</b><span data-sl="ringZiel"></span></div></div>'
|
||||
+ '<div class="lade-text">'
|
||||
+ '<div class="lade-auto">Antriebsbatterie <span class="lade-pille" data-sl="zustand"></span></div>'
|
||||
+ '<div class="lade-gross" data-sl="gross">–</div>'
|
||||
+ '<div class="lade-zeile" data-sl="zeile1"></div>'
|
||||
+ '<div class="lade-zeile" data-sl="zeile2"></div>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<button type="button" class="lade-start" data-sl="start"><span class="lade-punkt"></span><span data-sl="startText">…</span></button>'
|
||||
+ '<div class="lade-erklaerung" data-sl="erklaerung"></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="lade-rechts">'
|
||||
+ '<div class="lade-abschnitt">'
|
||||
+ '<h3 id="skodaLadezielTitel">Ladeziel</h3>'
|
||||
+ '<div class="lade-strom skoda-ziele" role="radiogroup" aria-labelledby="skodaLadezielTitel">'
|
||||
+ LADEZIELE.map(p => '<button type="button" role="radio" aria-checked="false" data-ziel="' + p + '"><b>' + p + S + '%</b><span>'
|
||||
+ (p === 80 ? "schonend" : p === 100 ? "Reise" : "") + '</span></button>').join("")
|
||||
+ '</div>'
|
||||
+ '<div class="lade-zeile" data-sl="zielHinweis"></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="lade-meldung" data-sl="meldung" aria-live="polite"></div>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
|
||||
const fenster = $("skodaLadefenster");
|
||||
fenster.querySelectorAll("[data-ziel]").forEach(k => k.addEventListener("click", () => {
|
||||
ladezielWunsch = { prozent: Number(k.dataset.ziel), seit: Date.now(), gesendet: false };
|
||||
zeichneLadefenster(liveWerte());
|
||||
clearTimeout(ladezielUhr);
|
||||
ladezielUhr = setTimeout(ladezielSenden, LADEZIEL_WARTEN_MS);
|
||||
}));
|
||||
fenster.querySelector("[data-sl=start]").addEventListener("click", () => {
|
||||
const w = liveWerte();
|
||||
skodaBefehl(w.laedt ? "laden_stop" : "laden_start", {});
|
||||
});
|
||||
ladefensterMeldung("");
|
||||
zeichneLadefenster(liveWerte());
|
||||
steuerModal.show();
|
||||
}
|
||||
|
||||
function ladefensterMeldung(text, art) {
|
||||
const m = document.querySelector("#skodaLadefenster [data-sl=meldung]");
|
||||
if (!m) return;
|
||||
m.className = "lade-meldung" + (art ? " " + art : "");
|
||||
m.textContent = text || "Jeder Befehl ist einer von vier je Stunde. Was das Fahrzeug übernimmt, erscheint beim nächsten Abruf.";
|
||||
}
|
||||
|
||||
async function ladezielSenden() {
|
||||
if (!ladezielWunsch || ladezielWunsch.gesendet) return;
|
||||
const w = liveWerte();
|
||||
// Schon so eingestellt: nichts schicken, keinen Befehl verbrauchen.
|
||||
if (w.ziel === ladezielWunsch.prozent) {
|
||||
ladezielWunsch = null;
|
||||
zeichneLadefenster(w);
|
||||
return;
|
||||
}
|
||||
ladezielWunsch.gesendet = true;
|
||||
const ok = await skodaBefehl("ladeziel", { prozent: ladezielWunsch.prozent });
|
||||
if (!ok) ladezielWunsch = null;
|
||||
zeichneLadefenster(liveWerte());
|
||||
}
|
||||
|
||||
/** Einen Befehl schicken; waehrenddessen ist das Fenster gesperrt. */
|
||||
async function skodaBefehl(befehl, extra) {
|
||||
const fenster = $("skodaLadefenster");
|
||||
if (!fenster) return false;
|
||||
const knoepfe = fenster.querySelectorAll("button");
|
||||
fenster.dataset.sendet = "1";
|
||||
knoepfe.forEach(k => { k.disabled = true; });
|
||||
ladefensterMeldung("Befehl wird gesendet …", "warte");
|
||||
let ok = false;
|
||||
try {
|
||||
const antwort = await fetch("ajax/skodaCmd.php", {
|
||||
method: "POST", body: new URLSearchParams(Object.assign({ befehl: befehl }, extra)), cache: "no-store"
|
||||
});
|
||||
const d = await antwort.json();
|
||||
ok = !!d.ok;
|
||||
ladefensterMeldung(d.text, d.ok ? "gut" : "fehler");
|
||||
// Der Zustand aendert sich im Fahrzeug, nicht in der Tabelle - der
|
||||
// Abruf danach bringt ihn.
|
||||
if (d.ok) setTimeout(ladeLive, 20000);
|
||||
} catch (e) {
|
||||
ladefensterMeldung("Der Server hat nicht geantwortet.", "fehler");
|
||||
} finally {
|
||||
delete fenster.dataset.sendet;
|
||||
knoepfe.forEach(k => { k.disabled = false; });
|
||||
zeichneLadefenster(liveWerte());
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/** Das offene Ladefenster mit den Live-Werten nachziehen. */
|
||||
function zeichneLadefenster(w) {
|
||||
const fenster = $("skodaLadefenster");
|
||||
if (!fenster) return;
|
||||
const feld = name => fenster.querySelector("[data-sl=" + name + "]");
|
||||
const S = "\u2009";
|
||||
|
||||
// Die eigene Wahl gilt, bis das Fahrzeug sie meldet - oder zu lange nicht.
|
||||
if (ladezielWunsch && (w.ziel === ladezielWunsch.prozent || Date.now() - ladezielWunsch.seit > LADEZIEL_HALTEN_MS)) {
|
||||
ladezielWunsch = null;
|
||||
}
|
||||
const ziel = ladezielWunsch ? ladezielWunsch.prozent : w.ziel;
|
||||
const soc = w.soc !== null && Number.isFinite(w.soc) ? w.soc : null;
|
||||
|
||||
fenster.querySelector(".lade-ist").style.strokeDasharray = (soc === null ? 0 : Math.max(0, Math.min(100, soc))) + " 100";
|
||||
fenster.querySelector(".lade-ziel").style.strokeDasharray = (ziel ? ziel : 0) + " 100";
|
||||
feld("soc").textContent = soc === null ? "–" : nf(soc) + S + "%";
|
||||
feld("ringZiel").textContent = ziel ? "Ziel " + ziel + S + "%" : "";
|
||||
|
||||
const zustand = LADEZUSTAND[w.zustand];
|
||||
const pille = feld("zustand");
|
||||
pille.textContent = zustand ? zustand[0] : "";
|
||||
pille.className = "lade-pille" + (w.laedt ? " an" : w.zustand === "CHARGING_INTERRUPTED" ? " warn" : "");
|
||||
|
||||
feld("gross").innerHTML = w.laedt && w.kw
|
||||
? nf(w.kw, 1) + S + "kW<small>lädt</small>"
|
||||
: (w.km === null ? "–" : nf(w.km) + S + "km<small>Reichweite</small>");
|
||||
let fertig = "";
|
||||
if (w.laedt && w.restMin > 0) {
|
||||
const um = new Date(Date.now() + w.restMin * 60000);
|
||||
fertig = "fertig um " + String(um.getHours()).padStart(2, "0") + ":" + String(um.getMinutes()).padStart(2, "0");
|
||||
}
|
||||
feld("zeile1").textContent = w.laedt ? (w.km === null ? "" : nf(w.km) + S + "km Reichweite") : "";
|
||||
feld("zeile2").textContent = fertig;
|
||||
|
||||
// Ohne Kabel lehnt das Fahrzeug den Start ab - und der Versuch kostete
|
||||
// trotzdem einen der vier Befehle.
|
||||
const start = feld("start");
|
||||
const ohneKabel = !w.laedt && w.zustand === "CONNECT_CABLE";
|
||||
start.classList.toggle("laeuft", w.laedt);
|
||||
start.classList.toggle("aus", ohneKabel);
|
||||
if (!fenster.dataset.sendet) start.disabled = ohneKabel;
|
||||
feld("startText").textContent = w.laedt ? "Laden stoppen" : ohneKabel ? "Kabel anstecken" : "Laden starten";
|
||||
feld("erklaerung").textContent = w.zustand === "CONNECT_CABLE"
|
||||
? "Kein Kabel angesteckt. Das Ladeziel lässt sich trotzdem schon einstellen."
|
||||
: "Startet oder stoppt das Laden am Fahrzeug. Die Wallbox bleibt davon unberührt – sie gibt weiter frei, was sie freigibt.";
|
||||
|
||||
fenster.querySelectorAll("[data-ziel]").forEach(k =>
|
||||
k.setAttribute("aria-checked", String(Number(k.dataset.ziel) === ziel)));
|
||||
feld("zielHinweis").textContent = ladezielWunsch
|
||||
? (ladezielWunsch.gesendet ? "wird vom Fahrzeug übernommen …" : "wird gleich geschickt …")
|
||||
: (ziel ? "Das Fahrzeug hört bei " + ziel + S + "% auf zu laden." : "Das Fahrzeug meldet noch kein Ladeziel.");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ Start */
|
||||
|
||||
function alleLaden() {
|
||||
|
||||
@@ -142,248 +142,138 @@ function setFlowWidth(id, width) {
|
||||
flow.style.willChange = sichtbar ? "transform" : "auto";
|
||||
}
|
||||
|
||||
// Die Energiefluss-Uebersicht (js/solar/energiefluss.js) und die Anlage-Ansicht
|
||||
// (js/solar/anlage.js). Bis September 2026 stand hier updateValuesMQTT fuer das
|
||||
// feste realtime.svg - jeder Kreis mit eigenen Zeilen. Was gezeigt wird, steht
|
||||
// jetzt in den Katalogen (energieflussKatalog.js, anlageKatalog.js).
|
||||
let energiefluss = null;
|
||||
let anlage = null;
|
||||
let speicher = null;
|
||||
let meteogramm = null;
|
||||
|
||||
const solarSVG = {
|
||||
updateCnt: 99,
|
||||
/**
|
||||
* Beide Zwischenspeicher verwerfen - noetig, falls das SVG neu gezeichnet
|
||||
* wird. Nur die Knoten zu vergessen reicht nicht: letzterWert wuerde dann
|
||||
* jeden Schreibzugriff als "unveraendert" ueberspringen und das frische
|
||||
* SVG behielte seine Platzhalter.
|
||||
* Beide Zwischenspeicher verwerfen - noetig, wenn die Uebersicht neu
|
||||
* gezeichnet wird (Wechsel quer/hochkant). Nur die Knoten zu vergessen
|
||||
* reicht nicht: letzterWert wuerde dann jeden Schreibzugriff als
|
||||
* "unveraendert" ueberspringen und die frischen Elemente behielten ihre
|
||||
* Platzhalter.
|
||||
*/
|
||||
fillElementArray: function () {
|
||||
for (const id in svgElements) delete svgElements[id];
|
||||
letzterWert.clear();
|
||||
},
|
||||
updateValuesMQTT: function (mqttData) {
|
||||
if (energiefluss) energiefluss.aktualisieren(mqttData);
|
||||
// Auch verborgen: so schreibt die Anlage ihren Verlauf je Platte mit.
|
||||
if (anlage) anlage.aktualisieren(mqttData);
|
||||
if (speicher) speicher.aktualisieren(mqttData);
|
||||
// Nur die Live-Leiste des Meteogramms; die Tafel zeigt Stunden und wird
|
||||
// davon nicht neu gezeichnet.
|
||||
if (meteogramm) meteogramm.aktualisieren(mqttData);
|
||||
}
|
||||
};
|
||||
|
||||
const angleFactor = 15000 / 288;
|
||||
const sixkWangleFactor = 6000 / 288;
|
||||
var cons = Number(mqttData["solarManager/P_Load"]);
|
||||
var pvn = JSON.parse(mqttData["solarManager/P_PVn"]);
|
||||
var pv = Number(mqttData["solarManager/P_PV"]);
|
||||
var pv2 = Number(pvn[1]);
|
||||
var pv1 = Number(pvn[0]);
|
||||
var pv3 = pv - (pv1 + pv2);
|
||||
var pbatt = Number(mqttData["solarManager/P_Akku"]);
|
||||
var limbatt = Number(mqttData["solarManager/crgMaxPct"]);
|
||||
var grid = Number(mqttData["solarManager/P_Grid"]);
|
||||
var soc = Number(mqttData["solarManager/SOC"]);
|
||||
var og = Number(mqttData["solarManager/og"]);
|
||||
var eg = Number(mqttData["solarManager/eg"]);
|
||||
var ug = Number(mqttData["solarManager/ug"]);
|
||||
var evsoc = Number(mqttData["solarManager/evSOC"]);
|
||||
var evlock = Number(mqttData["solarManager/evLock"]);
|
||||
var evMode = mqttData["solarManager/evMode"];
|
||||
var evPower = Number(mqttData["solarManager/evPower"]);
|
||||
var plugev = Number(mqttData["solarManager/evPlug"]);
|
||||
var fuelev = Number(mqttData["solarManager/evFuel"]);
|
||||
var heatOG = Number(mqttData["solarManager/heatOG"]);
|
||||
var heatEG = Number(mqttData["solarManager/heatEG"]);
|
||||
var puffO = Number(mqttData["solarManager/t_buffT"]);
|
||||
var puffM = Number(mqttData["solarManager/t_buffM"]);
|
||||
var puffU = Number(mqttData["solarManager/t_buffB"]);
|
||||
var heatMode = mqttData["solarManager/heatMode"];
|
||||
var carRemChrg = Number(mqttData["solarManager/carRemChrg"]);
|
||||
var aut = Number(mqttData["solarManager/autarky"]);
|
||||
var Pheat = Number(mqttData["solarManager/pHeat"]);
|
||||
var waterHeight = Number(mqttData["solarManager/waterHeight"]);
|
||||
var waterTemp = Number(mqttData["solarManager/waterTemp"]);
|
||||
var p_wr = Number(mqttData["solarManager/P_WR"]);
|
||||
var eff = Number(mqttData["solarManager/eff"]);
|
||||
var i_l1evu = Number(mqttData["solarManager/i_l1evu"]);
|
||||
var i_l2evu = Number(mqttData["solarManager/i_l2evu"]);
|
||||
var i_l3evu = Number(mqttData["solarManager/i_l3evu"]);
|
||||
var evPowerOG = Number(mqttData["solarManager/evPowerOG"]);
|
||||
var evPlugOG = Number(mqttData["solarManager/evPlugOG"]);
|
||||
var evModeOG = mqttData["solarManager/evModeOG"];
|
||||
/** Energiefluss aufbauen und den Umschalter Uebersicht/Anlage verdrahten. */
|
||||
function energieflussStarten() {
|
||||
const behaelter = document.getElementById("energiefluss");
|
||||
if (!behaelter || typeof Energiefluss === "undefined") return;
|
||||
|
||||
const zeigeAnsicht = function (name) {
|
||||
document.querySelectorAll("[data-enf-ansicht]").forEach(function (knopf) {
|
||||
const an = knopf.dataset.enfAnsicht === name;
|
||||
knopf.classList.toggle("active", an);
|
||||
knopf.setAttribute("aria-selected", an ? "true" : "false");
|
||||
});
|
||||
document.querySelectorAll("[data-enf-ansicht-flaeche]").forEach(function (flaeche) {
|
||||
const an = flaeche.dataset.enfAnsichtFlaeche === name;
|
||||
flaeche.hidden = !an;
|
||||
if (an) {
|
||||
flaeche.classList.remove("enf-rein");
|
||||
void flaeche.offsetWidth;
|
||||
flaeche.classList.add("enf-rein");
|
||||
}
|
||||
});
|
||||
};
|
||||
document.querySelectorAll("[data-enf-ansicht]").forEach(function (knopf) {
|
||||
knopf.addEventListener("click", function () { zeigeAnsicht(knopf.dataset.enfAnsicht); });
|
||||
});
|
||||
|
||||
var common = -cons - ug - eg + og ;
|
||||
if(common < 0){
|
||||
common = 0;
|
||||
// Anzeige-Modus (festes Display, js/solar/anzeige.js): immer quer.
|
||||
const anzeigeModus = document.body.classList.contains("anzeige");
|
||||
energiefluss = new Energiefluss(behaelter, {
|
||||
hochkantUnter: anzeigeModus ? 0 : undefined,
|
||||
einstellungen: typeof energieflussEinstellungen !== "undefined" ? energieflussEinstellungen : {},
|
||||
onKlick: function (knoten) {
|
||||
if (knoten.klick.ansicht) zeigeAnsicht(knoten.klick.ansicht);
|
||||
else if (knoten.klick.modal) openModal(knoten.klick.modal);
|
||||
},
|
||||
onGezeichnet: function () {
|
||||
solarSVG.fillElementArray();
|
||||
if (typeof updateWateringStatus === "function") updateWateringStatus();
|
||||
},
|
||||
});
|
||||
energiefluss.zeichnen();
|
||||
|
||||
const anlageBehaelter = document.getElementById("anlage");
|
||||
if (anlageBehaelter && typeof Anlage !== "undefined") {
|
||||
anlage = new Anlage(anlageBehaelter, { hochkantUnter: anzeigeModus ? 0 : undefined });
|
||||
anlage.zeichnen();
|
||||
}
|
||||
const speicherBehaelter = document.getElementById("speicher");
|
||||
if (speicherBehaelter && typeof Speicher !== "undefined") {
|
||||
speicher = new Speicher(speicherBehaelter);
|
||||
speicher.zeichnen();
|
||||
}
|
||||
}
|
||||
|
||||
setAttr('consumerArc', "d", describeArc(100, 100, 95, 0, Math.round(-cons / angleFactor)));
|
||||
/**
|
||||
* Das Meteogramm aufsetzen und den Umschalter eigenes/meteoblue verdrahten.
|
||||
*
|
||||
* mountMeteogram() wird ausdruecklich NICHT beim Laden gerufen, sondern erst
|
||||
* beim ersten Umschalten auf meteoblue. Das Widget ist ein vollstaendiges
|
||||
* Fremd-Dokument in einem iframe - wer es nie ansieht, soll es auch nicht
|
||||
* laden. Das ist nebenbei der groesste Ladezeitgewinn dieser Karte.
|
||||
*/
|
||||
function meteogrammStarten() {
|
||||
const behaelter = document.getElementById("meteogramm");
|
||||
if (!behaelter || typeof Meteogramm === "undefined") {
|
||||
// Seite ohne eigenes Meteogramm: dann wie frueher das Fremd-Widget.
|
||||
if (typeof mountMeteogram === "function") mountMeteogram();
|
||||
return;
|
||||
}
|
||||
// Anzeige-Modus (festes Display, 800 x 480): dort ist fuer fuenf Tage
|
||||
// kein Platz, zwei Tage bleiben lesbar.
|
||||
const anzeigeModus = document.body.classList.contains("anzeige");
|
||||
meteogramm = new Meteogramm(behaelter, {
|
||||
kopf: document.querySelector(".mtg-kopf"),
|
||||
tage: anzeigeModus ? 2 : undefined,
|
||||
});
|
||||
meteogramm.zeichnen();
|
||||
|
||||
setAttr("ogArc", "d", describeArc(100, 100, 95, 0, Math.round(-og / angleFactor)));
|
||||
setText("consumerText", powerToString(-cons));
|
||||
|
||||
setText("consumerTextAllg", "Gemein: "+powerToString(common));
|
||||
|
||||
if (evPower > 0) {
|
||||
setDisplay("evCharge", "");
|
||||
if (carRemChrg > -1) {
|
||||
var today = new Date();
|
||||
today.setMinutes(today.getMinutes() + carRemChrg);
|
||||
var h = today.getHours();
|
||||
var m = today.getMinutes();
|
||||
if (h < 10)
|
||||
h = "0" + h;
|
||||
if (m < 10)
|
||||
m = "0" + m;
|
||||
setText("evRemText", h + ":" + m);
|
||||
let blueGeladen = false;
|
||||
const zeigeQuelle = function (name) {
|
||||
document.querySelectorAll("[data-mtg-quelle]").forEach(function (knopf) {
|
||||
const an = knopf.dataset.mtgQuelle === name;
|
||||
knopf.classList.toggle("active", an);
|
||||
knopf.setAttribute("aria-selected", an ? "true" : "false");
|
||||
});
|
||||
document.querySelectorAll("[data-mtg-flaeche]").forEach(function (flaeche) {
|
||||
flaeche.hidden = flaeche.dataset.mtgFlaeche !== name;
|
||||
});
|
||||
if (name === "blue" && !blueGeladen && typeof mountMeteogram === "function") {
|
||||
blueGeladen = true;
|
||||
mountMeteogram();
|
||||
}
|
||||
} else {
|
||||
setText("evRemText", "");
|
||||
}
|
||||
setText("ogText", powerToString(-og));
|
||||
setText("egText", powerToString(eg));
|
||||
setText("ugText", powerToString(ug));
|
||||
setText("pvText", powerToString(pv));
|
||||
setText("pv1txt", powerToString(pv1));
|
||||
setText("pv2txt", powerToString(pv2));
|
||||
setText("pv3txt", powerToString(pv3));
|
||||
setText("il1txt", Math.round(i_l1evu * 10) / 10 + " A");
|
||||
setText("il2txt", Math.round(i_l2evu * 10) / 10 + " A");
|
||||
setText("il3txt", Math.round(i_l3evu * 10) / 10 + " A");
|
||||
setText("batText", powerToString(pbatt));
|
||||
setText("heatText", powerToString(Pheat));
|
||||
setText("evText", powerToString(evPower * 1000));
|
||||
setText("evTextOG", powerToString(evPowerOG * 1000));
|
||||
setText("genText", "Autarkie: " + Math.round(aut) + " %");
|
||||
setText("genInfoText", "Wirkungsgrad: " + Math.round(eff) + " %");// powerToString(p_wr);
|
||||
setText("gridText", powerToString(grid));
|
||||
|
||||
for (i = 0; i < pvn.length; i++) {
|
||||
setText("det_pv" + i + "P", powerToString(pvn[i]));
|
||||
}
|
||||
|
||||
tttext = ""
|
||||
var i = 0;
|
||||
while (mqttData["solarManager/inverters" + i]) {
|
||||
if (mqttData["solarManager/inverters" + i+"/error"] > 0) {
|
||||
tttext = tttext + '<tspan x="0" dy="1.2em">' + mqttData["solarManager/inverters" + i+"/name"] + ":</tspan>"
|
||||
tttext = tttext + '<tspan x="160">' + "--" + " °C</tspan>"
|
||||
tttext = tttext + '<tspan x="215">' + "-- W" + "</tspan>"
|
||||
} else {
|
||||
tttext = tttext + '<tspan x="0" dy="1.2em">' + mqttData["solarManager/inverters" + i+"/name"] + ":</tspan>"
|
||||
tttext = tttext + '<tspan x="160">' + Math.round(mqttData["solarManager/inverters" + i+"/temp"]) + " °C</tspan>"
|
||||
tttext = tttext + '<tspan x="215">' + powerToString(mqttData["solarManager/inverters" + i+"/p_AC"]) + "</tspan>"
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
setHTML("invList", tttext);
|
||||
setAttr("egArc", "d", describeArc(100, 100, 95, 0, Math.round(eg / angleFactor)));
|
||||
setAttr("ugArc", "d", describeArc(100, 100, 95, 0, Math.round(ug / angleFactor)));
|
||||
setAttr("pvArc", "d", describeArc(100, 100, 95, 0, Math.round(pv1 / angleFactor)));
|
||||
setAttr("pv2Arc", "d", describeArc(100, 100, 95, Math.round(pv1 / angleFactor), Math.round(pv1 / angleFactor) + Math.round(pv2 / angleFactor)));
|
||||
setAttr("pv3Arc", "d", describeArc(100, 100, 95, Math.round(pv1 / angleFactor) + Math.round(pv2 / angleFactor), Math.round(pv1 / angleFactor) + Math.round(pv2 / angleFactor) + Math.round(pv3 / angleFactor)));
|
||||
|
||||
if (pbatt < 0) {
|
||||
if (limbatt < 100) {
|
||||
setDisplay("charge", "none");
|
||||
setDisplay("batLim", "");
|
||||
setText("batLimText", Math.round(limbatt) + "%");
|
||||
} else {
|
||||
setDisplay("charge", "");
|
||||
setDisplay("batLim", "none");
|
||||
}
|
||||
setAttr("batArc", "d", describeArc(100, 100, 95, 0, Math.round(-pbatt / angleFactor)));
|
||||
setAttr("battani", "class", "stream-rev");
|
||||
setFlowWidth("battani", 55 * (1 - Math.exp(pbatt / 2000)));
|
||||
} else {
|
||||
setDisplay("charge", "none");
|
||||
setDisplay("batLim", "none");
|
||||
setAttr("batArc", "d", describeArc(100, 100, 95, 0, Math.round(pbatt / angleFactor)));
|
||||
setAttr("battani", "class", "stream");
|
||||
setFlowWidth("battani", 55 * (1 - Math.exp(-pbatt / 2000)));
|
||||
}
|
||||
setAttr("batChargeState", "y", (100 - soc) + "%");
|
||||
//setAttr("heatChargeState", "y", 100 - ((((puffO + puffM) / 2) - 30) * 1.66) + "%");
|
||||
setAttr('tmpH', "stop-color", assignColor("#2389BA", "#BA3B23", 20, 80, puffO));
|
||||
setAttr('tmpM', "stop-color", assignColor("#2389BA", "#BA3B23", 20, 80, puffM));
|
||||
setAttr('tmpL', "stop-color", assignColor("#2389BA", "#BA3B23", 20, 80, puffU));
|
||||
setText("batSOC", soc + " %");
|
||||
setAttr("evArc", "d", describeArc(100, 100, 95, 0, Math.round(evPower * 2000 / angleFactor)));
|
||||
setAttr("evChargeState", "y", (100 - evsoc) + "%");
|
||||
setText("evSOC", evsoc + " %");
|
||||
setText("evFuel", fuelev + " %");
|
||||
|
||||
setAttr("heatArc", "d", describeArc(100, 100, 95, 0, Math.round(Pheat / sixkWangleFactor)));
|
||||
setText("heatUp", Math.round(puffO * 10) / 10 + " °C");
|
||||
setText("heatDown", Math.round(puffM * 10) / 10 + " °C");
|
||||
|
||||
if (evlock == false) {
|
||||
setDisplay("evLock", "none");
|
||||
} else {
|
||||
setDisplay("evLock", "");
|
||||
}
|
||||
if (heatMode == "eco") {
|
||||
setDisplay("heatEco", "");
|
||||
setDisplay("heatDefault", "none");
|
||||
} else {
|
||||
setDisplay("heatEco", "none");
|
||||
setDisplay("heatDefault", "");
|
||||
}
|
||||
if (evMode == "Eco") {
|
||||
setDisplay("evEco", "");
|
||||
setDisplay("evNextTrip", "none");
|
||||
setDisplay("evDefault", "none");
|
||||
} else if (evMode == "Next Trip") {
|
||||
setDisplay("evEco", "none");
|
||||
setDisplay("evNextTrip", "");
|
||||
setDisplay("evDefault", "none");
|
||||
} else {
|
||||
setDisplay("evEco", "none");
|
||||
setDisplay("evNextTrip", "none");
|
||||
setDisplay("evDefault", "");
|
||||
}
|
||||
if (heatOG) {
|
||||
setDisplay("heatOG", "");
|
||||
} else {
|
||||
setDisplay("heatOG", "none");
|
||||
}
|
||||
if (heatEG) {
|
||||
setDisplay("heatEG", "");
|
||||
} else {
|
||||
setDisplay("heatEG", "none");
|
||||
}
|
||||
if (plugev != "no car") {
|
||||
setDisplay("evPlug", "");
|
||||
} else {
|
||||
setDisplay("evPlug", "none");
|
||||
}
|
||||
if (evModeOG == "Eco") {
|
||||
setDisplay("evEcoOG", "");
|
||||
setDisplay("evNextTripOG", "none");
|
||||
setDisplay("evDefaultOG", "none");
|
||||
} else if (evModeOG == "Next Trip") {
|
||||
setDisplay("evEcoOG", "none");
|
||||
setDisplay("evNextTripOG", "");
|
||||
setDisplay("evDefaultOG", "none");
|
||||
} else {
|
||||
setDisplay("evEcoOG", "none");
|
||||
setDisplay("evNextTripOG", "none");
|
||||
setDisplay("evDefaultOG", "");
|
||||
}
|
||||
if (evPlugOG > 0) {
|
||||
setDisplay("evPlugOG", "");
|
||||
} else {
|
||||
setDisplay("evPlugOG", "none");
|
||||
}
|
||||
if (grid < 0) {
|
||||
setAttr("gridArc", "d", describeArc(100, 100, 95, 0, -grid / angleFactor));
|
||||
setAttr("consGridani", "class", "stream-rev");
|
||||
setFlowWidth("consGridani", 55 * (1 - Math.exp(grid / 2000)));
|
||||
} else {
|
||||
setAttr("gridArc", "d", describeArc(100, 100, 95, 0, grid / angleFactor));
|
||||
setAttr("consGridani", "class", "stream");
|
||||
setFlowWidth("consGridani", 55 * (1 - Math.exp(-grid / 2000)));
|
||||
}
|
||||
|
||||
setFlowWidth("consEVani", 55 * (1 - Math.exp(-evPower)));
|
||||
setFlowWidth("consEVOGani", 55 * (1 - Math.exp(-evPowerOG)));
|
||||
setFlowWidth("consOGani", 55 * (1 - Math.exp(og / 2000)));
|
||||
|
||||
setFlowWidth("consUGani", 55 * (1 - Math.exp( -ug / 2000)));
|
||||
|
||||
setFlowWidth("consHeatAni", 55 * (1 - Math.exp(-Pheat / 2000)));
|
||||
setFlowWidth("consAni", 55 * (1 - Math.exp(cons / 2000)));
|
||||
setFlowWidth("consEGani", 55 * (1 - Math.exp(-eg / 2000)));
|
||||
setFlowWidth("pvani", 55 * (1 - Math.exp(-pv / 2000)));
|
||||
}
|
||||
|
||||
try { localStorage.setItem("mtgQuelle", name); } catch (e) { /* privates Fenster */ }
|
||||
};
|
||||
document.querySelectorAll("[data-mtg-quelle]").forEach(function (knopf) {
|
||||
knopf.addEventListener("click", function () { zeigeQuelle(knopf.dataset.mtgQuelle); });
|
||||
});
|
||||
let gemerkt = null;
|
||||
try { gemerkt = localStorage.getItem("mtgQuelle"); } catch (e) { /* egal */ }
|
||||
if (gemerkt === "blue") zeigeQuelle("blue");
|
||||
}
|
||||
|
||||
var chartSettings = {
|
||||
@@ -597,24 +487,17 @@ var chartSettings = {
|
||||
// hier ist sie Beiwerk, dort der Zweck der Seite.
|
||||
baueJahresstatistik(document.getElementById("Stats-Year"),
|
||||
{ spalten: 1, werkzeuge: false, fussnoten: false });
|
||||
solarSVG.fillElementArray();
|
||||
mountMeteogram();
|
||||
energieflussStarten();
|
||||
meteogrammStarten();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Setzt den Schieberegler aus den Wattstunden, die die Wallbox meldet.
|
||||
function setzeLadeRegler(wattstunden) {
|
||||
const regler = document.getElementById("modal-slider");
|
||||
const whProProzent = Number(regler.dataset.whProProzent);
|
||||
if (whProProzent > 0) {
|
||||
regler.value = Math.round(Number(String(wattstunden)) / whProProzent);
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(type) {
|
||||
document.getElementById("modalSaveBtn").style.display = type === "watering" ? "none" : "";
|
||||
// Alle Fenster der Solar-Seite uebernehmen jede Aenderung sofort -
|
||||
// "Save changes" braucht hier keines mehr.
|
||||
document.getElementById("modalSaveBtn").style.display = "none";
|
||||
switch(type){
|
||||
case "CarEG":
|
||||
document.getElementById("modal-title").innerHTML = "Autoladung EG";
|
||||
@@ -640,8 +523,6 @@ var chartSettings = {
|
||||
|
||||
modalBodyElement = document.getElementById('modal-body');
|
||||
modalBodyElement.innerHTML = loadingHTML("Wird geladen...");
|
||||
document.getElementById("modalSaveBtn").addEventListener("click", submitFormAjax);
|
||||
document.getElementById("modalSaveBtn").contentURL = contentURL;
|
||||
modalEV.show();
|
||||
fetch(contentURL, {
|
||||
method: 'GET',
|
||||
@@ -654,73 +535,17 @@ var chartSettings = {
|
||||
.then(html => {
|
||||
modalBodyElement.innerHTML = html;
|
||||
|
||||
var slider = document.getElementById("modal-slider");
|
||||
var span = document.getElementById("modal-slider-label");
|
||||
// Aufbau und Logik der Fenster: ladefenster.js und bedienfenster.js.
|
||||
switch(type){
|
||||
case "CarEG":
|
||||
bindModalSlider(slider, span, value => value + "%");
|
||||
document.getElementById("evStart/Stop").addEventListener("click", submitFormAjax);
|
||||
document.getElementById("evStart/Stop").contentURL = contentURL;
|
||||
mode = new TextDecoder().decode(mqttData["wattpilot/properties/lmo/state"]);
|
||||
time = String(mqttData["wattpilot/properties/ftt/state"]);
|
||||
energy = mqttData["wattpilot/properties/fte/state"];
|
||||
amp = new TextDecoder().decode(mqttData["wattpilot/properties/amp/state"]);
|
||||
car = new TextDecoder().decode(mqttData["wattpilot/properties/car/state"]);
|
||||
charge = Number(mqttData["solarManager/p_l1ev"])+Number(mqttData["solarManager/p_l2ev"])+Number(mqttData["solarManager/p_l3ev"])
|
||||
if (charge < 0.2) { document.getElementById("evStart/Stop").innerHTML = 'Laden starten' }
|
||||
else { document.getElementById("evStart/Stop").innerHTML = 'Laden stoppen' }
|
||||
if (mode == "Awattar") { document.getElementById("EV_Eco").click(); }
|
||||
else if (mode == "Default") { document.getElementById("EV_Default").click(); }
|
||||
else if (mode == "AutomaticStop") { document.getElementById("EV_NextTrip").click(); }
|
||||
else { alert(mode); }
|
||||
document.getElementById("EV_NextTripTime").value = time.toHHMM();
|
||||
// Wattstunden je Prozent liefert das Formular mit (carForm.php);
|
||||
// die Akkugroesse steht in restricted/config.php. Frueher standen
|
||||
// hier feste 14000 Wh - fuer beide Autos dasselbe, und fuer keines
|
||||
// von beiden richtig.
|
||||
setzeLadeRegler(energy);
|
||||
document.getElementById("modal-slider").dispatchEvent(new Event('input'));
|
||||
if (amp == "16") { document.getElementById("EV_16A").click(); }
|
||||
else if (amp == "10") { document.getElementById("EV_10A").click(); }
|
||||
else if (amp == "6") { document.getElementById("EV_6A").click(); }
|
||||
break;
|
||||
case "CarOG":
|
||||
bindModalSlider(slider, span, value => value + "%");
|
||||
document.getElementById("evStart/Stop").addEventListener("click", submitFormAjax);
|
||||
document.getElementById("evStart/Stop").contentURL = contentURL;
|
||||
mode = mqttData["go-eCharger/270003/lmo"];
|
||||
time = String(mqttData["go-eCharger/270003/att"]);
|
||||
energy = mqttData["go-eCharger/270003/ate"];
|
||||
amp = mqttData["go-eCharger/270003/amp"];
|
||||
car = mqttData["go-eCharger/270003/car"];
|
||||
if (car != "2") { document.getElementById("evStart/Stop").innerHTML = 'Laden starten' }
|
||||
else { document.getElementById("evStart/Stop").innerHTML = 'Laden stoppen' }
|
||||
if (mode == "4") { document.getElementById("EV_Eco").click(); }
|
||||
else if (mode == "3") { document.getElementById("EV_Default").click(); }
|
||||
else if (mode == "5") { document.getElementById("EV_NextTrip").click(); }
|
||||
else { alert(mode); }
|
||||
document.getElementById("EV_NextTripTime").value = time.toHHMM();
|
||||
// Wattstunden je Prozent liefert das Formular mit (carForm.php);
|
||||
// die Akkugroesse steht in restricted/config.php. Frueher standen
|
||||
// hier feste 14000 Wh - fuer beide Autos dasselbe, und fuer keines
|
||||
// von beiden richtig.
|
||||
setzeLadeRegler(energy);
|
||||
document.getElementById("modal-slider").dispatchEvent(new Event('input'));
|
||||
if (amp == "16") { document.getElementById("EV_16A").click(); }
|
||||
else if (amp == "10") { document.getElementById("EV_10A").click(); }
|
||||
else if (amp == "6") { document.getElementById("EV_6A").click(); }
|
||||
ladefensterStarten(modalBodyElement.querySelector(".lade"), contentURL);
|
||||
break;
|
||||
case "heater":
|
||||
bindModalSlider(slider, span, value => value == 0 ? "Automatik" : (value / 10) + " kW");
|
||||
if(mqttData["solarManager/heatMode"] == "eco"){
|
||||
document.getElementById("modal-slider").value = 0;
|
||||
}else{
|
||||
document.getElementById("modal-slider").value = Math.round(mqttData["solarManager/pHeat"]/100);
|
||||
}
|
||||
document.getElementById("modal-slider").dispatchEvent(new Event('input'));
|
||||
heizfensterStarten(modalBodyElement.querySelector(".heiz"), contentURL);
|
||||
break;
|
||||
case "watering":
|
||||
initializeWateringModal();
|
||||
wasserfensterStarten(modalBodyElement.querySelector(".wasser"), contentURL);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -730,315 +555,10 @@ var chartSettings = {
|
||||
});
|
||||
}
|
||||
|
||||
const wateringTimersLastSeen = {};
|
||||
// Eine Tabelle fuer alles: Ventil, Beschriftung und die Anzeige-Ids im
|
||||
// Realtime-SVG. Vorher standen dieselben Zuordnungen in drei getrennten
|
||||
// Tabellen.
|
||||
const wateringZones = {
|
||||
troughs: {
|
||||
topic: "Gartenwasser/vorn", valve: 1, label: "Tröge",
|
||||
statusId: "wateringTroughStatus", iconId: "wateringTroughIcon"
|
||||
},
|
||||
frontGarden: {
|
||||
topic: "Gartenwasser/vorn", valve: 6, label: "Vorn",
|
||||
statusId: "wateringFrontStatus", iconId: "wateringFrontIcon"
|
||||
},
|
||||
raisedBeds: {
|
||||
topic: "Gartenwasser/hinten", valve: 6, label: "Beete",
|
||||
statusId: "wateringBedsStatus", iconId: "wateringBedsIcon"
|
||||
}
|
||||
};
|
||||
// Feste Liste - der Sekundentakt soll sie nicht jedes Mal neu aufbauen.
|
||||
const wateringZoneList = Object.entries(wateringZones);
|
||||
|
||||
function wateringCommand(topic, command, directTopic = false) {
|
||||
const feedback = document.getElementById("watering-feedback");
|
||||
const targetTopic = directTopic ? topic : topic + "/set";
|
||||
const payload = typeof command === "string" ? command : JSON.stringify(command);
|
||||
const endpoint = window.location.pathname.replace(/\/index\.php$/, "").replace(/\/$/, "") + "/ajax/watering.php";
|
||||
const showFeedback = (message, type) => {
|
||||
if (!feedback) return;
|
||||
feedback.textContent = message;
|
||||
feedback.className = "alert alert-" + type + " mb-3";
|
||||
feedback.style.whiteSpace = "pre-line";
|
||||
};
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ topic: targetTopic, payload: payload })
|
||||
}).then(async (response) => {
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) throw new Error(result.message || "Unbekannter MQTT-Fehler.");
|
||||
showFeedback("Befehl gesendet.\nTopic: " + result.topic + "\nInhalt: " + result.payload, "success");
|
||||
}).catch((error) => {
|
||||
showFeedback("Befehl konnte nicht gesendet werden: " + error.message + "\nTopic: " + targetTopic + "\nInhalt: " + payload, "danger");
|
||||
});
|
||||
}
|
||||
|
||||
function initializeWateringModal() {
|
||||
document.querySelectorAll("[data-watering-toggle]").forEach((button) => {
|
||||
const updateButton = () => {
|
||||
const zone = wateringZones[button.dataset.wateringZone];
|
||||
setzeZonenButton(button, wateringLabel(zone.topic, zone.valve));
|
||||
};
|
||||
button.addEventListener("click", () => {
|
||||
const zone = wateringZones[button.dataset.wateringZone];
|
||||
if (wateringLabel(zone.topic, zone.valve) === "aus") {
|
||||
const timerInput = document.querySelector(`[data-watering-timer='${button.dataset.wateringZone}']`);
|
||||
const timer = Math.min(120, Math.max(1, Number(timerInput.value) || 30));
|
||||
wateringCommand(zone.topic, { valve: zone.valve, set: "open", timer: timer });
|
||||
} else {
|
||||
wateringCommand(zone.topic, { valve: zone.valve, set: "close" });
|
||||
}
|
||||
});
|
||||
updateButton();
|
||||
});
|
||||
document.querySelectorAll("[data-watering-auto-toggle]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const topics = (button.dataset.wateringTopics || button.dataset.wateringTopic).split(",");
|
||||
const modes = button.dataset.wateringAuto.split(",");
|
||||
const active = topics.every((topic, index) => wateringAutoActive(topic, modes[index]));
|
||||
topics.forEach((topic, index) => {
|
||||
wateringCommand(topic + "/auto", active ? "Stop" : modes[index], true);
|
||||
});
|
||||
});
|
||||
updateWateringAutoButton(button);
|
||||
});
|
||||
updateWateringStatus();
|
||||
}
|
||||
|
||||
// Die Anzeige laeuft im Sekundentakt, die Nutzlasten aendern sich aber nur,
|
||||
// wenn eine neue MQTT-Nachricht kommt. messageReceived legt dabei jedes Mal
|
||||
// ein neues Buffer-Objekt ab - der Identitaetsvergleich reicht also als
|
||||
// Gueltigkeitspruefung, ein Invalidieren von Hand braucht es nicht.
|
||||
const wateringJsonCache = {};
|
||||
const wateringDecoder = new TextDecoder();
|
||||
|
||||
function wateringJson(topic) {
|
||||
const value = mqttData[topic];
|
||||
if (!value) return null;
|
||||
const cached = wateringJsonCache[topic];
|
||||
if (cached && cached.raw === value) return cached.parsed;
|
||||
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(typeof value === "string" ? value : wateringDecoder.decode(value));
|
||||
} catch (error) {
|
||||
parsed = null;
|
||||
}
|
||||
wateringJsonCache[topic] = { raw: value, parsed: parsed };
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function wateringHistory(topic, valve) {
|
||||
const data = wateringJson(topic + "/LastWatering");
|
||||
if (!data) return { dateTime: "--", duration: "--" };
|
||||
return {
|
||||
dateTime: data["wateringDateTime" + valve] || "--",
|
||||
duration: data["wateringDurationSecs" + valve] !== undefined
|
||||
? wateringTimeLabel(Number(data["wateringDurationSecs" + valve]))
|
||||
: "--"
|
||||
};
|
||||
}
|
||||
|
||||
// Tagesdatum im Format der Ventilsteuerung ("29.08.26").
|
||||
function wateringDayStamp(date) {
|
||||
const zwei = (zahl) => String(zahl).padStart(2, "0");
|
||||
return zwei(date.getDate()) + "." + zwei(date.getMonth() + 1) + "." + zwei(date.getFullYear() % 100);
|
||||
}
|
||||
|
||||
// Tagessummen der Ventilsteuerung: WateringToday fuer den laufenden Tag,
|
||||
// LastWateringDay fuer den letzten Tag, an dem ueberhaupt bewaessert wurde.
|
||||
// Beide Nutzlasten tragen den zugehoerigen Tag als "wateringDay" mit. Bei
|
||||
// WateringToday wird der geprueft - die Nachricht ist retained, nach einem
|
||||
// Tageswechsel ohne Bewaesserung stuende sonst die Summe von gestern als
|
||||
// "heute" in der Karte.
|
||||
function wateringDayTotal(topic, subTopic, feld, valve, mussHeuteSein) {
|
||||
const data = wateringJson(topic + "/" + subTopic);
|
||||
if (!data) return { label: "--", day: "", sekunden: null };
|
||||
const tag = data.wateringDay || "";
|
||||
const sekunden = data[feld + valve];
|
||||
if (sekunden === undefined) return { label: "--", day: tag, sekunden: null };
|
||||
if (mussHeuteSein && tag !== wateringDayStamp(new Date())) {
|
||||
return { label: "--", day: tag, sekunden: null };
|
||||
}
|
||||
return { label: wateringTimeLabel(Number(sekunden)), day: tag, sekunden: Number(sekunden) };
|
||||
}
|
||||
|
||||
function wateringAutoActive(topic, mode) {
|
||||
const timers = wateringJson(topic + "/Timers");
|
||||
return !!(timers && timers.auto === mode);
|
||||
}
|
||||
|
||||
function wateringValue(data, valve) {
|
||||
if (!data) return null;
|
||||
const candidates = [
|
||||
data.timers && data.timers[valve],
|
||||
data.timers && data.timers[String(valve)],
|
||||
data[valve],
|
||||
data[String(valve)],
|
||||
data.valves && data.valves[valve],
|
||||
data.valves && data.valves[String(valve)],
|
||||
data.status && data.status[valve],
|
||||
data.status && data.status[String(valve)],
|
||||
data.valveState && data.valveState[valve],
|
||||
data.valveState && data.valveState[String(valve)],
|
||||
data.setValveState && data.setValveState[valve],
|
||||
data.setValveState && data.setValveState[String(valve)]
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate !== undefined) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function wateringState(value) {
|
||||
if (value && typeof value === "object") {
|
||||
return value.state ?? value.status ?? value.valveState ?? value.set;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function wateringSeconds(value) {
|
||||
if (value && typeof value === "object") {
|
||||
value = value.remaining ?? value.timer ?? value.seconds ?? value.time;
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function wateringLabel(topic, valve) {
|
||||
const timers = wateringJson(topic + "/Timers");
|
||||
const status = wateringJson(topic + "/status");
|
||||
const lastSeen = wateringTimersLastSeen[topic + "/Timers"];
|
||||
if (!lastSeen || Date.now() - lastSeen > 3000) return "aus";
|
||||
const remaining = wateringValue(timers, valve);
|
||||
if (remaining !== null) {
|
||||
const seconds = wateringSeconds(remaining);
|
||||
if (seconds !== 0) return wateringTimeLabel(Math.abs(seconds));
|
||||
return "aus";
|
||||
}
|
||||
const state = wateringState(wateringValue(status, valve));
|
||||
if (state === true || state === 1 || state === "1" || state === "open" || state === "OPEN") return "an";
|
||||
if (state === false || state === 0 || state === "0" || state === "close" || state === "closed" || state === "CLOSED") return "aus";
|
||||
return "--";
|
||||
}
|
||||
|
||||
function wateringTimeLabel(seconds) {
|
||||
if (seconds < 60) return seconds + "s";
|
||||
return Math.ceil(seconds / 60) + "m";
|
||||
}
|
||||
|
||||
function wateringIsPaused(topic, valve) {
|
||||
const timers = wateringJson(topic + "/Timers");
|
||||
const remaining = wateringValue(timers, valve);
|
||||
return remaining !== null && wateringSeconds(remaining) < 0;
|
||||
}
|
||||
|
||||
// Die Zonensymbole im Bewaesserungskreis; das Fenster aktualisiert sich
|
||||
// selbst (bedienfenster.js).
|
||||
function updateWateringStatus() {
|
||||
wateringZoneList.forEach(([key, zone]) => {
|
||||
const state = wateringLabel(zone.topic, zone.valve);
|
||||
const paused = wateringIsPaused(zone.topic, zone.valve);
|
||||
const heute = wateringDayTotal(zone.topic, "WateringToday", "wateringDurationTodaySecs", zone.valve, true);
|
||||
// Die Regenautomatik laeuft zum Sonnenaufgang; wenn jemand auf die
|
||||
// Anzeige schaut, ist die Entscheidung laengst gefallen. Angezeigt wird
|
||||
// deshalb nicht mehr, was geplant ist, sondern was passiert ist. Der
|
||||
// Zaehler unterscheidet nicht, wer ausgeloest hat - eine Bewaesserung
|
||||
// von Hand zaehlt genauso, und das ist so gewollt.
|
||||
const bewaessert = state === "aus" && heute.sekunden > 0;
|
||||
|
||||
// Die Anzeigen liegen im Realtime-SVG und bleiben bestehen -> svgElement.
|
||||
const status = svgElement(zone.statusId);
|
||||
const icon = svgElement(zone.iconId);
|
||||
if (status && icon) updateWateringIndicator(status, icon, zone.label, state, paused, bewaessert);
|
||||
|
||||
// Die Modal-Elemente werden bei jedem Oeffnen neu erzeugt -> nicht cachen.
|
||||
setzeZonenButton(document.querySelector(`[data-watering-toggle][data-watering-zone='${key}']`), state);
|
||||
setzeSchild(`[data-watering-state='${key}']`, state, paused, bewaessert);
|
||||
|
||||
const history = wateringHistory(zone.topic, zone.valve);
|
||||
setzeZelle(`[data-watering-last='${key}']`, history.dateTime);
|
||||
setzeZelle(`[data-watering-duration='${key}']`, history.duration);
|
||||
const vortag = wateringDayTotal(zone.topic, "LastWateringDay", "wateringDurationDaySecs", zone.valve, false);
|
||||
setzeZelle(`[data-watering-today='${key}']`, heute.label, heute.day && "Stand: " + heute.day);
|
||||
setzeZelle(`[data-watering-prevday='${key}']`, vortag.label, vortag.day && "Bewässerungstag: " + vortag.day);
|
||||
});
|
||||
document.querySelectorAll("[data-watering-auto-toggle]").forEach(updateWateringAutoButton);
|
||||
}
|
||||
|
||||
// Der Knopf zeigt die naechste Aktion, nicht den Zustand: laeuft die Zone,
|
||||
// steht "Aus" darauf.
|
||||
function setzeZonenButton(button, state) {
|
||||
if (!button) return;
|
||||
const beschriftung = state === "aus" ? "Ein" : "Aus";
|
||||
if (button.textContent !== beschriftung) button.textContent = beschriftung;
|
||||
button.classList.toggle("btn-success", state === "aus");
|
||||
button.classList.toggle("btn-danger", state !== "aus");
|
||||
}
|
||||
|
||||
// Zustandsschild der Zonenkarte, Farben wie im SVG: laufend gruen,
|
||||
// pausiert gelb, heute schon bewaessert blau, sonst grau.
|
||||
function setzeSchild(selektor, state, paused, bewaessert) {
|
||||
const schild = document.querySelector(selektor);
|
||||
if (!schild) return;
|
||||
let text = "unbekannt";
|
||||
let farbe = "bg-secondary";
|
||||
if (state !== "--" && state !== "aus") {
|
||||
text = paused ? "pausiert " + state : "läuft " + state;
|
||||
farbe = paused ? "bg-warning text-dark" : "bg-success";
|
||||
} else if (state === "aus") {
|
||||
text = bewaessert ? "heute bewässert" : "aus";
|
||||
farbe = bewaessert ? "bg-primary" : "bg-secondary";
|
||||
}
|
||||
if (schild.textContent !== text) schild.textContent = text;
|
||||
const klasse = "badge " + farbe;
|
||||
if (schild.className !== klasse) schild.className = klasse;
|
||||
}
|
||||
|
||||
// Schreibt nur bei echter Aenderung - die Tabelle wird im Sekundentakt
|
||||
// aktualisiert, ihre Werte aendern sich aber hoechstens minuetlich.
|
||||
function setzeZelle(selektor, text, titel) {
|
||||
const element = document.querySelector(selektor);
|
||||
if (!element) return;
|
||||
if (element.textContent !== text) element.textContent = text;
|
||||
if (titel) {
|
||||
if (element.getAttribute("title") !== titel) element.setAttribute("title", titel);
|
||||
} else if (element.hasAttribute("title")) {
|
||||
element.removeAttribute("title");
|
||||
}
|
||||
}
|
||||
|
||||
function updateWateringAutoButton(button) {
|
||||
const topics = (button.dataset.wateringTopics || button.dataset.wateringTopic).split(",");
|
||||
const modes = button.dataset.wateringAuto.split(",");
|
||||
const active = topics.every((topic, index) => wateringAutoActive(topic, modes[index]));
|
||||
if (!button.dataset.wateringLabel) {
|
||||
button.dataset.wateringLabel = button.textContent.replace(/^Automatik\s+/, "");
|
||||
}
|
||||
button.textContent = (active ? "Aus: " : "Ein: ") + button.dataset.wateringLabel;
|
||||
button.classList.toggle("btn-outline-primary", !active);
|
||||
button.classList.toggle("btn-outline-danger", active);
|
||||
}
|
||||
|
||||
function updateWateringIndicator(status, icon, name, state, paused, bewaessert) {
|
||||
// "Aus" bleibt stehen, solange die Zone heute noch kein Wasser hatte;
|
||||
// danach ersetzt das Haekchen (U+2713, ohne Emoji-Variationsselektor ->
|
||||
// einfarbig, folgt der Icon-Farbe) den Text, um Platz auf der textPath zu
|
||||
// sparen. Laeuft im Sekundentakt, aendert sich aber selten - ohne Pruefung
|
||||
// wuerde jede Sekunde derselbe Wert geschrieben und das Icon samt
|
||||
// Filter neu gezeichnet.
|
||||
const text = state === "aus" ? (bewaessert ? "✓" : "Aus") : state;
|
||||
if (status.textContent !== text) status.textContent = text;
|
||||
// Das Blau ("#3A6292") entspricht dem Wassertropfen-Icon (id="water").
|
||||
const color = state !== "aus" ? (paused ? "#ffc107" : "#20c997") : bewaessert ? "#3A6292" : "#6c757d";
|
||||
if (icon.getAttribute("color") !== color) {
|
||||
icon.setAttribute("color", color);
|
||||
icon.setAttribute("fill", color);
|
||||
icon.style.color = color;
|
||||
}
|
||||
const titel = name + ": " + (state === "aus" && bewaessert ? "Aus (heute bereits bewässert)" : state);
|
||||
const titelKnoten = icon.querySelector("title");
|
||||
if (titelKnoten.textContent !== titel) titelKnoten.textContent = titel;
|
||||
wateringSymboleSetzen(svgElement);
|
||||
}
|
||||
|
||||
window.setInterval(updateWateringStatus, 1000);
|
||||
|
||||