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 | ||
|
|
8f040dec19 | ||
|
|
b180e3ef98 | ||
|
|
1451c5e42b | ||
|
|
df55a77d97 | ||
|
|
29742bf77c | ||
|
|
5210ea242d | ||
|
|
a57ffe41cc | ||
|
|
b53e4fe7e3 | ||
|
|
ea98d60435 | ||
|
|
9cfc0107df | ||
|
|
62c9b49073 | ||
|
|
e525731264 | ||
|
|
592a134a1e | ||
|
|
b7c2bc82c7 | ||
|
|
b0596721fa | ||
|
|
20e084bbc8 | ||
|
|
0fd73499d9 | ||
|
|
060336c891 | ||
|
|
cd398bc800 | ||
|
|
eb1aa8c760 | ||
|
|
07bca5104e | ||
|
|
1b58579c22 | ||
|
|
cca452806f | ||
|
|
0cb0d4c3ae | ||
|
|
22ecc1b50f | ||
|
|
fe8602a79f | ||
|
|
fe73517c17 | ||
|
|
67c2a774ac | ||
|
|
60c4ad989b | ||
|
|
8914133d01 | ||
|
|
958408eba9 | ||
|
|
97b5c78fda | ||
|
|
7c646c5aee | ||
|
|
3073c5afdb | ||
|
|
0196a52454 | ||
|
|
fad6d5ab34 | ||
|
|
a5920ac7cc | ||
|
|
93fb054b66 | ||
|
|
abdf6880eb | ||
|
|
182b5b66b4 | ||
|
|
f29ed5abfa | ||
|
|
bcc685dcfa | ||
|
|
1c80ec588e | ||
|
|
8119645123 | ||
|
|
d30f4ac0ab | ||
|
|
ff74ec3c68 | ||
|
|
e2b97c049e | ||
|
|
3c67303acf | ||
|
|
762af074e8 | ||
|
|
d3c1cb4682 | ||
|
|
71f2f844c8 | ||
|
|
7b9c9362a6 | ||
|
|
03f823ae39 | ||
|
|
128acae48e | ||
|
|
37962224c1 | ||
|
|
77651a860a | ||
|
|
4bbc7ee7a7 | ||
|
|
2ef946c732 | ||
|
|
61ea98db1d | ||
|
|
b4b5e59cb4 | ||
|
|
cbc9353f0a | ||
|
|
abfa84dff8 |
@@ -0,0 +1,17 @@
|
||||
# Zeilenenden gehoeren zum Repository, nicht zur Maschine.
|
||||
#
|
||||
# Ohne diese Datei entscheidet jede Arbeitsstation fuer sich: Git fuer
|
||||
# Windows setzt core.autocrlf=input in seiner System-Konfiguration, die NAS
|
||||
# setzt nichts. Dasselbe Repository, zwei Verhalten - und weil der Bestand
|
||||
# selbst gemischt war (34 PHP-Dateien mit LF, 30 mit CRLF), gab es kein
|
||||
# richtiges Zeilenende, an das ein Werkzeug sich haette halten koennen.
|
||||
# Jede Ergaenzung mit LF in einer CRLF-Datei ergab eine gemischte Datei.
|
||||
#
|
||||
# LF und nicht CRLF, weil dieses Verzeichnis der Web-Ordner der NAS ist und
|
||||
# von Linux ausgeliefert wird.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Eingekaufter Fremdcode bleibt byteweise so, wie er geliefert wurde - dann
|
||||
# bleibt ein Update dagegen vergleichbar. Darunter liegen auch 292
|
||||
# Zertifikate, die niemand anfassen sollte.
|
||||
restricted/WebAuthn/** -text
|
||||
@@ -44,6 +44,11 @@ logs/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Der Vorschaubild-Ordner der Synology. Sie legt ihn neben jedem Bild an,
|
||||
# auch in assets/img - das sind ihre Miniaturen, kein Inhalt des Projekts.
|
||||
@eaDir/
|
||||
@SynoResource
|
||||
|
||||
# Temporäre Dateien
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
@@ -0,0 +1,990 @@
|
||||
# Smart-Dashboard
|
||||
|
||||
Die Anzeige und Bedienung des Hauses: Grundrisse mit Messwerten, Solar- und
|
||||
Verbrauchsverläufe, das Fahrzeug, die Automatiken, die Einstellungen. Alles,
|
||||
was einen offenen Browser braucht, liegt hier.
|
||||
|
||||
Gegenstück ist das Repo **`admin/SolarManager`** unter
|
||||
`/volume1/homes/wagner/SolarManager` — dort laufen die Hintergrundprozesse,
|
||||
die Messwerte einsammeln und die Automatiken ausführen. Beide Seiten reden
|
||||
über zwei Dinge miteinander und nie direkt: den **MQTT-Broker** für alles
|
||||
Aktuelle, die **Datenbanken** für alles, was bleiben soll. Wer wissen will,
|
||||
woher eine Zahl ursprünglich kommt, liest die README dort; hier steht, was
|
||||
mit ihr im Browser passiert.
|
||||
|
||||
Dieses Verzeichnis ist zugleich der Web-Root der NAS
|
||||
(`/volume1/web/smart`, ausgeliefert unter `https://nas.el-wa.org/smart`).
|
||||
Der Arbeitsordner *ist* die laufende Anwendung — jede gespeicherte Datei ist
|
||||
sofort live.
|
||||
|
||||
Für einige Teile reicht diese Landkarte nicht, weil ihr Verhalten aus dem
|
||||
Zusammenspiel mehrerer Prozesse entsteht. Sie haben eigene, ausführliche
|
||||
Dokumente unter [`doku/`](doku/README.md):
|
||||
**[Automatiken](doku/automatiken.md)**, **[Zeitleiste](doku/zeitleiste.md)**,
|
||||
**[Benachrichtigungen](doku/benachrichtigungen.md)**, **[Batterie](doku/byd.md)**,
|
||||
**[Meteogramm](doku/meteogramm.md)**,
|
||||
**[Solar-Übersicht](doku/uebersicht.md)**, **[Datenbanken](doku/datenbank.md)**
|
||||
und **[Einstellungsseite](doku/einstellungen.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Inhalt
|
||||
|
||||
1. [Auf einen Blick](#auf-einen-blick)
|
||||
2. [Wie eine Seite entsteht](#wie-eine-seite-entsteht)
|
||||
3. [Zwei Wege für Daten](#zwei-wege-für-daten)
|
||||
4. [Die Seitenkarte](#die-seitenkarte)
|
||||
5. [Die JavaScript-Schicht](#die-javascript-schicht)
|
||||
6. [Die PHP-Schicht](#die-php-schicht)
|
||||
7. [Die Datenbanken](#die-datenbanken)
|
||||
8. [Vier Bausteine im Detail](#vier-bausteine-im-detail)
|
||||
9. [Wiederkehrende Bauteile und Konventionen](#wiederkehrende-bauteile-und-konventionen)
|
||||
10. [Wo fange ich an, wenn ich … ändern will](#wo-fange-ich-an-wenn-ich--ändern-will)
|
||||
11. [Entwickeln und Prüfen](#entwickeln-und-prüfen)
|
||||
|
||||
---
|
||||
|
||||
## Auf einen Blick
|
||||
|
||||
```
|
||||
smart/
|
||||
├── index.php Router: Anmeldung prüfen, Seite auswählen, drei Includes
|
||||
├── helper.php Session, Heimnetzerkennung, checkLogin(), httpGetJson()
|
||||
├── authServer.php Passkeys (WebAuthn): Registrierung und Anmeldung
|
||||
├── addUser.php Einmal-Link zum Anlegen eines neuen Schlüssels
|
||||
├── .htaccess sperrt .git, *.py, *.sql, *.ini … für den Browser
|
||||
│
|
||||
├── restricted/ Vorlagen (was man sieht) und Modelle (was es weiß)
|
||||
│ ├── header.php footer.php Rahmen jeder Seite, Menü, Skript-Einbindung
|
||||
│ ├── home.php solar.php heat.php history.php
|
||||
│ ├── skoda.php weather.php settings.php logs.php
|
||||
│ ├── einfuehrung.php ← die neun Seitenvorlagen
|
||||
│ ├── rooms.php Etagen und Räume lesen (homeMesh), Kachelvorgabe, Symbole
|
||||
│ ├── grundriss.php Etagen, Räume, Kachelpositionen, Bilder ändern (Reiter Grundriss)
|
||||
│ ├── zeitleiste.php Automatiken eines Tages: Lage, Bahn, ob dran, Läufe
|
||||
│ ├── meshdb.php meshDb(): Verbindung zu homeMesh
|
||||
│ ├── costs.php Preistabellen + solarDb()
|
||||
│ ├── automations.php Automatiken, Gerätekatalog
|
||||
│ ├── kacheln.php Anzeigewerte der Home-Kacheln (Modell zur Maske)
|
||||
│ ├── roomControls.php Bedienelemente des Raum-Modals
|
||||
│ ├── commands.php ein Kommando tatsächlich abschicken
|
||||
│ ├── wallboxen.php beide Wallboxen über MQTT
|
||||
│ ├── skodaKeys.php MyŠkoda-Schlüssel in skoda.conf (nie zurück an den Browser)
|
||||
│ ├── logdateien.php Logdateien lesen
|
||||
│ ├── aussenplan.php Schrägbild des Außengeländes (SVG, gerechnet)
|
||||
│ ├── reiter.php das eine Reiter-Bauteil der ganzen Seite
|
||||
│ ├── config.php Zahlen, die an der Wirklichkeit hängen
|
||||
│ ├── mysql.php Zugangsdaten — NICHT im Git (mysql.php.example)
|
||||
│ └── deviceDiscovery/ Python: Geräte suchen und in homeMesh eintragen
|
||||
│
|
||||
├── ajax/ Endpunkte. Verteiler, keine Logik (Ausnahmen unten)
|
||||
├── js/solar/ der eigene JavaScript-Code
|
||||
├── js/ Fremdbibliotheken, alle lokal (kein CDN)
|
||||
├── css/solar.css das eigene Stylesheet (Theme + alle Bauteile)
|
||||
├── assets/img/ mitgelieferte Grundriss-Renderings dieses Hauses, Icons
|
||||
├── tiles/ beschreibbar, nicht im Git: Kartenkacheln, grundriss/ (hochgeladene Grundrisse)
|
||||
└── *.sql Schema-Dateien zum Nachziehen von Hand
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wie eine Seite entsteht
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant B as Browser
|
||||
participant I as index.php
|
||||
participant H as helper.php
|
||||
participant T as Vorlage + Modelle
|
||||
participant F as footer.php
|
||||
|
||||
B->>I: GET index.php?action=home&floor=OG
|
||||
I->>H: checkLogin()
|
||||
H-->>I: Heimnetz? sonst Passkey-Sitzung
|
||||
Note over I: $pages[...] wählt Vorlage,<br/>Bibliotheken und Seitenskripte
|
||||
I->>T: include header.php
|
||||
T-->>B: Kopf und Menü, gebaut aus $floors
|
||||
I->>T: include restricted/(seite).php
|
||||
T-->>B: Karten, SVG, eingebettete JSON-Daten
|
||||
I->>F: include footer.php
|
||||
F-->>B: Bootstrap, ggf. mqtt.js/Chart.js/Leaflet,<br/>common.js, dann die Seitenskripte
|
||||
B->>B: Skripte verbinden sich mit Broker und holen per fetch nach
|
||||
```
|
||||
|
||||
**Anmeldung.** `helper.php` kennt zwei Wege. Aus dem Heimnetz
|
||||
(`LOCAL_NETWORKS`, geprüft binär über `inet_pton`, ausschließlich anhand von
|
||||
`REMOTE_ADDR` — Weiterleitungs-Header sind fälschbar und werden bewusst
|
||||
ignoriert) ist man ohne Anmeldung drin. Von außen zählt ein Passkey:
|
||||
`authServer.php` mit der WebAuthn-Bibliothek unter `restricted/WebAuthn`,
|
||||
Schlüssel in der Datenbank `Logins`. Ein neuer Schlüssel entsteht nur über
|
||||
einen Einmal-Link (`addUser.php`, Tabelle `addUser`, eine Minute gültig).
|
||||
|
||||
**Die Seitentabelle** in `index.php` ist die einzige Stelle, an der steht,
|
||||
welche Seite es gibt und was sie braucht:
|
||||
|
||||
```php
|
||||
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false,
|
||||
"meteogram" => false, "leaflet" => false,
|
||||
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]],
|
||||
```
|
||||
|
||||
`footer.php` lädt daraus die Bibliotheken — Chart.js nur wo gezeichnet wird,
|
||||
mqtt.js nur wo Livewerte laufen, Leaflet nur auf der Fahrzeugseite. Danach
|
||||
kommt immer `common.js` und dann die Seitenskripte, jeweils mit
|
||||
`?v=<filemtime>`, damit ein Browser nie eine alte Fassung behält.
|
||||
|
||||
**Vorlage und Modell sind getrennt.** Eine Seitenvorlage baut Markup und ruft
|
||||
dafür Funktionen aus den Modelldateien; die Modelldateien enthalten kein
|
||||
Markup außer dort, wo sie ein Bedienelement zeichnen (`roomControls.php`,
|
||||
`reiter.php`). Die Endpunkte in `ajax/` benutzen dieselben Modellfunktionen —
|
||||
so kann eine Maske nicht etwas anderes behaupten als der Endpunkt, der sie
|
||||
speichert.
|
||||
|
||||
---
|
||||
|
||||
## Zwei Wege für Daten
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Browser
|
||||
JS["js/solar/*.js"]
|
||||
SVG["SVG-Grundriss<br/>Diagramme<br/>Masken"]
|
||||
end
|
||||
|
||||
BROKER{{"MQTT-Broker<br/><small>wss://mqtt.nas.el-wa.org:443</small>"}}
|
||||
AJAX["ajax/*.php"]
|
||||
MODELL["restricted/*.php<br/><small>Modell-Schicht</small>"]
|
||||
SOLARLOG[("solarLog<br/><small>Verlauf</small>")]
|
||||
HOMEMESH[("homeMesh<br/><small>Geräte, Automatiken</small>")]
|
||||
GERAETE["Geräte<br/><small>Tahoma · WLED · Shelly · Wallboxen</small>"]
|
||||
|
||||
BROKER -->|"laufende Werte"| JS
|
||||
JS --> SVG
|
||||
JS <-->|"fetch: Verlauf, Struktur, Masken"| AJAX
|
||||
AJAX --> MODELL
|
||||
MODELL --> SOLARLOG
|
||||
MODELL --> HOMEMESH
|
||||
MODELL -.->|"Kommandos"| BROKER
|
||||
MODELL -.->|"HTTP · Tahoma · WLED"| GERAETE
|
||||
|
||||
classDef speicher fill:#eaf5ee,stroke:#6fa981
|
||||
classDef code fill:#fff6e5,stroke:#d0a548
|
||||
class SOLARLOG,HOMEMESH,BROKER speicher
|
||||
class JS,AJAX,MODELL code
|
||||
```
|
||||
|
||||
Die Regel dahinter, und sie gilt ohne Ausnahme:
|
||||
|
||||
| Was | Weg | Warum |
|
||||
|---|---|---|
|
||||
| Aktueller Messwert (Leistung, Temperatur, Ladestand) | **MQTT über `wss`, direkt vom Broker** | Er weiß es ohnehin, und er sagt es von selbst — kein Nachfragen im Takt |
|
||||
| Verlauf, Statistik, Gerätelisten, Formulare | **`fetch` auf `ajax/*.php`** | Das weiß nur die Datenbank |
|
||||
| Jeder Knopfdruck (schalten, speichern, Fahrzeugbefehl) | **immer über den Server** | Zugangsdaten dürfen die Seite nicht verlassen, und der Broker ist von außen nicht erreichbar |
|
||||
|
||||
Der Browser abonniert je nach Seite `solarManager/#`, `weatherStation/#`,
|
||||
`wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` und — auf der Home-Seite —
|
||||
genau die Topics, die auf den Kacheln stehen (`tileTopics()` in `rooms.php`).
|
||||
|
||||
---
|
||||
|
||||
## Die Seitenkarte
|
||||
|
||||
| Seite (`?action=`) | Vorlage | Seitenskripte | holt per fetch | hört auf MQTT |
|
||||
|---|---|---|---|---|
|
||||
| `solar` (Vorgabe) | `solar.php` | `jahresstatistik.js`, `anlageKatalog.js`, `anlage.js`, `speicher.js`, `meteogrammKatalog.js`, `meteogramm.js`, `energieflussKatalog.js`, `energiefluss.js`, `bewaesserungAnzeige.js`, `ladefenster.js`, `bedienfenster.js`, `solarMQTT.js` | `speicher`, `meteogramm`, `getProdData`, `getConsData`, `getForecastData`, `getSunrise`, `getStats`, `carEG`, `carOG`, `heater`, `watering` | `solarManager/#`, `weatherStation/#`, `wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` |
|
||||
| `home` | `home.php` | `autoActionFuncs.js`, `zeitleiste.js`, `raumfenster.js`, `homeMQTT.js` | `room.php`, `AutoAction.php` | die Topics der Kacheln, `Raumtemp/#` |
|
||||
| `heat` | `heat.php` | `heatMQTT.js` | `getHeaterData`, `getWaterData`, `getSunrise` | `solarManager/#`, `weatherStation/#`, Wallbox-Topics |
|
||||
| `history` | `history.php` | `jahresstatistik.js`, `historyMQTT.js` | `energyHistory` (6 ×), `getStats` | — |
|
||||
| `skoda` | `skoda.php` | `skodaMQTT.js` | `skoda.php?was=…`, `skodaCmd.php`, `tile.php` | `solarManager/#` |
|
||||
| `weather` | `weather.php` | `weatherMQTT.js` | nichts — hier steht noch das eingebettete Meteogramm von meteoblue (`js/meteogram.js`); das eigene gibt es bisher nur auf der Solar-Seite | `weatherStation/#` |
|
||||
| `settings` | `settings.php` | `settings.js`, `grundriss.js`, `energieflussKatalog.js`, `energiefluss.js`, `energieflussEinstellungen.js`, `benachrichtigungen.js` | `settings.php?action=…`, `push.php?action=…` | — |
|
||||
| `logs` | `logs.php` | `logs.js` | `logs.php?action=…` | — |
|
||||
| `einfuehrung` | `einfuehrung.php` | `einfuehrung.js` | nichts — Folien und Bilder stehen in der Seite | — |
|
||||
| `anzeige` | `solar.php` im Anzeige-Modus | wie `solar`, dazu `anzeige.js` | wie `solar` | wie `solar` |
|
||||
|
||||
**Anzeige-Modus** (`?action=anzeige`) — die Solar-Seite für ein festes Display,
|
||||
gebaut für den Raspberry Pi 3B+ mit 800 × 480 im Kioskmodus. In `index.php` eine
|
||||
Kopie des Eintrags `solar` mit `"anzeige" => true`: `header.php` lässt Kopfleiste
|
||||
und Menü weg und setzt eine schmale Leiste (Übersicht, Anlage, Speicher,
|
||||
Diagramme), `footer.php` den Fuß; die Realtime-Karte füllt einen Bildschirm,
|
||||
die Übersicht bleibt quer. Bedienfenster, Diagramme und Bewässerung kommen
|
||||
unverändert aus `solarMQTT.js`. `anzeige.js` ergänzt nur: zurück zur Übersicht
|
||||
nach zwei Minuten ohne Berührung (offene Fenster schließen), Verbindungspunkt
|
||||
(grün/gelb/rot) und Uhr in der Leiste, Neuladen alle sechs Stunden. Ohne
|
||||
weitere Parameter läuft sie „ruhig“ — nur die Flüsse bewegen sich, in groben
|
||||
Stufen — und ohne Mauszeiger; `&animation=1` schaltet alle Animationen ein,
|
||||
`&maus=1` zeigt den Zeiger.
|
||||
|
||||
Und dasselbe als Abhängigkeitsbild — von der Seite bis in den Speicher:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph S["Seiten"]
|
||||
HOME["home"]; SOLAR["solar"]; HIST["history"]; HEAT["heat"]
|
||||
SKODA["skoda"]; SET["settings"]; LOGS["logs"]; WEA["weather"]
|
||||
end
|
||||
|
||||
subgraph J["js/solar"]
|
||||
COMMON["common.js<br/><small>immer geladen</small>"]
|
||||
HOMEJS["homeMQTT.js"]; AUTOJS["autoActionFuncs.js"]; SOLJS["solarMQTT.js"]
|
||||
HISTJS["historyMQTT.js"]; STATJS["jahresstatistik.js"]; HEATJS["heatMQTT.js"]
|
||||
SKODAJS["skodaMQTT.js"]; SETJS["settings.js"]; LOGJS["logs.js"]; WEAJS["weatherMQTT.js"]
|
||||
end
|
||||
|
||||
subgraph A["ajax"]
|
||||
ROOM["room.php"]; AUTOA["AutoAction.php"]; SETA["settings.php"]
|
||||
STATS["getStats.php"]; EHIST["energyHistory.php"]; PROD["getProdData<br/>getConsData<br/>getForecastData"]
|
||||
HEATA["getHeaterData<br/>getWaterData"]; SKODAA["skoda.php<br/>skodaCmd.php<br/>tile.php"]; LOGA["logs.php"]
|
||||
end
|
||||
|
||||
subgraph M["restricted (Modell)"]
|
||||
ROOMS["rooms.php"]; COSTS["costs.php"]; AUTOM["automations.php"]
|
||||
KACH["kacheln.php"]; RCTRL["roomControls.php"]; CMD["commands.php"]; LOGD["logdateien.php"]
|
||||
end
|
||||
|
||||
SOLARLOG[("solarLog")]; HOMEMESH[("homeMesh")]; DATEIEN[/"Logdateien"/]; BROKER{{"Broker"}}
|
||||
|
||||
HOME --> HOMEJS & AUTOJS
|
||||
SOLAR --> SOLJS & STATJS
|
||||
HIST --> HISTJS & STATJS
|
||||
HEAT --> HEATJS
|
||||
SKODA --> SKODAJS
|
||||
SET --> SETJS
|
||||
LOGS --> LOGJS
|
||||
WEA --> WEAJS
|
||||
S -.-> COMMON
|
||||
|
||||
HOMEJS --> ROOM
|
||||
AUTOJS --> AUTOA
|
||||
SOLJS --> PROD
|
||||
HISTJS --> EHIST
|
||||
STATJS --> STATS
|
||||
HEATJS --> HEATA
|
||||
SKODAJS --> SKODAA
|
||||
SETJS --> SETA
|
||||
LOGJS --> LOGA
|
||||
|
||||
ROOM --> RCTRL & CMD & AUTOM
|
||||
AUTOA --> AUTOM
|
||||
SETA --> COSTS & KACH & AUTOM
|
||||
STATS --> COSTS
|
||||
SETA --> GRM["grundriss.php"]
|
||||
SETA --> ENF["energiefluss.php"]
|
||||
ENF --> HOMEMESH
|
||||
KACH --> ROOMS
|
||||
GRM --> ROOMS
|
||||
LOGA --> LOGD
|
||||
|
||||
COSTS --> SOLARLOG
|
||||
AUTOM --> HOMEMESH
|
||||
CMD --> HOMEMESH
|
||||
CMD -.-> BROKER
|
||||
PROD --> SOLARLOG
|
||||
EHIST --> SOLARLOG
|
||||
HEATA --> SOLARLOG
|
||||
SKODAA --> SOLARLOG
|
||||
LOGD --> DATEIEN
|
||||
|
||||
classDef speicher fill:#eaf5ee,stroke:#6fa981
|
||||
class SOLARLOG,HOMEMESH,DATEIEN,BROKER speicher
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Die JavaScript-Schicht
|
||||
|
||||
Kein Framework, kein Bundler. Jede Datei ist ein Skript, das der Browser so
|
||||
lädt, wie es dasteht; geteilt wird über globale Funktionen aus `common.js`.
|
||||
Die Namen sind deutsch, sobald es um die Sache geht — englisch nur dort, wo
|
||||
eine Bibliothek es vorgibt.
|
||||
|
||||
### `common.js` — immer geladen
|
||||
|
||||
| Funktion | wofür |
|
||||
|---|---|
|
||||
| `powerToString(w)` | Leistung als `W` oder `kW`, dieselbe Rundung überall |
|
||||
| `getData(chart, url, opt)` | Diagrammdaten holen, eintragen, alle 5 min auffrischen; optional Sonnenauf-/-untergang aus `getSunrise.php` dazu |
|
||||
| `mountMeteogram()`, `updateWeatherCards()` | Wetterteile der Solar- und Wetterseite |
|
||||
| `reiterMerken()` | offener Reiter steht in der Adresse und übersteht ein Neuladen |
|
||||
| `loadingHTML(text)` | der eine Ladezustand für alle Modals |
|
||||
|
||||
### Die Seitenskripte
|
||||
|
||||
**`homeMQTT.js`** — Grundriss und Kacheln. Zwei Objekte: `homeMQTT` hält die
|
||||
Verbindung und den Nachrichtenbaum (`mqttData`), `homeSVG` schreibt in die
|
||||
SVG-Kacheln. Was auf einer Kachel steht, kommt nicht aus dem Skript, sondern
|
||||
aus `homeRooms` — der Raumtabelle, die `home.php` als JSON in die Seite
|
||||
schreibt. `homeSVG.kachelWert()` ist die Stelle, an der aus einer Nachricht
|
||||
Text wird: Topic oder Wechselrichtersumme lesen, ggf. JSON-Pfad
|
||||
herausgreifen, Vorzeichen drehen (`negativ`), skalieren (`format:
|
||||
"leistung"` für Watt-Quellen, `"leistung_kw"` für Quellen, die schon
|
||||
Kilowatt melden — die Wallboxen), runden (`stellen`), Einheit anhängen. Klick auf eine Kachel
|
||||
öffnet das Raum-Modal über `ajax/room.php?room=<Raumnummer>`; bedient wird es
|
||||
von `raumfenster.js`. Heizsymbol und Ist-Werte lesen aus dem Thermostat-Zweig
|
||||
des Raums (`mqttZweig()`).
|
||||
|
||||
**`raumfenster.js`** — das Raum-Modal im Stil von Lade-, Heiz- und
|
||||
Bewässerungsfenster: Reiter-Kacheln mit Zustand, Beschattung mit
|
||||
Auf/Stop/my/Zu, Höhe und Lamellen in 25-%-Stufen, Ziehen im Fensterbild und
|
||||
„aktuellen Stand als my speichern“; Heizung als Ring mit ziehbarem Soll-Punkt;
|
||||
Lampen mit Birne als Schalter, Farbfeldern und Effekten. Jede Wahl gilt
|
||||
sofort. Jalousien werden nach einem Befehl bis zum Stillstand verfolgt
|
||||
(`rfJalousieVerfolgen()`), die Heizwerte kommen über `raumfensterMqtt()` live.
|
||||
|
||||
**`auswahl.js`** — eine Auswahlliste für die ganze Anwendung. Wird auf jeder
|
||||
Seite geladen (`footer.php`) und ergänzt jedes `<select>` um einen eigenen
|
||||
Knopf; das `<select>` bleibt stehen und hält weiter den Wert, vorhandene
|
||||
`onchange`-Zuhörer merken nichts davon. Die aufgeklappte Liste hängt an
|
||||
`<body>` und liegt mit `position: fixed` über der Seite — im Fluss machte sie
|
||||
ein Modal höher, und der ganze Inhalt sprang. Ab neun Einträgen gibt es ein
|
||||
Suchfeld, Gruppen (`<optgroup>`) bleiben Gruppen, die Tastatur bedient sie.
|
||||
Neue `<select>` übernimmt ein `MutationObserver` von selbst. Wer den Wert im
|
||||
Code setzt, ruft `auswahlAktualisieren(select)` oder löst `change` aus;
|
||||
`data-auswahl="aus"` lässt ein Feld in Ruhe.
|
||||
|
||||
**`solarMQTT.js`** — die Solar-Seite. Startet die Energiefluss-Übersicht
|
||||
(`energieflussStarten()`) samt Anlage-Ansicht, füllt die Bewässerungszonen,
|
||||
darunter hängen die Diagramme Prognose, Verbrauch und Erzeugung über
|
||||
`getData()`. Aktualisiert wird höchstens einmal je Sekunde
|
||||
(`solarSvgUpdatePending`); die Setter (`setText`, `setAttr`) schreiben nur,
|
||||
was sich geändert hat.
|
||||
|
||||
**Energiefluss-Übersicht** — drei Dateien mit klarer Arbeitsteilung:
|
||||
|
||||
| Datei | enthält |
|
||||
|---|---|
|
||||
| `energieflussKatalog.js` | **jeden Kreis genau einmal**: Name, Symbol, Farbe, Elternkreis, Lage, Ringskala, Klickziel und `lesen(m, opt)`, das aus den MQTT-Werten Leistung, Zeile darunter, Ringsegmente, Fluss, Plaketten und Symbolzustand macht. Ein neuer Kreis ist ein neuer Eintrag hier — sonst nichts. |
|
||||
| `energiefluss.js` | den Renderer (`new Energiefluss(behälter, opt)`): Symbole (24er-Raster, animierbare Teile), Kreise mit Ring und Füllstandswelle, Flüsse als Bögen mit laufenden Punkten, Plaketten, Hochkant-Ansicht (x/y vertauscht unter 720 px Breite), Bearbeitungsmodus mit Ziehen. Kennt keinen Kreis beim Namen. |
|
||||
| `energieflussEinstellungen.js` | Reiter „Übersicht“ in den Einstellungen: Vorschau mit Beispielwerten, Kreise an/aus, Name, Lage per Ziehen, PV-Flächen (welche Einträge von `P_PVn` zu welcher Fläche gehören). |
|
||||
|
||||
Was ein Haus vom Katalog abweicht, steht in `homeMesh.energiefluss`
|
||||
(`homeMesh_energiefluss.sql`, gelesen/geschrieben in `restricted/energiefluss.php`) —
|
||||
nur echte Abweichungen, eine leere Tabelle ist der Katalog. Ist ein Kreis aus,
|
||||
hängen seine Kinder am nächsten eingeschalteten darüber, und sein Anteil im
|
||||
Ring des Elternkreises fällt in das letzte Segment. Die Animationen stehen
|
||||
still, solange die Karte nicht zu sehen ist, und entfallen bei
|
||||
`prefers-reduced-motion`. Stile: Abschnitt „Energiefluss-Übersicht“ in
|
||||
`css/solar.css`. Bis September 2026 stand hier `assets/img/realtime.svg` mit
|
||||
festen Kreisen.
|
||||
|
||||
**Anlage-Ansicht** (Umschalter „Anlage“ oder Klick auf die Sonne) — dieselbe
|
||||
Arbeitsteilung:
|
||||
|
||||
| Datei | enthält |
|
||||
|---|---|
|
||||
| `anlageKatalog.js` | die PV-Anlage dieses Hauses: Flächen mit Umriss, Wechselrichter (`invertersN` und ihre Eingänge in `P_PVn`), Strings ohne Einzelwerte (Gen24) und jede Platte mit ihrer echten Lage. Auch die Flächen im Sonnenring der Übersicht leiten sich daraus ab. |
|
||||
| `anlage.js` | den Renderer (`new Anlage(behälter)`): Flächen mit Leistungsanteil, Lageplan (hochkant gedreht unter 800 px), Detail zu Platte, String oder Wechselrichter, Wechselrichterliste mit Auslastung und Zustand. |
|
||||
|
||||
Die Farbe einer Platte zeigt ihre Leistung im Vergleich zu den stärksten
|
||||
ihrer Fläche (90. Perzentil) — nur Anzeige, kein Urteil. Ein Warnzeichen
|
||||
bekommt sie erst, wenn sie bei genug Licht (Flächenmedian ab 30 W) unter 70 %
|
||||
des Medians liegt. Meldet ein Wechselrichter nichts, stehen seine Platten auf
|
||||
„ohne Daten“ statt „auffällig“. Den Verlauf je Platte gibt es nicht in der
|
||||
Datenbank; die Ansicht schreibt ihn ab dem Seitenaufruf mit (40 Minuten).
|
||||
|
||||
**Speicher-Ansicht** (Umschalter „Speicher“ oder Klick auf die Batterie) —
|
||||
`speicher.js` zeigt die Batterie bis zur Zelle: Kennzahlen (Ladestand mit kWh
|
||||
und „voll/leer um“, Leistung, SOH, Zellspreizung, Temperaturen, Spannung,
|
||||
Durchsatz mit gerechneten Zyklen, Zustand), den Turm aus BMU und Modulen mit
|
||||
je 16 Zellbalken (Farbe und Höhe = Abweichung vom Mittel aller Zellen), das
|
||||
gewählte Modul mit Zellspannungen und Temperaturfühlern sowie zwei
|
||||
Verläufe aus `ajax/speicher.php` (Ladestand und Leistung aus `EnergyFlow`,
|
||||
Spreizung und Temperatur aus `byd`). Live-Werte kommen über MQTT
|
||||
`solarManager/byd/#` (`gatherBYDData.py`); fehlen sie, zeigt die Ansicht, was
|
||||
der Wechselrichter weiß. Die Nennenergie je Modul (2,76 kWh, HVM) steht als
|
||||
Konstante oben in `speicher.js` — die BMU meldet sie nicht. Wie die Werte
|
||||
aus der BMU herauskommen, steht in **[doku/byd.md](doku/byd.md)**.
|
||||
|
||||
**Karte „Wetter“** — `meteogramm.js` zeichnet aus `meteogrammKatalog.js` drei
|
||||
Felder mit gemeinsamer Zeitachse: Temperatur mit Wettersymbolen und
|
||||
Tag/Nacht-Schattierung, Niederschlag mit der Bewölkung als Höhenband,
|
||||
Wind mit Böen und Richtungspfeilen; dazu die Globalstrahlung auf der rechten
|
||||
Achse des ersten Feldes. Umschaltbar 2 / 5 / 7 Tage, am Handy mit 2 Tagen
|
||||
startend. **Links vom Jetzt-Strich stehen die gemessenen Werte der eigenen
|
||||
Station**, rechts die Vorhersage aus `ajax/meteogramm.php`
|
||||
(`weatherHours`/`weatherDays`, gefüllt von `gatherForecastData.py`). Das
|
||||
eingebettete Meteogramm von meteoblue ist über einen Umschalter weiter
|
||||
erreichbar und wird erst beim Hinschalten geladen. Einzelheiten in
|
||||
**[doku/meteogramm.md](doku/meteogramm.md)**.
|
||||
|
||||
**`skodaMQTT.js`** — Fahrzeugseite. Holt alles über `ajax/skoda.php?was=…`
|
||||
(live, ladungen, kurve, gesundheit, fahrten, strecke), zeichnet das Fahrzeug
|
||||
als SVG-Draufsicht mit Ladestand im Akku, Ladeknopf und Klimaknopf, und die
|
||||
Fahrtenkarte mit Leaflet über den eigenen Kachelspeicher `ajax/tile.php`.
|
||||
Befehle gehen per POST an `ajax/skodaCmd.php` — nie direkt an die API.
|
||||
|
||||
**`jahresstatistik.js`** — ein Bauteil, zwei Seiten. `baueJahresstatistik()`
|
||||
baut aus der Antwort von `getStats.php` die Vergleichstabelle (Jahre
|
||||
nebeneinander, Gesamtspalte, Δ in Prozentpunkten bei Quoten, Sparklines je
|
||||
Monat). Die Kennzahlen stehen **nicht** im Skript: sie beschreiben sich in der
|
||||
Antwort selbst.
|
||||
|
||||
**`autoActionFuncs.js`** — der Automatik-Editor: Gerätewähler mit Suche,
|
||||
Bedingungen in UND-/ODER-Gruppen, Aktionen mit Parametern, Klartext-Vorschau.
|
||||
Er bekommt Gerätekatalog und Datensatz in einem Dokument von
|
||||
`AutoAction.php?action=editor` — früher kamen je Bedingung zwei weitere
|
||||
Anfragen dazu, und die Auswahlfelder füllten sich asynchron. Dazu die
|
||||
Übersicht: Nachfolger einklappen (`toggleAutomationGroup`) und die Rückfrage
|
||||
vor dem Löschen (`frageNachfolger`).
|
||||
|
||||
**`zeitleiste.js`** — die Automatismen als Zeitleiste eines Tages, neben der
|
||||
bisherigen Liste (Umschalter oben in der Karte, gemerkt im Browser). Eine Bahn
|
||||
je Geräteart, abgeleitet aus den geschalteten Geräten über `bedienform()` — die
|
||||
Mehrheit gewinnt, Bewässerung wird am Gerätetyp erkannt. Die Lage rechnet
|
||||
`restricted/zeitleiste.php` nach festen Regeln: feste Uhrzeit und Sonnenstand
|
||||
als Punkt, Zeitfenster mit Messwert als Balken, Ketten hinter ihrem Auslöser,
|
||||
ohne jede Zeit unter „Jederzeit“, pausierte unter „Pausiert“. Ob eine
|
||||
Automatik an einem Tag dran ist, wird wie im Runner gerechnet (Wochentage,
|
||||
Ferien, Feiertage, Vorabend); nicht dran heißt gestrichelt, nie ausgeblendet.
|
||||
Der Zähler „x von y“ macht sichtbar, dass keine fehlt. Etagen lassen sich
|
||||
einzeln einblenden. Sonnenzeiten kommen aus `solarLog.daylight`, für spätere
|
||||
Tage vom selben Kalendertag eines Vorjahres.
|
||||
|
||||
**`grundriss.js`** — der Reiter Grundriss: Etagen, Räume, Grundrissbilder und
|
||||
der Plan-Editor, in dem Kacheln gezogen und gesetzt werden. Eigene Datei, weil
|
||||
`settings.js` schon für vier Reiter reicht; es nutzt `textSchuetzen()` von dort.
|
||||
|
||||
**`settings.js`** — vier Reiter in einer Datei, deutlich getrennt: Preistabellen,
|
||||
Home-Kacheln, Geräte, Fahrzeug. Jeder Abschnitt hat sein Modell (`kachelModell`,
|
||||
`geraeteModell`, `skodaModell`), seine `zeichne…()`-Funktion und eine `…Binden()`-Funktion,
|
||||
die genau einen Ereignisbehandler auf den Container legt (Delegation) —
|
||||
deshalb überlebt Bedienung jedes Neuzeichnen.
|
||||
|
||||
**`heatMQTT.js`, `historyMQTT.js`, `weatherMQTT.js`, `logs.js`** — jeweils
|
||||
klein: Diagramme füllen bzw. Logzeilen nachladen und einfärben.
|
||||
|
||||
---
|
||||
|
||||
## Die PHP-Schicht
|
||||
|
||||
### Endpunkte (`ajax/`)
|
||||
|
||||
| Endpunkt | Aufruf | liefert / tut |
|
||||
|---|---|---|
|
||||
| `room.php` | `?room=<rooms.id>`, `POST ?action=command\|temp\|jalousie\|my\|wled` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden; Jalousie-Stand und WLED-Zustand (Effekt, Preset) live lesen; aktuellen Stand als my-Position in den Motor schreiben (`setMemorized1Position`/`-Orientation`) |
|
||||
| `AutoAction.php` | `?action=editor\|list\|zeitleiste\|followers\|werte`, `POST save\|delete\|toggle` | Automatik-Editor, Übersicht als Liste und als Zeitleiste (`&datum=YYYY-MM-DD`) |
|
||||
| `settings.php` | `?action=list\|kacheln\|geraete\|grundriss\|skoda-keys`, `POST save\|kacheln-save\|geraete-raeume\|geraet-loeschen\|etage-speichern\|etagen-reihenfolge\|etage-loeschen\|etage-bild\|raum-speichern\|raum-position\|raum-loeschen\|skoda-keys-save` | Einstellungsseite; die Grundriss-Aktionen antworten mit dem ganzen neuen Stand, `skoda-keys` meldet nur, ob ein Schlüssel hinterlegt ist und worauf er endet |
|
||||
| `getStats.php` | `GET` | Jahresstatistik, alle Jahre, mit Metadaten je Kennzahl |
|
||||
| `energyHistory.php` | `?series=prod\|cons&range=month\|year\|decade` | Verbrauchs-/Erzeugungsverlauf (Rohdaten oder Stundenarchiv) |
|
||||
| `getProdData` `getConsData` `getForecastData` `getHeaterData` `getWaterData` | `?FROM=&TO=` | Chart.js-Datensätze für die jeweilige Karte |
|
||||
| `getSunrise.php` | `?FROM=&TO=` | Sonnenauf- und -untergänge als Diagramm-Markierungen |
|
||||
| `meteogramm.php` | `?tage=2..7&rueck=0..72` | Vorhersage und gemessene Stunden zu einer Reihe verschmolzen, für das Meteogramm |
|
||||
| `skoda.php` | `?was=live\|ladungen\|kurve\|gesundheit\|fahrten\|strecke` | Fahrzeugauswertungen aus `solarLog.skoda` |
|
||||
| `skodaCmd.php` | `POST befehl=…` | Laden/Klima/Lüftung am Fahrzeug |
|
||||
| `tile.php` | `?z=&x=&y=` | Kartenkachel aus dem eigenen Zwischenspeicher, sonst einmalig von OSM |
|
||||
| `logs.php` | `?action=list\|read` | Zustand und Zeilen der Logdateien |
|
||||
| `carEG.php` `carOG.php` `carSteuerung.php` `carForm.php` `heater.php` `watering.php` `tahoma.php` | | Wallboxen, Heizstab, Bewässerung, Tahoma-Bedienung. Das Ladefenster (`carForm.php` + `js/solar/ladefenster.js`) liest live aus MQTT und schreibt jede Änderung sofort als JSON an `carSteuerung.php` (Aktionen `frc`, `modus`, `strom`, `plan`). Heizstab- und Bewässerungsfenster (`heater.php`, `watering.php` + `js/solar/bedienfenster.js`) arbeiten genauso: Zustand live aus MQTT, jede Wahl gilt sofort |
|
||||
| `chartData.php` | (kein Endpunkt) | gemeinsame Bausteine der Chart-Endpunkte |
|
||||
| `phpMQTT.php` | (Bibliothek) | MQTT aus PHP heraus, für Kommandos |
|
||||
|
||||
Die meisten Endpunkte sind **Verteiler**: Eingabe prüfen, Modellfunktion
|
||||
rufen, JSON zurückgeben. Wo Logik in einem Endpunkt steht, hat das einen
|
||||
Grund, der oben in der Datei erklärt ist (`getStats.php` rechnet die
|
||||
Kennzahlen, `energyHistory.php` wählt die Quelle nach Zeitraum,
|
||||
`skoda.php` bildet Fahrten und Ladungen aus einer unregelmäßigen Zeitreihe).
|
||||
|
||||
### Modelle (`restricted/`)
|
||||
|
||||
| Datei | Zuständig für | Wichtige Funktionen |
|
||||
|---|---|---|
|
||||
| `rooms.php` | Etagen und Räume lesen (`homeMesh`), Kachelvorgabe, Symbole | `grundriss()`, `allRooms()`, `roomsOnFloor()`, `floorsWithPlan()`, `standardEtage()`, `etageBild()`, `roomsWithTile()`, `tileTopics()`, `kachelVorgabe()`, `bootstrapIcons()`, `kachelIcon()` |
|
||||
| `zeitleiste.php` | Automatiken eines Tages für die Zeitleiste | `zeitleiste()`, `zlTagPasst()`, `zlSonne()`, `zlBahn()` |
|
||||
| `grundriss.php` | Etagen, Räume, Kachelpositionen und Grundrissbilder ändern | `grundrissStand()`, `etageSpeichern()`, `etagenReihenfolge()`, `etageLoeschen()`, `etageBildSpeichern()`, `raumSpeichern()`, `raumPosition()`, `raumLoeschen()` |
|
||||
| `meshdb.php` | Verbindung zu `homeMesh` | `meshDb()` |
|
||||
| `costs.php` | Preistabellen, Verbindung zu `solarLog` | `solarDb()`, `preiseLaden()`, `preiseSpeichern()`, `grundpreisImZeitraum()` |
|
||||
| `automations.php` | Automatiken, Gerätekatalog, Raumzuordnung der Geräte | `deviceCatalog()`, `raumListe()`, `loadAutomation()`, `saveAutomation()`, `deleteAutomation()`, `automationFollowers()`, `pruefeKreis()`, `kalendertagChoices()`, `tagVorlagen()`, `stateValues()`, `geraeteListe()`, `geraetLoeschen()`, `saveRooms()` |
|
||||
| `kacheln.php` | Anzeigewerte der Home-Kacheln (Maskenseite) | `kachelListe()`, `kachelKatalog()`, `kachelWertPruefen()`, `kachelWerteSpeichern()`, `kachelSymbole()` |
|
||||
| `roomControls.php` | Welches Gerät welches Bedienelement bekommt | `bedienform()`, `zeichneBeschattung()`, `zeichneLicht()`, `zeichneMesswerte()` |
|
||||
| `commands.php` | Ein Kommando abschicken (MQTT/WLED/HTTP/Tahoma) | `executeCommand()`, `sendeMqtt()`, `sendeWled()`, `sendeHttp()`, `sendeTahoma()` |
|
||||
| `wallboxen.php` | Beide Wallboxen über MQTT | `wallboxen()`, `wallboxSetzen()` |
|
||||
| `skodaKeys.php` | MyŠkoda-Schlüssel in `skoda.conf` (SolarManager): lesen, prüfen, eintragen — ein Wert geht nie an den Browser zurück | `skodaStand()`, `skodaSchluesselSpeichern()` |
|
||||
| `logdateien.php` | Logdateien vom Ende her lesen | `logDateien()`, `logLesen()`, `logZerlegen()` |
|
||||
| `aussenplan.php` | Schrägbild des Außengeländes | Projektion + SVG |
|
||||
| `reiter.php` | Reiter | `reiterLeiste()`, `reiterFlaecheAuf/Zu()`, `reiterBlock()` |
|
||||
| `config.php` | Zahlen, die an der Wirklichkeit hängen (welches Auto an welcher Box) | |
|
||||
| `mysql.php` | Zugangsdaten, **nicht im Git** | Vorlage: `mysql.php.example` |
|
||||
|
||||
Include-Graph der Modellschicht — flach gehalten, damit man einer Zeile bis zur
|
||||
Datenbank folgen kann, ohne zu springen:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
HELPER["helper.php"] --> MYSQL["mysql.php<br/><small>Zugangsdaten</small>"]
|
||||
COSTS["costs.php"] --> MYSQL
|
||||
MESH["meshdb.php<br/><small>meshDb()</small>"] --> MYSQL
|
||||
ROOMS["rooms.php"] --> MESH
|
||||
GRM["grundriss.php"] --> ROOMS
|
||||
KACH["kacheln.php"] --> ROOMS
|
||||
KACH -. "nur wenn vorhanden" .-> AUTOM["automations.php"]
|
||||
AUTOM --> MESH
|
||||
AUTOM --> ROOMS
|
||||
CMD["commands.php"] --> MYSQL
|
||||
CMD --> TAH["tahoma_EG.php"]
|
||||
CMD --> PHPMQTT["ajax/phpMQTT.php"]
|
||||
HOME["home.php"] --> ROOMS
|
||||
HOME --> REITER
|
||||
SET["settings.php"] --> COSTS & KACH & REITER
|
||||
SET --> SKK["skodaKeys.php<br/><small>skoda.conf</small>"]
|
||||
HEADER["header.php"] --> ROOMS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Die Datenbanken
|
||||
|
||||
Drei Schemata, klar getrennte Zuständigkeit:
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
actors ||--o{ actor_states : "meldet"
|
||||
actors ||--o{ actor_commands : "kann"
|
||||
actor_commands ||--o{ command_parameters : "nimmt"
|
||||
automations ||--o{ automation_conditions : "wenn"
|
||||
automations ||--o{ automation_actions : "dann"
|
||||
automation_conditions }o--|| actor_states : "vergleicht"
|
||||
automation_actions }o--|| actor_commands : "schickt"
|
||||
automation_actions ||--o{ automation_action_params : "mit"
|
||||
```
|
||||
|
||||
**`homeMesh`** — Geräte und Automatiken (Schema: `homeMesh_DB-layout.sql`,
|
||||
`homeMesh_automations.sql`).
|
||||
|
||||
| Tabelle | Inhalt | geschrieben von |
|
||||
|---|---|---|
|
||||
| `actors` | ein Gerät je Zeile, `url` bestimmt den Weg (`mqtt://`, `http://`, `wled://`, `io://`, `Logic`) | Discovery; `room` von Hand (Einstellungen → Geräte) |
|
||||
| `actor_states` | Messwerte: `url` = Topic **oder** Feldname, `value_path` = Schlüssel darin, `current_value` | Discovery (Adressen), Runner (Werte) |
|
||||
| `actor_commands`, `command_parameters` | was ein Gerät kann und welche Parameter dazugehören | Discovery |
|
||||
| `automations`, `automation_conditions`, `automation_actions`, `automation_action_params` | das Regelwerk | Web-Editor |
|
||||
| `automation_log` | was wann ausgelöst hat | Runner |
|
||||
| `calendar_days` | Feiertage und Ferien | `fetch_calendar.py` (SolarManager) |
|
||||
| `state_types` | Datentypen der Messwerte (Zahl, Text, Auswahl …) | fest |
|
||||
|
||||
**`solarLog`** — alles, was einen Verlauf hat.
|
||||
|
||||
| Tabelle | Inhalt | geschrieben von |
|
||||
|---|---|---|
|
||||
| `EnergyFlow`, `EnergyFlow_hourly` | Leistungen im Rohtakt und als Stundenarchiv | `solarManager.py` |
|
||||
| `stats_daily` | Tageswerte für die Jahresstatistik | `solarManager.py` |
|
||||
| `Heater`, `puffertemp`, `wasser`, `zisterne`, `windrad`, `kiga_windrad` | Heizung, Speicher, Wasser, Windrad | `solarManager.py` |
|
||||
| `skoda`, `skoda_raw` | Fahrzeugzustand im Verlauf | `gatherSkodaData.py` |
|
||||
| `skoda_ladepunkte` | Wallbox-Verlauf während einer Ladung, im Minutentakt, wird nicht ausgedünnt (`solarLog_skoda_ladepunkte.sql`) | `gatherSkodaData.py`; ältere Ladungen `skoda_ladepunkte_nachtragen.py` |
|
||||
| `byd`, `byd_zellen` | BYD-Speicher direkt aus der BMU: Ladestand, SOH, Temperaturen, Spreizung, Zähler alle 5 Minuten; alle 128 Zellspannungen und 64 Temperaturen alle 15 Minuten (`solarLog_byd.sql`) | `gatherBYDData.py` |
|
||||
| `weatherStation` | die eigene Wetterstation, alle 5 Minuten | die Station selbst (`/volume1/web/weatherStation.php`, außerhalb der Repos) |
|
||||
| `weatherHours`, `weatherDays`, `weatherTilted`, `weatherForecastLog` | Wettervorhersage und Archiv; nichts wird gelöscht (`solarLog_weather.sql`, → [doku/meteogramm.md](doku/meteogramm.md)) | `gatherForecastData.py` (Open-Meteo), einmalig `wetterarchiv_nachtragen.py` |
|
||||
| `daylight`, `simPower` | Sonnenzeiten (nur bis morgen), Ertragsprognose | `solarManager.py`; Solcast über `gatherSolar.php` |
|
||||
| `gridCosts`, `gasCosts`, `fuelCosts` | Preiszeitreihen (Stichtag, kein Enddatum) | Einstellungsseite (`costs.php`) |
|
||||
|
||||
**`Logins`** — `users` (Passkeys) und `addUser` (Einmal-Links).
|
||||
|
||||
---
|
||||
|
||||
## Vier Bausteine im Detail
|
||||
|
||||
### 1. Grundriss und Home-Kacheln
|
||||
|
||||
Etagen, Räume, Kachelpositionen und Kachelwerte sind **Inhalt, kein Code**.
|
||||
Sie liegen in `homeMesh.floors` und `homeMesh.rooms` (`homeMesh_grundriss.sql`)
|
||||
und werden unter **Einstellungen → Grundriss** und **→ Home-Kacheln** gepflegt.
|
||||
Bis September 2026 standen sie als Tabelle in `rooms.php`; für ein anderes
|
||||
Haus hätte man den Quelltext umschreiben müssen.
|
||||
|
||||
| Tabelle | trägt |
|
||||
|---|---|
|
||||
| `floors` | `code` (fest: URL, SVG-Ids, `automations.floor`), `label`, Reihenfolge, Grundrissbild, Standard-Etage |
|
||||
| `rooms` | feste Nummer `id`, Etage, Name, `kuerzel` (SVG-Ids), Kachel `x`/`y` (leer = keine Kachel), `thermostat` (MQTT-Zweig), `werte` (JSON, leer = Vorgabe) |
|
||||
| `energiefluss` | Abweichungen der Solar-Übersicht vom Katalog: `aktiv`, `name`, `x`/`y`, `optionen` (JSON) — eigene Datei `homeMesh_energiefluss.sql`, keine Zeile = Katalog |
|
||||
|
||||
Auf die feste Raumnummer zeigen `actors.room_id` und das Raum-Modal
|
||||
(`room.php?room=12`). Ein umbenannter Raum behält damit Geräte, Regler und
|
||||
Kachel. `automations.floor` ist ein Fremdschlüssel auf `floors`: eine Etage mit
|
||||
Automatiken lässt sich nicht löschen.
|
||||
|
||||
**Die Vorgabe einer Kachel** ist allein das Thermostat: mit `thermostat` Soll,
|
||||
Ist und Feuchte aus diesem Zweig, sonst nur der Name. Derselbe Zweig trägt
|
||||
Heizsymbol und Temperaturregler (`mqttZweig()` in `homeMQTT.js`,
|
||||
`<zweig>/changeSetTemp` in `room.php`) — nichts setzt mehr voraus, dass ein
|
||||
Thermostat unter `Raumtemp/<Etage>/<Raum>` meldet.
|
||||
|
||||
**Grundrissbilder** kommen per Upload nach `tiles/grundriss/` — das einzige
|
||||
Verzeichnis, in das der Webserver schreiben darf, und nicht im Git. PNG, JPEG
|
||||
oder WebP, geprüft am Inhalt; SVG bewusst nicht, es könnte Skript enthalten.
|
||||
Der Name trägt einen Teil der Prüfsumme, ein neues Bild hat also eine neue
|
||||
Adresse. Die mitgelieferten unter `assets/img` bleiben daneben gültig.
|
||||
|
||||
**Der Plan-Editor** (`js/solar/grundriss.js`, Modell `restricted/grundriss.php`)
|
||||
löst `tools/kachelpositionen.php` ab: Kacheln werden gezogen, ein Raum ohne
|
||||
Kachel mit „Platzieren“ und einem Klick gesetzt. Übernommen sind Raster,
|
||||
Geisterkachel und die Umrechnung von der gezeigten Mitte zur gespeicherten
|
||||
linken oberen Ecke. Jede Aktion antwortet mit dem ganzen neuen Stand. Nach
|
||||
einer Änderung laden „Home-Kacheln“ und „Geräte“ beim nächsten Öffnen nach.
|
||||
|
||||
Ohne Etage mit platziertem Raum zeigt die Startseite statt der Zeichenfläche
|
||||
den Weg zu den Einstellungen — der Anfang jeder neuen Installation.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
DB[("homeMesh.floors<br/>homeMesh.rooms")] --> GR["grundriss()<br/><small>rooms.php</small>"]
|
||||
GR --> MIT["mitWerten()"]
|
||||
MIT --> RWT["roomsWithTile()"]
|
||||
RWT -->|"als JSON in die Seite"| HOMEJS["homeMQTT.js<br/><small>homeRooms</small>"]
|
||||
RWT --> SVG["home.php<br/><small>SVG-Kacheln</small>"]
|
||||
TT["tileTopics()"] -->|"abonniert genau diese"| BROKER{{Broker}}
|
||||
BROKER --> HOMEJS
|
||||
HOMEJS -->|"kachelWert()"| SVG
|
||||
|
||||
GRJS["grundriss.js<br/><small>Reiter Grundriss</small>"] --> SETA["ajax/settings.php"]
|
||||
SETJS["settings.js<br/><small>Reiter Kacheln</small>"] --> SETA
|
||||
SETA --> GRM["grundriss.php<br/><small>Etagen, Räume, Bilder</small>"] --> DB
|
||||
SETA --> KACHM["kacheln.php<br/><small>Werte prüfen, speichern</small>"] --> DB
|
||||
KACHM -->|"Auswahlliste"| KAT["deviceCatalog()<br/><small>automations.php</small>"]
|
||||
```
|
||||
|
||||
Eine Wertdefinition ist ein kleines JSON-Objekt, überall in derselben Form:
|
||||
|
||||
```json
|
||||
{ "topic": "Power_UG/status/em:0", "pfad": "total_act_power",
|
||||
"einheit": "W", "stellen": 0, "negativ": true,
|
||||
"icon": "box-arrow-up", "gross": true }
|
||||
```
|
||||
|
||||
`wechselrichter` + `feld` statt `topic` summiert mehrere Wechselrichter;
|
||||
`format: "leistung"` skaliert selbst zwischen W und kW (`"leistung_kw"`, wenn
|
||||
die Quelle schon Kilowatt schickt); `negativ` dreht das
|
||||
Vorzeichen (für einen Zähler, der verkehrt herum eingebaut ist); `icon` ist
|
||||
der Name **irgendeines** Bootstrap-Icons — die Codepunkte liest
|
||||
`bootstrapIcons()` aus `css/bootstrap-icons.min.css`, gezeichnet wird als
|
||||
SVG-Text in der Icon-Schrift.
|
||||
|
||||
|
||||
### 2. Das Raum-Modal
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant R as ajax/room.php
|
||||
participant RC as roomControls.php
|
||||
participant C as commands.php
|
||||
participant G as Gerät
|
||||
|
||||
B->>R: GET ?room=12 (rooms.id)
|
||||
R->>RC: Geräte des Raums (actors.room_id) → Bedienformen
|
||||
RC-->>B: fertiges Markup (Rollladen, Licht, Messwerte …)
|
||||
B->>R: POST ?action=command {command_id, params}
|
||||
R->>C: executeCommand()
|
||||
alt mqtt://
|
||||
C-->>G: Nutzlast auf das Topic
|
||||
else wled://
|
||||
C-->>G: JSON an /json/state
|
||||
else http://
|
||||
C-->>G: Abfrageargumente an die Geräte-URL
|
||||
else Tahoma
|
||||
C-->>G: exec/apply an die Box
|
||||
end
|
||||
```
|
||||
|
||||
`roomControls.php` entscheidet **an den Fähigkeiten**, nicht am Typnamen: Hat
|
||||
das Gerät ein `setClosure`, wird es ein Rollladen mit Auf/Stop/Zu und
|
||||
Höhenstufen, mit `my` kommt der my-Knopf dazu, mit Neigung die Lamellenstufen;
|
||||
hat es drei Parameter rot/grün/blau, wird es eine Lampe mit Farbfeldern. Ein neu gefundenes Gerät bekommt so ohne Codeänderung ein
|
||||
passendes Bedienelement.
|
||||
|
||||
`commands.php` ist das Gegenstück zu `transports.py` im SolarManager-Repo —
|
||||
dieselbe Zuordnung „welche URL gehört zu welchem Weg“, einmal für den Runner,
|
||||
einmal für den Browser.
|
||||
|
||||
### 3. Die Automatiken
|
||||
|
||||
Editor (`autoActionFuncs.js` + `AutoAction.php` + `automations.php`) schreibt
|
||||
das Regelwerk nach `homeMesh`; ausgeführt wird es von
|
||||
`autoActions/autoaction_runner.py` im anderen Repo. **Die Weboberfläche
|
||||
schaltet nichts von sich aus** — sie beschreibt nur, was gelten soll.
|
||||
|
||||
**Drei Bänder statt eines Akkordeons: Wenn · Wann · Dann.** Das Akkordeon
|
||||
schloss beim Öffnen die anderen Fächer — Auslöser und Aktion waren also nie
|
||||
gleichzeitig zu sehen, ausgerechnet bei einer Regel, die genau daraus
|
||||
besteht. Und das mittlere Fach hieß „Bedingungen", enthielt aber die
|
||||
*Rahmen*bedingungen. Gebaut aus `.card` mit farbigem Rand und `.card-header`,
|
||||
kein eigenes Bauteil.
|
||||
|
||||
**Der Rahmen steht zusammengefaltet da**, solange nichts von der Vorgabe
|
||||
abweicht — und das ist der Normalfall. `rahmenKurzText()` schreibt ihn in
|
||||
eine Zeile („täglich · rund um die Uhr"), `rahmenIstVorgabe()` entscheidet,
|
||||
ob sie matt oder hell ist. Vorher nahm er die meiste Fläche für den
|
||||
seltensten Inhalt.
|
||||
|
||||
**Neben jeder Bedingung läuft ihr aktueller Wert mit.** `?action=werte`
|
||||
liefert alle acht Sekunden `{state_id: {value, unit}}` aus `actor_states` —
|
||||
bewusst aus der Tabelle und nicht über MQTT, weil dort nur ein Teil der
|
||||
Geräte auftaucht (die Jalousien hängen an der Tahoma-Box, die gerechneten
|
||||
Werte an gar nichts); der Runner schreibt dagegen jeden Messwert zurück.
|
||||
Das ersetzt den Satz, der früher am Ende stand und die Regel Wort für Wort
|
||||
nacherzählte: statt zu wiederholen, was darüber steht, beantwortet die Zeile
|
||||
die Frage, die man wirklich hat — warum läuft die Automatik gerade nicht?
|
||||
|
||||
Ein voller Punkt heißt erfüllt, ein leerer noch nicht, ein **gestrichelter
|
||||
heißt „entscheidet der Runner"**. Bei `time`, `date`, `datetime`,
|
||||
`deltatime` und `elapsed` fällt der Browser bewusst kein Urteil: ob
|
||||
„Uhrzeit um 07:30" gerade zutrifft, hängt am Nachholfenster, am Tagesrand
|
||||
und beim Datentyp `elapsed` am echten Abstand seit der letzten Auslösung —
|
||||
das alles steht in `bedingung_erfuellt()` im Runner. Es hier nachzubauen
|
||||
hieße, eine zweite Wahrheit zu pflegen, die irgendwann 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, Wetterstation, Logic); die
|
||||
Datenbanklogik liegt allein im Hauptskript.
|
||||
|
||||
Drei Eigenschaften, die man kennen muss:
|
||||
|
||||
* **Ein Suchlauf löscht nichts.** `clear_tables` steht in `config.ini` auf
|
||||
`false`, weil die Automatiken auf die Ids von `actor_states` zeigen.
|
||||
Bestehende Zeilen werden über den Schlüssel `(actor_id, state_name)`
|
||||
aktualisiert, neue kommen dazu. Abgebaute Geräte verschwinden nur über
|
||||
**Einstellungen → Geräte → Papierkorb** (und auch dort nur, wenn keine
|
||||
Automatik mehr auf sie zeigt).
|
||||
* **Shelly, zwei Wege — und zwar je Komponente.** Alte Shellys können kein
|
||||
MQTT und bleiben ganz beim HTTP-Weg: in `actor_states.url` steht ein
|
||||
Feldname der JSON-Antwort. Bei Gen2-Geräten mit eingeschaltetem `status_ntf`
|
||||
steht dagegen das Topic in `url` (`Power_EG/status/em:0`) und der Schlüssel
|
||||
in `value_path`.
|
||||
|
||||
Das gilt aber nur für Komponenten, die auch wirklich senden — die Liste
|
||||
steht als `MQTT_KOMPONENTEN` in `modules/shelly_module.py` und enthält
|
||||
heute `em` und `switch`. Der Pro 3EM etwa hat eine Temperaturkomponente im
|
||||
RPC-Status, veröffentlicht sie aber nie; ein Messwert auf diesem Topic
|
||||
bliebe für immer auf seinem letzten Wert stehen. Alles außerhalb der Liste
|
||||
wird deshalb weiter abgefragt. Wer eine Komponente ergänzen will, hört
|
||||
vorher mit (`mosquitto_sub -t '<präfix>/#' -v`).
|
||||
|
||||
Geschaltet werden auch die neuen Geräte über HTTP. Der Runner teilt seine
|
||||
Messwerte deshalb je Wert zu, nicht je Gerät.
|
||||
* **Vier Module suchen gar nichts.** Gartenwasser, SolarManager, Wetterstation
|
||||
und Logic schreiben feste Geräte hin, weil sich diese Quellen nicht von
|
||||
selbst melden. Wer einen Messwert vermisst, trägt im jeweiligen Modul eine
|
||||
Zeile ein — bei der Wetterstation in `MESSWERTE`, beim SolarManager in
|
||||
`GERAETE`. Bewusst knapp gehalten: `solarManager/#` allein trägt gut 130
|
||||
Topics, und eine vollständige Liste machte die Auswahl im Automatik-Editor
|
||||
unbrauchbar.
|
||||
|
||||
Die **Wetterstation** hängt am Websocket von `192.168.179.42`;
|
||||
`wsMQTTbridge.py` im SolarManager legt ihre Felder unter `weatherStation/#`
|
||||
ab. Von dort kommen Außentemperatur, Wind und Böe — die Außentemperatur ist
|
||||
für den Hitzeschutz die bessere Bedingung als die Raumtemperatur (ist es
|
||||
drinnen schon warm, ist es zum Verschatten zu spät), und ohne den Wind gibt
|
||||
es für Markise und Sonnensegel keinen Sturmschutz. Die trägen Werte
|
||||
(Temperatur, Feuchte, Druck, Taupunkt) gehen nur bei jeder einundzwanzigsten
|
||||
Nachricht raus; sie sind retained, für eine Bedingung macht das keinen
|
||||
Unterschied.
|
||||
|
||||
---
|
||||
|
||||
## Wiederkehrende Bauteile und Konventionen
|
||||
|
||||
**Zwei Radien, eine Anzeigeschrift.** `solar.css` tauscht die Palette über
|
||||
Variablen aus, statt Regel für Regel zu überschreiben — dasselbe gilt für
|
||||
Form und Schrift. `--ton-radius` (`.875rem`) gilt für Flächen, `--ton-radius-klein`
|
||||
(`.5rem`) für alles, was in einer Reihe steht; beide hängen an
|
||||
`--bs-border-radius` und `--bs-border-radius-sm`, also zieht jede Karte,
|
||||
jedes Modal und jeder kleine Knopf von allein mit. `--font-anzeige` ist
|
||||
Poppins — die Schrift lag seit jeher unter `assets/fonts` und trug nur die
|
||||
vier Etagenknöpfe im Außenplan; jetzt trägt sie alle Überschriften,
|
||||
`.card-title` und `.modal-title`. Fließtext und Zahlenkolonnen bleiben
|
||||
bewusst bei der Schrift des Themes: Poppins bringt keine Tabellenziffern
|
||||
mit, Messwerte würden sonst springen.
|
||||
|
||||
**Drei Klassen aus dem Automatik-Editor stehen absichtlich in `solar.css`
|
||||
und nicht dort.** `.feld-als-text` ist ein Auswahlfeld, das wie Text
|
||||
aussieht, bis man es anfährt — dieselbe Not haben das Raum-Modal und die
|
||||
Kachel-Einstellungen, wo ebenfalls jede Kleinigkeit in einem Kasten steht.
|
||||
`.punkt` ist an oder aus. `.wert-jetzt` ist die eine echte Ausnahme —
|
||||
Bedingungen gibt es sonst nirgends — und als einzelne Klasse auch als solche
|
||||
erkennbar.
|
||||
|
||||
|
||||
**Reiter.** Es gibt genau eine Bauart, `restricted/reiter.php`. Umgeschaltet
|
||||
wird von Bootstrap (`data-bs-toggle="tab"`), gemerkt wird der offene Reiter von
|
||||
`reiterMerken()` in `common.js`. Weniger als zwei Reiter ergeben keine Leiste.
|
||||
|
||||
**Stylesheet-Reihenfolge — die häufigste Falle.** `header.php` lädt
|
||||
`css/solar.css` **vor** `css/adminlte.min.css`. Eine eigene Regel mit gleicher
|
||||
Spezifität wie eine AdminLTE-Regel verliert deshalb. Wenn eine Änderung
|
||||
„nicht wirkt“, ist das fast immer der Grund: Selektor verlängern
|
||||
(`.nav-tabs.reiter .nav-link` statt `.reiter .nav-link`), nicht `!important`.
|
||||
|
||||
**Zwischenspeicher.** Jedes eigene Skript und `solar.css` werden mit
|
||||
`?v=<filemtime>` eingebunden — gespeichert heißt geladen.
|
||||
|
||||
**Sprache.** Bezeichner und Kommentare sind deutsch, wo es um die Sache geht;
|
||||
englisch bleibt, was von außen vorgegeben ist (`floor`, `topic`, Chart.js-
|
||||
Optionen, Spaltennamen). Kommentare erklären **warum**, nicht was.
|
||||
|
||||
**Fehlertoleranz vor Vollständigkeit.** Was eine Seite nicht zwingend braucht,
|
||||
wird in `try/catch` geholt: fehlt die Mesh-Datenbank oder der Grundriss, zeigt
|
||||
die Startseite einen Hinweis statt eines Fatal Errors.
|
||||
|
||||
**Zugriffsschutz.** `.htaccess` sperrt Punkt-Dateien (allen voran `.git`) und
|
||||
alle Dateitypen, die der Browser nie anfordert (`.py`, `.sql`, `.ini`, `.md`
|
||||
…). `restricted/mysql.php` steht in `.gitignore`; Vorlage ist
|
||||
`mysql.php.example`. Zugangsdaten gehören nirgendwo sonst hin.
|
||||
|
||||
**Zeilenenden.** `.gitattributes` erzwingt LF — der Ordner wird von Linux
|
||||
ausgeliefert.
|
||||
|
||||
---
|
||||
|
||||
## Wo fange ich an, wenn ich … ändern will
|
||||
|
||||
| Vorhaben | Anlaufstelle |
|
||||
|---|---|
|
||||
| Ein Raum oder eine Etage kommt dazu, heißt anders, bekommt ein neues Grundrissbild | Einstellungen → Grundriss (keine Codeänderung) — Kachel, Menü und MQTT-Abo ergeben sich daraus |
|
||||
| Andere Werte auf einer Kachel | Einstellungen → Home-Kacheln (keine Codeänderung). Vorgabe ohne eigene Werte: `kachelVorgabe()` in `rooms.php` |
|
||||
| Ein anderes Haus einrichten | `homeMesh_DB-layout.sql`, `homeMesh_automations.sql`, `homeMesh_grundriss.sql`, `homeMesh_energiefluss.sql` in dieser Reihenfolge; dann Einstellungen → Grundriss und → Übersicht |
|
||||
| Kreis der Energiefluss-Übersicht aus, umbenennen, verschieben; PV-Flächen | Einstellungen → Übersicht (keine Codeänderung) |
|
||||
| Neuer Kreis in der Energiefluss-Übersicht (Gerät, Etage, Quelle) | ein Eintrag in `js/solar/energieflussKatalog.js`; neues Symbol dazu in `SYMBOLE` (`energiefluss.js`) |
|
||||
| Auswahlliste irgendwo | ein gewöhnliches `<select>` schreiben — `js/solar/auswahl.js` macht daraus die eigene Liste. Keine zweite Variante bauen |
|
||||
| Neues Symbol auf einer Kachel | Name eines Bootstrap-Icons ins Feld tippen. Vorschlagsliste: `kachelSymbole()` in `kacheln.php` |
|
||||
| Symbol für eine Gerätegruppe (Raum-Modal, Zeitleiste, Editor) | ein Bootstrap-Icon in `raumGruppenTitel()` (`roomControls.php`), `zeitleisteBahnen()` (`zeitleiste.php`) und `GERAETE_SYMBOL` (`autoActionFuncs.js`) — überall dasselbe. Fehlt es in der Icon-Schrift, eine eigene Maske in `css/solar.css` anlegen (Vorbild: `bi-beschattung`) |
|
||||
| Neue Kennzahl in der Jahresstatistik | `KENNZAHLEN` **und** `kennzahlenAus()` in `ajax/getStats.php` — sonst nichts |
|
||||
| Neues Diagramm auf einer Seite | Karte in der Vorlage, `<canvas>`, dazu ein Endpunkt nach dem Muster von `getProdData.php`, geladen mit `getData()` |
|
||||
| Neue Seite | Zeile in `$pages` (`index.php`), Vorlage unter `restricted/`, Skript unter `js/solar/` |
|
||||
| Anderes Bedienelement für ein Gerät | `bedienform()` in `roomControls.php` (nach Fähigkeit, nicht nach Typname) |
|
||||
| Neue Geräteart erkennen | neues Modul unter `restricted/deviceDiscovery/modules/` + Eintrag in der Liste in `device_discovery.py` |
|
||||
| Neuer Geräteweg zum Schalten | `commands.php` (Browser) **und** `transports.py` im SolarManager-Repo (Runner) |
|
||||
| Aussehen der ganzen Seite | `css/solar.css`, oberster Abschnitt: die `--ton-*`-Tokens und die Bootstrap-Zuordnung |
|
||||
| Reiter irgendwo | `reiterLeiste()`/`reiterBlock()` aus `restricted/reiter.php` benutzen, keine eigene Variante bauen. Ausnahme: das Raum-Modal hat Reiter-Kacheln mit Zustand (`zeichneGruppen()`), passend zu den anderen Bedienfenstern |
|
||||
| Neue Einstellung | Reiter in `$reiter` (`restricted/settings.php`), Abschnitt in `settings.js`, Aktion in `ajax/settings.php`, Modellfunktion daneben |
|
||||
|
||||
---
|
||||
|
||||
## Entwickeln und Prüfen
|
||||
|
||||
Der Ordner ist die laufende Anwendung — es gibt keinen Build und keine
|
||||
Staging-Stufe. Entsprechend gilt:
|
||||
|
||||
```bash
|
||||
# PHP-Syntax (auf der NAS; /usr/bin/php ist 8.1 ohne mysqli)
|
||||
/usr/local/bin/php82 -l restricted/kacheln.php
|
||||
|
||||
# JavaScript-Syntax
|
||||
/var/packages/Node.js_v20/target/usr/local/bin/node --check js/solar/settings.js
|
||||
|
||||
# Modellfunktion einzeln ausprobieren, ohne Browser
|
||||
/usr/local/bin/php82 -r 'require "restricted/automations.php"; print_r(geraeteListe()[0]);'
|
||||
|
||||
# Datenbank ansehen (readDB, nur SELECT)
|
||||
/usr/local/mariadb10/bin/mysql -h 127.0.0.1 -e 'SELECT * FROM homeMesh.actors LIMIT 5;'
|
||||
```
|
||||
|
||||
Für Masken lohnt eine **Probeseite**: eine kleine HTML-Datei, die `solar.css`
|
||||
und das Skript lädt und `window.fetch` durch eine Attrappe mit echten Daten
|
||||
ersetzt. Damit lässt sich das Zeichnen prüfen, ohne angemeldet zu sein und
|
||||
ohne etwas zu speichern.
|
||||
|
||||
**Die Einführung** (Menü → Einführung) sind Folien für Einsteiger in zwei
|
||||
Teilen: Bedienen im Alltag, Einrichten und Verwalten. Die Texte stehen in
|
||||
`einfuehrungFolien()` in `restricted/einfuehrung.php`, die Bilder sind echte
|
||||
Aufnahmen unter `assets/img/einfuehrung`. Ändert sich die Oberfläche, nimmt
|
||||
`tools/einfuehrung/aufnehmen.ps1` sie auf dem Arbeitsrechner neu auf — Edge
|
||||
ohne Fenster über das DevTools-Protokoll, mit echten Wartezeiten für die
|
||||
Livewerte, Klicks und Scrollen laut `plan.json`:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File tools\einfuehrung\aufnehmen.ps1 -Plan tools\einfuehrung\plan.json -Ausgabe assets\img\einfuehrung
|
||||
```
|
||||
|
||||
Zu jedem Bild schreibt es eine `.json` mit der Lage der markierten Elemente;
|
||||
daraus setzt die Seite ihre nummerierten Punkte, sie wandern also mit. Ort und
|
||||
Koordinaten im Wetterkopf sowie die Endungen der Fahrzeugschlüssel deckt der
|
||||
Plan vor der Aufnahme ab. Die Seite muss dafür im Heimnetz erreichbar sein.
|
||||
|
||||
Zwei Repositories, beide auf Gitea (`gitea.nas.el-wa.org`):
|
||||
|
||||
| Repo | Ort | Inhalt |
|
||||
|---|---|---|
|
||||
| `admin/Smart-Dashboard` | `/volume1/web/smart` | dieses hier |
|
||||
| `admin/SolarManager` | `/volume1/homes/wagner/SolarManager` | Sammler, Brücken, Automatik-Runner |
|
||||
|
||||
Rekursive Suchen (`grep -r`) laufen über die SMB-Freigabe in Zeitüberschreitungen —
|
||||
auf der NAS selbst sind sie sofort fertig.
|
||||
@@ -1,16 +1,16 @@
|
||||
<?php
|
||||
require_once("restricted/mysql.php");
|
||||
require_once("helper.php");
|
||||
$mysql = new mysqli($mysql_server,$mysql_user,$mysql_pass,$mysql_db);
|
||||
if(!mysqli_query($mysql,"DELETE FROM addUser WHERE datetime < DATE_SUB(NOW(), INTERVAL 1 MINUTE);")){
|
||||
echo mysqli_error($mysql);
|
||||
}
|
||||
if(isLocal()){
|
||||
$accessKey = strval(random_int(0,99999999));
|
||||
if(!mysqli_query($mysql,"INSERT INTO addUser SET accesskey=".$accessKey.", datetime=NOW();")){
|
||||
echo mysqli_error($mysql);
|
||||
}
|
||||
header('Location: https://nas.el-wa.org/smart?addUser='.$accessKey);
|
||||
echo 'https://nas.el-wa.org/smart?addUser='.$accessKey;
|
||||
}
|
||||
<?php
|
||||
require_once("restricted/mysql.php");
|
||||
require_once("helper.php");
|
||||
$mysql = new mysqli($mysql_server,$mysql_user,$mysql_pass,$mysql_db);
|
||||
if(!mysqli_query($mysql,"DELETE FROM addUser WHERE datetime < DATE_SUB(NOW(), INTERVAL 1 MINUTE);")){
|
||||
echo mysqli_error($mysql);
|
||||
}
|
||||
if(isLocal()){
|
||||
$accessKey = strval(random_int(0,99999999));
|
||||
if(!mysqli_query($mysql,"INSERT INTO addUser SET accesskey=".$accessKey.", datetime=NOW();")){
|
||||
echo mysqli_error($mysql);
|
||||
}
|
||||
header('Location: https://nas.el-wa.org/smart?addUser='.$accessKey);
|
||||
echo 'https://nas.el-wa.org/smart?addUser='.$accessKey;
|
||||
}
|
||||
?>
|
||||
@@ -9,11 +9,11 @@
|
||||
* GET ?action=editor&id=N Editor fuer eine vorhandene Automatik
|
||||
* GET ?action=editor&floor=OG Editor fuer eine neue Automatik
|
||||
* GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen"
|
||||
* GET ?action=rooms Zuordnung Geraet -> Raum zum Bearbeiten
|
||||
* POST ?action=save JSON-Rumpf, legt an oder ueberschreibt
|
||||
* POST ?action=delete {"id": N}
|
||||
* GET ?action=followers&id=N JSON: wer haengt an dieser Automatik?
|
||||
* GET ?action=werte&ids=1,2,3 JSON: was diese Messwerte gerade sagen
|
||||
* POST ?action=delete {"id": N, "mode": "abhaengen"|"mitloeschen"}
|
||||
* POST ?action=toggle {"id": N} - pausieren / fortsetzen
|
||||
* POST ?action=saveRooms {"<actor_id>": "OG/Bad", ...}
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
@@ -33,7 +33,7 @@ function jsonAntwort($data, $code = 200)
|
||||
}
|
||||
|
||||
$action = $_GET["action"] ?? "editor";
|
||||
$floor = in_array($_GET["floor"] ?? "", ["UG", "EG", "OG"], true) ? $_GET["floor"] : "";
|
||||
$floor = in_array($_GET["floor"] ?? "", $floors, true) ? $_GET["floor"] : "";
|
||||
|
||||
// --- Schreibende Aufrufe -------------------------------------------------
|
||||
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
@@ -47,12 +47,15 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
$id = saveAutomation($body);
|
||||
jsonAntwort(["id" => $id]);
|
||||
case "delete":
|
||||
deleteAutomation(intval($body["id"] ?? 0));
|
||||
// Der Modus kommt aus der Rueckfrage im Browser. Unbekanntes
|
||||
// faellt auf das schonendere "abhaengen" zurueck - lieber eine
|
||||
// pausierte Automatik zu viel als eine geloeschte zu viel.
|
||||
$modus = ($body["mode"] ?? "") === "mitloeschen"
|
||||
? "mitloeschen" : "abhaengen";
|
||||
deleteAutomation(intval($body["id"] ?? 0), $modus);
|
||||
jsonAntwort(["ok" => true]);
|
||||
case "toggle":
|
||||
jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]);
|
||||
case "saveRooms":
|
||||
jsonAntwort(["gespeichert" => saveRooms($body["rooms"] ?? [])]);
|
||||
default:
|
||||
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||
}
|
||||
@@ -63,6 +66,34 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wer haengt an dieser Automatik? -------------------------------------
|
||||
// Wird vor dem Loeschen gefragt, damit die Rueckfrage im Browser die Namen
|
||||
// nennen kann und nicht nur eine Zahl.
|
||||
if ($action === "followers") {
|
||||
jsonAntwort(["followers" => automationFollowers(intval($_GET["id"] ?? 0))]);
|
||||
}
|
||||
|
||||
// --- Was sagen diese Messwerte gerade? -----------------------------------
|
||||
// Der offene Editor fragt das im Takt nach, damit neben jeder Bedingung der
|
||||
// laufende Wert steht. Absichtlich ein eigener, winziger Endpunkt: den
|
||||
// ganzen Geraetekatalog neu zu holen waeren je Runde ueber hundert
|
||||
// Kilobyte fuer ein paar Zahlen.
|
||||
if ($action === "werte") {
|
||||
$ids = array_slice(explode(",", $_GET["ids"] ?? ""), 0, 200);
|
||||
jsonAntwort(["werte" => stateValues($ids)]);
|
||||
}
|
||||
|
||||
// --- Zeitleiste ----------------------------------------------------------
|
||||
// Alle Automatiken eines Tages mit ihrer Lage, siehe restricted/zeitleiste.php.
|
||||
if ($action === "zeitleiste") {
|
||||
require_once("../restricted/zeitleiste.php");
|
||||
try {
|
||||
jsonAntwort(zeitleiste(strval($_GET["datum"] ?? date("Y-m-d"))));
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => "Zeitleiste nicht abrufbar: " . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Uebersichtstabelle --------------------------------------------------
|
||||
if ($action === "list") {
|
||||
$tage = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
@@ -78,7 +109,77 @@ if ($action === "list") {
|
||||
. "<th class='d-none d-sm-table-cell'>Aktion</th>"
|
||||
. "<th class='d-none d-md-table-cell'>Wann</th>"
|
||||
. "<th class='d-none d-md-table-cell'>Zuletzt</th><th></th></tr></thead><tbody>";
|
||||
|
||||
/*
|
||||
* Eine Automatik, die von einer anderen ausgeloest wird, steht unter
|
||||
* ihr statt alphabetisch irgendwo dazwischen - sonst liest man
|
||||
* "Rollladen Magdalena morgens" und sieht nicht, woran es haengt.
|
||||
*
|
||||
* Eingeklappt, weil die Uebersicht sonst mit jeder Verkettung laenger
|
||||
* wird, ohne mehr zu sagen: der Ausloeser ist das, was man sucht. Der
|
||||
* Zaehler am Pfeil verraet, dass da noch etwas ist.
|
||||
*
|
||||
* Gruppiert wird nur die Darstellung. Jeder Nachfolger bleibt eine
|
||||
* vollwertige Automatik mit eigener Pause, eigenen Rahmenbedingungen
|
||||
* und eigener Zeile im Protokoll - sonst waere zu klaeren, wem die
|
||||
* Pause gehoert und was das Protokoll zeigt.
|
||||
*/
|
||||
$nachFolgern = [];
|
||||
foreach ($list as $a) {
|
||||
foreach ($a["triggers"] as $davor) {
|
||||
$nachFolgern[intval($davor)][] = intval($a["id"]);
|
||||
}
|
||||
}
|
||||
$vorhanden = [];
|
||||
foreach ($list as $a) {
|
||||
$vorhanden[intval($a["id"])] = $a;
|
||||
}
|
||||
// Wessen Ausloeser nicht in dieser Etage steht, faengt selbst oben an.
|
||||
$wurzeln = [];
|
||||
foreach ($list as $a) {
|
||||
$hatVater = false;
|
||||
foreach ($a["triggers"] as $davor) {
|
||||
if (isset($vorhanden[intval($davor)])) {
|
||||
$hatVater = true;
|
||||
}
|
||||
}
|
||||
if (!$hatVater) {
|
||||
$wurzeln[] = intval($a["id"]);
|
||||
}
|
||||
}
|
||||
$reihe = [];
|
||||
$einordnen = function ($id, $tiefe, $vater) use (&$einordnen, &$reihe, $nachFolgern, $vorhanden) {
|
||||
if (!isset($vorhanden[$id]) || isset($reihe[$id])) {
|
||||
return;
|
||||
}
|
||||
$reihe[$id] = ["auto" => $vorhanden[$id], "tiefe" => $tiefe,
|
||||
"vater" => $vater, "kinder" => 0];
|
||||
foreach ($nachFolgern[$id] ?? [] as $kind) {
|
||||
$einordnen($kind, $tiefe + 1, $id);
|
||||
}
|
||||
};
|
||||
foreach ($wurzeln as $id) {
|
||||
$einordnen($id, 0, 0);
|
||||
}
|
||||
// Was durch einen Kreis nicht erreicht wurde, kommt trotzdem mit.
|
||||
foreach ($list as $a) {
|
||||
$einordnen(intval($a["id"]), 0, 0);
|
||||
}
|
||||
// Gezaehlt wird erst jetzt, und nur was wirklich eingeordnet wurde:
|
||||
// wer zwei Ausloeser hat, steht nur unter dem ersten. Aus der rohen
|
||||
// Nachfolgerliste gezaehlt verspraeche der zweite Pfeil eine Zeile,
|
||||
// die er nicht aufklappen kann.
|
||||
foreach ($reihe as $eintrag) {
|
||||
if ($eintrag["vater"]) {
|
||||
$reihe[$eintrag["vater"]]["kinder"]++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($reihe as $eintrag) {
|
||||
$a = $eintrag["auto"];
|
||||
$tiefe = $eintrag["tiefe"];
|
||||
$kinder = $eintrag["kinder"];
|
||||
$vater = $eintrag["vater"];
|
||||
// Nur die aktiven Tage nennen. Sieben Haekchen untereinander
|
||||
// haben die Zeile frueher unnoetig hoch gemacht.
|
||||
$aktiveTage = [];
|
||||
@@ -87,16 +188,52 @@ if ($action === "list") {
|
||||
$aktiveTage[] = $tage[$i];
|
||||
}
|
||||
}
|
||||
$wann = count($aktiveTage) === 7 ? "täglich" : implode(" ", $aktiveTage);
|
||||
// Kalendertage auf "immer" gehoeren zur Tagesangabe, nicht in
|
||||
// die Nachbemerkung: sie fuegen Tage hinzu. Ohne angehakten
|
||||
// Wochentag tragen sie die Automatik sogar allein - "nur
|
||||
// Feiertage" ist damit eine gueltige Einstellung.
|
||||
$dazu = [];
|
||||
if ($a["on_vacation"] == 2) {
|
||||
$dazu[] = "Ferientage";
|
||||
}
|
||||
if ($a["on_holiday"] == 2) {
|
||||
$dazu[] = "Feiertage";
|
||||
}
|
||||
if (count($aktiveTage) === 7) {
|
||||
$wann = "täglich";
|
||||
} elseif ($aktiveTage) {
|
||||
$wann = implode(" ", $aktiveTage);
|
||||
if ($dazu) {
|
||||
$wann .= " + " . implode(" + ", $dazu);
|
||||
}
|
||||
} elseif ($dazu) {
|
||||
$wann = "nur " . implode(" + ", $dazu);
|
||||
} else {
|
||||
$wann = "nie – kein Tag ausgewählt";
|
||||
}
|
||||
// Vorabend: alle Tagesangaben meinen morgen. Vorne, damit auch
|
||||
// "nicht an Feiertagen" weiter hinten als Folgetag gelesen wird.
|
||||
if ($a["next_day"]) {
|
||||
$wann = "Vorabend von " . $wann;
|
||||
}
|
||||
if ($a["window_from"] !== "00:00" || $a["window_to"] !== "23:59") {
|
||||
$wann .= ", " . $a["window_from"] . "–" . $a["window_to"];
|
||||
}
|
||||
if (!$a["on_holiday"]) {
|
||||
if ($a["on_holiday"] == 0) {
|
||||
$wann .= ", nicht an Feiertagen";
|
||||
}
|
||||
if (!$a["on_vacation"]) {
|
||||
if ($a["on_vacation"] == 0) {
|
||||
$wann .= ", nicht in den Ferien";
|
||||
}
|
||||
if ($a["once_per_day"]) {
|
||||
$wann .= ", nur einmal am Tag";
|
||||
}
|
||||
// Vor der Sperre, weil es auch zeitlich davor liegt: erst muss
|
||||
// die Bedingung stehen bleiben, dann wird ausgeloest.
|
||||
if (!empty($a["hold_secs"])) {
|
||||
$wann .= ", erst nach "
|
||||
. haltezeitKurz($a["hold_secs"]) . " ununterbrochen";
|
||||
}
|
||||
if ($a["lockout_secs"] > 0) {
|
||||
$wann .= ", frühestens alle "
|
||||
. ($a["lockout_secs"] >= 60
|
||||
@@ -105,13 +242,43 @@ if ($action === "list") {
|
||||
}
|
||||
|
||||
$zuletzt = $a["last_run"] ? date("d.m. H:i", strtotime($a["last_run"])) : "–";
|
||||
$klasse = $a["enabled"] ? "" : " class='opacity-50'";
|
||||
$pause = $a["enabled"] ? "bi-pause-fill" : "bi-play-fill";
|
||||
$titel = $a["enabled"] ? "Pausieren" : "Fortsetzen";
|
||||
$id = intval($a["id"]);
|
||||
|
||||
echo "<tr" . $klasse . ">";
|
||||
echo "<td>" . htmlspecialchars($a["name"]) . "</td>";
|
||||
$klassen = [];
|
||||
if (!$a["enabled"]) {
|
||||
$klassen[] = "opacity-50";
|
||||
}
|
||||
if ($tiefe > 0) {
|
||||
// Eingeklappt starten: die Zeile gehoert dem Ausloeser, und
|
||||
// der steht direkt darueber.
|
||||
$klassen[] = "auto-folge";
|
||||
$klassen[] = "auto-von-" . $vater;
|
||||
$klassen[] = "d-none";
|
||||
}
|
||||
$klasse = $klassen ? " class='" . implode(" ", $klassen) . "'" : "";
|
||||
|
||||
// Der Pfeil klappt die Nachfolger auf. Er steht nur da, wo es
|
||||
// welche gibt - ein Pfeil ohne Inhalt waere eine Einladung ins
|
||||
// Leere.
|
||||
$griff = "";
|
||||
if ($kinder > 0) {
|
||||
$griff = "<button type='button' class='btn btn-link btn-sm p-0 me-1 text-decoration-none'"
|
||||
. " title='Abhängige Automatiken zeigen'"
|
||||
. " data-gruppe='" . $id . "'"
|
||||
. " onclick='toggleAutomationGroup(" . $id . ", this)'>"
|
||||
. "<i class='bi bi-chevron-right'></i> " . $kinder . "</button>";
|
||||
}
|
||||
$einzug = $tiefe > 0
|
||||
? "<span class='text-body-secondary me-1'>"
|
||||
. str_repeat(" ", $tiefe)
|
||||
. "<i class='bi bi-arrow-return-right'></i></span> "
|
||||
: "";
|
||||
|
||||
echo "<tr data-auto='" . $id . "'" . $klasse . ">";
|
||||
echo "<td class='text-nowrap'>" . $einzug . $griff
|
||||
. htmlspecialchars($a["name"]) . "</td>";
|
||||
echo "<td>" . htmlspecialchars($a["conditionText"]) . "</td>";
|
||||
echo "<td class='d-none d-sm-table-cell'>" . htmlspecialchars($a["actionText"]) . "</td>";
|
||||
echo "<td class='text-nowrap d-none d-md-table-cell'>" . htmlspecialchars($wann) . "</td>";
|
||||
@@ -130,73 +297,6 @@ if ($action === "list") {
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Geraete den Raeumen zuordnen ---------------------------------------
|
||||
if ($action === "rooms") {
|
||||
$labels = geraeteBeschriftungen();
|
||||
$raeume = geraeteRaeume();
|
||||
$vorschlag = raumVorschlaege();
|
||||
$auswahl = raumListe();
|
||||
|
||||
$res = meshDb()->query("SELECT id, type FROM actors");
|
||||
$geraete = [];
|
||||
while ($row = $res->fetch_assoc()) {
|
||||
$id = intval($row["id"]);
|
||||
$geraete[] = [
|
||||
"id" => $id,
|
||||
"label" => $labels[$id] ?? "?",
|
||||
"type" => $row["type"],
|
||||
"room" => $raeume[$id] ?? null,
|
||||
"tipp" => $vorschlag[$id] ?? null,
|
||||
];
|
||||
}
|
||||
// Unzugeordnete nach oben: das ist die Arbeit, die noch zu tun ist.
|
||||
usort($geraete, function ($a, $b) {
|
||||
$offen = (int)!empty($b["room"]) - (int)!empty($a["room"]);
|
||||
return $offen !== 0 ? $offen : strcoll($a["label"], $b["label"]);
|
||||
});
|
||||
|
||||
$offen = count(array_filter($geraete, function ($g) { return empty($g["room"]); }));
|
||||
$tipps = count(array_filter($geraete, function ($g) {
|
||||
return empty($g["room"]) && !empty($g["tipp"]);
|
||||
}));
|
||||
|
||||
echo "<div class='p-2'>";
|
||||
echo "<p class='small text-body-secondary'>Die Zuordnung ordnet die Geräteliste im Editor "
|
||||
. "nach Räumen statt nach Geräteart. Sie wird von Hand vergeben und bleibt bei "
|
||||
. "einem neuen Geräte-Suchlauf erhalten.</p>";
|
||||
if ($tipps) {
|
||||
echo "<div class='alert alert-secondary py-2 small d-flex justify-content-between align-items-center'>"
|
||||
. "<span>" . $tipps . " Gerät(e) verraten ihren Raum im MQTT-Topic.</span>"
|
||||
. "<button type='button' class='btn btn-outline-secondary btn-sm' id='btnRoomTipps'>"
|
||||
. "Vorschläge übernehmen</button></div>";
|
||||
}
|
||||
echo "<div class='table-responsive' style='max-height:60vh;overflow-y:auto'>";
|
||||
echo "<table class='table table-sm table-hover align-middle'><thead><tr>"
|
||||
. "<th>Gerät</th><th class='d-none d-sm-table-cell'>Art</th><th>Raum</th>"
|
||||
. "</tr></thead><tbody>";
|
||||
foreach ($geraete as $g) {
|
||||
echo "<tr" . (empty($g["room"]) ? " class='table-active'" : "") . ">";
|
||||
echo "<td>" . htmlspecialchars($g["label"]) . "</td>";
|
||||
echo "<td class='d-none d-sm-table-cell small text-body-secondary'>"
|
||||
. htmlspecialchars($g["type"]) . "</td>";
|
||||
echo "<td><select class='form-select form-select-sm' data-actor='" . $g["id"] . "'"
|
||||
. ($g["tipp"] ? " data-tipp='" . htmlspecialchars($g["tipp"], ENT_QUOTES) . "'" : "")
|
||||
. ">";
|
||||
echo "<option value=''" . (empty($g["room"]) ? " selected" : "") . ">–</option>";
|
||||
foreach ($auswahl as $raum) {
|
||||
echo "<option value='" . htmlspecialchars($raum["key"], ENT_QUOTES) . "'"
|
||||
. ($g["room"] === $raum["key"] ? " selected" : "") . ">"
|
||||
. htmlspecialchars($raum["label"]) . "</option>";
|
||||
}
|
||||
echo "</select></td></tr>";
|
||||
}
|
||||
echo "</tbody></table></div>";
|
||||
echo "<p class='small text-body-secondary mb-0'>" . $offen . " von " . count($geraete)
|
||||
. " Geräten noch ohne Raum.</p>";
|
||||
echo "</div>";
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Editor --------------------------------------------------------------
|
||||
$id = intval($_GET["id"] ?? 0);
|
||||
$auto = $id > 0 ? loadAutomation($id) : null;
|
||||
@@ -212,6 +312,9 @@ $nutzdaten = json_encode([
|
||||
"automation" => $auto,
|
||||
"devices" => deviceCatalog(),
|
||||
"lockouts" => lockoutChoices(),
|
||||
"ferien" => kalendertagChoices("Ferien", "in den Ferien"),
|
||||
"feiertage" => kalendertagChoices("Feiertage", "an Feiertagen"),
|
||||
"tagvorlagen" => tagVorlagen(),
|
||||
// Nur fuer die Reihenfolge der Raum-Gruppen: rooms.php gibt sie vor, und
|
||||
// Etage fuer Etage liest sich besser als alphabetisch.
|
||||
"rooms" => raumListe(),
|
||||
@@ -234,99 +337,184 @@ $nutzdaten = json_encode([
|
||||
die Reiter, damit der Rueckfall auf den ersten Eintrag dieselbe
|
||||
Etage trifft wie die Startseite. -->
|
||||
<select class="form-select" id="autoFloor">
|
||||
<option value="OG">OG</option>
|
||||
<option value="EG">EG</option>
|
||||
<option value="UG">UG</option>
|
||||
<?php foreach ($floors as $etage): ?>
|
||||
<option value="<?= $etage ?>" title="<?= htmlspecialchars(floorLabel($etage)) ?>"><?= $etage ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label for="autoFloor">Etage</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion" id="actionAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#collapseOne" aria-expanded="true">
|
||||
<i class="bi bi-lightning-fill"></i> Auslöser
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapseOne" class="accordion-collapse collapse show" data-bs-parent="#actionAccordion">
|
||||
<div class="accordion-body">
|
||||
<!-- Ein Block ist eine UND-Gruppe, zwischen den Bloecken steht ODER.
|
||||
Die Klammerung ist damit gezeichnet und nicht bloss vereinbart. -->
|
||||
<div id="condBlocks"></div>
|
||||
<button class="btn btn-outline-secondary btn-sm mt-2" type="button" id="btnAddGroup">
|
||||
<i class="bi bi-plus-lg"></i> Alternative (oder)
|
||||
</button>
|
||||
<div class="alert alert-secondary mt-3 mb-0 py-2 small" id="condSummary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--
|
||||
Drei Baender statt eines Akkordeons: Wenn - Wann - Dann.
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#collapseTwo" aria-expanded="false">
|
||||
<i class="bi bi-calendar-check-fill"></i> Bedingungen
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapseTwo" class="accordion-collapse collapse" data-bs-parent="#actionAccordion">
|
||||
<div class="accordion-body">
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Aktiver Zeitraum</span>
|
||||
<div class="form-floating">
|
||||
Das Akkordeon schloss beim Oeffnen die anderen Faecher, Ausloeser und
|
||||
Aktion waren also nie gleichzeitig zu sehen - ausgerechnet bei einer
|
||||
Regel, die genau daraus besteht. Und das mittlere Fach hiess
|
||||
"Bedingungen", enthielt aber die *Rahmen*bedingungen; die echten
|
||||
Bedingungen standen im ersten.
|
||||
|
||||
Gebaut aus .card mit farbigem Rand und .card-header - kein eigenes
|
||||
Bauteil. Die Ueberschriften tragen .card-title und bekommen darueber die
|
||||
Anzeigeschrift aus solar.css.
|
||||
-->
|
||||
|
||||
<div class="card border-primary mb-2">
|
||||
<div class="card-header d-flex flex-wrap align-items-center gap-2 py-2">
|
||||
<i class="bi bi-lightning-charge-fill text-primary"></i>
|
||||
<span class="card-title mb-0 fs-5 text-primary">Wenn</span>
|
||||
<span class="small text-body-secondary">… das alles zusammenkommt</span>
|
||||
<span class="flex-grow-1"></span>
|
||||
<!-- Wie viele Bedingungen gerade zutreffen. Fuellt renderConditions(). -->
|
||||
<span id="condStand" class="d-flex align-items-center gap-2"></span>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<!-- Ein Block ist eine UND-Gruppe, zwischen den Bloecken steht ODER.
|
||||
Die Klammerung ist damit gezeichnet und nicht bloss vereinbart. -->
|
||||
<div id="condBlocks"></div>
|
||||
<button class="btn btn-outline-secondary btn-sm mt-2" type="button" id="btnAddGroup">
|
||||
<i class="bi bi-plus-lg"></i> oder stattdessen …
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-2">
|
||||
<!--
|
||||
Der Rahmen steht zusammengefaltet da, solange er nur Vorgaben enthaelt -
|
||||
und das ist der Normalfall. Frueher nahm er die meiste Flaeche fuer den
|
||||
seltensten Inhalt.
|
||||
-->
|
||||
<!--
|
||||
Zugeklappt liest man hier, was gilt; aufgeklappt greift man zu. Die drei
|
||||
Vorlagen tauschen deshalb mit der Kurzfassung den Platz, statt eine
|
||||
eigene Zeile im Rumpf zu bekommen - das war eine Zeile mehr und eine
|
||||
Ueberschrift ("An welchen Tagen?"), die neben dem Wort "Wann" ohnehin
|
||||
nichts Neues sagte.
|
||||
|
||||
ACHTUNG beim Umbauen: die Vorlagen duerfen NICHT innerhalb des Elements
|
||||
stehen, das data-bs-toggle traegt. Bootstrap haengt seinen Umschalter
|
||||
nicht an dieses Element, sondern ans Dokument - und dort in der
|
||||
Capture-Phase, die vor jedem Klick auf einen Knopf darin laeuft. Ein
|
||||
stopPropagation() im Knopf kommt deshalb grundsaetzlich zu spaet, und
|
||||
jede Vorlage waere zugleich ein Zuklappen. Deshalb traegt nur der linke
|
||||
Teil den Umschalter, die Vorlagen stehen daneben.
|
||||
-->
|
||||
<div class="card-header d-flex flex-wrap align-items-center gap-2 py-2">
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1" id="rahmenKopf"
|
||||
role="button" data-bs-toggle="collapse" data-bs-target="#rahmenBody"
|
||||
aria-expanded="false">
|
||||
<i class="bi bi-calendar-week text-warning"></i>
|
||||
<span class="card-title mb-0 fs-5 text-warning">Wann</span>
|
||||
<span id="rahmenKurz" class="text-body-secondary"></span>
|
||||
<span class="flex-grow-1"></span>
|
||||
<span class="small text-body-secondary text-nowrap" id="rahmenAendern">ändern <i class="bi bi-chevron-down"></i></span>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group" id="tagVorlagen" hidden></div>
|
||||
<i class="bi bi-chevron-up text-body-secondary" id="rahmenZu" role="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#rahmenBody" hidden></i>
|
||||
</div>
|
||||
<div id="rahmenBody" class="collapse">
|
||||
<div class="card-body">
|
||||
<div class="btn-group btn-group-sm flex-wrap mb-2" role="group" id="weekdayGroup"></div>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-sm-6 d-flex align-items-center gap-2">
|
||||
<span class="text-body-secondary small" style="min-width:5rem">Ferien</span>
|
||||
<div class="btn-group btn-group-sm" role="group" id="ferienGroup"></div>
|
||||
</div>
|
||||
<div class="col-sm-6 d-flex align-items-center gap-2">
|
||||
<span class="text-body-secondary small" style="min-width:5rem">Feiertage</span>
|
||||
<div class="btn-group btn-group-sm" role="group" id="feiertagGroup"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Vorabend: fuer alles, was abends fuer morgen geschieht - "Kinder
|
||||
ins Bett, wenn morgen Schule ist" ist dann Mo-Fr, Ferien nie,
|
||||
Feiertage nie, wie der Wecker. Uhrzeit und Zeitfenster bleiben
|
||||
beim heutigen Tag. -->
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="nextDay">
|
||||
<label class="form-check-label" for="nextDay">
|
||||
Am Vorabend – Wochentage, Ferien und Feiertage gelten für den folgenden Tag
|
||||
</label>
|
||||
</div>
|
||||
<!--
|
||||
.callout ist ein AdminLTE-Bauteil: farbiger Balken links, getoenter
|
||||
Grund, und die Toene kommen aus Bootstraps *-bg-subtle-Variablen -
|
||||
es zieht also mit dem Theme mit, ohne dass hier eine Regel steht.
|
||||
Als .alert stand der Satz vorher in einem eingesunkenen Kasten mit
|
||||
Rahmen ringsum und las sich wie eine Warnung; er ist aber das
|
||||
Ergebnis der Schalter darueber. Die Polsterung stellen Hilfsklassen
|
||||
kleiner - .callout ist auf ganze Absaetze ausgelegt, hier steht ein
|
||||
Satz.
|
||||
-->
|
||||
<div class="callout callout-primary py-2 px-3 mt-0 mb-3" id="rahmenSummary"></div>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-sm-6">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">von</span>
|
||||
<input type="time" class="form-control" id="tSpanFrom" value="00:00">
|
||||
<label for="tSpanFrom">Von</label>
|
||||
</div>
|
||||
<div class="form-floating">
|
||||
<span class="input-group-text">bis</span>
|
||||
<input type="time" class="form-control" id="tSpanTo" value="23:59">
|
||||
<label for="tSpanTo">Bis</label>
|
||||
</div>
|
||||
</div>
|
||||
Aktive Wochentage:
|
||||
<div class="input-group mb-3" id="weekdayGroup"></div>
|
||||
<div class="input-group mb-3">
|
||||
<div class="input-group-text">
|
||||
<input class="form-check-input mt-0 me-2" type="checkbox" id="actFerien">
|
||||
<label class="form-check-label" for="actFerien">In den Ferien ausführen</label>
|
||||
</div>
|
||||
<div class="input-group-text">
|
||||
<input class="form-check-input mt-0 me-2" type="checkbox" id="actFeier">
|
||||
<label class="form-check-label" for="actFeier">An Feiertagen ausführen</label>
|
||||
<div class="col-sm-6">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">Sperre</span>
|
||||
<select class="form-select" id="lockoutSecs"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Sperrzeit nach dem Auslösen</span>
|
||||
<select class="form-select" id="lockoutSecs"></select>
|
||||
</div>
|
||||
<div class="input-group mb-0">
|
||||
<div class="input-group-text">
|
||||
<input class="form-check-input mt-0 me-2" type="checkbox" id="runOnce">
|
||||
<label class="form-check-label" for="runOnce">
|
||||
Am Ende des Zeitraums auf jeden Fall ausführen, falls der Auslöser nie zutraf
|
||||
</label>
|
||||
<!--
|
||||
Haltezeit. Sie steht neben der Sperre, weil beide dasselbe Mittel
|
||||
benutzen - Zeit - und trotzdem entgegengesetzt wirken: die Sperre
|
||||
bremst die Wiederholung, die Haltezeit den ersten Lauf.
|
||||
|
||||
Frei einstellbar statt aus einer Auswahlliste: Wonach man sucht,
|
||||
hängt am Messwert - eine offene Tür fällt nach zwei Minuten auf,
|
||||
ein laufender Wasserhahn erst nach Stunden.
|
||||
|
||||
Dasselbe Zeitfeld wie von/bis darüber, nur als Dauer gelesen. Dass
|
||||
es keine Uhrzeit meint, sagt der Text ringsum ("erst nach … am
|
||||
Stück"); die Zeile über dem Rahmen schreibt es beim Tippen aus
|
||||
("erst nach 3,5 Std.").
|
||||
-->
|
||||
<div class="col-sm-6">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">Auslösen erst nach</span>
|
||||
<input type="time" class="form-control" id="holdZeit" value="00:00"
|
||||
title="Stunden und Minuten, die die Bedingung ohne Unterbrechung erfüllt sein muss. 00:00 = sofort.">
|
||||
<span class="input-group-text">am Stück</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#collapseThree" aria-expanded="false">
|
||||
<i class="bi bi-play-fill"></i> Aktionen
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapseThree" class="accordion-collapse collapse" data-bs-parent="#actionAccordion">
|
||||
<div class="accordion-body">
|
||||
<div id="actionList"></div>
|
||||
<button class="btn btn-outline-success btn-sm mt-2" type="button" id="btnAddActor">
|
||||
<i class="bi bi-plus-lg"></i> Aktion
|
||||
</button>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="oncePerDay">
|
||||
<label class="form-check-label" for="oncePerDay">
|
||||
Nur einmal am Tag – danach bis Mitternacht gesperrt
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="runOnce">
|
||||
<label class="form-check-label" for="runOnce">
|
||||
Am Ende des Zeitraums auf jeden Fall ausführen, falls der Auslöser nie zutraf
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-success mb-0">
|
||||
<div class="card-header d-flex flex-wrap align-items-center gap-2 py-2">
|
||||
<i class="bi bi-play-fill text-success"></i>
|
||||
<span class="card-title mb-0 fs-5 text-success">Dann</span>
|
||||
<span class="small text-body-secondary">… passiert das hier</span>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<div id="actionList"></div>
|
||||
<button class="btn btn-outline-success btn-sm mt-2" type="button" id="btnAddActor">
|
||||
<i class="bi bi-plus-lg"></i> und noch etwas tun
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,73 +1,12 @@
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
|
||||
if (checkLogin()) {
|
||||
chdir("/volume1/homes/wagner/");
|
||||
putenv('PYTHONPATH="/var/services/homes/wagner/.local/lib/python3.8/site-packages/');
|
||||
$close = 0;
|
||||
if (isset($_POST["evStart/Stop"])) {
|
||||
if ($_POST["evStart/Stop"] == "Laden starten") {
|
||||
$close = 1;
|
||||
exec("python wattpilot.py -start 2>&1", $output, $return_var);
|
||||
} elseif ($_POST["evStart/Stop"] == "Laden stoppen") {
|
||||
$close = 1;
|
||||
exec("python wattpilot.py -stop 2>&1", $output, $return_var);
|
||||
}
|
||||
$close = 1;
|
||||
} else if (isset($_POST["evMode"])) {
|
||||
// Eingaben pruefen, bevor sie in die Kommandozeile gehen. ftt kam bisher
|
||||
// ungeprueft aus dem Formular und liess sich mit "12:00; befehl" zum
|
||||
// Anhaengen beliebiger Shell-Befehle missbrauchen.
|
||||
$fte = intval($_POST["fte"]);
|
||||
if ($fte > 100)
|
||||
$fte = 100;
|
||||
else if ($fte < 1)
|
||||
$fte = 1;
|
||||
$fte = $fte * 140;
|
||||
|
||||
$evAmp = intval($_POST["evAmp"]);
|
||||
if ($evAmp > 16)
|
||||
$evAmp = 16;
|
||||
if ($evAmp < 6)
|
||||
$evAmp = 6;
|
||||
|
||||
// Das Formularfeld ist ein <input type="time">, liefert also HH:MM.
|
||||
// Alles andere gilt als "keine Ladeplanung" und faellt in den else-Zweig.
|
||||
$ftt = "";
|
||||
if (isset($_POST["ftt"]) && preg_match('/^([01][0-9]|2[0-3]):[0-5][0-9]$/', $_POST["ftt"])) {
|
||||
$ftt = $_POST["ftt"];
|
||||
}
|
||||
$setTime = gmdate("Y-m-d\TH:i:s", time()) . ".000";
|
||||
|
||||
if ($_POST["evMode"] == "eco") {
|
||||
$close = 1;
|
||||
if ($ftt !== "" && $fte > 0) {
|
||||
exec("python wattpilot.py -eco -time " . escapeshellarg($ftt) . " -energy " . $fte . " -maxCurr " . $evAmp . " -setTime " . escapeshellarg($setTime) . " 2>&1", $output, $return_var);
|
||||
} else {
|
||||
exec("/bin/python wattpilot.py -e 2>&1", $output, $return_var);
|
||||
}
|
||||
} else if ($_POST["evMode"] == "default") {
|
||||
$close = 1;
|
||||
if ($ftt !== "" && $fte > 0) {
|
||||
exec("python wattpilot.py -default -time " . escapeshellarg($ftt) . " -energy " . $fte . " -maxCurr " . $evAmp . " -setTime " . escapeshellarg($setTime) . " 2>&1", $output, $return_var);
|
||||
} else {
|
||||
exec("python wattpilot.py -d 2>&1", $output, $return_var);
|
||||
}
|
||||
} else if ($_POST["evMode"] == "NextTrip") {
|
||||
$close = 1;
|
||||
if ($ftt !== "" && $fte > 0) {
|
||||
exec("python wattpilot.py -trip -time " . escapeshellarg($ftt) . " -energy " . $fte . " -maxCurr " . $evAmp . " -setTime " . escapeshellarg($setTime) . " 2>&1", $output, $return_var);
|
||||
} else {
|
||||
exec("python wattpilot.py -t 2>&1", $output, $return_var);
|
||||
}
|
||||
}
|
||||
$close = 1;
|
||||
}
|
||||
} else {
|
||||
$close = 1;
|
||||
}
|
||||
if (!$close) {
|
||||
$formId = "carEG_form";
|
||||
include __DIR__ . "/carForm.php";
|
||||
}
|
||||
<?php
|
||||
/*
|
||||
* Autoladung EG - die Wattpilot in der Garage.
|
||||
*
|
||||
* Die Ansteuerung ist fuer beide Wallboxen dieselbe und steht in
|
||||
* carSteuerung.php; hier bleibt nur, welche gemeint ist.
|
||||
*/
|
||||
|
||||
$wallbox = "eg";
|
||||
$formId = "carEG_form";
|
||||
|
||||
require __DIR__ . "/carSteuerung.php";
|
||||
|
||||
@@ -1,81 +1,99 @@
|
||||
<?php
|
||||
/*
|
||||
* Formular des Auto-Lademodals. carEG.php und carOG.php zeigen dasselbe
|
||||
* Formular; nur die Ansteuerung der Wallbox dahinter unterscheidet sich.
|
||||
* $formId trennt die beiden Formulare fuer das JavaScript.
|
||||
* Das Ladefenster, gemeinsam für beide Wallboxen (carEG.php, carOG.php über
|
||||
* carSteuerung.php).
|
||||
*
|
||||
* Hier steht nur das Gerüst mit dem, was der Server weiß: welches Auto, wie
|
||||
* groß sein Akku ist. Werte, Zustände und jede Änderung übernimmt
|
||||
* js/solar/ladefenster.js - live aus den MQTT-Werten der Seite, geschrieben
|
||||
* wird sofort per POST an carSteuerung.php.
|
||||
*/
|
||||
$auto = fahrzeuge()[$wallbox];
|
||||
$box = wallboxen()[$wallbox];
|
||||
?>
|
||||
<div class="d-grid gap-2 col-6 mx-auto my-4">
|
||||
<button class="btn btn-primary" id="evStart/Stop">Laden starten</button>
|
||||
<div class="lade" data-wallbox="<?= htmlspecialchars($wallbox) ?>"
|
||||
data-wh-pro-prozent="<?= wallboxWhProProzent($wallbox) ?>"
|
||||
data-akku-kwh="<?= htmlspecialchars((string)$auto["akkuKWh"]) ?>">
|
||||
|
||||
<div class="lade-links">
|
||||
<div class="lade-status">
|
||||
<div class="lade-ring" aria-hidden="true">
|
||||
<svg viewBox="0 0 120 120">
|
||||
<circle class="lade-spur" cx="60" cy="60" r="50" pathLength="100"/>
|
||||
<circle class="lade-ziel" cx="60" cy="60" r="50" pathLength="100"/>
|
||||
<circle class="lade-ist" cx="60" cy="60" r="50" pathLength="100"/>
|
||||
</svg>
|
||||
<div class="lade-ring-mitte">
|
||||
<b data-lade="soc">–</b>
|
||||
<span data-lade="ringUnten"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lade-text">
|
||||
<div class="lade-auto"><?= htmlspecialchars($auto["name"]) ?> <span data-lade="stecker" class="lade-pille"></span></div>
|
||||
<div class="lade-gross" data-lade="leistung">–</div>
|
||||
<div class="lade-zeile" data-lade="quelle"></div>
|
||||
<div class="lade-zeile" data-lade="fertig"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="lade-start" data-lade="start"><span class="lade-punkt"></span><span data-lade="startText">…</span></button>
|
||||
<div class="lade-erklaerung" data-lade="erklaerung"></div>
|
||||
</div>
|
||||
|
||||
<div class="lade-rechts">
|
||||
<div class="lade-abschnitt">
|
||||
<h3 id="lade-modus-<?= $wallbox ?>">Wie laden?</h3>
|
||||
<div class="lade-modi" role="radiogroup" aria-labelledby="lade-modus-<?= $wallbox ?>">
|
||||
<button type="button" class="lade-modus" role="radio" aria-checked="false" data-modus="sofort" style="--c:#6cbe58">
|
||||
<span class="lade-sym"><svg viewBox="0 0 24 24"><path d="M13 3 5 14h6l-1 7 8-11h-6z"/></svg></span>
|
||||
<b>Sofort</b><span>volle Leistung, auch aus dem Netz</span>
|
||||
</button>
|
||||
<button type="button" class="lade-modus" role="radio" aria-checked="false" data-modus="sonne" style="--c:#f7c000">
|
||||
<span class="lade-sym"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg></span>
|
||||
<b>Sonne</b><span>nur Überschuss der Anlage</span>
|
||||
</button>
|
||||
<button type="button" class="lade-modus" role="radio" aria-checked="false" data-modus="plan" style="--c:#4b9fd6">
|
||||
<span class="lade-sym"><svg viewBox="0 0 24 24"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2M9 2h6"/></svg></span>
|
||||
<b>Geplant</b><span>bis zur Abfahrt geladen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lade-planung" data-lade="planung" hidden>
|
||||
<div>
|
||||
<div class="lade-plan-zeile">
|
||||
<label for="lade-ziel-<?= $wallbox ?>" data-lade="zielTitel">Ladestand bei Abfahrt</label>
|
||||
<output data-lade="zielAusgabe"></output>
|
||||
</div>
|
||||
<input type="range" id="lade-ziel-<?= $wallbox ?>" data-lade="ziel" min="0" max="100" step="5" value="80">
|
||||
<div class="lade-marken"><span data-lade="markeLinks">0 %</span><span data-lade="markeMitte"></span><span>100 %</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="lade-plan-zeile"><label>Abfahrt</label><output data-lade="abfahrtAusgabe"></output></div>
|
||||
<div class="lade-chips" role="group" aria-label="Abfahrt">
|
||||
<button type="button" class="lade-chip" aria-pressed="false" data-zeit="06:00">06:00</button>
|
||||
<button type="button" class="lade-chip" aria-pressed="false" data-zeit="07:00">07:00</button>
|
||||
<button type="button" class="lade-chip" aria-pressed="false" data-zeit="08:00">08:00</button>
|
||||
<!-- Eigene Zeit: der Knopf zeigt sie an und öffnet den Zeitwähler;
|
||||
das Feld liegt unsichtbar darunter. Direkt eingebettet war es
|
||||
zu schmal, und Chromium öffnete den Wähler nur über sein
|
||||
eigenes Uhrsymbol. -->
|
||||
<button type="button" class="lade-chip lade-chip-eigen" aria-pressed="false" data-lade="chipEigen">
|
||||
<i class="bi bi-clock"></i><span data-lade="chipEigenText">andere</span>
|
||||
<input type="time" class="lade-zeitfeld" data-lade="zeitEigen" tabindex="-1" aria-label="eigene Abfahrtszeit">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lade-abschnitt">
|
||||
<h3 id="lade-strom-<?= $wallbox ?>">Höchstens</h3>
|
||||
<div class="lade-strom" role="radiogroup" aria-labelledby="lade-strom-<?= $wallbox ?>">
|
||||
<button type="button" role="radio" aria-checked="false" data-ampere="6"><b>6 A</b><span>≈ 4,1 kW</span></button>
|
||||
<button type="button" role="radio" aria-checked="false" data-ampere="10"><b>10 A</b><span>≈ 6,9 kW</span></button>
|
||||
<button type="button" role="radio" aria-checked="false" data-ampere="16"><b>16 A</b><span>≈ 11 kW</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lade-meldung" data-lade="meldung" aria-live="polite">Änderungen gelten sofort · <?= htmlspecialchars($box["titel"]) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<!--begin::Form-->
|
||||
<form id="<?= $formId ?>">
|
||||
<div class="card m-3">
|
||||
<div class="card-header py-1 px-3">
|
||||
<div class="card-title">Modus</div>
|
||||
</div>
|
||||
<!--begin::Body-->
|
||||
<div class="card-body">
|
||||
<div class="col-sm-10">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="evMode" id="EV_Default" value="default">
|
||||
<label class="form-check-label" for="EV_Default"> Immer Laden </label></input>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="evMode" id="EV_Eco" value="eco">
|
||||
<label class="form-check-label" for="EV_Eco"> Überschuss </label></input>
|
||||
</div>
|
||||
<div class="form-check disabled">
|
||||
<input class="form-check-input" type="radio" name="evMode" id="EV_NextTrip" value="NextTrip">
|
||||
<label class="form-check-label" for="EV_NextTrip"> Ladeplanung </label></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card m-3">
|
||||
<div class="card-header py-1 px-3">
|
||||
<div class="card-title">Max. Ladestrom</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="col-sm-10">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="evAmp" id="EV_6A" value="6">
|
||||
<label class="form-check-label" for="EV_6A"> 6A </label></input>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="evAmp" id="EV_10A" value="10">
|
||||
<label class="form-check-label" for="EV_10A"> 10A </label></input>
|
||||
</div>
|
||||
<div class="form-check disabled">
|
||||
<input class="form-check-input" type="radio" name="evAmp" id="EV_16A" value="16">
|
||||
<label class="form-check-label" for="EV_16A"> 16A </label></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card m-3">
|
||||
<div class="card-header py-1 px-3">
|
||||
<div class="card-title">Ladeplanung</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="form-check">
|
||||
<label class="form-label" for="addedCharge">Zusätzlich benötigte Ladung (%): </label>
|
||||
<div style="font-size: 15px;color: #297195;display: inline-block;" id="modal-slider-label">sdf</div>
|
||||
<input type="range" id="modal-slider" name="fte" class="form-range range-color-track" min="1" max="100" />
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="max-amount f-16 font-weight-normal darkGray-color text-right mt-1">1%</span>
|
||||
<span class="max-amount f-16 font-weight-normal darkGray-color text-right mt-1">100%</span>
|
||||
</div>
|
||||
<!--<input name="fte" class="form-control" type="number" max="100" min="1" value="" />-->
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="form-check">
|
||||
<label class="form-label" for="planedTime">Zeitpunkt zudem geladen sein soll: </label>
|
||||
<input id="EV_NextTripTime" name="ftt" class="form-control" size="2" type="time" value=""/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,58 +1,12 @@
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
$close = 0;
|
||||
if (isset($_POST["evStart/Stop"])) {
|
||||
if ($_POST["evStart/Stop"] == "Laden starten") {
|
||||
$close = 1;
|
||||
httpGetJson("http://192.168.179.122/api/set?frc=2");
|
||||
} else if ($_POST["evStart/Stop"] == "Laden stoppen") {
|
||||
$close = 1;
|
||||
httpGetJson("http://192.168.179.122/api/set?frc=1");
|
||||
}
|
||||
$close = 1;
|
||||
} else if (isset($_POST["evMode"])) {
|
||||
if ($_POST["fte"] > 100)
|
||||
$_POST["fte"] = 100;
|
||||
else if ($_POST["fte"] < 0)
|
||||
$_POST["fte"] = 0;
|
||||
if ($_POST["evAmp"] > 16)
|
||||
$_POST["evAmp"] = 16;
|
||||
if ($_POST["evAmp"] < 6)
|
||||
$_POST["evAmp"] = 6;
|
||||
$_POST["fte"] = $_POST["fte"] * 90;
|
||||
$seconds = strtotime('1970-01-01 ' . $_POST["ftt"] . ':00GMT');
|
||||
if ($_POST["evMode"] == "eco") {
|
||||
$close = 1;
|
||||
if ($_POST["ftt"] && $_POST["fte"] > 0) {
|
||||
httpGetJson("http://192.168.179.122/api/set?ate=" . $_POST["fte"] . "&att=" . $seconds . "&=" . $_POST["evAmp"] . "&fup=true&lmo=4");
|
||||
} else {
|
||||
httpGetJson("http://192.168.179.122/api/set?amp=" . $_POST["evAmp"] . "&fup=true&lmo=4");
|
||||
}
|
||||
} else if ($_POST["evMode"] == "default") {
|
||||
$close = 1;
|
||||
if ($_POST["ftt"] && $_POST["fte"] > 0) {
|
||||
httpGetJson("http://192.168.179.122/api/set?ate=" . $_POST["fte"] . "&att=" . $seconds . "&=" . $_POST["evAmp"] . "&fup=true&lmo=3");
|
||||
} else {
|
||||
httpGetJson("http://192.168.179.122/api/set?amp=" . $_POST["evAmp"] . "&fup=true&lmo=3");
|
||||
}
|
||||
} else if ($_POST["evMode"] == "NextTrip") {
|
||||
$close = 1;
|
||||
if ($_POST["ftt"] && $_POST["fte"] > 0) {
|
||||
httpGetJson("http://192.168.179.122/api/set?ate=" . $_POST["fte"] . "&att=" . $seconds . "&=" . $_POST["evAmp"] . "&fup=true&lmo=5");
|
||||
} else {
|
||||
httpGetJson("http://192.168.179.122/api/set?amp=" . $_POST["evAmp"] . "&fup=true&lmo=5");
|
||||
}
|
||||
}
|
||||
$close = 1;
|
||||
}
|
||||
} else {
|
||||
$close = 1;
|
||||
}
|
||||
if (!$close) {
|
||||
$formId = "carOG_form";
|
||||
include __DIR__ . "/carForm.php";
|
||||
}
|
||||
<?php
|
||||
/*
|
||||
* Autoladung OG - der go-e Charger.
|
||||
*
|
||||
* Die Ansteuerung ist fuer beide Wallboxen dieselbe und steht in
|
||||
* carSteuerung.php; hier bleibt nur, welche gemeint ist.
|
||||
*/
|
||||
|
||||
$wallbox = "og";
|
||||
$formId = "carOG_form";
|
||||
|
||||
require __DIR__ . "/carSteuerung.php";
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
/**
|
||||
* Ladesteuerung beider Wallboxen.
|
||||
*
|
||||
* carEG.php und carOG.php setzen nur $wallbox und binden diese Datei ein.
|
||||
* Was die beiden Wallboxen wirklich trennt, steht in restricted/wallboxen.php.
|
||||
*
|
||||
* GET das Ladefenster (carForm.php)
|
||||
* POST {"aktion": "frc", "wert": 0|1|2} Freigabe: 0 Modus entscheidet, 1 gesperrt, 2 sofort
|
||||
* POST {"aktion": "modus", "modus": "sofort"|"sonne"}
|
||||
* POST {"aktion": "strom", "ampere": 6..16}
|
||||
* POST {"aktion": "plan", "wh": 1.., "zeit": "HH:MM"} Ladeplanung: Energie bis Uhrzeit
|
||||
*
|
||||
* Seit September 2026 gilt jede Änderung sofort (js/solar/ladefenster.js) -
|
||||
* vorher gingen Modus, Strom und Planung gemeinsam über "Save changes".
|
||||
* Antwort ist JSON: {"ok": true} oder {"error": "..."}. Ob die Wallbox den
|
||||
* Wert genommen hat, meldet sie über MQTT; das Fenster zeigt es beim
|
||||
* nächsten Takt.
|
||||
*/
|
||||
|
||||
require_once(__DIR__ . "/../helper.php");
|
||||
require_once(__DIR__ . "/../restricted/wallboxen.php");
|
||||
|
||||
// lmo, der Betriebsmodus - beide Wallboxen nutzen dieselben Nummern.
|
||||
const CAR_MODI = [
|
||||
"sofort" => 3, // Default: volle Leistung, auch aus dem Netz
|
||||
"sonne" => 4, // Eco (Awattar): Überschuss
|
||||
"plan" => 5, // Next Trip (AutomaticStop): Energie bis Uhrzeit
|
||||
];
|
||||
|
||||
function ladeAntwort($daten, $code = 200)
|
||||
{
|
||||
http_response_code($code);
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
echo json_encode($daten, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!checkLogin()) {
|
||||
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
ladeAntwort(["error" => "Nicht angemeldet."], 403);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER["REQUEST_METHOD"] !== "POST") {
|
||||
include __DIR__ . "/carForm.php";
|
||||
exit;
|
||||
}
|
||||
|
||||
$konf = wallboxen()[$wallbox];
|
||||
$body = json_decode(file_get_contents("php://input"), true);
|
||||
if (!is_array($body)) {
|
||||
ladeAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
|
||||
}
|
||||
|
||||
switch ($body["aktion"] ?? "") {
|
||||
case "frc":
|
||||
$wert = intval($body["wert"] ?? -1);
|
||||
if (!in_array($wert, [0, 1, 2], true)) {
|
||||
ladeAntwort(["error" => "Unbekannte Freigabe."], 400);
|
||||
}
|
||||
$werte = ["frc" => $wert];
|
||||
break;
|
||||
|
||||
case "modus":
|
||||
$modus = CAR_MODI[$body["modus"] ?? ""] ?? null;
|
||||
if ($modus === null || $modus === CAR_MODI["plan"]) {
|
||||
// Die Planung braucht Energie und Uhrzeit - siehe "plan".
|
||||
ladeAntwort(["error" => "Unbekannter Modus."], 400);
|
||||
}
|
||||
$werte = ["fup" => true, "lmo" => $modus];
|
||||
break;
|
||||
|
||||
case "strom":
|
||||
$werte = ["amp" => min(16, max(6, intval($body["ampere"] ?? 6)))];
|
||||
break;
|
||||
|
||||
case "plan":
|
||||
// Die Uhrzeit ging früher in eine Kommandozeile und ließ sich mit
|
||||
// "12:00; befehl" missbrauchen - deshalb bleibt die Prüfung streng.
|
||||
if (!preg_match('/^([01][0-9]|2[0-3]):([0-5][0-9])$/', (string)($body["zeit"] ?? ""), $teile)) {
|
||||
ladeAntwort(["error" => "Die Uhrzeit ist ungültig."], 400);
|
||||
}
|
||||
$wh = intval($body["wh"] ?? 0);
|
||||
$max = (int) round(fahrzeuge()[$wallbox]["akkuKWh"] * 1000);
|
||||
if ($wh < 100 || $wh > $max) {
|
||||
ladeAntwort(["error" => "Die Energiemenge liegt außerhalb dessen, was der Akku fasst."], 400);
|
||||
}
|
||||
// Reihenfolge zählt: Uhr, Ziel, dann der Modus.
|
||||
$werte = [];
|
||||
if (!empty($konf["uhrStellen"])) {
|
||||
$werte["utc"] = gmdate("Y-m-d\TH:i:s") . ".000";
|
||||
}
|
||||
$werte["fte"] = $wh;
|
||||
$werte["ftt"] = intval($teile[1]) * 3600 + intval($teile[2]) * 60;
|
||||
$werte["fup"] = true;
|
||||
$werte["lmo"] = CAR_MODI["plan"];
|
||||
break;
|
||||
|
||||
default:
|
||||
ladeAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||
}
|
||||
|
||||
if (!wallboxSetzen($wallbox, $werte)) {
|
||||
ladeAntwort(["error" => "Der MQTT-Broker ist nicht erreichbar."], 502);
|
||||
}
|
||||
ladeAntwort(["ok" => true]);
|
||||
@@ -1,162 +1,162 @@
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
if(!isset($_GET["TO"])){
|
||||
$_GET["TO"] = 12;
|
||||
}
|
||||
$_GET["TO"] = intval($_GET["TO"]);
|
||||
if(!isset($_GET["FROM"])){
|
||||
$_GET["FROM"] = -24;
|
||||
}
|
||||
$_GET["FROM"] = intval($_GET["FROM"]);
|
||||
//-totalConsumption-PL1_EV*1000-PL2_EV*1000-PL3_EV*1000-PL1_EVog*1000-PL2_EVog*1000-PL3_EVog*1000+PL1_OG+PL2_OG+PL3_OG-heaterPwr AS 'UG',
|
||||
$consQuery = "SELECT
|
||||
UNIX_TIMESTAMP(EnergyFlow.datetime) AS time,
|
||||
pvP AS 'Solarleistung',
|
||||
soc AS Ladestand,
|
||||
-totalConsumption-PL1_EV*1000-PL2_EV*1000-PL3_EV*1000-PL1_EVog*1000-PL2_EVog*1000-PL3_EVog*1000+PL1_OG+PL2_OG+PL3_OG-heaterPwr-PL1_UG-PL2_UG-PL3_UG-PL1_EG-PL2_EG-PL3_EG AS 'Gemein',
|
||||
(PL1_UG+PL2_UG+PL3_UG) AS 'UG',
|
||||
(PL1_EG+PL2_EG+PL3_EG) AS 'EG',
|
||||
-(PL1_OG+PL2_OG+PL3_OG) AS 'OG',
|
||||
(PL1_EV+PL2_EV+PL3_EV)*1000 AS 'Auto UG',
|
||||
(PL1_EVog+PL2_EVog+PL3_EVog)*1000 AS 'Auto OG',
|
||||
heaterPwr AS 'Heizstab',
|
||||
IF(battP<0, -battP, 0) AS Batterieladung,
|
||||
gridPfeed AS Einspeisung
|
||||
FROM solarLog.EnergyFlow
|
||||
WHERE EnergyFlow.datetime BETWEEN DATE_ADD(NOW(),INTERVAL ".($_GET["FROM"])." HOUR) and DATE_ADD(NOW(),INTERVAL ".$_GET["TO"]." HOUR)
|
||||
ORDER BY EnergyFlow.datetime";
|
||||
$simQuery = "SELECT
|
||||
UNIX_TIMESTAMP(simPower.period_end) AS time,
|
||||
power*1000 AS 'Vorhersage'
|
||||
FROM solarLog.simPower
|
||||
WHERE simPower.period_end BETWEEN DATE_ADD(NOW(),INTERVAL ".($_GET["FROM"])." HOUR) and DATE_ADD(NOW(),INTERVAL ".$_GET["TO"]." HOUR)
|
||||
ORDER BY simPower.period_end";
|
||||
$linecolors["Solarleistung"] = "#FFFF00";
|
||||
$linecolors["Gemein"] = "#FF9900";
|
||||
$linecolors["UG"] = "#FF8800";
|
||||
$linecolors["EG"] = "#FF6600";
|
||||
$linecolors["OG"] = "#FF4400";
|
||||
$linecolors["Auto UG"] = "#00aaFF";
|
||||
$linecolors["Auto OG"] = "#0044FF";
|
||||
$linecolors["Heizstab"] = "#FF0000";
|
||||
$linecolors["Batterieladung"] = "#00aa00";
|
||||
$linecolors["Einspeisung"] = "#b0b0b0";
|
||||
$linecolors["Ladestand"] = "#00aa00";
|
||||
$linecolors["Vorhersage"] = "#2222FF";
|
||||
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||
$result = mysqli_query($mysql, $consQuery);
|
||||
$simRes = mysqli_query($mysql,$simQuery);
|
||||
if(!$result){
|
||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
$obj = (object)[]; // Cast empty array to object
|
||||
$obj->labels = [];
|
||||
$obj->datasets = [];
|
||||
$i = 0;
|
||||
$filled = 0;
|
||||
if ($simRes->num_rows > 1) {
|
||||
$dataset = (object)[];
|
||||
$row1 = $simRes->fetch_assoc();
|
||||
$dataset->borderColor = $linecolors["Vorhersage"];
|
||||
$dataset->backgroundColor = $linecolors["Vorhersage"]."55";
|
||||
$dataset->borderWidth=1.5;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
$dataset->stack = "sim";
|
||||
$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y';
|
||||
$dataset->label = "Vorhersage";
|
||||
/*$pt = (object)[];
|
||||
$pt->x = $row1["time"]*1000;
|
||||
$pt->y = $row1["Vorhersage"];
|
||||
$dataset->data[] = clone $pt;
|
||||
while ($row1 = $simRes->fetch_assoc()) {
|
||||
$pt = (object)[];
|
||||
$pt->x = $row1["time"]*1000 + 30*60*1000;
|
||||
$pt->y = $row1["Vorhersage"];
|
||||
$dataset->data[] = clone $pt;
|
||||
}*/
|
||||
$obj->datasets[] = clone $dataset;
|
||||
$rownext = $simRes->fetch_assoc();
|
||||
$nextSimTimestamp = $rownext["time"]*1000 + 30*60*1000;
|
||||
}
|
||||
if ($result->num_rows > 1) {
|
||||
$ii = 1;
|
||||
$row = $result->fetch_assoc();
|
||||
foreach ($row as $name => $value) {
|
||||
$dataset = (object)[];
|
||||
if ($name != "time") {
|
||||
$dataset->borderColor = $linecolors[$name];
|
||||
$dataset->backgroundColor = $linecolors[$name]."55";
|
||||
$dataset->borderWidth=1;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
if ($name == "Solarleistung") {
|
||||
$dataset->stack = "SolarPwr";
|
||||
$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y';
|
||||
} else if ($name == "Ladestand") {
|
||||
$dataset->stack = "Charge";
|
||||
$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y1';
|
||||
} else {
|
||||
$dataset->stack = "Consumers";
|
||||
if ($filled == 0) {
|
||||
$filled = 1;
|
||||
$dataset->fill = "origin";
|
||||
} else {
|
||||
$dataset->fill = "-1";
|
||||
}
|
||||
$dataset->yAxisID = 'y';
|
||||
}
|
||||
$dataset->label = $name;
|
||||
|
||||
$dataset->data[] = $value;
|
||||
$obj->datasets[] = clone $dataset;
|
||||
$ii++;
|
||||
} else {
|
||||
$obj->labels[] = $value * 1000;
|
||||
}
|
||||
}
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ii = 1;
|
||||
foreach ($row as $name => $value) {
|
||||
if ($name != "time") {
|
||||
$obj->datasets[$ii]->data[] = $value;
|
||||
$ii++;
|
||||
} else {
|
||||
if(($value * 1000) < $nextSimTimestamp){
|
||||
$obj->datasets[0]->data[] = $row1["Vorhersage"];
|
||||
}else{
|
||||
$row1 = $rownext;
|
||||
$rownext = $simRes->fetch_assoc();
|
||||
$nextSimTimestamp = $rownext["time"]*1000 + 30*60*1000;
|
||||
$obj->datasets[0]->data[] = $row1["Vorhersage"];
|
||||
}
|
||||
$obj->labels[] = $value * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$obj->labels[] = $nextSimTimestamp; //Draw future forecast
|
||||
$obj->datasets[0]->data[] = $rownext["Vorhersage"];
|
||||
while($rownext = $simRes->fetch_assoc()){
|
||||
$obj->labels[] = $rownext["time"]*1000 + 30*60*1000;
|
||||
$obj->datasets[0]->data[] = $rownext["Vorhersage"];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
//header('Content-Type: application/json');
|
||||
echo json_encode($obj);
|
||||
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
if(!isset($_GET["TO"])){
|
||||
$_GET["TO"] = 12;
|
||||
}
|
||||
$_GET["TO"] = intval($_GET["TO"]);
|
||||
if(!isset($_GET["FROM"])){
|
||||
$_GET["FROM"] = -24;
|
||||
}
|
||||
$_GET["FROM"] = intval($_GET["FROM"]);
|
||||
//-totalConsumption-PL1_EV*1000-PL2_EV*1000-PL3_EV*1000-PL1_EVog*1000-PL2_EVog*1000-PL3_EVog*1000+PL1_OG+PL2_OG+PL3_OG-heaterPwr AS 'UG',
|
||||
$consQuery = "SELECT
|
||||
UNIX_TIMESTAMP(EnergyFlow.datetime) AS time,
|
||||
pvP AS 'Solarleistung',
|
||||
soc AS Ladestand,
|
||||
-totalConsumption-PL1_EV*1000-PL2_EV*1000-PL3_EV*1000-PL1_EVog*1000-PL2_EVog*1000-PL3_EVog*1000+PL1_OG+PL2_OG+PL3_OG-heaterPwr-PL1_UG-PL2_UG-PL3_UG-PL1_EG-PL2_EG-PL3_EG AS 'Gemein',
|
||||
(PL1_UG+PL2_UG+PL3_UG) AS 'UG',
|
||||
(PL1_EG+PL2_EG+PL3_EG) AS 'EG',
|
||||
-(PL1_OG+PL2_OG+PL3_OG) AS 'OG',
|
||||
(PL1_EV+PL2_EV+PL3_EV)*1000 AS 'Auto UG',
|
||||
(PL1_EVog+PL2_EVog+PL3_EVog)*1000 AS 'Auto OG',
|
||||
heaterPwr AS 'Heizstab',
|
||||
IF(battP<0, -battP, 0) AS Batterieladung,
|
||||
gridPfeed AS Einspeisung
|
||||
FROM solarLog.EnergyFlow
|
||||
WHERE EnergyFlow.datetime BETWEEN DATE_ADD(NOW(),INTERVAL ".($_GET["FROM"])." HOUR) and DATE_ADD(NOW(),INTERVAL ".$_GET["TO"]." HOUR)
|
||||
ORDER BY EnergyFlow.datetime";
|
||||
$simQuery = "SELECT
|
||||
UNIX_TIMESTAMP(simPower.period_end) AS time,
|
||||
power*1000 AS 'Vorhersage'
|
||||
FROM solarLog.simPower
|
||||
WHERE simPower.period_end BETWEEN DATE_ADD(NOW(),INTERVAL ".($_GET["FROM"])." HOUR) and DATE_ADD(NOW(),INTERVAL ".$_GET["TO"]." HOUR)
|
||||
ORDER BY simPower.period_end";
|
||||
$linecolors["Solarleistung"] = "#FFFF00";
|
||||
$linecolors["Gemein"] = "#FF9900";
|
||||
$linecolors["UG"] = "#FF8800";
|
||||
$linecolors["EG"] = "#FF6600";
|
||||
$linecolors["OG"] = "#FF4400";
|
||||
$linecolors["Auto UG"] = "#00aaFF";
|
||||
$linecolors["Auto OG"] = "#0044FF";
|
||||
$linecolors["Heizstab"] = "#FF0000";
|
||||
$linecolors["Batterieladung"] = "#00aa00";
|
||||
$linecolors["Einspeisung"] = "#b0b0b0";
|
||||
$linecolors["Ladestand"] = "#00aa00";
|
||||
$linecolors["Vorhersage"] = "#2222FF";
|
||||
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||
$result = mysqli_query($mysql, $consQuery);
|
||||
$simRes = mysqli_query($mysql,$simQuery);
|
||||
if(!$result){
|
||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
$obj = (object)[]; // Cast empty array to object
|
||||
$obj->labels = [];
|
||||
$obj->datasets = [];
|
||||
$i = 0;
|
||||
$filled = 0;
|
||||
if ($simRes->num_rows > 1) {
|
||||
$dataset = (object)[];
|
||||
$row1 = $simRes->fetch_assoc();
|
||||
$dataset->borderColor = $linecolors["Vorhersage"];
|
||||
$dataset->backgroundColor = $linecolors["Vorhersage"]."55";
|
||||
$dataset->borderWidth=1.5;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
$dataset->stack = "sim";
|
||||
$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y';
|
||||
$dataset->label = "Vorhersage";
|
||||
/*$pt = (object)[];
|
||||
$pt->x = $row1["time"]*1000;
|
||||
$pt->y = $row1["Vorhersage"];
|
||||
$dataset->data[] = clone $pt;
|
||||
while ($row1 = $simRes->fetch_assoc()) {
|
||||
$pt = (object)[];
|
||||
$pt->x = $row1["time"]*1000 + 30*60*1000;
|
||||
$pt->y = $row1["Vorhersage"];
|
||||
$dataset->data[] = clone $pt;
|
||||
}*/
|
||||
$obj->datasets[] = clone $dataset;
|
||||
$rownext = $simRes->fetch_assoc();
|
||||
$nextSimTimestamp = $rownext["time"]*1000 + 30*60*1000;
|
||||
}
|
||||
if ($result->num_rows > 1) {
|
||||
$ii = 1;
|
||||
$row = $result->fetch_assoc();
|
||||
foreach ($row as $name => $value) {
|
||||
$dataset = (object)[];
|
||||
if ($name != "time") {
|
||||
$dataset->borderColor = $linecolors[$name];
|
||||
$dataset->backgroundColor = $linecolors[$name]."55";
|
||||
$dataset->borderWidth=1;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
if ($name == "Solarleistung") {
|
||||
$dataset->stack = "SolarPwr";
|
||||
$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y';
|
||||
} else if ($name == "Ladestand") {
|
||||
$dataset->stack = "Charge";
|
||||
$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y1';
|
||||
} else {
|
||||
$dataset->stack = "Consumers";
|
||||
if ($filled == 0) {
|
||||
$filled = 1;
|
||||
$dataset->fill = "origin";
|
||||
} else {
|
||||
$dataset->fill = "-1";
|
||||
}
|
||||
$dataset->yAxisID = 'y';
|
||||
}
|
||||
$dataset->label = $name;
|
||||
|
||||
$dataset->data[] = $value;
|
||||
$obj->datasets[] = clone $dataset;
|
||||
$ii++;
|
||||
} else {
|
||||
$obj->labels[] = $value * 1000;
|
||||
}
|
||||
}
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ii = 1;
|
||||
foreach ($row as $name => $value) {
|
||||
if ($name != "time") {
|
||||
$obj->datasets[$ii]->data[] = $value;
|
||||
$ii++;
|
||||
} else {
|
||||
if(($value * 1000) < $nextSimTimestamp){
|
||||
$obj->datasets[0]->data[] = $row1["Vorhersage"];
|
||||
}else{
|
||||
$row1 = $rownext;
|
||||
$rownext = $simRes->fetch_assoc();
|
||||
$nextSimTimestamp = $rownext["time"]*1000 + 30*60*1000;
|
||||
$obj->datasets[0]->data[] = $row1["Vorhersage"];
|
||||
}
|
||||
$obj->labels[] = $value * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$obj->labels[] = $nextSimTimestamp; //Draw future forecast
|
||||
$obj->datasets[0]->data[] = $rownext["Vorhersage"];
|
||||
while($rownext = $simRes->fetch_assoc()){
|
||||
$obj->labels[] = $rownext["time"]*1000 + 30*60*1000;
|
||||
$obj->datasets[0]->data[] = $rownext["Vorhersage"];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
//header('Content-Type: application/json');
|
||||
echo json_encode($obj);
|
||||
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
|
||||
?>
|
||||
@@ -1,171 +1,171 @@
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
|
||||
$consEstQuery = "SELECT
|
||||
UNIX_TIMESTAMP(DATE_ADD(datetime, INTERVAL 28 DAY)) AS 'time',
|
||||
SUM(-(totalConsumption + heaterPwr)/48) AS 'Vorraussichtl. Verbrauch'
|
||||
FROM EnergyFlow
|
||||
WHERE
|
||||
DATE(datetime) >= DATE(DATE_SUB(NOW(),INTERVAL 28 DAY)) And DATE(datetime) != DATE(NOW())
|
||||
GROUP BY WEEKDAY(datetime)
|
||||
ORDER BY datetime";
|
||||
|
||||
$prodEstQuery = "SELECT UNIX_TIMESTAMP(CONVERT_TZ(period_End,'GMT','Europe/Berlin')) AS 'time',
|
||||
SUM(power*500) AS 'Vorhersage'
|
||||
FROM simPower
|
||||
WHERE DATE(CONVERT_TZ(period_End,'GMT','Europe/Berlin')) >= DATE(DATE_SUB(NOW(),INTERVAL 7 DAY))
|
||||
GROUP BY DAY(CONVERT_TZ(period_End,'GMT','Europe/Berlin'))
|
||||
ORDER BY period_End;";
|
||||
|
||||
$prodRealQuery = "SELECT
|
||||
UNIX_TIMESTAMP(datetime) AS 'time',
|
||||
SUM(pvP/12) AS 'Tatsächliche Erzeugung',
|
||||
SUM(-totalConsumption/12) AS 'Tatsächlicher Verbrauch'
|
||||
FROM EnergyFlow
|
||||
WHERE
|
||||
DATE(datetime) >= DATE(DATE_SUB(NOW(),INTERVAL 7 DAY))
|
||||
GROUP BY DAY(datetime)
|
||||
ORDER BY datetime";
|
||||
|
||||
|
||||
$linecolors["Tatsächliche Erzeugung"] = "#cccc00";
|
||||
$linecolors["Tatsächlicher Verbrauch"] = "#EE9900";
|
||||
$linecolors["Vorraussichtl. Verbrauch"] = "#BB4400";
|
||||
$linecolors["Auto UG"] = "#00aaFF";
|
||||
$linecolors["Auto OG"] = "#0044FF";
|
||||
$linecolors["Heizstab"] = "#FF0000";
|
||||
$linecolors["Batterieladung"] = "#00aa00";
|
||||
$linecolors["Einspeisung"] = "#b0b0b0";
|
||||
$linecolors["Ladestand"] = "#00aa00";
|
||||
$linecolors["Vorhersage"] = "#4444FF";
|
||||
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||
$consEst = mysqli_query($mysql, $consEstQuery);
|
||||
$prodEst = mysqli_query($mysql,$prodEstQuery);
|
||||
$prodReal = mysqli_query($mysql,$prodRealQuery);
|
||||
if(!$consEst){
|
||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
if(!$prodEst){
|
||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
if(!$prodRealQuery){
|
||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
|
||||
$obj = (object)[]; // Cast empty array to object
|
||||
$obj->labels = [];
|
||||
$obj->datasets = [];
|
||||
$i = 0;
|
||||
$filled = 0;
|
||||
|
||||
if($consEst->num_rows > 1){
|
||||
$row = $consEst->fetch_assoc();
|
||||
foreach ($row as $name => $value) {
|
||||
$dataset = (object)[];
|
||||
if ($name != "time") {
|
||||
$dataset->borderColor = $linecolors[$name];
|
||||
$dataset->backgroundColor = $linecolors[$name]."66";
|
||||
$dataset->borderWidth=1;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
$dataset->stack = $name;
|
||||
//$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y';
|
||||
$dataset->label = $name;
|
||||
for($i=0;$i<7;$i++){
|
||||
$dataset->data[] = NULL;
|
||||
}
|
||||
$dataset->data[] = $value;
|
||||
$obj->datasets[] = clone $dataset;
|
||||
}
|
||||
}
|
||||
while ($row = $consEst->fetch_assoc()) {
|
||||
$ii = 0;
|
||||
foreach ($row as $name => $value) {
|
||||
if ($name != "time") {
|
||||
$obj->datasets[$ii]->data[] = $value;
|
||||
$ii++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($prodEst->num_rows > 1) {
|
||||
$row = $prodEst->fetch_assoc();
|
||||
foreach ($row as $name => $value) {
|
||||
$dataset = (object)[];
|
||||
if ($name != "time") {
|
||||
$dataset->borderColor = $linecolors[$name];
|
||||
$dataset->backgroundColor = $linecolors[$name]."55";
|
||||
$dataset->borderWidth=1;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
$dataset->stack = $name;
|
||||
//$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y';
|
||||
$dataset->label = $name;
|
||||
$dataset->data[] = $value;
|
||||
$obj->datasets[] = clone $dataset;
|
||||
} else {
|
||||
$obj->labels[] = $value * 1000;
|
||||
}
|
||||
}
|
||||
while ($row = $prodEst->fetch_assoc()) {
|
||||
$ii = 1;
|
||||
foreach ($row as $name => $value) {
|
||||
if ($name != "time") {
|
||||
$obj->datasets[$ii]->data[] = $value;
|
||||
$ii++;
|
||||
} else {
|
||||
$obj->labels[] = $value * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if($prodReal->num_rows > 1){
|
||||
$row = $prodReal->fetch_assoc();
|
||||
foreach ($row as $name => $value) {
|
||||
$dataset = (object)[];
|
||||
if ($name != "time") {
|
||||
$dataset->borderColor = $linecolors[$name];
|
||||
$dataset->backgroundColor = $linecolors[$name]."55";
|
||||
$dataset->borderWidth=1;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
$dataset->stack = $name;
|
||||
//$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y';
|
||||
$dataset->label = $name;
|
||||
$dataset->data[] = $value;
|
||||
$obj->datasets[] = clone $dataset;
|
||||
}
|
||||
}
|
||||
while ($row = $prodReal->fetch_assoc()) {
|
||||
$ii = 2;
|
||||
foreach ($row as $name => $value) {
|
||||
if ($name != "time") {
|
||||
$obj->datasets[$ii]->data[] = $value;
|
||||
$ii++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
//header('Content-Type: application/json');
|
||||
echo json_encode($obj);
|
||||
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
|
||||
$consEstQuery = "SELECT
|
||||
UNIX_TIMESTAMP(DATE_ADD(datetime, INTERVAL 28 DAY)) AS 'time',
|
||||
SUM(-(totalConsumption + heaterPwr)/48) AS 'Vorraussichtl. Verbrauch'
|
||||
FROM EnergyFlow
|
||||
WHERE
|
||||
DATE(datetime) >= DATE(DATE_SUB(NOW(),INTERVAL 28 DAY)) And DATE(datetime) != DATE(NOW())
|
||||
GROUP BY WEEKDAY(datetime)
|
||||
ORDER BY datetime";
|
||||
|
||||
$prodEstQuery = "SELECT UNIX_TIMESTAMP(CONVERT_TZ(period_End,'GMT','Europe/Berlin')) AS 'time',
|
||||
SUM(power*500) AS 'Vorhersage'
|
||||
FROM simPower
|
||||
WHERE DATE(CONVERT_TZ(period_End,'GMT','Europe/Berlin')) >= DATE(DATE_SUB(NOW(),INTERVAL 7 DAY))
|
||||
GROUP BY DAY(CONVERT_TZ(period_End,'GMT','Europe/Berlin'))
|
||||
ORDER BY period_End;";
|
||||
|
||||
$prodRealQuery = "SELECT
|
||||
UNIX_TIMESTAMP(datetime) AS 'time',
|
||||
SUM(pvP/12) AS 'Tatsächliche Erzeugung',
|
||||
SUM(-totalConsumption/12) AS 'Tatsächlicher Verbrauch'
|
||||
FROM EnergyFlow
|
||||
WHERE
|
||||
DATE(datetime) >= DATE(DATE_SUB(NOW(),INTERVAL 7 DAY))
|
||||
GROUP BY DAY(datetime)
|
||||
ORDER BY datetime";
|
||||
|
||||
|
||||
$linecolors["Tatsächliche Erzeugung"] = "#cccc00";
|
||||
$linecolors["Tatsächlicher Verbrauch"] = "#EE9900";
|
||||
$linecolors["Vorraussichtl. Verbrauch"] = "#BB4400";
|
||||
$linecolors["Auto UG"] = "#00aaFF";
|
||||
$linecolors["Auto OG"] = "#0044FF";
|
||||
$linecolors["Heizstab"] = "#FF0000";
|
||||
$linecolors["Batterieladung"] = "#00aa00";
|
||||
$linecolors["Einspeisung"] = "#b0b0b0";
|
||||
$linecolors["Ladestand"] = "#00aa00";
|
||||
$linecolors["Vorhersage"] = "#4444FF";
|
||||
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||
$consEst = mysqli_query($mysql, $consEstQuery);
|
||||
$prodEst = mysqli_query($mysql,$prodEstQuery);
|
||||
$prodReal = mysqli_query($mysql,$prodRealQuery);
|
||||
if(!$consEst){
|
||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
if(!$prodEst){
|
||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
if(!$prodRealQuery){
|
||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
|
||||
$obj = (object)[]; // Cast empty array to object
|
||||
$obj->labels = [];
|
||||
$obj->datasets = [];
|
||||
$i = 0;
|
||||
$filled = 0;
|
||||
|
||||
if($consEst->num_rows > 1){
|
||||
$row = $consEst->fetch_assoc();
|
||||
foreach ($row as $name => $value) {
|
||||
$dataset = (object)[];
|
||||
if ($name != "time") {
|
||||
$dataset->borderColor = $linecolors[$name];
|
||||
$dataset->backgroundColor = $linecolors[$name]."66";
|
||||
$dataset->borderWidth=1;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
$dataset->stack = $name;
|
||||
//$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y';
|
||||
$dataset->label = $name;
|
||||
for($i=0;$i<7;$i++){
|
||||
$dataset->data[] = NULL;
|
||||
}
|
||||
$dataset->data[] = $value;
|
||||
$obj->datasets[] = clone $dataset;
|
||||
}
|
||||
}
|
||||
while ($row = $consEst->fetch_assoc()) {
|
||||
$ii = 0;
|
||||
foreach ($row as $name => $value) {
|
||||
if ($name != "time") {
|
||||
$obj->datasets[$ii]->data[] = $value;
|
||||
$ii++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($prodEst->num_rows > 1) {
|
||||
$row = $prodEst->fetch_assoc();
|
||||
foreach ($row as $name => $value) {
|
||||
$dataset = (object)[];
|
||||
if ($name != "time") {
|
||||
$dataset->borderColor = $linecolors[$name];
|
||||
$dataset->backgroundColor = $linecolors[$name]."55";
|
||||
$dataset->borderWidth=1;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
$dataset->stack = $name;
|
||||
//$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y';
|
||||
$dataset->label = $name;
|
||||
$dataset->data[] = $value;
|
||||
$obj->datasets[] = clone $dataset;
|
||||
} else {
|
||||
$obj->labels[] = $value * 1000;
|
||||
}
|
||||
}
|
||||
while ($row = $prodEst->fetch_assoc()) {
|
||||
$ii = 1;
|
||||
foreach ($row as $name => $value) {
|
||||
if ($name != "time") {
|
||||
$obj->datasets[$ii]->data[] = $value;
|
||||
$ii++;
|
||||
} else {
|
||||
$obj->labels[] = $value * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if($prodReal->num_rows > 1){
|
||||
$row = $prodReal->fetch_assoc();
|
||||
foreach ($row as $name => $value) {
|
||||
$dataset = (object)[];
|
||||
if ($name != "time") {
|
||||
$dataset->borderColor = $linecolors[$name];
|
||||
$dataset->backgroundColor = $linecolors[$name]."55";
|
||||
$dataset->borderWidth=1;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
$dataset->stack = $name;
|
||||
//$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y';
|
||||
$dataset->label = $name;
|
||||
$dataset->data[] = $value;
|
||||
$obj->datasets[] = clone $dataset;
|
||||
}
|
||||
}
|
||||
while ($row = $prodReal->fetch_assoc()) {
|
||||
$ii = 2;
|
||||
foreach ($row as $name => $value) {
|
||||
if ($name != "time") {
|
||||
$obj->datasets[$ii]->data[] = $value;
|
||||
$ii++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
//header('Content-Type: application/json');
|
||||
echo json_encode($obj);
|
||||
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
|
||||
|
||||
@@ -1,105 +1,105 @@
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
|
||||
$heatQuery = "SELECT
|
||||
UNIX_TIMESTAMP(Heater.datetime) AS time,
|
||||
PufferU AS 'Speicher unten',
|
||||
PufferM AS 'Speicher mitte',
|
||||
PufferO AS 'Speicher oben',
|
||||
|
||||
|
||||
thermeVLfb AS 'Therme Vorlauf Fußboden',
|
||||
thermeRL AS 'Therme Rücklauf',
|
||||
heaterVL AS 'Heizstab Vorlauf',
|
||||
heaterRL AS 'Heizstab Rücklauf',
|
||||
fbVL AS 'Fußboden Vorlauf',
|
||||
fbRL AS 'Fußboden Rücklauf'
|
||||
FROM Heater
|
||||
WHERE Heater.datetime BETWEEN DATE_SUB(NOW(),INTERVAL 24 HOUR) and NOW()
|
||||
ORDER BY Heater.datetime";
|
||||
|
||||
$waterQuery = "SELECT UNIX_TIMESTAMP(wasser.datetime) AS time, rate AS 'Wasserverbrauch'
|
||||
FROM solarLog.wasser
|
||||
WHERE wasser.datetime BETWEEN DATE_SUB(NOW(),INTERVAL 24 HOUR) and NOW()
|
||||
ORDER BY wasser.datetime";
|
||||
|
||||
$linecolors["Speicher oben"] = "#FF5500";
|
||||
$linecolors["Speicher mitte"] = "#FFaa00";
|
||||
$linecolors["Speicher unten"] = "#FFFF00";
|
||||
$linecolors["Therme Vorlauf Fußboden"] = "#bb0000";
|
||||
$linecolors["Therme Rücklauf"] = "#ee0000";
|
||||
$linecolors["Heizstab Vorlauf"] = "#9900bb";
|
||||
$linecolors["Heizstab Rücklauf"] = "#8800aa";
|
||||
$linecolors["Fußboden Vorlauf"] = "#00FF00";
|
||||
$linecolors["Fußboden Rücklauf"] = "#00aa00";
|
||||
$linecolors["Wasserverbrauch"] = "#2222FF";
|
||||
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||
$result = mysqli_query($mysql, $heatQuery);
|
||||
$simRes = mysqli_query($mysql,$waterQuery);
|
||||
if(!$result){
|
||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
$obj = (object)[]; // Cast empty array to object
|
||||
$obj->labels = [];
|
||||
$obj->datasets = [];
|
||||
$i = 0;
|
||||
$filled = 0;
|
||||
|
||||
if ($result->num_rows > 1) {
|
||||
$ii = 0;
|
||||
$row = $result->fetch_assoc();
|
||||
foreach ($row as $name => $value) {
|
||||
$dataset = (object)[];
|
||||
if ($name != "time") {
|
||||
$dataset->borderColor = $linecolors[$name];
|
||||
$dataset->backgroundColor = $linecolors[$name]."22";
|
||||
$dataset->borderWidth=2;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
if(strpos($name,"Speicher") === false) {
|
||||
$dataset->fill = "none";
|
||||
} else {
|
||||
// $dataset->stack = "Consumers";
|
||||
if ($filled == 0) {
|
||||
$filled = 1;
|
||||
$dataset->fill = "origin";
|
||||
} else {
|
||||
$dataset->fill = "-1";
|
||||
}
|
||||
$dataset->yAxisID = 'y';
|
||||
}
|
||||
$dataset->label = $name;
|
||||
|
||||
$dataset->data[] = $value;
|
||||
$obj->datasets[] = clone $dataset;
|
||||
$ii++;
|
||||
} else {
|
||||
$obj->labels[] = $value * 1000;
|
||||
}
|
||||
}
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ii = 0;
|
||||
foreach ($row as $name => $value) {
|
||||
if ($name != "time") {
|
||||
$obj->datasets[$ii]->data[] = $value;
|
||||
$ii++;
|
||||
} else {
|
||||
$obj->labels[] = $value * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
//header('Content-Type: application/json');
|
||||
echo json_encode($obj);
|
||||
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
|
||||
$heatQuery = "SELECT
|
||||
UNIX_TIMESTAMP(Heater.datetime) AS time,
|
||||
PufferU AS 'Speicher unten',
|
||||
PufferM AS 'Speicher mitte',
|
||||
PufferO AS 'Speicher oben',
|
||||
|
||||
|
||||
thermeVLfb AS 'Therme Vorlauf Fußboden',
|
||||
thermeRL AS 'Therme Rücklauf',
|
||||
heaterVL AS 'Heizstab Vorlauf',
|
||||
heaterRL AS 'Heizstab Rücklauf',
|
||||
fbVL AS 'Fußboden Vorlauf',
|
||||
fbRL AS 'Fußboden Rücklauf'
|
||||
FROM Heater
|
||||
WHERE Heater.datetime BETWEEN DATE_SUB(NOW(),INTERVAL 24 HOUR) and NOW()
|
||||
ORDER BY Heater.datetime";
|
||||
|
||||
$waterQuery = "SELECT UNIX_TIMESTAMP(wasser.datetime) AS time, rate AS 'Wasserverbrauch'
|
||||
FROM solarLog.wasser
|
||||
WHERE wasser.datetime BETWEEN DATE_SUB(NOW(),INTERVAL 24 HOUR) and NOW()
|
||||
ORDER BY wasser.datetime";
|
||||
|
||||
$linecolors["Speicher oben"] = "#FF5500";
|
||||
$linecolors["Speicher mitte"] = "#FFaa00";
|
||||
$linecolors["Speicher unten"] = "#FFFF00";
|
||||
$linecolors["Therme Vorlauf Fußboden"] = "#bb0000";
|
||||
$linecolors["Therme Rücklauf"] = "#ee0000";
|
||||
$linecolors["Heizstab Vorlauf"] = "#9900bb";
|
||||
$linecolors["Heizstab Rücklauf"] = "#8800aa";
|
||||
$linecolors["Fußboden Vorlauf"] = "#00FF00";
|
||||
$linecolors["Fußboden Rücklauf"] = "#00aa00";
|
||||
$linecolors["Wasserverbrauch"] = "#2222FF";
|
||||
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||
$result = mysqli_query($mysql, $heatQuery);
|
||||
$simRes = mysqli_query($mysql,$waterQuery);
|
||||
if(!$result){
|
||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
$obj = (object)[]; // Cast empty array to object
|
||||
$obj->labels = [];
|
||||
$obj->datasets = [];
|
||||
$i = 0;
|
||||
$filled = 0;
|
||||
|
||||
if ($result->num_rows > 1) {
|
||||
$ii = 0;
|
||||
$row = $result->fetch_assoc();
|
||||
foreach ($row as $name => $value) {
|
||||
$dataset = (object)[];
|
||||
if ($name != "time") {
|
||||
$dataset->borderColor = $linecolors[$name];
|
||||
$dataset->backgroundColor = $linecolors[$name]."22";
|
||||
$dataset->borderWidth=2;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
if(strpos($name,"Speicher") === false) {
|
||||
$dataset->fill = "none";
|
||||
} else {
|
||||
// $dataset->stack = "Consumers";
|
||||
if ($filled == 0) {
|
||||
$filled = 1;
|
||||
$dataset->fill = "origin";
|
||||
} else {
|
||||
$dataset->fill = "-1";
|
||||
}
|
||||
$dataset->yAxisID = 'y';
|
||||
}
|
||||
$dataset->label = $name;
|
||||
|
||||
$dataset->data[] = $value;
|
||||
$obj->datasets[] = clone $dataset;
|
||||
$ii++;
|
||||
} else {
|
||||
$obj->labels[] = $value * 1000;
|
||||
}
|
||||
}
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ii = 0;
|
||||
foreach ($row as $name => $value) {
|
||||
if ($name != "time") {
|
||||
$obj->datasets[$ii]->data[] = $value;
|
||||
$ii++;
|
||||
} else {
|
||||
$obj->labels[] = $value * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
//header('Content-Type: application/json');
|
||||
echo json_encode($obj);
|
||||
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
|
||||
|
||||
@@ -1,165 +1,165 @@
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
if(!isset($_GET["TO"])){
|
||||
$_GET["TO"] = 12;
|
||||
}
|
||||
$_GET["TO"] = intval($_GET["TO"]);
|
||||
if(!isset($_GET["FROM"])){
|
||||
$_GET["FROM"] = -24;
|
||||
}
|
||||
$_GET["FROM"] = intval($_GET["FROM"]);
|
||||
$consQuery = "SELECT
|
||||
UNIX_TIMESTAMP(EnergyFlow.datetime) AS time,
|
||||
pvP AS Solarleistung,
|
||||
IF(-totalConsumption - IF(gridP>0, gridP, 0) - IF(battP>0, battP, 0) > 0, -totalConsumption - IF(gridP>0, gridP, 0) - IF(battP>0, battP, 0), 0) AS Direktverbrauch,
|
||||
IF(battP>0, battP, 0) AS Batteriebezug,
|
||||
gridPcons AS Netzbezug,
|
||||
-totalConsumption AS Verbrauch,".
|
||||
//IF(battP<0, -battP, 0) AS Batterieladung,
|
||||
"soc AS Ladestand
|
||||
FROM solarLog.EnergyFlow
|
||||
WHERE EnergyFlow.datetime BETWEEN DATE_ADD(NOW(),INTERVAL ".($_GET["FROM"])." HOUR) and DATE_ADD(NOW(),INTERVAL ".$_GET["TO"]." HOUR)
|
||||
ORDER BY EnergyFlow.datetime";
|
||||
|
||||
$simQuery = "SELECT
|
||||
UNIX_TIMESTAMP(simPower.period_end) AS time,
|
||||
power*1000 AS 'Vorhersage'
|
||||
FROM solarLog.simPower
|
||||
WHERE simPower.period_end BETWEEN DATE_ADD(NOW(),INTERVAL ".($_GET["FROM"])." HOUR) and DATE_ADD(NOW(),INTERVAL ".$_GET["TO"]." HOUR)
|
||||
ORDER BY simPower.period_end";
|
||||
$linecolors["Solarleistung"] = "#FFFF00";
|
||||
$linecolors["Direktverbrauch"] = "#FFcc00";
|
||||
$linecolors["Verbrauch"] = "#FFaa44";
|
||||
$linecolors["Auto UG"] = "#00aaFF";
|
||||
$linecolors["Auto OG"] = "#0044FF";
|
||||
$linecolors["Netzbezug"] = "#FF0000";
|
||||
$linecolors["Batteriebezug"] = "#00aa00";
|
||||
$linecolors["Batterieladung"] = "#0033aa";
|
||||
$linecolors["Einspeisung"] = "#b0b0b0";
|
||||
$linecolors["Ladestand"] = "#00aa00";
|
||||
$linecolors["Vorhersage"] = "#2222FF";
|
||||
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
|
||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||
$result = mysqli_query($mysql, $consQuery);
|
||||
$simRes = mysqli_query($mysql,$simQuery);
|
||||
if(!$result){
|
||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
$obj = (object)[]; // Cast empty array to object
|
||||
$obj->labels = [];
|
||||
$obj->datasets = [];
|
||||
$i = 0;
|
||||
$filled = 0;
|
||||
if ($simRes->num_rows > 1) {
|
||||
$dataset = (object)[];
|
||||
$row1 = $simRes->fetch_assoc();
|
||||
$dataset->borderColor = $linecolors["Vorhersage"];
|
||||
$dataset->backgroundColor = $linecolors["Vorhersage"]."55";
|
||||
$dataset->borderWidth=1.5;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
$dataset->stack = "sim";
|
||||
$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y';
|
||||
$dataset->label = "Vorhersage";
|
||||
/*$pt = (object)[];
|
||||
$pt->x = $row["time"]*1000;
|
||||
$pt->y = $row["Vorhersage"];
|
||||
$dataset->data[] = clone $pt;
|
||||
while ($row = $simRes->fetch_assoc()) {
|
||||
$pt = (object)[];
|
||||
$pt->x = $row["time"]*1000 + 30*60*1000;
|
||||
$pt->y = $row["Vorhersage"];
|
||||
$dataset->data[] = clone $pt;
|
||||
}*/
|
||||
$obj->datasets[] = clone $dataset;
|
||||
$rownext = $simRes->fetch_assoc();
|
||||
$nextSimTimestamp = $rownext["time"]*1000 + 30*60*1000;
|
||||
}
|
||||
if ($result->num_rows > 1) {
|
||||
$ii = 1;
|
||||
$row = $result->fetch_assoc();
|
||||
foreach ($row as $name => $value) {
|
||||
$dataset = (object)[];
|
||||
if ($name != "time") {
|
||||
$dataset->borderColor = $linecolors[$name];
|
||||
$dataset->backgroundColor = $linecolors[$name]."22";
|
||||
$dataset->borderWidth=1;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
if ($name == "Solarleistung") {
|
||||
$dataset->stack = "SolarPwr";
|
||||
$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y';
|
||||
}else if ($name == "Verbrauch") {
|
||||
$dataset->stack = "ConsPwr";
|
||||
$dataset->fill = "1";
|
||||
$dataset->yAxisID = 'y';
|
||||
}else if ($name == "Batterieladung") {
|
||||
$dataset->stack = "ConsPwr";
|
||||
$dataset->fill = "-1";
|
||||
$dataset->backgroundColor = $linecolors[$name]."77";
|
||||
$dataset->yAxisID = 'y';
|
||||
} else if ($name == "Ladestand") {
|
||||
$dataset->stack = "Charge";
|
||||
$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y1';
|
||||
} else {
|
||||
$dataset->stack = "Consumers";
|
||||
if ($filled == 0) {
|
||||
$filled = 1;
|
||||
$dataset->fill = "origin";
|
||||
} else {
|
||||
$dataset->fill = "-1";
|
||||
}
|
||||
$dataset->yAxisID = 'y';
|
||||
}
|
||||
$dataset->label = $name;
|
||||
|
||||
$dataset->data[] = $value;
|
||||
$obj->datasets[] = clone $dataset;
|
||||
$ii++;
|
||||
} else {
|
||||
$obj->labels[] = $value * 1000;
|
||||
}
|
||||
}
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ii = 1;
|
||||
foreach ($row as $name => $value) {
|
||||
if ($name != "time") {
|
||||
$obj->datasets[$ii]->data[] = $value;
|
||||
$ii++;
|
||||
} else {
|
||||
if(($value * 1000) < $nextSimTimestamp){
|
||||
$obj->datasets[0]->data[] = $row1["Vorhersage"];
|
||||
}else{
|
||||
$row1 = $rownext;
|
||||
$rownext = $simRes->fetch_assoc();
|
||||
$nextSimTimestamp = $rownext["time"]*1000 + 30*60*1000;
|
||||
$obj->datasets[0]->data[] = $row1["Vorhersage"];
|
||||
}
|
||||
$obj->labels[] = $value * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$obj->labels[] = $nextSimTimestamp; //Draw future forecast
|
||||
$obj->datasets[0]->data[] = $rownext["Vorhersage"];
|
||||
while($rownext = $simRes->fetch_assoc()){
|
||||
$obj->labels[] = $rownext["time"]*1000 + 30*60*1000;
|
||||
$obj->datasets[0]->data[] = $rownext["Vorhersage"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//header('Content-Type: application/json');
|
||||
echo json_encode($obj);
|
||||
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
if(!isset($_GET["TO"])){
|
||||
$_GET["TO"] = 12;
|
||||
}
|
||||
$_GET["TO"] = intval($_GET["TO"]);
|
||||
if(!isset($_GET["FROM"])){
|
||||
$_GET["FROM"] = -24;
|
||||
}
|
||||
$_GET["FROM"] = intval($_GET["FROM"]);
|
||||
$consQuery = "SELECT
|
||||
UNIX_TIMESTAMP(EnergyFlow.datetime) AS time,
|
||||
pvP AS Solarleistung,
|
||||
IF(-totalConsumption - IF(gridP>0, gridP, 0) - IF(battP>0, battP, 0) > 0, -totalConsumption - IF(gridP>0, gridP, 0) - IF(battP>0, battP, 0), 0) AS Direktverbrauch,
|
||||
IF(battP>0, battP, 0) AS Batteriebezug,
|
||||
gridPcons AS Netzbezug,
|
||||
-totalConsumption AS Verbrauch,".
|
||||
//IF(battP<0, -battP, 0) AS Batterieladung,
|
||||
"soc AS Ladestand
|
||||
FROM solarLog.EnergyFlow
|
||||
WHERE EnergyFlow.datetime BETWEEN DATE_ADD(NOW(),INTERVAL ".($_GET["FROM"])." HOUR) and DATE_ADD(NOW(),INTERVAL ".$_GET["TO"]." HOUR)
|
||||
ORDER BY EnergyFlow.datetime";
|
||||
|
||||
$simQuery = "SELECT
|
||||
UNIX_TIMESTAMP(simPower.period_end) AS time,
|
||||
power*1000 AS 'Vorhersage'
|
||||
FROM solarLog.simPower
|
||||
WHERE simPower.period_end BETWEEN DATE_ADD(NOW(),INTERVAL ".($_GET["FROM"])." HOUR) and DATE_ADD(NOW(),INTERVAL ".$_GET["TO"]." HOUR)
|
||||
ORDER BY simPower.period_end";
|
||||
$linecolors["Solarleistung"] = "#FFFF00";
|
||||
$linecolors["Direktverbrauch"] = "#FFcc00";
|
||||
$linecolors["Verbrauch"] = "#FFaa44";
|
||||
$linecolors["Auto UG"] = "#00aaFF";
|
||||
$linecolors["Auto OG"] = "#0044FF";
|
||||
$linecolors["Netzbezug"] = "#FF0000";
|
||||
$linecolors["Batteriebezug"] = "#00aa00";
|
||||
$linecolors["Batterieladung"] = "#0033aa";
|
||||
$linecolors["Einspeisung"] = "#b0b0b0";
|
||||
$linecolors["Ladestand"] = "#00aa00";
|
||||
$linecolors["Vorhersage"] = "#2222FF";
|
||||
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
|
||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||
$result = mysqli_query($mysql, $consQuery);
|
||||
$simRes = mysqli_query($mysql,$simQuery);
|
||||
if(!$result){
|
||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
$obj = (object)[]; // Cast empty array to object
|
||||
$obj->labels = [];
|
||||
$obj->datasets = [];
|
||||
$i = 0;
|
||||
$filled = 0;
|
||||
if ($simRes->num_rows > 1) {
|
||||
$dataset = (object)[];
|
||||
$row1 = $simRes->fetch_assoc();
|
||||
$dataset->borderColor = $linecolors["Vorhersage"];
|
||||
$dataset->backgroundColor = $linecolors["Vorhersage"]."55";
|
||||
$dataset->borderWidth=1.5;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
$dataset->stack = "sim";
|
||||
$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y';
|
||||
$dataset->label = "Vorhersage";
|
||||
/*$pt = (object)[];
|
||||
$pt->x = $row["time"]*1000;
|
||||
$pt->y = $row["Vorhersage"];
|
||||
$dataset->data[] = clone $pt;
|
||||
while ($row = $simRes->fetch_assoc()) {
|
||||
$pt = (object)[];
|
||||
$pt->x = $row["time"]*1000 + 30*60*1000;
|
||||
$pt->y = $row["Vorhersage"];
|
||||
$dataset->data[] = clone $pt;
|
||||
}*/
|
||||
$obj->datasets[] = clone $dataset;
|
||||
$rownext = $simRes->fetch_assoc();
|
||||
$nextSimTimestamp = $rownext["time"]*1000 + 30*60*1000;
|
||||
}
|
||||
if ($result->num_rows > 1) {
|
||||
$ii = 1;
|
||||
$row = $result->fetch_assoc();
|
||||
foreach ($row as $name => $value) {
|
||||
$dataset = (object)[];
|
||||
if ($name != "time") {
|
||||
$dataset->borderColor = $linecolors[$name];
|
||||
$dataset->backgroundColor = $linecolors[$name]."22";
|
||||
$dataset->borderWidth=1;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
if ($name == "Solarleistung") {
|
||||
$dataset->stack = "SolarPwr";
|
||||
$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y';
|
||||
}else if ($name == "Verbrauch") {
|
||||
$dataset->stack = "ConsPwr";
|
||||
$dataset->fill = "1";
|
||||
$dataset->yAxisID = 'y';
|
||||
}else if ($name == "Batterieladung") {
|
||||
$dataset->stack = "ConsPwr";
|
||||
$dataset->fill = "-1";
|
||||
$dataset->backgroundColor = $linecolors[$name]."77";
|
||||
$dataset->yAxisID = 'y';
|
||||
} else if ($name == "Ladestand") {
|
||||
$dataset->stack = "Charge";
|
||||
$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y1';
|
||||
} else {
|
||||
$dataset->stack = "Consumers";
|
||||
if ($filled == 0) {
|
||||
$filled = 1;
|
||||
$dataset->fill = "origin";
|
||||
} else {
|
||||
$dataset->fill = "-1";
|
||||
}
|
||||
$dataset->yAxisID = 'y';
|
||||
}
|
||||
$dataset->label = $name;
|
||||
|
||||
$dataset->data[] = $value;
|
||||
$obj->datasets[] = clone $dataset;
|
||||
$ii++;
|
||||
} else {
|
||||
$obj->labels[] = $value * 1000;
|
||||
}
|
||||
}
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ii = 1;
|
||||
foreach ($row as $name => $value) {
|
||||
if ($name != "time") {
|
||||
$obj->datasets[$ii]->data[] = $value;
|
||||
$ii++;
|
||||
} else {
|
||||
if(($value * 1000) < $nextSimTimestamp){
|
||||
$obj->datasets[0]->data[] = $row1["Vorhersage"];
|
||||
}else{
|
||||
$row1 = $rownext;
|
||||
$rownext = $simRes->fetch_assoc();
|
||||
$nextSimTimestamp = $rownext["time"]*1000 + 30*60*1000;
|
||||
$obj->datasets[0]->data[] = $row1["Vorhersage"];
|
||||
}
|
||||
$obj->labels[] = $value * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$obj->labels[] = $nextSimTimestamp; //Draw future forecast
|
||||
$obj->datasets[0]->data[] = $rownext["Vorhersage"];
|
||||
while($rownext = $simRes->fetch_assoc()){
|
||||
$obj->labels[] = $rownext["time"]*1000 + 30*60*1000;
|
||||
$obj->datasets[0]->data[] = $rownext["Vorhersage"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//header('Content-Type: application/json');
|
||||
echo json_encode($obj);
|
||||
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
|
||||
?>
|
||||
@@ -1,211 +1,369 @@
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
require_once("../restricted/costs.php");
|
||||
if(!isset($_GET["year"])){
|
||||
$_GET["year"] = date("Y");
|
||||
}else{
|
||||
$_GET["year"] = intval($_GET["year"]);
|
||||
}
|
||||
if($_GET["year"] < 2018 || $_GET["year"] > date("Y")){
|
||||
exit;
|
||||
}
|
||||
if(isset($_GET["type"])){
|
||||
if(strtolower($_GET["type"]) =="lastyear"){
|
||||
$_GET["year"] = date("Y")-1;
|
||||
}elseif(strtolower($_GET["type"]) =="prelastyear"){
|
||||
$_GET["year"] = date("Y")-2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Jahresstatistik als eine Abfrage - jetzt auf stats_daily.
|
||||
*
|
||||
* Gerechnet wird nicht mehr ueber die Rohdaten aus EnergyFlow, sondern ueber
|
||||
* die Tageswerte. Zwei Gruende:
|
||||
*
|
||||
* 1. Die Rohdaten werden nach zwoelf Monaten ausgeduennt bzw. ins
|
||||
* Stundenarchiv verschoben (solarlog-rollup.sh). Eine Kachel, die auf
|
||||
* EnergyFlow rechnet, zeigt fuer alte Jahre danach zu wenig an.
|
||||
* 2. Ein Jahr sind 365 Zeilen statt rund 105.000.
|
||||
*
|
||||
* Die Tarife bleiben unveraendert: je Zeile ein Stichtag, das Ende holt
|
||||
* LEAD() beim naechsten. Weil ein Stichtag ein DATUM ist, kann ein Tag nie
|
||||
* zwei Tarife haben - die Verdichtung auf Tage verliert hier also nichts.
|
||||
*
|
||||
* Was in stats_daily vorberechnet sein MUSS, weil es eine Bedingung je
|
||||
* Messwert auswertet und sich aus Tagessummen nicht rekonstruieren laesst:
|
||||
*
|
||||
* eigenverbrauch_kwh pv - Einspeisung - Batterieladung
|
||||
* - Heizstab + Batterieentladung. Dazu addiert
|
||||
* wird pv_fehlbetrag_kwh: bei Ausfall einzelner
|
||||
* Wechselrichter fehlt deren Beitrag in pv, alle
|
||||
* Senken sind dagegen gemessen - die Differenz
|
||||
* faellt sonst zu klein oder negativ aus.
|
||||
* heizstab_solar_kwh Heizstab, anteilig nach autonomy
|
||||
* auto_*_solar_kwh Wallbox, anteilig nach autonomy - dem vom
|
||||
* Geraet in feiner Aufloesung gerechneten
|
||||
* Nicht-Netz-Anteil der Zufuhr
|
||||
*
|
||||
* Einheiten sind in stats_daily bereits bereinigt: PL*_EV und PL*_EVog
|
||||
* liefern Kilowatt, alle uebrigen Leistungsspalten Watt. In der alten
|
||||
* Abfrage stand das als /12 gegen /12000 nebeneinander.
|
||||
*
|
||||
* Die Solaranteile teilen jetzt durch NULLIF(...): ohne Ladung im Jahr kam
|
||||
* vorher 0/0 heraus.
|
||||
*
|
||||
* "Ersparnis Batterie" rechnet den Nutzen netto: was die entladene Energie
|
||||
* aus dem Netz gekostet haette, abzueglich der Verguetung, die fuer den zum
|
||||
* Laden verwendeten Strom entgangen ist. Brutto waere die Kachel zu
|
||||
* freundlich - der Speicher ist nicht umsonst zu fuellen.
|
||||
*
|
||||
* Achtung beim Lesen: "Verbrauchsersparnis" ist der GESAMTE Eigenverbrauch
|
||||
* zum Strompreis. "Ersparnis Batterie" und "Ersparnis Solarladung" sind
|
||||
* Teilmengen davon, nach Herkunft bzw. Verwendung herausgegriffen. Sie
|
||||
* duerfen nicht addiert werden.
|
||||
*
|
||||
* Der Grundpreis fehlt weiterhin bewusst - er haengt an den Tagen des
|
||||
* Jahres, nicht an einem Messwert, und kommt aus grundpreisImJahr() dazu.
|
||||
*/
|
||||
function statistikAbfrage($jahr)
|
||||
{
|
||||
return "WITH
|
||||
preis AS (SELECT active_date, cost, gain,
|
||||
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gridCosts),
|
||||
gas AS (SELECT active_date, cost, kwhPerLitre,
|
||||
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gasCosts),
|
||||
sprit AS (SELECT active_date, cost, lPer100km, kwhPer100km,
|
||||
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM fuelCosts)
|
||||
SELECT
|
||||
SUM(d.netz_bezug_kwh) AS 'Stromverbrauch',
|
||||
SUM(d.netz_bezug_kwh * preis.cost) AS 'Stromkosten',
|
||||
SUM((d.eigenverbrauch_kwh + IFNULL(d.pv_fehlbetrag_kwh,0)) * preis.cost) AS 'Verbrauchsersparnis',
|
||||
SUM(d.netz_einsp_kwh * preis.gain) AS 'Einspeisevergütung',
|
||||
SUM(d.heizstab_solar_kwh * gas.cost / NULLIF(gas.kwhPerLitre,0)) AS 'Ersparnis Heizung',
|
||||
SUM(d.batt_entladen_kwh * preis.cost - d.batt_laden_kwh * preis.gain) AS 'Ersparnis Batterie',
|
||||
SUM(d.autarkie_avg * d.stunden_erfasst) / NULLIF(SUM(d.stunden_erfasst),0) AS 'Ø Autarkie',
|
||||
SUM(d.auto_eg_kwh) AS 'Autoladung ges. EG',
|
||||
SUM(d.auto_eg_solar_kwh) / NULLIF(SUM(d.auto_eg_kwh),0) * 100 AS 'Autoladung Solar EG',
|
||||
SUM(d.auto_eg_solar_kwh * preis.cost) AS 'Ersparnis Solarladung EG',
|
||||
SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart EG',
|
||||
SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin EG',
|
||||
SUM(d.auto_og_kwh) AS 'Autoladung ges. OG',
|
||||
SUM(d.auto_og_solar_kwh) / NULLIF(SUM(d.auto_og_kwh),0) * 100 AS 'Autoladung Solar OG',
|
||||
SUM(d.auto_og_solar_kwh * preis.cost) AS 'Ersparnis Solarladung OG',
|
||||
SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart OG',
|
||||
SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin OG'
|
||||
FROM stats_daily d
|
||||
JOIN preis ON d.tag >= preis.active_date AND (preis.folgt IS NULL OR d.tag < preis.folgt)
|
||||
LEFT JOIN gas ON d.tag >= gas.active_date AND (gas.folgt IS NULL OR d.tag < gas.folgt)
|
||||
LEFT JOIN sprit ON d.tag >= sprit.active_date AND (sprit.folgt IS NULL OR d.tag < sprit.folgt)
|
||||
WHERE YEAR(d.tag) = " . intval($jahr) . ";";
|
||||
}
|
||||
|
||||
$Query = statistikAbfrage($_GET["year"]);
|
||||
$PrevQuery = statistikAbfrage($_GET["year"] - 1);
|
||||
|
||||
// Reihenfolge wie in der Abfrage, mit "Verg.+Einsp. Strom" an Stelle 4.
|
||||
$units = Array("kWh","€","€","€","€","€","€","%",
|
||||
"kWh","%","€","L","€",
|
||||
"kWh","%","€","L","€");
|
||||
// Nur bei Verbrauch und Kosten ist weniger besser.
|
||||
$LessIsBetter = array_merge([true, true], array_fill(0, count($units) - 2, false));
|
||||
|
||||
function array_insert($array,$values,$offset) {
|
||||
return array_slice($array, 0, $offset, true) + $values + array_slice($array, $offset, NULL, true);
|
||||
}
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
|
||||
$html = "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
|
||||
$mysql = solarDb();
|
||||
$Res = mysqli_query($mysql,$Query);
|
||||
$ResPrev = mysqli_query($mysql,$PrevQuery);
|
||||
if(!$Res || !$ResPrev){
|
||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
if ($Res->num_rows > 0) {
|
||||
$i = 0;
|
||||
$row = $Res->fetch_assoc();
|
||||
$rowPrev = $ResPrev->fetch_assoc();
|
||||
// Der Grundpreis faellt unabhaengig vom Verbrauch an und laesst sich
|
||||
// deshalb nicht aus den Messwerten summieren.
|
||||
$row["Stromkosten"] += grundpreisImJahr($_GET["year"]);
|
||||
$rowPrev["Stromkosten"] += grundpreisImJahr($_GET["year"] - 1);
|
||||
//$row["Vergütung+Einsparung Strom"] = $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"];
|
||||
$row = array_insert($row,["Verg.+Einsp. Strom" => $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"]],4);
|
||||
$rowPrev = array_insert($rowPrev,["Verg.+Einsp. Strom" => $rowPrev["Einspeisevergütung"]+$rowPrev["Verbrauchsersparnis"]],4);
|
||||
$i = 0;
|
||||
foreach ($row as $name => $value) {
|
||||
if(str_starts_with($name,"Autoladung ges.")){
|
||||
$html .= "</div><hr class='mt-3 mb-3 border-light' />";
|
||||
$html .= "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
|
||||
}
|
||||
/*$html .= "<div class='col-12 col-sm-4 col-md-2 col-xl-1'>
|
||||
<div class='info-box'>
|
||||
<div class='info-box-content'>
|
||||
<span class='info-box-text'>".$name."</span>
|
||||
<span class='info-box-number'>".
|
||||
number_format(floatval($value),2,",",".")
|
||||
."<small>".$units[$i++]."</small>
|
||||
</span>
|
||||
</div>
|
||||
<!-- /.info-box-content -->
|
||||
</div>
|
||||
</div>";*/
|
||||
$html .= "<div class='col'>
|
||||
<div class='card stats border-0'>
|
||||
<div class='card-body bg-dark bg-gradient rounded-top pb-1 pt-1'>
|
||||
".$name."
|
||||
</div>
|
||||
<div class='card-footer text-center'>";
|
||||
if($_GET["year"] != date("Y")){
|
||||
if($LessIsBetter[$i]){
|
||||
if($value == 0)
|
||||
$dev=100;
|
||||
else
|
||||
$dev = $rowPrev[$name]/$value;
|
||||
//$first = round($rowPrev[$name]*10/$rowPrev[$name]); //allow for 10% deviation for displaying no tendency
|
||||
//$second = round($value); //allow for 10% deviation for displaying no tendency
|
||||
$arrowGood = "down";
|
||||
$arrowBad = "up";
|
||||
}else{
|
||||
if($rowPrev[$name] == 0)
|
||||
$dev=100;
|
||||
else
|
||||
$dev = $value/$rowPrev[$name];
|
||||
//$first = round($value); //allow for 10% deviation for displaying no tendency
|
||||
//$second = round($rowPrev[$name]); //allow for 10% deviation for displaying no tendency
|
||||
$arrowGood = "up";
|
||||
$arrowBad = "down";
|
||||
}
|
||||
if($dev > 1.05){
|
||||
$html .= "<span class='float-right text-success-emphasis'>
|
||||
<i class='bi bi-arrow-".$arrowGood."' style='font-size: 0.9em;'></i> ";
|
||||
}elseif($dev < 0.95){
|
||||
$html .= "<span class='float-right text-danger-emphasis'>
|
||||
<i class='bi bi-arrow-".$arrowBad."' style='font-size: 0.9em;'></i> ";
|
||||
}else{
|
||||
$html .= "<span class='float-right'>
|
||||
<i class='bi bi-arrow-right' style='font-size: 0.9em;'></i> ";
|
||||
}
|
||||
}
|
||||
else{
|
||||
$html .= "<span class='float-right'>";
|
||||
}
|
||||
$html .= number_format(floatval($value),2,",",".")
|
||||
."<small> ".$units[$i]."</small></span></div>
|
||||
<!-- /.info-box-content -->
|
||||
</div>
|
||||
</div>";
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
$html .= "</div>";
|
||||
}
|
||||
|
||||
|
||||
//header('Content-Type: application/json');
|
||||
echo $html;
|
||||
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
|
||||
<?php
|
||||
/*
|
||||
* Jahresstatistik der Anlage - alle Jahre, alle Zeitraeume, als JSON.
|
||||
*
|
||||
* Frueher lieferte diese Datei fertiges HTML: je Aufruf ein Jahr, 18 Kacheln,
|
||||
* Einheit und Richtung in zwei Arrays, die ueber den Spaltenindex mit der
|
||||
* Abfrage synchron gehalten werden mussten. Wer eine Kennzahl einfuegte,
|
||||
* musste an drei Stellen zaehlen.
|
||||
*
|
||||
* Jetzt kommt alles auf einmal und beschreibt sich selbst:
|
||||
*
|
||||
* kennzahlen Metadaten je Kennzahl - Name, Einheit, Nachkommastellen,
|
||||
* Richtung, Gruppe, Einrueckung. Wer eine hinzufuegt, schreibt
|
||||
* sie EINMAL in KENNZAHLEN und liefert ihren Wert in
|
||||
* kennzahlenAus(); Anzeige und Reihenfolge ergeben sich daraus.
|
||||
* werte drei Saetze: ganze Jahre, gleicher Zeitraum (1. Januar bis
|
||||
* zum heutigen Stichtag) und die Summe ueber alle Jahre.
|
||||
* monate Monatswerte je Jahr fuer die Verlaufsbalken.
|
||||
*
|
||||
* Warum drei Saetze und nicht einer: ein laufendes Jahr gegen ein volles zu
|
||||
* stellen vergleicht Aepfel mit Birnen. Der Satz "gleich" schneidet jedes Jahr
|
||||
* am selben Kalendertag ab und macht das laufende Jahr damit beurteilbar.
|
||||
*
|
||||
* Gerechnet wird auf stats_daily, den Tageswerten:
|
||||
*
|
||||
* 1. Die Rohdaten werden nach zwoelf Monaten ausgeduennt bzw. ins
|
||||
* Stundenarchiv verschoben (solarlog-rollup.sh). Wer auf EnergyFlow
|
||||
* rechnet, zeigt fuer alte Jahre zu wenig an.
|
||||
* 2. Ein Jahr sind 365 Zeilen statt rund 105.000.
|
||||
*
|
||||
* Die Tarife bleiben unveraendert: je Zeile ein Stichtag, das Ende holt LEAD()
|
||||
* beim naechsten. Weil ein Stichtag ein DATUM ist, kann ein Tag nie zwei
|
||||
* Tarife haben - die Verdichtung auf Tage verliert hier also nichts.
|
||||
*
|
||||
* Was in stats_daily vorberechnet sein MUSS, weil es eine Bedingung je
|
||||
* Messwert auswertet und sich aus Tagessummen nicht rekonstruieren laesst:
|
||||
*
|
||||
* eigenverbrauch_kwh pv - Einspeisung - Batterieladung - Heizstab
|
||||
* + Batterieentladung. Dazu addiert wird
|
||||
* pv_fehlbetrag_kwh: bei Ausfall einzelner
|
||||
* Wechselrichter fehlt deren Beitrag in pv, alle
|
||||
* Senken sind dagegen gemessen - die Differenz
|
||||
* faellt sonst zu klein oder negativ aus.
|
||||
* heizstab_solar_kwh Heizstab, anteilig nach autonomy
|
||||
* auto_*_solar_kwh Wallbox, anteilig nach autonomy - dem vom Geraet
|
||||
* in feiner Aufloesung gerechneten Nicht-Netz-
|
||||
* Anteil der Zufuhr
|
||||
*
|
||||
* Die Abfragen liefern nur SUMMEN. Jede Verhaeltniszahl - Autarkie, Quoten,
|
||||
* effektiver Preis - rechnet kennzahlenAus() daraus, und zwar auf jeder Ebene
|
||||
* neu. Das ist keine Formsache: Durchschnitte darf man nicht addieren, ein
|
||||
* Jahresmittel ist nicht das Mittel der Monatsmittel, und die Autarkie ueber
|
||||
* acht Jahre schon gar nicht die Summe von acht Prozentzahlen.
|
||||
*
|
||||
* Der Grundpreis faellt unabhaengig vom Verbrauch an und steckt deshalb in
|
||||
* keiner Summe, sondern kommt aus grundpreisImZeitraum() dazu.
|
||||
*
|
||||
* Achtung beim Lesen der Ertragsgruppe: Eigenverbrauch, Einspeisung und
|
||||
* Heizung TEILEN den Ertrag auf, sie ergeben ihn zusammen. Der Beitrag der
|
||||
* Batterie und die Solarladung des Autos stecken dagegen IM EIGENVERBRAUCH -
|
||||
* der Strom, der aus der Batterie kommt oder ins Auto geht, ist ja Strom, der
|
||||
* im Haus geblieben ist. Sie sind nach Herkunft bzw. Verwendung
|
||||
* herausgegriffen und duerfen nicht dazuaddiert werden. Die Heizung ist davon
|
||||
* unberuehrt: heizstab_solar_kwh ist aus eigenverbrauch_kwh herausgerechnet
|
||||
* und gegen den Gaspreis bewertet, nicht gegen den Strompreis.
|
||||
*
|
||||
* Beim Beitrag der Batterie ist "darin" der Groesse nach zu lesen: gerechnet
|
||||
* ist er netto, also abzueglich der Verguetung, die fuer den zum Laden
|
||||
* verwendeten Strom entgangen ist. Brutto waere die Zeile zu freundlich - der
|
||||
* Speicher ist nicht umsonst zu fuellen.
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
require_once("../restricted/costs.php");
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if (!checkLogin()) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Nicht angemeldet."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Kennzahlen --
|
||||
*
|
||||
* Die Anzeige-Beschreibung. Reihenfolge ist Anzeigereihenfolge.
|
||||
*
|
||||
* einheit wie sie an der Zeile steht
|
||||
* dez Nachkommastellen. Jahressummen brauchen keine Cent.
|
||||
* invers weniger ist besser (Verbrauch, Kosten)
|
||||
* punkte Prozentwert; Veraenderungen sind Prozentpunkte, nicht Prozent
|
||||
* summe Zeile, unter der sich Teilbetraege aufreihen
|
||||
* stufe/teil eingerueckt: "davon" teilt die Summe darueber auf
|
||||
* teilmenge "darin": steckt bereits in der genannten Zeile und darf nicht
|
||||
* dazuaddiert werden. Der Wert ist deren id, damit die Anzeige
|
||||
* sagen kann, WORIN es steckt - die blosse Reihenfolge liesse
|
||||
* offen, ob die vorangehende oder die uebergeordnete Zeile
|
||||
* gemeint ist.
|
||||
* klapp Zeile mit Teilzeilen, zunaechst zusammengeklappt
|
||||
* unter gehoert in die Klappgruppe dieser Zeile. Das ist etwas
|
||||
* anderes als teilmenge: unter sagt, WOMIT eine Zeile
|
||||
* auf- und zugeht, teilmenge, WORIN ihr Wert steckt.
|
||||
* braucht Spalte in stats_daily, ohne die es die Kennzahl nicht gibt
|
||||
*/
|
||||
const KENNZAHLEN = [
|
||||
["gruppe" => "Strom vom Netz"],
|
||||
["id" => "bezug", "name" => "Netzbezug", "einheit" => "kWh", "dez" => 0, "invers" => true],
|
||||
["id" => "kosten", "name" => "Netzkosten (mit Grundpreis)", "einheit" => "€", "dez" => 0, "invers" => true],
|
||||
["id" => "preis", "name" => "Effektiver Strompreis", "einheit" => "ct/kWh", "dez" => 1, "invers" => true],
|
||||
|
||||
["gruppe" => "Was die Anlage einbringt"],
|
||||
["id" => "ertrag", "name" => "Ertrag der Anlage", "einheit" => "€", "dez" => 0, "summe" => true, "klapp" => true],
|
||||
["id" => "eigen", "name" => "Eigenverbrauch", "einheit" => "€", "dez" => 0, "teil" => true, "unter" => "ertrag"],
|
||||
["id" => "batterie", "name" => "Beitrag der Batterie", "einheit" => "€", "dez" => 0, "teilmenge" => "eigen", "unter" => "ertrag"],
|
||||
["id" => "solarauto", "name" => "Solarladung des Autos", "einheit" => "€", "dez" => 0, "teilmenge" => "eigen", "unter" => "ertrag"],
|
||||
["id" => "einsp", "name" => "Einspeisung", "einheit" => "€", "dez" => 0, "teil" => true, "unter" => "ertrag"],
|
||||
["id" => "heizung", "name" => "Heizung über den Heizstab", "einheit" => "€", "dez" => 0, "teil" => true, "unter" => "ertrag"],
|
||||
["id" => "autarkie", "name" => "Autarkie", "einheit" => "%", "dez" => 1, "punkte" => true],
|
||||
["id" => "quote", "name" => "Eigenverbrauchsquote", "einheit" => "%", "dez" => 1, "punkte" => true,
|
||||
"braucht" => "pv_kwh"],
|
||||
|
||||
["gruppe" => "Auto"],
|
||||
["id" => "geladen", "name" => "Geladen", "einheit" => "kWh", "dez" => 0, "summe" => true, "klapp" => true],
|
||||
["id" => "ladenEG", "name" => "Wallbox EG", "einheit" => "kWh", "dez" => 0, "teil" => true, "unter" => "geladen"],
|
||||
["id" => "ladenOG", "name" => "Wallbox OG", "einheit" => "kWh", "dez" => 0, "teil" => true, "unter" => "geladen"],
|
||||
["id" => "solaranteil","name" => "Solaranteil der Ladung", "einheit" => "%", "dez" => 1, "punkte" => true],
|
||||
["id" => "benzin", "name" => "Benzin gespart", "einheit" => "L", "dez" => 0],
|
||||
["id" => "benzingeld", "name" => "Ersparnis gegenüber Benzin", "einheit" => "€", "dez" => 0],
|
||||
];
|
||||
|
||||
/* Die drei Kennzahlen, die oben gross stehen. */
|
||||
const LEITZAHLEN = ["ertrag", "kosten", "autarkie"];
|
||||
|
||||
/* ------------------------------------------------------------------ Summen --
|
||||
*
|
||||
* Alle Grundgroessen in einem Ausdruck. Was hier steht, ist addierbar - genau
|
||||
* das ist die Bedingung dafuer, dass Monat, Jahr, Zeitraum und Gesamtsumme
|
||||
* aus derselben Abfrage entstehen koennen.
|
||||
*/
|
||||
function summenSQL($hatPv)
|
||||
{
|
||||
return "
|
||||
SUM(d.netz_bezug_kwh) AS bezug_kwh,
|
||||
SUM(d.netz_bezug_kwh * preis.cost) AS bezug_eur,
|
||||
SUM(d.netz_einsp_kwh) AS einsp_kwh,
|
||||
SUM(d.netz_einsp_kwh * preis.gain) AS einsp_eur,
|
||||
SUM((d.eigenverbrauch_kwh + IFNULL(d.pv_fehlbetrag_kwh,0)) * preis.cost) AS eigen_eur,
|
||||
" . ($hatPv ? "SUM(d.pv_kwh + IFNULL(d.pv_fehlbetrag_kwh,0))" : "NULL") . " AS pv_kwh,
|
||||
SUM(d.heizstab_solar_kwh * gas.cost / NULLIF(gas.kwhPerLitre,0)) AS heiz_eur,
|
||||
SUM(d.batt_entladen_kwh * preis.cost - d.batt_laden_kwh * preis.gain) AS batt_eur,
|
||||
SUM(d.autarkie_avg * d.stunden_erfasst) AS autarkie_gew,
|
||||
SUM(d.stunden_erfasst) AS stunden,
|
||||
SUM(d.auto_eg_kwh) AS eg_kwh,
|
||||
SUM(d.auto_og_kwh) AS og_kwh,
|
||||
SUM(d.auto_eg_solar_kwh) AS eg_solar_kwh,
|
||||
SUM(d.auto_og_solar_kwh) AS og_solar_kwh,
|
||||
SUM(d.auto_eg_solar_kwh * preis.cost) AS eg_solar_eur,
|
||||
SUM(d.auto_og_solar_kwh * preis.cost) AS og_solar_eur,
|
||||
SUM((d.auto_eg_kwh + d.auto_og_kwh) * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS benzin_l,
|
||||
SUM((d.auto_eg_kwh + d.auto_og_kwh) * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)
|
||||
* sprit.cost) AS benzin_eur";
|
||||
}
|
||||
|
||||
/** Der gemeinsame Rumpf: Tageswerte, an jedem Tag der damals gueltige Tarif. */
|
||||
function vonTarifen()
|
||||
{
|
||||
return "
|
||||
FROM stats_daily d
|
||||
JOIN preis ON d.tag >= preis.active_date AND (preis.folgt IS NULL OR d.tag < preis.folgt)
|
||||
LEFT JOIN gas ON d.tag >= gas.active_date AND (gas.folgt IS NULL OR d.tag < gas.folgt)
|
||||
LEFT JOIN sprit ON d.tag >= sprit.active_date AND (sprit.folgt IS NULL OR d.tag < sprit.folgt)";
|
||||
}
|
||||
|
||||
function mitTarifen()
|
||||
{
|
||||
return "WITH
|
||||
preis AS (SELECT active_date, cost, gain,
|
||||
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gridCosts),
|
||||
gas AS (SELECT active_date, cost, kwhPerLitre,
|
||||
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gasCosts),
|
||||
sprit AS (SELECT active_date, cost, lPer100km, kwhPer100km,
|
||||
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM fuelCosts)
|
||||
SELECT ";
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- Rechnung --
|
||||
*
|
||||
* Aus Summen werden Kennzahlen. Diese Funktion ist die einzige Stelle, an der
|
||||
* eine Kennzahl definiert ist - sie laeuft fuer jeden Monat, jedes Jahr, jeden
|
||||
* Zeitraum und die Gesamtsumme mit denselben Regeln.
|
||||
*
|
||||
* null heisst "nicht bestimmbar" und nicht "null": ohne Bezug gibt es keinen
|
||||
* effektiven Preis, ohne Ladung keinen Solaranteil. Die Anzeige zeigt darauf
|
||||
* einen Strich statt einer 0.
|
||||
*/
|
||||
function kennzahlenAus($s, $grundpreis)
|
||||
{
|
||||
$teile = function ($zaehler, $nenner, $faktor = 1) {
|
||||
return ($nenner === null || $nenner <= 0 || $zaehler === null)
|
||||
? null : $zaehler / $nenner * $faktor;
|
||||
};
|
||||
|
||||
$bezug = floatval($s["bezug_kwh"]);
|
||||
$kosten = floatval($s["bezug_eur"]) + $grundpreis;
|
||||
$geladen = floatval($s["eg_kwh"]) + floatval($s["og_kwh"]);
|
||||
$solar = floatval($s["eg_solar_kwh"]) + floatval($s["og_solar_kwh"]);
|
||||
|
||||
return [
|
||||
"bezug" => $bezug,
|
||||
"kosten" => $kosten,
|
||||
"preis" => $teile($kosten, $bezug, 100),
|
||||
"ertrag" => floatval($s["eigen_eur"]) + floatval($s["einsp_eur"]) + floatval($s["heiz_eur"]),
|
||||
"eigen" => floatval($s["eigen_eur"]),
|
||||
"einsp" => floatval($s["einsp_eur"]),
|
||||
"heizung" => floatval($s["heiz_eur"]),
|
||||
"batterie" => floatval($s["batt_eur"]),
|
||||
"solarauto" => floatval($s["eg_solar_eur"]) + floatval($s["og_solar_eur"]),
|
||||
"autarkie" => $teile($s["autarkie_gew"], $s["stunden"]),
|
||||
// Eigenverbrauchsquote: der Anteil der Erzeugung, der im Haus blieb.
|
||||
"quote" => $s["pv_kwh"] === null ? null
|
||||
: $teile(floatval($s["pv_kwh"]) - floatval($s["einsp_kwh"]),
|
||||
floatval($s["pv_kwh"]), 100),
|
||||
"geladen" => $geladen,
|
||||
"ladenEG" => floatval($s["eg_kwh"]),
|
||||
"ladenOG" => floatval($s["og_kwh"]),
|
||||
"solaranteil" => $teile($solar, $geladen, 100),
|
||||
"benzin" => floatval($s["benzin_l"]),
|
||||
"benzingeld" => floatval($s["benzin_eur"]),
|
||||
];
|
||||
}
|
||||
|
||||
/** Zwei Summensaetze addieren - fuer Jahre aus Monaten und die Gesamtsumme. */
|
||||
function summenPlus($a, $b)
|
||||
{
|
||||
if ($a === null) return $b;
|
||||
foreach ($b as $k => $v) {
|
||||
if ($v === null) continue;
|
||||
$a[$k] = ($a[$k] === null ? 0 : $a[$k]) + $v;
|
||||
}
|
||||
return $a;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ Abruf --*/
|
||||
|
||||
$db = solarDb();
|
||||
|
||||
// Gibt es die Erzeugung als Tageswert? Ohne sie entfaellt die
|
||||
// Eigenverbrauchsquote - lieber eine Kennzahl weniger als eine Abfrage, die
|
||||
// an einer fehlenden Spalte scheitert.
|
||||
$hatPv = false;
|
||||
if ($res = $db->query("SHOW COLUMNS FROM stats_daily LIKE 'pv_kwh'")) {
|
||||
$hatPv = $res->num_rows > 0;
|
||||
}
|
||||
|
||||
$heute = new DateTimeImmutable("today");
|
||||
|
||||
/*
|
||||
* Monatswerte. Daraus entstehen die Verlaufsbalken, durch Addition die ganzen
|
||||
* Jahre und durch nochmalige Addition die Gesamtsumme - eine Abfrage fuer
|
||||
* drei Ebenen.
|
||||
*/
|
||||
$monate = [];
|
||||
$sql = mitTarifen() . "YEAR(d.tag) AS jahr, MONTH(d.tag) AS monat, " . summenSQL($hatPv)
|
||||
. vonTarifen() . " GROUP BY 1, 2 ORDER BY 1, 2";
|
||||
$res = $db->query($sql);
|
||||
if (!$res) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Statistik nicht abrufbar: " . $db->error]);
|
||||
exit;
|
||||
}
|
||||
while ($z = $res->fetch_assoc()) {
|
||||
$jahr = intval($z["jahr"]);
|
||||
$monat = intval($z["monat"]);
|
||||
unset($z["jahr"], $z["monat"]); // ab hier sind nur noch Summen drin
|
||||
$monate[$jahr][$monat] = $z;
|
||||
}
|
||||
|
||||
/*
|
||||
* Gleicher Zeitraum: jedes Jahr vom 1. Januar bis zum heutigen Kalendertag.
|
||||
* Verglichen wird nach Monat und Tag statt nach DAYOFYEAR - der 7. September
|
||||
* ist in einem Schaltjahr der 251., sonst der 250. Tag, und ein Vergleich
|
||||
* ueber die Tagesnummer waere in Schaltjahren um einen Tag verschoben.
|
||||
*/
|
||||
$gleichSummen = [];
|
||||
$sql = mitTarifen() . "YEAR(d.tag) AS jahr, " . summenSQL($hatPv) . vonTarifen()
|
||||
. " WHERE (MONTH(d.tag) < MONTH(CURDATE())
|
||||
OR (MONTH(d.tag) = MONTH(CURDATE()) AND DAY(d.tag) <= DAY(CURDATE())))
|
||||
GROUP BY 1 ORDER BY 1";
|
||||
$res = $db->query($sql);
|
||||
while ($res && $z = $res->fetch_assoc()) {
|
||||
$jahr = intval($z["jahr"]);
|
||||
unset($z["jahr"]);
|
||||
$gleichSummen[$jahr] = $z;
|
||||
}
|
||||
|
||||
$jahre = array_keys($monate);
|
||||
sort($jahre);
|
||||
|
||||
/* ------------------------------------------------------- Antwort aufbauen --*/
|
||||
|
||||
$antwort = [
|
||||
"stichtag" => $heute->format("Y-m-d"),
|
||||
"jahre" => array_values($jahre),
|
||||
"kennzahlen" => array_values(array_filter(KENNZAHLEN, function ($k) use ($hatPv) {
|
||||
return !isset($k["braucht"]) || ($k["braucht"] === "pv_kwh" && $hatPv);
|
||||
})),
|
||||
"leitzahlen" => LEITZAHLEN,
|
||||
"voll" => [],
|
||||
"gleich" => [],
|
||||
"monate" => [],
|
||||
];
|
||||
|
||||
$gesamtSummen = null;
|
||||
$gesamtGrund = 0.0;
|
||||
|
||||
foreach ($jahre as $jahr) {
|
||||
$jahresSummen = null;
|
||||
$grundJahr = grundpreisImJahr($jahr);
|
||||
|
||||
// Monatswerte. Der Grundpreis des Jahres verteilt sich nach Tagen auf die
|
||||
// Monate - fuer den Verlauf genau genug, und eine Abfrage je Monat waere
|
||||
// hundert Abfragen fuer eine Balkenhoehe.
|
||||
$tageImJahr = intval((new DateTimeImmutable($jahr . "-12-31"))->format("z")) + 1;
|
||||
for ($m = 1; $m <= 12; $m++) {
|
||||
if (!isset($monate[$jahr][$m])) continue;
|
||||
$tageImMonat = intval((new DateTimeImmutable(sprintf("%d-%02d-01", $jahr, $m)))->format("t"));
|
||||
$werte = kennzahlenAus($monate[$jahr][$m], $grundJahr * $tageImMonat / $tageImJahr);
|
||||
foreach ($werte as $id => $wert) {
|
||||
$antwort["monate"][$jahr][$id][$m - 1] = $wert;
|
||||
}
|
||||
$jahresSummen = summenPlus($jahresSummen, $monate[$jahr][$m]);
|
||||
}
|
||||
// Monate ohne Daten bleiben als Luecke stehen, damit der Verlauf zwoelf
|
||||
// Plaetze hat und der Januar links liegt.
|
||||
foreach (array_keys($antwort["monate"][$jahr] ?? []) as $id) {
|
||||
for ($m = 0; $m < 12; $m++) {
|
||||
if (!array_key_exists($m, $antwort["monate"][$jahr][$id])) {
|
||||
$antwort["monate"][$jahr][$id][$m] = null;
|
||||
}
|
||||
}
|
||||
ksort($antwort["monate"][$jahr][$id]);
|
||||
$antwort["monate"][$jahr][$id] = array_values($antwort["monate"][$jahr][$id]);
|
||||
}
|
||||
|
||||
if ($jahresSummen !== null) {
|
||||
$antwort["voll"][$jahr] = kennzahlenAus($jahresSummen, $grundJahr);
|
||||
$gesamtSummen = summenPlus($gesamtSummen, $jahresSummen);
|
||||
$gesamtGrund += $grundJahr;
|
||||
}
|
||||
|
||||
if (isset($gleichSummen[$jahr])) {
|
||||
/*
|
||||
* Der Stichtag im jeweiligen Jahr. Der Tag wird auf die Laenge des
|
||||
* Monats begrenzt: am 29. Februar gibt es in den anderen Jahren
|
||||
* keinen 29. Februar, und ein ungueltiges Datum rutschte sonst
|
||||
* stillschweigend in den Maerz.
|
||||
*/
|
||||
$tag = min(intval($heute->format("d")),
|
||||
intval((new DateTimeImmutable($jahr . "-" . $heute->format("m") . "-01"))->format("t")));
|
||||
$bis = sprintf("%d-%s-%02d", $jahr, $heute->format("m"), $tag);
|
||||
$antwort["gleich"][$jahr] = kennzahlenAus(
|
||||
$gleichSummen[$jahr], grundpreisImZeitraum($jahr . "-01-01", $bis));
|
||||
}
|
||||
}
|
||||
|
||||
// Die Summe ueber alles - dieselbe Rechnung, nur ueber alle Jahre.
|
||||
$antwort["gesamt"] = $gesamtSummen === null ? null : kennzahlenAus($gesamtSummen, $gesamtGrund);
|
||||
|
||||
echo json_encode($antwort, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
if(!isset($_GET["TO"])){
|
||||
$_GET["TO"] = 12;
|
||||
}
|
||||
$_GET["TO"] = intval($_GET["TO"]);
|
||||
if(!isset($_GET["FROM"])){
|
||||
$_GET["FROM"] = -24;
|
||||
}
|
||||
$_GET["FROM"] = intval($_GET["FROM"]);
|
||||
|
||||
$Query = "SELECT
|
||||
UNIX_TIMESTAMP(CONCAT(date,' ',sunrise)) AS sunrise,
|
||||
UNIX_TIMESTAMP(CONCAT(date,' ',sunset)) AS sunset
|
||||
FROM solarLog.daylight
|
||||
WHERE date BETWEEN DATE_ADD(NOW(),INTERVAL ".($_GET["FROM"]-24)." HOUR) and DATE_ADD(NOW(),INTERVAL ".$_GET["TO"]." HOUR)
|
||||
ORDER BY date";
|
||||
|
||||
$linecolors["Solarleistung"] = "#FFFF00";
|
||||
$linecolors["UG"] = "#FFaa00";
|
||||
$linecolors["OG"] = "#FF4400";
|
||||
$linecolors["Auto UG"] = "#00aaFF";
|
||||
$linecolors["Auto OG"] = "#0044FF";
|
||||
$linecolors["Heizstab"] = "#FF0000";
|
||||
$linecolors["Batterieladung"] = "#00aa00";
|
||||
$linecolors["Einspeisung"] = "#b0b0b0";
|
||||
$linecolors["Ladestand"] = "#00aa00";
|
||||
$linecolors["Vorhersage"] = "#2222FF";
|
||||
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||
$result = mysqli_query($mysql, $Query);
|
||||
if (!$result) {
|
||||
echo "Error:<br>" . mysqli_error($mysql) . "<br />";
|
||||
}
|
||||
//$obj[] = (object)[]; // Cast empty array to object
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ii = 1;
|
||||
$anno = (object)[];
|
||||
if($row["sunrise"] < (time()+$_GET["FROM"]*60*60)){
|
||||
$anno->xMin = (time()+$_GET["FROM"]*60*60)*1000;
|
||||
}else{
|
||||
$anno->xMin = $row["sunrise"]*1000;
|
||||
}
|
||||
if($row["sunset"] > (time()+$_GET["TO"]*60*60)){
|
||||
$anno->xMax = round(time()+$_GET["TO"]*60*60)*1000;
|
||||
}else{
|
||||
$anno->xMax = $row["sunset"]*1000;
|
||||
}
|
||||
$anno->borderWidth = 0;
|
||||
if($row["sunset"] > (time()+$_GET["FROM"]*60*60)){
|
||||
$obj[] = clone $anno;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//header('Content-Type: application/json');
|
||||
echo json_encode($obj);
|
||||
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
if(!isset($_GET["TO"])){
|
||||
$_GET["TO"] = 12;
|
||||
}
|
||||
$_GET["TO"] = intval($_GET["TO"]);
|
||||
if(!isset($_GET["FROM"])){
|
||||
$_GET["FROM"] = -24;
|
||||
}
|
||||
$_GET["FROM"] = intval($_GET["FROM"]);
|
||||
|
||||
$Query = "SELECT
|
||||
UNIX_TIMESTAMP(CONCAT(date,' ',sunrise)) AS sunrise,
|
||||
UNIX_TIMESTAMP(CONCAT(date,' ',sunset)) AS sunset
|
||||
FROM solarLog.daylight
|
||||
WHERE date BETWEEN DATE_ADD(NOW(),INTERVAL ".($_GET["FROM"]-24)." HOUR) and DATE_ADD(NOW(),INTERVAL ".$_GET["TO"]." HOUR)
|
||||
ORDER BY date";
|
||||
|
||||
$linecolors["Solarleistung"] = "#FFFF00";
|
||||
$linecolors["UG"] = "#FFaa00";
|
||||
$linecolors["OG"] = "#FF4400";
|
||||
$linecolors["Auto UG"] = "#00aaFF";
|
||||
$linecolors["Auto OG"] = "#0044FF";
|
||||
$linecolors["Heizstab"] = "#FF0000";
|
||||
$linecolors["Batterieladung"] = "#00aa00";
|
||||
$linecolors["Einspeisung"] = "#b0b0b0";
|
||||
$linecolors["Ladestand"] = "#00aa00";
|
||||
$linecolors["Vorhersage"] = "#2222FF";
|
||||
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||
$result = mysqli_query($mysql, $Query);
|
||||
if (!$result) {
|
||||
echo "Error:<br>" . mysqli_error($mysql) . "<br />";
|
||||
}
|
||||
//$obj[] = (object)[]; // Cast empty array to object
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ii = 1;
|
||||
$anno = (object)[];
|
||||
if($row["sunrise"] < (time()+$_GET["FROM"]*60*60)){
|
||||
$anno->xMin = (time()+$_GET["FROM"]*60*60)*1000;
|
||||
}else{
|
||||
$anno->xMin = $row["sunrise"]*1000;
|
||||
}
|
||||
if($row["sunset"] > (time()+$_GET["TO"]*60*60)){
|
||||
$anno->xMax = round(time()+$_GET["TO"]*60*60)*1000;
|
||||
}else{
|
||||
$anno->xMax = $row["sunset"]*1000;
|
||||
}
|
||||
$anno->borderWidth = 0;
|
||||
if($row["sunset"] > (time()+$_GET["FROM"]*60*60)){
|
||||
$obj[] = clone $anno;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//header('Content-Type: application/json');
|
||||
echo json_encode($obj);
|
||||
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
|
||||
$waterQuery = "SELECT UNIX_TIMESTAMP(datetime) AS time, rate AS 'Wasserverbrauch'
|
||||
FROM solarLog.wasser
|
||||
WHERE wasser.datetime BETWEEN DATE_SUB(NOW(),INTERVAL 24 HOUR) and NOW()
|
||||
ORDER BY wasser.datetime";
|
||||
|
||||
$linecolors["Speicher oben"] = "#FF5500";
|
||||
$linecolors["Speicher mitte"] = "#FFaa00";
|
||||
$linecolors["Speicher unten"] = "#FFFF00";
|
||||
$linecolors["Therme Vorlauf Fußboden"] = "#bb0000";
|
||||
$linecolors["Therme Rücklauf"] = "#ee0000";
|
||||
$linecolors["Heizstab Vorlauf"] = "#9900bb";
|
||||
$linecolors["Heizstab Rücklauf"] = "#8800aa";
|
||||
$linecolors["Fußboden Vorlauf"] = "#00FF00";
|
||||
$linecolors["Fußboden Rücklauf"] = "#00aa00";
|
||||
$linecolors["Wasserverbrauch"] = "#2222FF";
|
||||
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||
$waterRes = mysqli_query($mysql,$waterQuery);
|
||||
if(!$waterRes){
|
||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
$obj = (object)[]; // Cast empty array to object
|
||||
$obj->labels = [];
|
||||
$obj->datasets = [];
|
||||
$i = 0;
|
||||
$filled = 0;
|
||||
if ($waterRes->num_rows > 1) {
|
||||
$dataset = (object)[];
|
||||
$row = $waterRes->fetch_assoc();
|
||||
$dataset->borderColor = $linecolors["Wasserverbrauch"];
|
||||
$dataset->backgroundColor = $linecolors["Wasserverbrauch"]."00";
|
||||
$dataset->borderWidth=2;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y1';
|
||||
$dataset->label = "Wasserverbrauch";
|
||||
$pt = (object)[];
|
||||
$pt->x = $row["time"]*1000;
|
||||
$pt->y = $row["Wasserverbrauch"];
|
||||
$dataset->data[] = clone $pt;
|
||||
while ($row = $waterRes->fetch_assoc()) {
|
||||
$pt = (object)[];
|
||||
$pt->x = $row["time"]*1000;
|
||||
$pt->y = $row["Wasserverbrauch"];
|
||||
$dataset->data[] = clone $pt;
|
||||
}
|
||||
$obj->datasets[] = clone $dataset;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
//header('Content-Type: application/json');
|
||||
echo json_encode($obj);
|
||||
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
|
||||
$waterQuery = "SELECT UNIX_TIMESTAMP(datetime) AS time, rate AS 'Wasserverbrauch'
|
||||
FROM solarLog.wasser
|
||||
WHERE wasser.datetime BETWEEN DATE_SUB(NOW(),INTERVAL 24 HOUR) and NOW()
|
||||
ORDER BY wasser.datetime";
|
||||
|
||||
$linecolors["Speicher oben"] = "#FF5500";
|
||||
$linecolors["Speicher mitte"] = "#FFaa00";
|
||||
$linecolors["Speicher unten"] = "#FFFF00";
|
||||
$linecolors["Therme Vorlauf Fußboden"] = "#bb0000";
|
||||
$linecolors["Therme Rücklauf"] = "#ee0000";
|
||||
$linecolors["Heizstab Vorlauf"] = "#9900bb";
|
||||
$linecolors["Heizstab Rücklauf"] = "#8800aa";
|
||||
$linecolors["Fußboden Vorlauf"] = "#00FF00";
|
||||
$linecolors["Fußboden Rücklauf"] = "#00aa00";
|
||||
$linecolors["Wasserverbrauch"] = "#2222FF";
|
||||
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||
$waterRes = mysqli_query($mysql,$waterQuery);
|
||||
if(!$waterRes){
|
||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
$obj = (object)[]; // Cast empty array to object
|
||||
$obj->labels = [];
|
||||
$obj->datasets = [];
|
||||
$i = 0;
|
||||
$filled = 0;
|
||||
if ($waterRes->num_rows > 1) {
|
||||
$dataset = (object)[];
|
||||
$row = $waterRes->fetch_assoc();
|
||||
$dataset->borderColor = $linecolors["Wasserverbrauch"];
|
||||
$dataset->backgroundColor = $linecolors["Wasserverbrauch"]."00";
|
||||
$dataset->borderWidth=2;
|
||||
$dataset->pointRadius= 0;
|
||||
$dataset->pointHoverRadius= 5;
|
||||
$dataset->tension=0.2;
|
||||
$dataset->fill = "none";
|
||||
$dataset->yAxisID = 'y1';
|
||||
$dataset->label = "Wasserverbrauch";
|
||||
$pt = (object)[];
|
||||
$pt->x = $row["time"]*1000;
|
||||
$pt->y = $row["Wasserverbrauch"];
|
||||
$dataset->data[] = clone $pt;
|
||||
while ($row = $waterRes->fetch_assoc()) {
|
||||
$pt = (object)[];
|
||||
$pt->x = $row["time"]*1000;
|
||||
$pt->y = $row["Wasserverbrauch"];
|
||||
$dataset->data[] = clone $pt;
|
||||
}
|
||||
$obj->datasets[] = clone $dataset;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
//header('Content-Type: application/json');
|
||||
echo json_encode($obj);
|
||||
//echo '{"labels":[1761322682000,1761322782000,1761322882000,1761322982000,1761323082000,1761323182000,1761323282000],"datasets":[{"stack": "Stack 0","cubicInterpolationMode":"monotone","fill":"origin","label":"Acquisitions by year","data":[10,20,50,20,10,5,70]},{"fill": "false","stack": "Stack 1","cubicInterpolationMode": "monotone","label": "Acquisitions by year","data": [10,20,50,20,10,5,70]}]}';
|
||||
|
||||
@@ -1,57 +1,117 @@
|
||||
<?php
|
||||
|
||||
require_once("../helper.php");
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
$close = 0;
|
||||
$hostname = "192.168.179.169";
|
||||
|
||||
|
||||
if (isset($_POST["setMode"]) || isset($_GET["setMode"])) {
|
||||
//send new value
|
||||
$_POST["setMode"] = intval($_POST["setMode"]) + intval($_GET["setMode"]);
|
||||
if($_POST["setMode"] > 0 && $_POST["setMode"] < 60){
|
||||
$pwr = $_POST["setMode"]*100;
|
||||
$headers[] = "GET /set?mode=man&pwr=".$pwr." HTTP/1.1";
|
||||
$headers[] = "Host: ".$hostname;
|
||||
$headers[] = "";
|
||||
$remote = fsockopen("tcp://".$hostname, 80, $errno, $errstr, 5);
|
||||
fwrite($remote, implode("\r\n", $headers)."\r\n");
|
||||
$file = '';
|
||||
$file .= fread($remote, 1024);
|
||||
fclose($remote);
|
||||
}elseif($_POST["setMode"] == 0){
|
||||
$headers[] = "GET /set?mode=eco&pwr=0 HTTP/1.1";
|
||||
$headers[] = "Host: ".$hostname;
|
||||
$headers[] = "";
|
||||
$remote = fsockopen("tcp://".$hostname, 80, $errno, $errstr, 5);
|
||||
fwrite($remote, implode("\r\n", $headers)."\r\n");
|
||||
$file = '';
|
||||
$file .= fread($remote, 1024);
|
||||
fclose($remote);
|
||||
}
|
||||
$close = 1;
|
||||
|
||||
}
|
||||
} else {
|
||||
$close = 1;
|
||||
}
|
||||
if (!$close) {
|
||||
echo <<<ENDE
|
||||
<!--begin::Form-->
|
||||
<form id="heater_form">
|
||||
<div class="col-sm-11">
|
||||
<div class="form-check">
|
||||
<label class="form-label" for="addedCharge">Leistung: </label>
|
||||
<div style="font-size: 15px;color: #297195;display: inline-block;" id="modal-slider-label">0</div>
|
||||
<input type="range" id="modal-slider" name="setMode" class="form-range range-color-track" min="0" max="60" />
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="max-amount f-16 font-weight-normal darkGray-color text-right mt-1">Auto</span>
|
||||
<span class="max-amount f-16 font-weight-normal darkGray-color text-right mt-1">6.0 kW</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
ENDE;
|
||||
}
|
||||
<?php
|
||||
/**
|
||||
* Heizstab im Pufferspeicher.
|
||||
*
|
||||
* GET das Bedienfenster (Werte und Logik: js/solar/bedienfenster.js)
|
||||
* POST setMode=0 Automatik: der SolarManager regelt nach Überschuss
|
||||
* POST setMode=1..60 feste Leistung in 100 W (60 = 6,0 kW)
|
||||
* POST setMode=-1 aus: feste Leistung von 0 W, der Heizstab bleibt kalt
|
||||
*
|
||||
* Seit September 2026 gilt jede Änderung sofort, ohne "Save changes". Die
|
||||
* Antwort ist leer, wenn der Befehl beim Heizstab ankam, sonst steht dort
|
||||
* eine Meldung für das Fenster.
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
|
||||
$hostname = "192.168.179.169";
|
||||
|
||||
if (!checkLogin()) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($_POST["setMode"]) || isset($_GET["setMode"])) {
|
||||
// Der Wert kommt aus dem Fenster (POST) oder von Hand aus der Adresse
|
||||
// (GET) - immer nur einer von beiden.
|
||||
$stufe = intval($_POST["setMode"] ?? $_GET["setMode"] ?? 0);
|
||||
if ($stufe >= 1 && $stufe <= 60) {
|
||||
$pfad = "/set?mode=man&pwr=" . ($stufe * 100);
|
||||
} elseif ($stufe === 0) {
|
||||
$pfad = "/set?mode=eco&pwr=0";
|
||||
} elseif ($stufe === -1) {
|
||||
// Aus ist eine feste Leistung von null: in der Automatik wuerde der
|
||||
// Heizstab beim naechsten Ueberschuss wieder anlaufen.
|
||||
$pfad = "/set?mode=man&pwr=0";
|
||||
} else {
|
||||
echo "Unbekannte Leistungsstufe.";
|
||||
exit;
|
||||
}
|
||||
$remote = @fsockopen("tcp://" . $hostname, 80, $errno, $errstr, 5);
|
||||
if ($remote) {
|
||||
fwrite($remote, "GET " . $pfad . " HTTP/1.1\r\nHost: " . $hostname . "\r\nConnection: close\r\n\r\n");
|
||||
stream_set_timeout($remote, 5);
|
||||
fread($remote, 1024);
|
||||
fclose($remote);
|
||||
} else {
|
||||
echo "Der Heizstab ist nicht erreichbar.";
|
||||
}
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<div class="lade heiz">
|
||||
<div class="lade-links">
|
||||
<div class="lade-status">
|
||||
<div class="heiz-tank" aria-hidden="true">
|
||||
<svg viewBox="0 0 80 120">
|
||||
<defs><clipPath id="heiz-innen"><rect x="14" y="8" width="52" height="100" rx="20"/></clipPath></defs>
|
||||
<g clip-path="url(#heiz-innen)">
|
||||
<rect x="0" y="0" width="80" height="42" data-heiz="t0"/>
|
||||
<rect x="0" y="42" width="80" height="34" data-heiz="t1"/>
|
||||
<rect x="0" y="76" width="80" height="44" data-heiz="t2"/>
|
||||
</g>
|
||||
<rect x="14" y="8" width="52" height="100" rx="20" class="heiz-huelle"/>
|
||||
<path d="M26 103l4-6 4 6 4-6 4 6 4-6 4 6 4-6" class="heiz-spirale" data-heiz="spirale"/>
|
||||
<path d="M26 108v7M54 108v7" class="heiz-huelle"/>
|
||||
<text x="40" y="30" data-heiz="tt0">–</text>
|
||||
<text x="40" y="63" data-heiz="tt1">–</text>
|
||||
<text x="40" y="90" data-heiz="tt2" class="klein">–</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="lade-text">
|
||||
<div class="lade-auto">Pufferspeicher <span data-heiz="pille" class="lade-pille"></span></div>
|
||||
<div class="lade-gross" data-heiz="leistung">–</div>
|
||||
<div class="lade-zeile" data-heiz="temps"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lade-erklaerung" data-heiz="erklaerung"></div>
|
||||
</div>
|
||||
|
||||
<div class="lade-rechts">
|
||||
<div class="lade-abschnitt">
|
||||
<h3 id="heiz-modus">Wie heizen?</h3>
|
||||
<div class="lade-modi" role="radiogroup" aria-labelledby="heiz-modus">
|
||||
<button type="button" class="lade-modus" role="radio" aria-checked="false" data-heiz-modus="auto" style="--c:#f7c000">
|
||||
<span class="lade-sym"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg></span>
|
||||
<b>Automatik</b><span>heizt mit dem Überschuss der Anlage</span>
|
||||
</button>
|
||||
<button type="button" class="lade-modus" role="radio" aria-checked="false" data-heiz-modus="aus" style="--c:#9aa6ad">
|
||||
<span class="lade-sym"><svg viewBox="0 0 24 24"><path d="M12 3v9"/><path d="M7.5 6.5a7 7 0 1 0 9 0"/></svg></span>
|
||||
<b>Aus</b><span>bleibt kalt, auch bei Überschuss</span>
|
||||
</button>
|
||||
<button type="button" class="lade-modus" role="radio" aria-checked="false" data-heiz-modus="fest" style="--c:#e5624f">
|
||||
<span class="lade-sym"><svg viewBox="0 0 24 24"><path d="M12 3c3 4 5 6.5 5 10a5 5 0 0 1-10 0c0-2 1-3.5 2-4.5.3 1.5 1 2.5 2 3 0-3 .5-5.5 1-8.5z"/></svg></span>
|
||||
<b>Fest</b><span>eingestellte Leistung, auch aus dem Netz</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lade-planung" data-heiz="fest" hidden>
|
||||
<div>
|
||||
<div class="lade-plan-zeile">
|
||||
<label for="heiz-regler">Leistung</label>
|
||||
<output data-heiz="reglerAusgabe"></output>
|
||||
</div>
|
||||
<input type="range" id="heiz-regler" data-heiz="regler" min="1" max="60" step="1" value="30" class="heiz-regler">
|
||||
<div class="lade-marken"><span>0,1 kW</span><span>3 kW</span><span>6 kW</span></div>
|
||||
</div>
|
||||
<div class="lade-chips" role="group" aria-label="Schnellwahl">
|
||||
<button type="button" class="lade-chip" aria-pressed="false" data-heiz-stufe="10">1 kW</button>
|
||||
<button type="button" class="lade-chip" aria-pressed="false" data-heiz-stufe="20">2 kW</button>
|
||||
<button type="button" class="lade-chip" aria-pressed="false" data-heiz-stufe="30">3 kW</button>
|
||||
<button type="button" class="lade-chip" aria-pressed="false" data-heiz-stufe="60">6 kW</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lade-meldung" data-heiz="meldung" aria-live="polite">Änderungen gelten sofort</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
/**
|
||||
* Endpunkt der Logseite.
|
||||
*
|
||||
* GET ?action=list Zustand aller bekannten Dateien
|
||||
* GET ?action=read&datei=runner&... Zeilen einer Datei
|
||||
*
|
||||
* Parameter von read:
|
||||
* datei Schluessel aus logDateien()
|
||||
* anzahl wieviele Zeilen (1 bis 5000)
|
||||
* suche Text, der vorkommen muss
|
||||
* level Mindestlevel: INFO, WARNING, ERROR
|
||||
*
|
||||
* Ein reiner Verteiler - gelesen wird in restricted/logdateien.php.
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
require_once("../restricted/logdateien.php");
|
||||
|
||||
if (!checkLogin()) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
function jsonAntwort($data, $code = 200)
|
||||
{
|
||||
http_response_code($code);
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$action = $_GET["action"] ?? "list";
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case "list":
|
||||
$liste = [];
|
||||
foreach (array_keys(logDateien()) as $key) {
|
||||
$liste[] = logZustand($key);
|
||||
}
|
||||
jsonAntwort(["dateien" => $liste]);
|
||||
|
||||
case "read":
|
||||
jsonAntwort(logLesen(
|
||||
$_GET["datei"] ?? "",
|
||||
$_GET["anzahl"] ?? 300,
|
||||
trim((string) ($_GET["suche"] ?? "")),
|
||||
strtoupper(trim((string) ($_GET["level"] ?? "")))
|
||||
));
|
||||
|
||||
default:
|
||||
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||
}
|
||||
} catch (InvalidArgumentException $e) {
|
||||
jsonAntwort(["error" => $e->getMessage()], 400);
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => $e->getMessage()], 500);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -30,6 +48,14 @@ if (!checkLogin()) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Die Sitzung wird ab hier nur noch gelesen, und PHP haelt ihre Datei bis
|
||||
// zum Ende der Anfrage exklusiv gesperrt. Ein "Zu" an einer Jalousie wartet
|
||||
// weiter unten ueber eine Minute auf das Ende der Fahrt - solange stand jede
|
||||
// weitere Anfrage desselben Browsers still, auch die, die waehrend der Fahrt
|
||||
// nachsehen will, wie weit sie ist. Alle Schreibzugriffe auf $_SESSION
|
||||
// stecken in checkLogin() und sind hier durch.
|
||||
session_write_close();
|
||||
|
||||
function raumAntwort($data, $code = 200)
|
||||
{
|
||||
http_response_code($code);
|
||||
@@ -38,6 +64,38 @@ function raumAntwort($data, $code = 200)
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Antwort abschicken, aber weiterarbeiten.
|
||||
*
|
||||
* Nach einem Fahrbefehl muss noch jemand abwarten, wo die Jalousie stehen
|
||||
* bleibt, und das aufschreiben - sonst steht in actor_states bis zum
|
||||
* naechsten Poll des Runners der alte Wert. Das dem Browser zu ueberlassen
|
||||
* ging schief: wer das Modal gleich nach dem Fahrbefehl zumachte, nahm den
|
||||
* einzigen Zuschauer mit.
|
||||
*
|
||||
* Dieselbe Bauart wie in ajax/tahoma.php: Content-Length setzen, ausliefern,
|
||||
* die Verbindung schliessen, weiterlaufen. Ohne FPM bleibt nur, die Puffer zu
|
||||
* leeren - dann hat der Browser seine Daten zwar, wartet aber trotzdem.
|
||||
*/
|
||||
function raumAntwortUndWeiter($data)
|
||||
{
|
||||
http_response_code(200);
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
$text = json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
header("Content-Length: " . strlen($text));
|
||||
echo $text;
|
||||
// Der Aufrufer ist weg, das Skript soll trotzdem zu Ende kommen.
|
||||
ignore_user_abort(true);
|
||||
if (function_exists("fastcgi_finish_request")) {
|
||||
fastcgi_finish_request();
|
||||
return;
|
||||
}
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
// --- Schaltende Aufrufe ---------------------------------------------------
|
||||
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
$body = json_decode(file_get_contents("php://input"), true);
|
||||
@@ -46,15 +104,30 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
}
|
||||
try {
|
||||
if (($_GET["action"] ?? "") === "command") {
|
||||
$text = executeCommand(intval($body["command_id"] ?? 0),
|
||||
$id = intval($body["command_id"] ?? 0);
|
||||
$text = executeCommand($id,
|
||||
is_array($body["params"] ?? null) ? $body["params"] : [],
|
||||
!empty($body["probe"]));
|
||||
raumAntwort(["ok" => $text]);
|
||||
// Bei einem Tahoma-Geraet ist mit dem Absenden noch nichts
|
||||
// festgehalten: die Box meldet die neue Stellung erst, wenn die
|
||||
// Fahrt zu Ende ist. Also Antwort raus und in Ruhe nachsehen.
|
||||
$url = empty($body["probe"]) ? tahomaUrlZuKommando($id) : null;
|
||||
if ($url === null) {
|
||||
raumAntwort(["ok" => $text]);
|
||||
}
|
||||
raumAntwortUndWeiter(["ok" => $text]);
|
||||
tahomaNachlesen($url);
|
||||
exit;
|
||||
}
|
||||
if (($_GET["action"] ?? "") === "temp") {
|
||||
// Bewusst derselbe Weg wie im alten Thermostat-Modal: das Topic
|
||||
// Raumtemp/<Etage>/<Raum>/changeSetTemp.
|
||||
$raum = preg_replace('/[^A-Za-z0-9_]/', '', strval($body["room"] ?? ""));
|
||||
// Derselbe Weg wie im alten Thermostat-Modal: <Zweig>/changeSetTemp.
|
||||
// Der Zweig steht am Raum (rooms.thermostat) und wird nicht mehr
|
||||
// aus Etage und Name zusammengesetzt - ein Thermostat in einem
|
||||
// anderen Haus meldet vielleicht unter einem ganz anderen.
|
||||
$raum = raumNachNummer($body["room"] ?? 0);
|
||||
if (!$raum || empty($raum["thermostat"])) {
|
||||
raumAntwort(["error" => "Dieser Raum hat kein Thermostat."], 400);
|
||||
}
|
||||
$temp = intval($body["temp"] ?? 0);
|
||||
if ($temp < 10 || $temp > 30) {
|
||||
raumAntwort(["error" => "Temperatur außerhalb 10–30 °C."], 400);
|
||||
@@ -63,11 +136,113 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
if (!$mqtt->connect(true, null, "", "")) {
|
||||
raumAntwort(["error" => "Keine Verbindung zum MQTT-Broker."], 500);
|
||||
}
|
||||
$mqtt->publish("Raumtemp/" . str_replace("_", "/", $raum) . "/changeSetTemp",
|
||||
$mqtt->publish(rtrim($raum["thermostat"], "/") . "/changeSetTemp",
|
||||
strval($temp), 0, false);
|
||||
$mqtt->close();
|
||||
raumAntwort(["ok" => "Solltemperatur " . $temp . " °C"]);
|
||||
}
|
||||
// Eine Jalousie waehrend ihrer Fahrt nachfuehren. actor_states wird
|
||||
// sonst nur vom Runner fortgeschrieben, und der fragt die Box alle
|
||||
// fuenf Minuten ab - so lange stand nach einem Fahrbefehl der alte
|
||||
// Wert im Modal. Hier wird genau ein Geraet gelesen, das kostet die
|
||||
// Box wenig, und der frische Stand landet gleich in der Tabelle.
|
||||
if (($_GET["action"] ?? "") === "jalousie") {
|
||||
$url = tahomaAktorUrl(intval($body["actor"] ?? 0));
|
||||
if ($url === null) {
|
||||
raumAntwort(["error" => "Kein Tahoma-Gerät mit dieser Nummer."], 404);
|
||||
}
|
||||
$zustaende = tahomaZustaende($url);
|
||||
if ($zustaende === null) {
|
||||
raumAntwort(["error" => "Tahoma-Box antwortet nicht."], 503);
|
||||
}
|
||||
tahomaZustaendeSchreiben($url, $zustaende);
|
||||
$zahl = function ($feld) use ($zustaende) {
|
||||
return isset($zustaende[$feld]) && is_numeric($zustaende[$feld])
|
||||
? intval($zustaende[$feld]) : null;
|
||||
};
|
||||
raumAntwort([
|
||||
"faehrt" => ($zustaende["core:MovingState"] ?? null) === true,
|
||||
"position" => $zahl("core:ClosureState"),
|
||||
"neigung" => $zahl("core:SlateOrientationState"),
|
||||
"myPosition" => $zahl("core:Memorized1PositionState"),
|
||||
"myNeigung" => $zahl("core:Memorized1OrientationState"),
|
||||
]);
|
||||
}
|
||||
// Den Zustand einer WLED-Lampe live lesen: an/aus, Helligkeit, Farbe,
|
||||
// Effekt und Preset. In actor_states stehen nur die ersten drei, und
|
||||
// ohne Effekt und Preset koennte das Raum-Modal nicht zeigen, was
|
||||
// gerade laeuft.
|
||||
if (($_GET["action"] ?? "") === "wled") {
|
||||
$db = commandDb();
|
||||
$stmt = $db->prepare("SELECT url FROM actors WHERE id = ?");
|
||||
$id = intval($body["actor"] ?? 0);
|
||||
$stmt->bind_param("i", $id);
|
||||
$stmt->execute();
|
||||
$zeile = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
if (!$zeile || strpos(strval($zeile["url"]), "wled://") !== 0) {
|
||||
raumAntwort(["error" => "Keine WLED-Lampe mit dieser Nummer."], 404);
|
||||
}
|
||||
$ch = curl_init("http://" . rtrim(substr($zeile["url"], strlen("wled://")), "/") . "/json/state");
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 3);
|
||||
$stand = json_decode(strval(curl_exec($ch)), true);
|
||||
curl_close($ch);
|
||||
if (!is_array($stand)) {
|
||||
raumAntwort(["error" => "Die Lampe antwortet nicht."], 503);
|
||||
}
|
||||
$segment = $stand["seg"][0] ?? [];
|
||||
$farbe = $segment["col"][0] ?? null;
|
||||
raumAntwort([
|
||||
"an" => !empty($stand["on"]),
|
||||
"bri" => isset($stand["bri"]) ? intval($stand["bri"]) : null,
|
||||
"farbe" => is_array($farbe) && count($farbe) >= 3 ? array_map("intval", array_slice($farbe, 0, 3)) : null,
|
||||
"effect" => isset($segment["fx"]) ? intval($segment["fx"]) : null,
|
||||
"preset" => isset($stand["ps"]) ? intval($stand["ps"]) : null,
|
||||
]);
|
||||
}
|
||||
// Den Stand, auf dem die Jalousie gerade steht, als my-Position in den
|
||||
// Motor schreiben. Die Box kennt dafuer setMemorized1Position und bei
|
||||
// Raffstores zusaetzlich setMemorized1Orientation; die Geraetesuche
|
||||
// uebernimmt beide nicht als Kommando, deshalb geht es hier direkt.
|
||||
// Gespeichert wird, was die Box meldet - nicht, was im Browser steht:
|
||||
// der koennte einer Fahrt hinterherhinken.
|
||||
if (($_GET["action"] ?? "") === "my") {
|
||||
$url = tahomaAktorUrl(intval($body["actor"] ?? 0));
|
||||
if ($url === null) {
|
||||
raumAntwort(["error" => "Kein Tahoma-Gerät mit dieser Nummer."], 404);
|
||||
}
|
||||
$vorher = tahomaZustaende($url);
|
||||
if ($vorher === null) {
|
||||
raumAntwort(["error" => "Tahoma-Box antwortet nicht."], 503);
|
||||
}
|
||||
if (($vorher["core:MovingState"] ?? null) === true) {
|
||||
raumAntwort(["error" => "Die Jalousie fährt noch – bitte warten, bis sie steht."], 409);
|
||||
}
|
||||
if (!isset($vorher["core:Memorized1PositionState"]) || !is_numeric($vorher["core:ClosureState"] ?? null)) {
|
||||
raumAntwort(["error" => "Dieses Gerät kann keine my-Position speichern."], 400);
|
||||
}
|
||||
$position = intval($vorher["core:ClosureState"]);
|
||||
$neigung = null;
|
||||
tahomaKommando($url, "setMemorized1Position", [$position]);
|
||||
$text = "my-Position " . $position . " %";
|
||||
if (isset($vorher["core:Memorized1OrientationState"]) && is_numeric($vorher["core:SlateOrientationState"] ?? null)) {
|
||||
$neigung = intval($vorher["core:SlateOrientationState"]);
|
||||
tahomaKommando($url, "setMemorized1Orientation", [$neigung]);
|
||||
$text .= ", Lamellen " . $neigung . " %";
|
||||
}
|
||||
// Die Box braucht einen Moment, bis sie den neuen Wert meldet.
|
||||
sleep(2);
|
||||
$nachher = tahomaZustaende($url);
|
||||
if ($nachher !== null) {
|
||||
tahomaZustaendeSchreiben($url, $nachher);
|
||||
}
|
||||
raumAntwort([
|
||||
"ok" => $text . " gespeichert",
|
||||
"myPosition" => $position,
|
||||
"myNeigung" => $neigung,
|
||||
]);
|
||||
}
|
||||
raumAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
raumAntwort(["error" => $e->getMessage()], 400);
|
||||
@@ -78,11 +253,13 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
|
||||
|
||||
// --- Inhalt des Modals ----------------------------------------------------
|
||||
$schluessel = preg_replace('/[^A-Za-z0-9_]/', '', strval($_GET["room"] ?? ""));
|
||||
$teile = explode("_", $schluessel, 2);
|
||||
$etage = $teile[0] ?? "";
|
||||
$name = $teile[1] ?? "";
|
||||
$raumKey = $etage . "/" . $name;
|
||||
$raum = raumNachNummer($_GET["room"] ?? 0);
|
||||
if (!$raum) {
|
||||
echo "<p class='text-body-secondary mb-0'>Diesen Raum gibt es nicht (mehr).</p>";
|
||||
exit;
|
||||
}
|
||||
// Derselbe Schluessel wie actors.room_id im Katalog - als Text, siehe raumListe().
|
||||
$raumKey = strval($raum["nr"]);
|
||||
|
||||
// Alles im Raum, sortiert nach Bedienform. Das Thermostat selbst wird nicht
|
||||
// als Karte gezeichnet: es wird zum Schieberegler im Heizungs-Reiter und
|
||||
@@ -113,31 +290,48 @@ foreach ($katalog as $d) {
|
||||
// einer Geraeteliste: ein Regler fuer den Raum statt einer Karte je Geraet.
|
||||
$fertig = [];
|
||||
if ($hatThermostat) {
|
||||
$karte = "<div class='card mb-2 raum-geraet'><div class='card-body py-2'>"
|
||||
. "<div class='d-flex justify-content-between align-items-baseline'>"
|
||||
. "<label class='form-label mb-0 small' for='modal-slider'>Solltemperatur</label>"
|
||||
. "<span id='modal-slider-label' class='fw-semibold text-nowrap ms-2' style='color:#297195'>–</span>"
|
||||
. "</div>"
|
||||
. "<input type='range' id='modal-slider' class='form-range range-color-track' min='10' max='30'>"
|
||||
// Der Ist-Zustand kommt live ueber MQTT, nicht aus der Datenbank -
|
||||
// der Browser hoert ohnehin schon auf Raumtemp/#, und ein Wert von
|
||||
// vorhin waere neben dem Regler nur irrefuehrend.
|
||||
. "<div class='d-flex flex-wrap gap-3 small text-body-secondary'>"
|
||||
. "<span><i class='bi bi-thermometer-half me-1'></i><span data-mqtt='Temp[degC]'>–</span> °C</span>"
|
||||
. "<span><i class='bi bi-droplet me-1'></i><span data-mqtt='rHum[%]'>–</span> %rF</span>"
|
||||
. "<span data-mqtt-heizung class='d-none'><i class='bi bi-fire text-danger me-1'></i>heizt</span>"
|
||||
. "</div></div></div>";
|
||||
$fertig["heizung"] = $karte;
|
||||
// Ring mit Ist-Temperatur und ziehbarem Soll-Punkt, daneben - und + und
|
||||
// die Schnellwahl. Alle Zahlen kommen live ueber MQTT (der Browser hoert
|
||||
// ohnehin auf den Thermostat-Zweig) und werden von js/solar/raumfenster.js
|
||||
// eingetragen. Das Thermostat nimmt ganze Grad von 10 bis 30 °C, siehe
|
||||
// action=temp.
|
||||
$fertig["heizung"] = "<div class='rf-heiz' data-rf-heiz-teil data-min='10' data-max='30'>"
|
||||
. "<div class='rf-ring'><svg viewBox='0 0 170 170'>"
|
||||
. "<defs><linearGradient id='rfHeizVerlauf' gradientUnits='userSpaceOnUse' gradientTransform='rotate(-135 85 85)' x1='15' y1='0' x2='155' y2='0'>"
|
||||
. "<stop offset='0' stop-color='#4b9fd6'/><stop offset='.55' stop-color='#e8a33a'/><stop offset='1' stop-color='#e5624f'/>"
|
||||
. "</linearGradient></defs>"
|
||||
. "<circle class='rf-ring-spur' cx='85' cy='85' r='70' transform='rotate(135 85 85)'/>"
|
||||
. "<circle class='rf-ring-ist' cx='85' cy='85' r='70' transform='rotate(135 85 85)'/>"
|
||||
. "<circle class='rf-ring-griff' cx='85' cy='85' r='70' transform='rotate(135 85 85)' data-rf-ringzug/>"
|
||||
. "<circle class='rf-ring-soll' cx='155' cy='85' r='10' data-rf-ringzug tabindex='0' role='slider'"
|
||||
. " aria-label='Solltemperatur' aria-valuemin='10' aria-valuemax='30'/></svg>"
|
||||
. "<div class='rf-ring-mitte'><small>im Raum</small><b data-rf-heiz='ist'>–</b><span data-rf-heiz='soll'>Soll –</span></div></div>"
|
||||
. "<div class='rf-heiz-rechts'><h3>Solltemperatur</h3>"
|
||||
. "<div class='rf-stepper'><button type='button' data-rf-schritt='-1' aria-label='1 Grad kälter'>−</button>"
|
||||
. "<output data-rf-heiz='wert'>–</output>"
|
||||
. "<button type='button' data-rf-schritt='1' aria-label='1 Grad wärmer'>+</button></div>"
|
||||
. "<div class='rf-chips'>"
|
||||
. "<button type='button' class='rf-chip' data-rf-soll='10' aria-pressed='false'>Minimum <span>10°</span></button>"
|
||||
. "<button type='button' class='rf-chip' data-rf-soll='17' aria-pressed='false'>Eco <span>17°</span></button>"
|
||||
. "<button type='button' class='rf-chip' data-rf-soll='21' aria-pressed='false'>Komfort <span>21°</span></button>"
|
||||
. "<button type='button' class='rf-chip' data-rf-soll='23' aria-pressed='false'>Warm <span>23°</span></button></div>"
|
||||
. "<div class='rf-hinweis' data-rf-heiz='hinweis' hidden><i class='bi bi-info-circle'></i><span></span></div>"
|
||||
. "<div class='rf-pillen'><span class='rf-pille'><i class='bi bi-droplet'></i><span data-rf-heiz='feuchte'>–</span></span>"
|
||||
. "<span class='rf-pille' data-rf-heiz='puffer' hidden><i class='bi bi-fire'></i>Pufferbetrieb</span></div>"
|
||||
. "</div></div>";
|
||||
}
|
||||
|
||||
echo "<div class='p-1' id='roomView' data-room='" . htmlspecialchars($schluessel, ENT_QUOTES) . "'>";
|
||||
// Der Thermostat-Zweig reist mit: das Modal fuellt Ist-Werte und Heizsymbol
|
||||
// aus dem laufenden MQTT-Strom und muss wissen, wo es nachsehen soll.
|
||||
echo "<div class='rf' id='roomView' data-room='" . $raum["nr"] . "'"
|
||||
. " data-thermostat='" . htmlspecialchars((string)$raum["thermostat"], ENT_QUOTES) . "'>";
|
||||
|
||||
if (!$gruppen && !$fertig) {
|
||||
echo "<p class='text-body-secondary mb-0'>Diesem Raum ist noch nichts zugeordnet. "
|
||||
echo "<p class='rf-leer'>Diesem Raum ist noch nichts zugeordnet. "
|
||||
. "Das geht über das Haus-Symbol in der Karte „Automatismen“.</p>";
|
||||
}
|
||||
|
||||
echo zeichneGruppen($gruppen, $fertig);
|
||||
|
||||
echo "<div id='roomFeedback' class='small mt-2'></div>";
|
||||
echo "<div id='roomFeedback' class='rf-meldung' aria-live='polite'>Änderungen gelten sofort</div>";
|
||||
echo "</div>";
|
||||
|
||||
@@ -4,14 +4,41 @@
|
||||
*
|
||||
* GET ?action=list&reihe=strom Zeitraeume einer Preistabelle
|
||||
* POST ?action=save {"reihe": "strom", "zeilen": [ ... ]}
|
||||
* GET ?action=kacheln Anzeigewerte aller Home-Kacheln
|
||||
* POST ?action=kacheln-save {"floor": "OG", "room": "bad",
|
||||
* "werte": [ ... ] oder null}
|
||||
* GET ?action=geraete Geraete mit Raum, Verwendung und Alter
|
||||
* POST ?action=geraete-raeume {"rooms": {"123": "11", ...}} Geraet -> Raumnummer
|
||||
* POST ?action=geraet-loeschen {"id": 12}
|
||||
* GET ?action=skoda-keys Stand der Fahrzeug-Schluessel (ohne Werte!)
|
||||
* POST ?action=skoda-keys-save {"felder": {"API_KEY2": "..."},
|
||||
* "loeschen": ["API_KEY3"]}
|
||||
*
|
||||
* GET ?action=grundriss Etagen, Raeume, Kachelpositionen
|
||||
* POST ?action=etage-speichern {"code": "EG", "label": "...", "standard": true, "neu": false}
|
||||
* POST ?action=etagen-reihenfolge {"codes": ["OG", "EG", ...]}
|
||||
* POST ?action=etage-loeschen {"code": "EG"}
|
||||
* POST ?action=etage-bild multipart: code, bild (Datei)
|
||||
* POST ?action=raum-speichern {"nr": 0|12, "floor": "EG", "name": "...", "kuerzel": "", "thermostat": ""}
|
||||
* POST ?action=raum-position {"nr": 12, "x": 120, "y": 80} (x/y null = vom Plan)
|
||||
* POST ?action=raum-loeschen {"nr": 12}
|
||||
* Die Grundriss-Aktionen antworten mit dem ganzen neuen Stand.
|
||||
*
|
||||
* GET ?action=energiefluss Abweichungen der Übersicht vom Katalog
|
||||
* POST ?action=energiefluss-save {"knoten": [{id, aktiv, name, x, y, optionen}, ...]}
|
||||
*
|
||||
* Ein reiner Verteiler: geprueft und geschrieben wird in
|
||||
* restricted/costs.php, damit Maske und Endpunkt dieselbe Wahrheit ueber
|
||||
* einen Zeitraum haben.
|
||||
* restricted/costs.php, kacheln.php bzw. grundriss.php, damit Maske und
|
||||
* Endpunkt dieselbe Wahrheit ueber einen Zeitraum oder eine Kachel haben.
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
require_once("../restricted/costs.php");
|
||||
require_once("../restricted/kacheln.php");
|
||||
require_once("../restricted/automations.php");
|
||||
require_once("../restricted/skodaKeys.php");
|
||||
require_once("../restricted/grundriss.php");
|
||||
require_once("../restricted/energiefluss.php");
|
||||
|
||||
if (!checkLogin()) {
|
||||
http_response_code(403);
|
||||
@@ -28,6 +55,20 @@ function jsonAntwort($data, $code = 200)
|
||||
|
||||
$action = $_GET["action"] ?? "list";
|
||||
|
||||
// Das Grundrissbild kommt als Formular mit Datei, nicht als JSON - deshalb
|
||||
// vor dem JSON-Teil.
|
||||
if ($_SERVER["REQUEST_METHOD"] === "POST" && $action === "etage-bild") {
|
||||
try {
|
||||
$bild = etageBildSpeichern($_POST["code"] ?? "", $_FILES["bild"] ?? null);
|
||||
grundriss(true);
|
||||
jsonAntwort(["bild" => $bild, "stand" => grundrissStand()]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
jsonAntwort(["error" => $e->getMessage()], 400);
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
$body = json_decode(file_get_contents("php://input"), true);
|
||||
if (!is_array($body)) {
|
||||
@@ -43,16 +84,144 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
"gespeichert" => $anzahl,
|
||||
"zeilen" => preiseLaden($body["reihe"]),
|
||||
]);
|
||||
|
||||
case "kacheln-save":
|
||||
/*
|
||||
* Fehlt "werte" oder steht dort null, faellt die Kachel auf
|
||||
* ihre Vorgabe zurueck - dann kommt diese zurueck, damit die
|
||||
* Maske zeigt, was jetzt gilt, statt zu leeren Zeilen.
|
||||
*/
|
||||
$floor = $body["floor"] ?? "";
|
||||
$room = $body["room"] ?? "";
|
||||
$werte = array_key_exists("werte", $body) ? $body["werte"] : null;
|
||||
$gespeichert = kachelWerteSpeichern($floor, $room, $werte);
|
||||
|
||||
$vorgabe = [];
|
||||
foreach (allRooms() as $r) {
|
||||
if ($r["floor"] === $floor && $r["id"] === $room) {
|
||||
$vorgabe = kachelVorgabe($r);
|
||||
}
|
||||
}
|
||||
jsonAntwort([
|
||||
"eigen" => $gespeichert !== null,
|
||||
"werte" => $gespeichert !== null ? $gespeichert : $vorgabe,
|
||||
]);
|
||||
case "geraete-raeume":
|
||||
jsonAntwort(["gespeichert" => saveRooms($body["rooms"] ?? [])]);
|
||||
|
||||
case "geraet-loeschen":
|
||||
/*
|
||||
* Loeschen ist endgueltig - abgelehnt wird es, wenn eine
|
||||
* Automatik auf das Geraet zeigt (siehe geraetLoeschen()).
|
||||
* Zurueck kommt die neue Liste, damit die Maske nicht raten
|
||||
* muss, was jetzt gilt.
|
||||
*/
|
||||
$name = geraetLoeschen($body["id"] ?? 0);
|
||||
jsonAntwort(["geloescht" => $name, "geraete" => geraeteListe()]);
|
||||
|
||||
case "etage-speichern":
|
||||
etageSpeichern($body);
|
||||
grundriss(true);
|
||||
jsonAntwort(["stand" => grundrissStand()]);
|
||||
case "etagen-reihenfolge":
|
||||
etagenReihenfolge($body["codes"] ?? null);
|
||||
grundriss(true);
|
||||
jsonAntwort(["stand" => grundrissStand()]);
|
||||
case "etage-loeschen":
|
||||
etageLoeschen($body["code"] ?? "");
|
||||
grundriss(true);
|
||||
jsonAntwort(["stand" => grundrissStand()]);
|
||||
case "raum-speichern":
|
||||
$nr = raumSpeichern($body);
|
||||
grundriss(true);
|
||||
jsonAntwort(["nr" => $nr, "stand" => grundrissStand()]);
|
||||
case "raum-position":
|
||||
$pos = raumPosition($body["nr"] ?? 0, $body["x"] ?? null, $body["y"] ?? null);
|
||||
grundriss(true);
|
||||
jsonAntwort(["position" => $pos, "stand" => grundrissStand()]);
|
||||
case "raum-loeschen":
|
||||
raumLoeschen($body["nr"] ?? 0);
|
||||
grundriss(true);
|
||||
jsonAntwort(["stand" => grundrissStand()]);
|
||||
|
||||
case "energiefluss-save":
|
||||
// Zurück kommt der gespeicherte Stand - ohne die Einträge,
|
||||
// die dem Katalog entsprechen.
|
||||
energieflussSpeichern($body["knoten"] ?? null);
|
||||
jsonAntwort(["einstellungen" => (object)energieflussLaden()]);
|
||||
|
||||
case "skoda-keys-save":
|
||||
/*
|
||||
* Neue Schluessel eintragen. Zurueck kommt der neue Stand -
|
||||
* aber nie ein Wert, siehe restricted/skodaKeys.php.
|
||||
*/
|
||||
jsonAntwort(skodaSchluesselSpeichern(
|
||||
$body["felder"] ?? [], $body["loeschen"] ?? []));
|
||||
|
||||
default:
|
||||
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||
}
|
||||
} catch (InvalidArgumentException $e) {
|
||||
jsonAntwort(["error" => $e->getMessage()], 400);
|
||||
} catch (RuntimeException $e) {
|
||||
// Nicht die Eingabe, sondern die Umgebung - Datei fehlt, Rechte
|
||||
// fehlen. Die Meldung sagt schon, was zu tun ist; kein Vorspann.
|
||||
jsonAntwort(["error" => $e->getMessage()], 500);
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === "kacheln") {
|
||||
try {
|
||||
jsonAntwort([
|
||||
"kacheln" => kachelListe(),
|
||||
// Alle Symbolnamen - die üblichen zuerst und mit Beschriftung.
|
||||
"symbole" => kachelIconNamen(),
|
||||
// Auswahlliste je Geraet - dieselbe Quelle wie im Automatik-Editor.
|
||||
"katalog" => kachelKatalog(),
|
||||
// Fuer die Eingabe von Hand, wenn etwas nicht im Katalog steht.
|
||||
"topics" => kachelTopicVorschlaege(),
|
||||
"tabelleDa" => grundrissDa(),
|
||||
"max" => KACHEL_MAX,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => "Kacheln nicht abrufbar: " . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === "grundriss") {
|
||||
try {
|
||||
jsonAntwort(grundrissStand());
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => "Grundriss nicht abrufbar: " . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === "energiefluss") {
|
||||
try {
|
||||
jsonAntwort(["einstellungen" => (object)energieflussLaden()]);
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => "Übersicht nicht abrufbar: " . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === "skoda-keys") {
|
||||
try {
|
||||
jsonAntwort(skodaStand());
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => "Schlüssel nicht abrufbar: " . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === "geraete") {
|
||||
try {
|
||||
jsonAntwort(["geraete" => geraeteListe(), "raeume" => raumListe()]);
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => "Geräte nicht abrufbar: " . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === "list") {
|
||||
try {
|
||||
jsonAntwort(["zeilen" => preiseLaden($_GET["reihe"] ?? "")]);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* ?was=kurve Verlauf einer einzelnen Ladung (SoC und kW über Zeit)
|
||||
* ?was=gesundheit kWh je 100 Ladestandsprozent, je Ladung, mit Trend
|
||||
* ?was=fahrten Fahrten zwischen zwei Parkständen, mit Koordinaten
|
||||
* ?was=strecke gefahrene Kilometer über Woche, Monat und Jahr
|
||||
*
|
||||
* Die Fahrzeugdaten stehen in der Tabelle skoda, geschrieben von
|
||||
* gatherSkodaData.py. Der Zeilentakt ist nicht fest: geschrieben wird, wenn
|
||||
@@ -41,54 +42,100 @@ if ($mysql->connect_errno) {
|
||||
$mysql->set_charset("utf8mb4");
|
||||
|
||||
/*
|
||||
* Ladevorgänge aus den Einzelzeilen schneiden. Aufeinanderfolgende Zeilen mit
|
||||
* chg_state = 'CHARGING' gehören zusammen, eine Pause über 30 Minuten trennt
|
||||
* zwei Vorgänge. Wird von mehreren Abschnitten gebraucht, deshalb einmal hier.
|
||||
* Ladevorgänge aus den Einzelzeilen schneiden. Wird von mehreren Abschnitten
|
||||
* gebraucht, deshalb einmal hier.
|
||||
*
|
||||
* Eine Ladung ist eine ununterbrochene Folge von Zeilen mit chg_state =
|
||||
* 'CHARGING'. Getrennt wird durch eine Zeile mit einem anderen Zustand, nicht
|
||||
* durch die Zeit dazwischen: gatherSkodaData.py schreibt nur, wenn das
|
||||
* Fahrzeug etwas Neues meldet, und beim Laden an der Wallbox tut es das
|
||||
* mitunter nur einmal in der Stunde. Früher trennte eine Pause über 30
|
||||
* Minuten - eine Ladung von 51 auf 80 % am 13.09.2026 zerfiel dadurch in drei
|
||||
* Stücke mit 0, 4 und 0 Prozentpunkten. Hört das Fahrzeug auf zu laden,
|
||||
* steht das in der nächsten Zeile; die Zeit allein trennt nur noch, wenn sie
|
||||
* so lang ist, dass der Abruf offenbar nicht lief (LADUNG_LUECKE_S).
|
||||
*
|
||||
* Dazu gehören die beiden Zeilen, die die Ladung einrahmen. Die davor sagt,
|
||||
* mit welchem Ladestand und Zählerstand sie begann - die erste CHARGING-Zeile
|
||||
* kommt oft erst Minuten später und hat dann schon geladen. Die danach
|
||||
* (CONNECT_CABLE, READY_FOR_CHARGING, ...) trägt den erreichten Ladestand:
|
||||
* bei einer Ladung bis zum Ziel ist das die einzige Zeile mit 80 %.
|
||||
*
|
||||
* Die Energie kommt aus der Differenz der Wallbox-Zählerstände statt aus einer
|
||||
* Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde.
|
||||
* Steht der Zähler still, wurde auswärts geladen (Schnellladesäule); dann
|
||||
* bleibt wh null und die Kapazitätsrechnung lässt die Ladung aus.
|
||||
* Ein Zählerstand 0 ist kein Stand, sondern ein ausgefallener Abruf der
|
||||
* Wallbox, und bleibt außen vor. Steht der Zähler still, wurde auswärts
|
||||
* geladen (Schnellladesäule); dann bleibt wh null und die Kapazitätsrechnung
|
||||
* lässt die Ladung aus.
|
||||
*/
|
||||
const LADUNG_LUECKE_S = 3 * 3600;
|
||||
|
||||
function sitzungenSQL($seit)
|
||||
{
|
||||
$luecke = LADUNG_LUECKE_S;
|
||||
return "
|
||||
SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende,
|
||||
MIN(soc) AS soc_von, MAX(soc) AS soc_bis,
|
||||
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc) AS SIGNED) AS zuwachs,
|
||||
MIN(soc_start) AS soc_von, MAX(soc) AS soc_bis,
|
||||
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc_start) AS SIGNED) AS zuwachs,
|
||||
MAX(chg_type) AS art, MAX(target_soc) AS ziel,
|
||||
ROUND(AVG(chg_kw), 2) AS kw_mittel, ROUND(MAX(chg_kw), 2) AS kw_spitze,
|
||||
MAX(wb_wh_total) - MIN(wb_wh_total) AS wh,
|
||||
ROUND(SUM(kwh_teil), 3) AS kwh_fahrzeug,
|
||||
ROUND(AVG(pv_kw), 2) AS pv_kw, COUNT(*) AS punkte
|
||||
ROUND(AVG(CASE WHEN laedt THEN chg_kw END), 2) AS kw_mittel,
|
||||
ROUND(MAX(CASE WHEN laedt THEN chg_kw END), 2) AS kw_spitze,
|
||||
COALESCE(MAX(NULLIF(wb_wh_total, 0)) - MIN(wh_start), 0) AS wh,
|
||||
/*
|
||||
* Fahrzeugseitige Energie nur, wenn sie lückenlos integriert werden
|
||||
* konnte - siehe kwh_teil. Sonst NULL: eine halbe Summe sähe aus
|
||||
* wie eine kleine Ladung und verfälschte die Kapazitätsrechnung.
|
||||
*/
|
||||
CASE WHEN SUM(zu_lang) = 0 THEN ROUND(SUM(kwh_teil), 3) END AS kwh_fahrzeug,
|
||||
ROUND(AVG(CASE WHEN laedt THEN pv_kw END), 2) AS pv_kw,
|
||||
COUNT(*) AS punkte
|
||||
FROM (
|
||||
SELECT s.*, SUM(s.neu) OVER (ORDER BY s.datetime) AS sitzung FROM (
|
||||
SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
|
||||
wb_wh_total, pv_kw,
|
||||
CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
|
||||
AND TIMESTAMPDIFF(MINUTE, LAG(datetime) OVER w, datetime) <= 30
|
||||
THEN 0 ELSE 1 END AS neu,
|
||||
SELECT b.*, SUM(b.neu) OVER (ORDER BY b.datetime) AS sitzung FROM (
|
||||
SELECT a.*,
|
||||
a.chg_state = 'CHARGING' AS laedt,
|
||||
(a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke) AS weiter,
|
||||
CASE WHEN a.chg_state = 'CHARGING'
|
||||
AND NOT (a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke)
|
||||
THEN 1 ELSE 0 END AS neu,
|
||||
-- Beginn aus der Zeile davor, wenn sie nah genug liegt.
|
||||
CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
|
||||
AND a.dt_vor <= $luecke
|
||||
THEN LEAST(a.soc, COALESCE(a.soc_vor, a.soc))
|
||||
ELSE a.soc END AS soc_start,
|
||||
CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
|
||||
AND a.dt_vor <= $luecke
|
||||
THEN COALESCE(NULLIF(a.wh_vor, 0), NULLIF(a.wb_wh_total, 0))
|
||||
ELSE NULLIF(a.wb_wh_total, 0) END AS wh_start,
|
||||
/*
|
||||
* Fahrzeugseitige Energie: chg_kw ist die Ladeleistung, die
|
||||
* das Fahrzeug selbst meldet - gemessen HINTER dem Ladegerät.
|
||||
* Die API kennt keine Kilowattstunden, deshalb wird hier über
|
||||
* die Zeit integriert, je Schritt mit dem Mittel aus vorigem
|
||||
* und aktuellem Wert (Trapez). Beim Laden mit Wechselstrom
|
||||
* liegt die Leistung fast konstant an, da ist das genau genug.
|
||||
* chg_kw ist die Ladeleistung, die das Fahrzeug selbst meldet -
|
||||
* gemessen HINTER dem Ladegerät. Die API kennt keine
|
||||
* Kilowattstunden, deshalb wird über die Zeit integriert, je
|
||||
* Schritt mit dem Mittel aus vorigem und aktuellem Wert.
|
||||
*
|
||||
* Die Bedingung hält die Summe innerhalb einer Ladung: über
|
||||
* eine Lücke von mehr als 30 Minuten hinweg wäre die Fläche
|
||||
* unter der Kurve frei erfunden.
|
||||
* Über mehr als 30 Minuten ohne Meldung wäre die Fläche unter
|
||||
* der Kurve erfunden: die Leistung kann in der Zeit von 4 auf
|
||||
* 10 kW gestiegen sein, ohne dass eine Zeile davon weiß.
|
||||
* zu_lang merkt sich das, und die Ladung bekommt dann keinen
|
||||
* fahrzeugseitigen Wert.
|
||||
*/
|
||||
CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
|
||||
AND TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime)
|
||||
BETWEEN 1 AND 1800
|
||||
THEN (COALESCE(chg_kw, 0) + COALESCE(LAG(chg_kw) OVER w, 0)) / 2
|
||||
* TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) / 3600
|
||||
ELSE 0 END AS kwh_teil
|
||||
FROM skoda WHERE datetime >= $seit
|
||||
WINDOW w AS (ORDER BY datetime)) s) t
|
||||
WHERE chg_state = 'CHARGING'
|
||||
CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor BETWEEN 1 AND $luecke
|
||||
THEN (COALESCE(a.chg_kw, 0) + COALESCE(a.kw_vor, 0)) / 2
|
||||
* a.dt_vor / 3600
|
||||
ELSE 0 END AS kwh_teil,
|
||||
CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor > 1800
|
||||
THEN 1 ELSE 0 END AS zu_lang
|
||||
FROM (
|
||||
SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
|
||||
wb_wh_total, pv_kw,
|
||||
LAG(chg_state) OVER w AS st_vor,
|
||||
TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) AS dt_vor,
|
||||
LAG(soc) OVER w AS soc_vor,
|
||||
LAG(wb_wh_total) OVER w AS wh_vor,
|
||||
LAG(chg_kw) OVER w AS kw_vor
|
||||
FROM skoda WHERE datetime >= $seit
|
||||
WINDOW w AS (ORDER BY datetime)) a) b) t
|
||||
-- Die Ladezeilen und die eine Zeile, mit der die Ladung endet.
|
||||
WHERE laedt OR weiter
|
||||
GROUP BY sitzung";
|
||||
}
|
||||
|
||||
@@ -166,6 +213,154 @@ function kapazitaetSchaetzen($mysql)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verlauf einer Ladung: Wallbox-Leistung dicht, Ladestand dazwischen gefüllt.
|
||||
*
|
||||
* Das Fahrzeug meldet beim Laden an der Wallbox mitunter nur einmal in der
|
||||
* Stunde, die Wallbox steht in skoda_ladepunkte im Minutentakt (siehe
|
||||
* solarLog_skoda_ladepunkte.sql). Der Ladestand zwischen zwei Meldungen wird
|
||||
* nach der geflossenen Energie aufgefüllt, nicht nach der Zeit:
|
||||
*
|
||||
* soc(t) = soc_a + (soc_b - soc_a) * (wh(t) - wh_a) / (wh_b - wh_a)
|
||||
*
|
||||
* Das braucht weder Akkukapazität noch Wirkungsgrad. Gegengeprüft am
|
||||
* 13.09.2026: zwischen 56 % (09:59) und 70 % (11:22) ergibt es für 10:29
|
||||
* 59,6 % - gemeldet waren 60 %. Nach der Zeit wären es 61,1 % gewesen.
|
||||
*
|
||||
* Eine Meldung gilt für den Zeitpunkt, zu dem das Fahrzeug sie erfasst hat
|
||||
* (cap_chg), nicht für den Abruf - dazwischen können Minuten mit 11 kW
|
||||
* liegen. Der Zählerstand zu diesem Zeitpunkt kommt deshalb aus dem
|
||||
* Wallbox-Verlauf selbst und nicht aus der Zeile.
|
||||
*
|
||||
* Vor der ersten und nach der letzten Meldung bleibt der Ladestand leer:
|
||||
* hochgerechnet wird nichts.
|
||||
*
|
||||
* Liefert [verlauf, meldungen, wallbox]; ohne Punkte in skoda_ladepunkte
|
||||
* zwei leere Listen - dann zeichnet die Seite wie früher aus den Zeilen.
|
||||
*/
|
||||
function ladeverlauf($mysql, $von, $bis)
|
||||
{
|
||||
// Welche Wallbox: die, die im Zeitraum Energie abgegeben hat.
|
||||
$stmt = $mysql->prepare(
|
||||
"SELECT wallbox, MAX(wh_total) - MIN(wh_total) AS wh, COUNT(*) AS n
|
||||
FROM skoda_ladepunkte
|
||||
WHERE datetime BETWEEN (? - INTERVAL 30 MINUTE) AND (? + INTERVAL 30 MINUTE)
|
||||
GROUP BY wallbox ORDER BY wh DESC, n DESC LIMIT 1");
|
||||
if (!$stmt) {
|
||||
return [[], [], null]; // Tabelle fehlt noch
|
||||
}
|
||||
$stmt->bind_param("ss", $von, $bis);
|
||||
$stmt->execute();
|
||||
$wahl = $stmt->get_result()->fetch_assoc();
|
||||
if (!$wahl) {
|
||||
return [[], [], null];
|
||||
}
|
||||
$wallbox = $wahl["wallbox"];
|
||||
$spalte = $wallbox === "og" ? "wbog_wh_total" : "wb_wh_total";
|
||||
|
||||
$stmt = $mysql->prepare(
|
||||
"SELECT datetime, kw, wh_total, quelle FROM skoda_ladepunkte
|
||||
WHERE wallbox = ? AND datetime BETWEEN (? - INTERVAL 30 MINUTE) AND (? + INTERVAL 30 MINUTE)
|
||||
ORDER BY datetime");
|
||||
$stmt->bind_param("sss", $wallbox, $von, $bis);
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
$verlauf = [];
|
||||
while ($z = $res->fetch_assoc()) {
|
||||
$verlauf[] = [
|
||||
"datetime" => $z["datetime"],
|
||||
"t" => strtotime($z["datetime"]),
|
||||
"kw" => floatval($z["kw"]),
|
||||
"wh" => $z["wh_total"] === null ? null : intval($z["wh_total"]),
|
||||
"quelle" => $z["quelle"],
|
||||
];
|
||||
}
|
||||
if (!$verlauf) {
|
||||
return [[], [], null];
|
||||
}
|
||||
|
||||
// Zählerstand zu einem beliebigen Zeitpunkt, aus dem Verlauf. Vor dem
|
||||
// ersten Punkt ruhte die Wallbox - dann gilt der Stand, den die Zeile
|
||||
// selbst trägt.
|
||||
$zaehlerBei = function ($t, $ersatz) use ($verlauf) {
|
||||
$vor = null;
|
||||
foreach ($verlauf as $p) {
|
||||
if ($p["wh"] === null) {
|
||||
continue;
|
||||
}
|
||||
if ($p["t"] >= $t) {
|
||||
if ($vor === null) {
|
||||
return $ersatz ?: $p["wh"];
|
||||
}
|
||||
$spanne = $p["t"] - $vor["t"];
|
||||
return $spanne > 0
|
||||
? $vor["wh"] + ($p["wh"] - $vor["wh"]) * ($t - $vor["t"]) / $spanne
|
||||
: $p["wh"];
|
||||
}
|
||||
$vor = $p;
|
||||
}
|
||||
return $vor ? $vor["wh"] : $ersatz;
|
||||
};
|
||||
|
||||
// Die Meldungen des Fahrzeugs, die den Verlauf einrahmen: die letzte vor
|
||||
// dem ersten Punkt, alle dazwischen, die erste nach dem letzten.
|
||||
$erster = $verlauf[0]["datetime"];
|
||||
$letzter = end($verlauf)["datetime"];
|
||||
$stmt = $mysql->prepare(
|
||||
"(SELECT COALESCE(cap_chg, datetime) AS erfasst, soc, chg_kw, chg_state, $spalte AS wh
|
||||
FROM skoda WHERE COALESCE(cap_chg, datetime) <= ? AND soc IS NOT NULL
|
||||
ORDER BY erfasst DESC LIMIT 1)
|
||||
UNION
|
||||
(SELECT COALESCE(cap_chg, datetime), soc, chg_kw, chg_state, $spalte
|
||||
FROM skoda WHERE COALESCE(cap_chg, datetime) > ? AND COALESCE(cap_chg, datetime) < ?
|
||||
AND soc IS NOT NULL)
|
||||
UNION
|
||||
(SELECT COALESCE(cap_chg, datetime), soc, chg_kw, chg_state, $spalte
|
||||
FROM skoda WHERE COALESCE(cap_chg, datetime) >= ? AND soc IS NOT NULL
|
||||
ORDER BY 1 LIMIT 1)
|
||||
ORDER BY erfasst");
|
||||
$stmt->bind_param("ssss", $erster, $erster, $letzter, $letzter);
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
$meldungen = [];
|
||||
while ($z = $res->fetch_assoc()) {
|
||||
$t = strtotime($z["erfasst"]);
|
||||
$meldungen[] = [
|
||||
"datetime" => $z["erfasst"],
|
||||
"t" => $t,
|
||||
"soc" => intval($z["soc"]),
|
||||
"chg_kw" => $z["chg_kw"] === null ? null : floatval($z["chg_kw"]),
|
||||
"chg_state"=> $z["chg_state"],
|
||||
"wh" => $zaehlerBei($t, intval($z["wh"])),
|
||||
];
|
||||
}
|
||||
|
||||
// Ladestand je Punkt, zwischen den beiden Meldungen, die ihn einrahmen.
|
||||
foreach ($verlauf as &$p) {
|
||||
$p["soc"] = null;
|
||||
for ($i = 1; $p["wh"] !== null && $i < count($meldungen); $i++) {
|
||||
$a = $meldungen[$i - 1];
|
||||
$b = $meldungen[$i];
|
||||
if ($p["t"] < $a["t"] || $p["t"] > $b["t"]) {
|
||||
continue;
|
||||
}
|
||||
$spanne = $b["wh"] - $a["wh"];
|
||||
$p["soc"] = $spanne > 0
|
||||
? round($a["soc"] + ($b["soc"] - $a["soc"]) * ($p["wh"] - $a["wh"]) / $spanne, 1)
|
||||
: floatval($a["soc"]);
|
||||
break;
|
||||
}
|
||||
unset($p["t"]);
|
||||
}
|
||||
unset($p);
|
||||
foreach ($meldungen as &$m) {
|
||||
unset($m["t"], $m["wh"]);
|
||||
}
|
||||
unset($m);
|
||||
|
||||
return [$verlauf, $meldungen, $wallbox];
|
||||
}
|
||||
|
||||
/** Median eines nicht leeren Zahlenfeldes. */
|
||||
function median($werte)
|
||||
{
|
||||
@@ -236,6 +431,9 @@ case "kurve":
|
||||
while ($z = $res->fetch_assoc()) { $antwort["punkte"][] = $z; }
|
||||
$antwort["von"] = $von;
|
||||
$antwort["bis"] = $bis;
|
||||
|
||||
list($antwort["verlauf"], $antwort["meldungen"], $antwort["wallbox"])
|
||||
= ladeverlauf($mysql, $von, $bis);
|
||||
break;
|
||||
|
||||
/* ------------------------------------------------------------ gesundheit -- */
|
||||
@@ -358,6 +556,52 @@ case "fahrten":
|
||||
$antwort["kapazitaet"] = kapazitaetSchaetzen($mysql);
|
||||
break;
|
||||
|
||||
/* --------------------------------------------------------------- strecke -- */
|
||||
case "strecke":
|
||||
/*
|
||||
* Gefahrene Kilometer über mehrere Zeiträume, gerechnet aus dem
|
||||
* Kilometerstand: der Zähler läuft nur vorwärts, also ist die Differenz
|
||||
* zwischen dem ältesten Stand im Zeitraum und dem jüngsten überhaupt die
|
||||
* gefahrene Strecke. Das ist belastbarer als eine Summe über die
|
||||
* erkannten Fahrten - fällt eine Zeile zwischendurch aus, fehlt die
|
||||
* Fahrt in der Liste, im Zählerstand aber nicht.
|
||||
*
|
||||
* CAST(... AS SIGNED) ist nötig: odo_km ist vorzeichenlos, und eine
|
||||
* Differenz unter null bräche die Abfrage ab.
|
||||
*/
|
||||
$zeitraeume = ["woche" => 7, "monat" => 30, "jahr" => 365];
|
||||
|
||||
$spalten = [];
|
||||
foreach ($zeitraeume as $name => $spanne) {
|
||||
$spalten[] = "MIN(CASE WHEN datetime >= NOW() - INTERVAL $spanne DAY
|
||||
THEN CAST(odo_km AS SIGNED) END) AS von_$name";
|
||||
}
|
||||
$sql = "SELECT MAX(CAST(odo_km AS SIGNED)) AS jetzt, MIN(datetime) AS seit, "
|
||||
. implode(", ", $spalten) . " FROM skoda WHERE odo_km IS NOT NULL";
|
||||
$res = $mysql->query($sql);
|
||||
$z = $res ? $res->fetch_assoc() : null;
|
||||
|
||||
$jetzt = ($z && $z["jetzt"] !== null) ? intval($z["jetzt"]) : null;
|
||||
$antwort["odo"] = $jetzt;
|
||||
$antwort["seit"] = $z ? $z["seit"] : null;
|
||||
$antwort["strecke"] = [];
|
||||
|
||||
foreach ($zeitraeume as $name => $spanne) {
|
||||
$von = ($z && $z["von_$name"] !== null) ? intval($z["von_$name"]) : null;
|
||||
$antwort["strecke"][$name] = [
|
||||
"tage" => $spanne,
|
||||
"km" => ($von === null || $jetzt === null) ? null : $jetzt - $von,
|
||||
/*
|
||||
* Reicht die Aufzeichnung überhaupt so weit zurück? Ein Jahreswert
|
||||
* aus drei Monaten Daten ist kein Jahreswert - die Anzeige muss
|
||||
* das sagen dürfen, statt eine zu kleine Zahl als Jahr auszugeben.
|
||||
*/
|
||||
"vollstaendig" => ($z && $z["seit"] !== null)
|
||||
&& strtotime($z["seit"]) <= time() - $spanne * 86400
|
||||
];
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(400);
|
||||
$antwort = ["error" => "Unbekannter Abschnitt."];
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -1,252 +1,252 @@
|
||||
<?php
|
||||
set_time_limit(60);
|
||||
ob_start();
|
||||
require_once("../helper.php");
|
||||
require_once("../restricted/tahoma_EG.php");
|
||||
|
||||
// Ohne diese Pruefung liessen sich Rollladen von beliebiger Stelle abfragen
|
||||
// und verfahren - das Tahoma-Token liegt serverseitig, ein Angreifer braucht
|
||||
// es also gar nicht.
|
||||
if (!checkLogin()) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
//67ebd23e3a61763386d9
|
||||
function getSSLPage($url, $tahoma_token)
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_HEADER, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json', "Authorization: Bearer ".$tahoma_token));
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_SSLVERSION, 3);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
$result = curl_exec($ch);
|
||||
if (curl_errno($ch)) {
|
||||
$error_msg = curl_error($ch);
|
||||
//print $error_msg;
|
||||
}
|
||||
curl_close($ch);
|
||||
return $result;
|
||||
}
|
||||
|
||||
function postSSLPage($url, $body, $tahoma_token)
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_HEADER, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json', "Authorization: Bearer ".$tahoma_token));
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_SSLVERSION, 3);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
$result = curl_exec($ch);
|
||||
if (curl_errno($ch)) {
|
||||
$error_msg = curl_error($ch);
|
||||
//print $error_msg;
|
||||
}
|
||||
curl_close($ch);
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function filter_devs($arr,$filter,$prefix=""){
|
||||
$ret = Array();
|
||||
$i = 0;
|
||||
foreach($arr as $key => $dev){
|
||||
$start = strpos(strtolower($dev["name"]),strtolower($filter));
|
||||
if($start !== false){
|
||||
$dev["name"] = str_replace("u_","",$dev["name"]);
|
||||
$ret[$i]["name"] = $prefix.substr($dev["name"],strlen($filter));
|
||||
$ret[$i]["id"] = $key;
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
return $ret;
|
||||
}
|
||||
|
||||
function searchForDevice($device){
|
||||
$dev = false;
|
||||
$devices = json_decode(file_get_contents($GLOBALS["tahoma_devlist"]), true);
|
||||
if (strlen($device) < 3 && intval($device) >= 0 && intval($device) < sizeof($devices)) {
|
||||
$dev = $devices[intval($device)];
|
||||
} else if ($key = array_search($device, array_column($devices, "name"))) {
|
||||
$dev = $devices[$key];
|
||||
} else if ($key = array_search($device, array_column($devices, "id"))) {
|
||||
$dev = $devices[$key];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return $dev;
|
||||
}
|
||||
|
||||
if ($_GET["action"] == "devlist") {
|
||||
$jalousien = array();
|
||||
$url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices';
|
||||
$ret = getSSLPage($url,$tahoma_token);
|
||||
$devices = json_decode($ret, true);
|
||||
$i = 0;
|
||||
foreach ($devices as $device) {
|
||||
if ($device["controllableName"] == "io:ExteriorVenetianBlindIOComponent") {
|
||||
$jalousien[$i]["name"] = $device["label"];
|
||||
$jalousien[$i]["id"] = $device["deviceURL"];
|
||||
$i = $i + 1;
|
||||
echo $device["label"] . "(".$device["deviceURL"].")<br />";
|
||||
}
|
||||
}
|
||||
//$file = fopen($tahoma_devlist, 'w');
|
||||
//fwrite($file, json_encode($jalousien));
|
||||
//fclose($file);
|
||||
echo json_encode($jalousien);
|
||||
}elseif ($_GET["action"] == "pos" && isset($_GET["device"])) {
|
||||
$dev = searchForDevice($_GET["device"])["id"];
|
||||
$devices = json_decode(file_get_contents($tahoma_devlist), true);
|
||||
$jalousien = array();
|
||||
$url = $url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices/'.urlencode($dev)."/states";
|
||||
$ret = getSSLPage($url,$tahoma_token);
|
||||
$states = json_decode($ret, true);
|
||||
$out = "{";
|
||||
foreach ($states as $state){
|
||||
if ($state["name"] == "core:SlateOrientationState")
|
||||
$out .= "\"rotation\":".$state["value"].",";
|
||||
if ($state["name"] == "core:ClosureState")
|
||||
$out .= "\"position\":".$state["value"].",";
|
||||
}
|
||||
$out = substr($out,0,-1);
|
||||
$out .= "}";
|
||||
echo $out;
|
||||
header('Connection: close');
|
||||
header('Content-Length: '.ob_get_length());
|
||||
ob_end_flush();
|
||||
@ob_flush();
|
||||
flush();
|
||||
fastcgi_finish_request();
|
||||
}elseif ($_GET["action"] == "moving" && isset($_GET["device"])) {
|
||||
$dev = searchForDevice($_GET["device"])["id"];
|
||||
$devices = json_decode(file_get_contents($tahoma_devlist), true);
|
||||
$jalousien = array();
|
||||
$url = $url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices/'.urlencode($dev)."/states/core:MovingState";
|
||||
$ret = getSSLPage($url,$tahoma_token);
|
||||
$moving = json_decode($ret, true);
|
||||
if($moving["value"])
|
||||
echo "true";
|
||||
else
|
||||
echo "false";
|
||||
header('Connection: close');
|
||||
header('Content-Length: '.ob_get_length());
|
||||
ob_end_flush();
|
||||
@ob_flush();
|
||||
flush();
|
||||
fastcgi_finish_request();
|
||||
}elseif ($_GET["action"] == "move" && isset($_GET["pos"]) && isset($_GET["angle"]) && isset($_GET["device"])) {
|
||||
$angle = $_GET["angle"];
|
||||
$pos = $_GET["pos"];
|
||||
if ($pos < 0 || $pos > 100) {
|
||||
echo "Position out of Range";
|
||||
exit;
|
||||
}
|
||||
if ($angle < 0 || $angle > 100) {
|
||||
echo "Angle out of Range";
|
||||
exit;
|
||||
}
|
||||
$dev = searchForDevice($_GET["device"])["id"];
|
||||
if($dev == false){
|
||||
exit;
|
||||
}
|
||||
header('Connection: close');
|
||||
header('Content-Length: '.ob_get_length());
|
||||
$url = $url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices/'.urlencode($dev)."/states/core:MovingState";
|
||||
$ret = json_decode(getSSLPage($url,$tahoma_token),true);
|
||||
|
||||
$action = array();
|
||||
$action["label"] = "myAction";
|
||||
$action["actions"] = array();
|
||||
$action["actions"][0]["deviceURL"] = $dev;
|
||||
$action["actions"][0]["commands"] = array();
|
||||
if(!isset($ret["value"])){
|
||||
$ret = json_decode(getSSLPage($url,$tahoma_token),true);
|
||||
}
|
||||
|
||||
if(!isset($ret["value"]) || $ret["value"] == false){
|
||||
$url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/exec/apply';
|
||||
echo "start";
|
||||
ob_end_flush();
|
||||
@ob_flush();
|
||||
flush();
|
||||
fastcgi_finish_request();
|
||||
$action["actions"][0]["commands"][0]["name"] = "setClosureAndOrientation";
|
||||
$action["actions"][0]["commands"][0]["parameters"][0] = intval($pos);
|
||||
$action["actions"][0]["commands"][0]["parameters"][1] = intval($angle);
|
||||
$res = json_decode(postSSLPage($url,json_encode($action),$tahoma_token));
|
||||
if(!isset($res["execId"])){
|
||||
sleep(1);
|
||||
$res = json_decode(postSSLPage($url,json_encode($action),$tahoma_token)); //2nd try
|
||||
}
|
||||
}elseif($ret["value"] == true){
|
||||
$url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/exec/apply';
|
||||
echo "stop";
|
||||
ob_end_flush();
|
||||
@ob_flush();
|
||||
flush();
|
||||
fastcgi_finish_request();
|
||||
$action["actions"][0]["commands"][0]["name"] = "stop";
|
||||
$res = json_decode(postSSLPage($url,json_encode($action),$tahoma_token));
|
||||
if(!isset($res["execId"])){
|
||||
sleep(1);
|
||||
$res = json_decode(postSSLPage($url,json_encode($action),$tahoma_token)); //2nd try
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if ($_GET["action"] == "myactors") {
|
||||
if(isset($_GET["filter"])){
|
||||
$host = $_GET["filter"];
|
||||
$host = str_replace("-", "", strtolower($host));
|
||||
}else{
|
||||
$host = gethostbyaddr("192.168.179.32");
|
||||
$host = str_replace("-", "", strtolower(substr($host, 0, strpos($host, "."))));
|
||||
}
|
||||
$devices = json_decode(file_get_contents($tahoma_devlist), true);
|
||||
switch ($host) {
|
||||
case "tmpegbad":
|
||||
echo json_encode(filter_devs($devices, "bad "));
|
||||
break;
|
||||
case "tmpegwozi":
|
||||
echo json_encode(filter_devs($devices, "wozi "));
|
||||
break;
|
||||
case "tmpegflorian": //flori
|
||||
$ret = filter_devs($devices, "florian ","Flori ");
|
||||
$ret = array_merge($ret, filter_devs($devices, "magdalena ","Magdalena "));
|
||||
echo json_encode($ret);
|
||||
break;
|
||||
case "tmpegmagdalena":
|
||||
echo json_encode(filter_devs($devices, "magdalena "));
|
||||
break;
|
||||
case "tmpegschlafzimmer":
|
||||
echo json_encode(filter_devs($devices, "schlafzimmer "));
|
||||
break;
|
||||
default:
|
||||
$ret2=Array();
|
||||
//$ret2[0]["name"] = $host;
|
||||
//$ret2[0]["id"] = 99;
|
||||
$ret = filter_devs($devices, "");
|
||||
//$ret = array_merge($ret, $ret2);
|
||||
echo json_encode($ret);
|
||||
break;
|
||||
}
|
||||
header('Connection: close');
|
||||
header('Content-Length: '.ob_get_length());
|
||||
ob_end_flush();
|
||||
@ob_flush();
|
||||
flush();
|
||||
fastcgi_finish_request();
|
||||
|
||||
|
||||
//echo postSSLPage($url,json_encode($action));
|
||||
}
|
||||
<?php
|
||||
set_time_limit(60);
|
||||
ob_start();
|
||||
require_once("../helper.php");
|
||||
require_once("../restricted/tahoma_EG.php");
|
||||
|
||||
// Ohne diese Pruefung liessen sich Rollladen von beliebiger Stelle abfragen
|
||||
// und verfahren - das Tahoma-Token liegt serverseitig, ein Angreifer braucht
|
||||
// es also gar nicht.
|
||||
if (!checkLogin()) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
//67ebd23e3a61763386d9
|
||||
function getSSLPage($url, $tahoma_token)
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_HEADER, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json', "Authorization: Bearer ".$tahoma_token));
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_SSLVERSION, 3);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
$result = curl_exec($ch);
|
||||
if (curl_errno($ch)) {
|
||||
$error_msg = curl_error($ch);
|
||||
//print $error_msg;
|
||||
}
|
||||
curl_close($ch);
|
||||
return $result;
|
||||
}
|
||||
|
||||
function postSSLPage($url, $body, $tahoma_token)
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_HEADER, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json', "Authorization: Bearer ".$tahoma_token));
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_SSLVERSION, 3);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
$result = curl_exec($ch);
|
||||
if (curl_errno($ch)) {
|
||||
$error_msg = curl_error($ch);
|
||||
//print $error_msg;
|
||||
}
|
||||
curl_close($ch);
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function filter_devs($arr,$filter,$prefix=""){
|
||||
$ret = Array();
|
||||
$i = 0;
|
||||
foreach($arr as $key => $dev){
|
||||
$start = strpos(strtolower($dev["name"]),strtolower($filter));
|
||||
if($start !== false){
|
||||
$dev["name"] = str_replace("u_","",$dev["name"]);
|
||||
$ret[$i]["name"] = $prefix.substr($dev["name"],strlen($filter));
|
||||
$ret[$i]["id"] = $key;
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
return $ret;
|
||||
}
|
||||
|
||||
function searchForDevice($device){
|
||||
$dev = false;
|
||||
$devices = json_decode(file_get_contents($GLOBALS["tahoma_devlist"]), true);
|
||||
if (strlen($device) < 3 && intval($device) >= 0 && intval($device) < sizeof($devices)) {
|
||||
$dev = $devices[intval($device)];
|
||||
} else if ($key = array_search($device, array_column($devices, "name"))) {
|
||||
$dev = $devices[$key];
|
||||
} else if ($key = array_search($device, array_column($devices, "id"))) {
|
||||
$dev = $devices[$key];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return $dev;
|
||||
}
|
||||
|
||||
if ($_GET["action"] == "devlist") {
|
||||
$jalousien = array();
|
||||
$url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices';
|
||||
$ret = getSSLPage($url,$tahoma_token);
|
||||
$devices = json_decode($ret, true);
|
||||
$i = 0;
|
||||
foreach ($devices as $device) {
|
||||
if ($device["controllableName"] == "io:ExteriorVenetianBlindIOComponent") {
|
||||
$jalousien[$i]["name"] = $device["label"];
|
||||
$jalousien[$i]["id"] = $device["deviceURL"];
|
||||
$i = $i + 1;
|
||||
echo $device["label"] . "(".$device["deviceURL"].")<br />";
|
||||
}
|
||||
}
|
||||
//$file = fopen($tahoma_devlist, 'w');
|
||||
//fwrite($file, json_encode($jalousien));
|
||||
//fclose($file);
|
||||
echo json_encode($jalousien);
|
||||
}elseif ($_GET["action"] == "pos" && isset($_GET["device"])) {
|
||||
$dev = searchForDevice($_GET["device"])["id"];
|
||||
$devices = json_decode(file_get_contents($tahoma_devlist), true);
|
||||
$jalousien = array();
|
||||
$url = $url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices/'.urlencode($dev)."/states";
|
||||
$ret = getSSLPage($url,$tahoma_token);
|
||||
$states = json_decode($ret, true);
|
||||
$out = "{";
|
||||
foreach ($states as $state){
|
||||
if ($state["name"] == "core:SlateOrientationState")
|
||||
$out .= "\"rotation\":".$state["value"].",";
|
||||
if ($state["name"] == "core:ClosureState")
|
||||
$out .= "\"position\":".$state["value"].",";
|
||||
}
|
||||
$out = substr($out,0,-1);
|
||||
$out .= "}";
|
||||
echo $out;
|
||||
header('Connection: close');
|
||||
header('Content-Length: '.ob_get_length());
|
||||
ob_end_flush();
|
||||
@ob_flush();
|
||||
flush();
|
||||
fastcgi_finish_request();
|
||||
}elseif ($_GET["action"] == "moving" && isset($_GET["device"])) {
|
||||
$dev = searchForDevice($_GET["device"])["id"];
|
||||
$devices = json_decode(file_get_contents($tahoma_devlist), true);
|
||||
$jalousien = array();
|
||||
$url = $url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices/'.urlencode($dev)."/states/core:MovingState";
|
||||
$ret = getSSLPage($url,$tahoma_token);
|
||||
$moving = json_decode($ret, true);
|
||||
if($moving["value"])
|
||||
echo "true";
|
||||
else
|
||||
echo "false";
|
||||
header('Connection: close');
|
||||
header('Content-Length: '.ob_get_length());
|
||||
ob_end_flush();
|
||||
@ob_flush();
|
||||
flush();
|
||||
fastcgi_finish_request();
|
||||
}elseif ($_GET["action"] == "move" && isset($_GET["pos"]) && isset($_GET["angle"]) && isset($_GET["device"])) {
|
||||
$angle = $_GET["angle"];
|
||||
$pos = $_GET["pos"];
|
||||
if ($pos < 0 || $pos > 100) {
|
||||
echo "Position out of Range";
|
||||
exit;
|
||||
}
|
||||
if ($angle < 0 || $angle > 100) {
|
||||
echo "Angle out of Range";
|
||||
exit;
|
||||
}
|
||||
$dev = searchForDevice($_GET["device"])["id"];
|
||||
if($dev == false){
|
||||
exit;
|
||||
}
|
||||
header('Connection: close');
|
||||
header('Content-Length: '.ob_get_length());
|
||||
$url = $url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/setup/devices/'.urlencode($dev)."/states/core:MovingState";
|
||||
$ret = json_decode(getSSLPage($url,$tahoma_token),true);
|
||||
|
||||
$action = array();
|
||||
$action["label"] = "myAction";
|
||||
$action["actions"] = array();
|
||||
$action["actions"][0]["deviceURL"] = $dev;
|
||||
$action["actions"][0]["commands"] = array();
|
||||
if(!isset($ret["value"])){
|
||||
$ret = json_decode(getSSLPage($url,$tahoma_token),true);
|
||||
}
|
||||
|
||||
if(!isset($ret["value"]) || $ret["value"] == false){
|
||||
$url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/exec/apply';
|
||||
echo "start";
|
||||
ob_end_flush();
|
||||
@ob_flush();
|
||||
flush();
|
||||
fastcgi_finish_request();
|
||||
$action["actions"][0]["commands"][0]["name"] = "setClosureAndOrientation";
|
||||
$action["actions"][0]["commands"][0]["parameters"][0] = intval($pos);
|
||||
$action["actions"][0]["commands"][0]["parameters"][1] = intval($angle);
|
||||
$res = json_decode(postSSLPage($url,json_encode($action),$tahoma_token));
|
||||
if(!isset($res["execId"])){
|
||||
sleep(1);
|
||||
$res = json_decode(postSSLPage($url,json_encode($action),$tahoma_token)); //2nd try
|
||||
}
|
||||
}elseif($ret["value"] == true){
|
||||
$url = 'https://gateway-'.$tahoma_PIN.':8443/enduser-mobile-web/1/enduserAPI/exec/apply';
|
||||
echo "stop";
|
||||
ob_end_flush();
|
||||
@ob_flush();
|
||||
flush();
|
||||
fastcgi_finish_request();
|
||||
$action["actions"][0]["commands"][0]["name"] = "stop";
|
||||
$res = json_decode(postSSLPage($url,json_encode($action),$tahoma_token));
|
||||
if(!isset($res["execId"])){
|
||||
sleep(1);
|
||||
$res = json_decode(postSSLPage($url,json_encode($action),$tahoma_token)); //2nd try
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if ($_GET["action"] == "myactors") {
|
||||
if(isset($_GET["filter"])){
|
||||
$host = $_GET["filter"];
|
||||
$host = str_replace("-", "", strtolower($host));
|
||||
}else{
|
||||
$host = gethostbyaddr("192.168.179.32");
|
||||
$host = str_replace("-", "", strtolower(substr($host, 0, strpos($host, "."))));
|
||||
}
|
||||
$devices = json_decode(file_get_contents($tahoma_devlist), true);
|
||||
switch ($host) {
|
||||
case "tmpegbad":
|
||||
echo json_encode(filter_devs($devices, "bad "));
|
||||
break;
|
||||
case "tmpegwozi":
|
||||
echo json_encode(filter_devs($devices, "wozi "));
|
||||
break;
|
||||
case "tmpegflorian": //flori
|
||||
$ret = filter_devs($devices, "florian ","Flori ");
|
||||
$ret = array_merge($ret, filter_devs($devices, "magdalena ","Magdalena "));
|
||||
echo json_encode($ret);
|
||||
break;
|
||||
case "tmpegmagdalena":
|
||||
echo json_encode(filter_devs($devices, "magdalena "));
|
||||
break;
|
||||
case "tmpegschlafzimmer":
|
||||
echo json_encode(filter_devs($devices, "schlafzimmer "));
|
||||
break;
|
||||
default:
|
||||
$ret2=Array();
|
||||
//$ret2[0]["name"] = $host;
|
||||
//$ret2[0]["id"] = 99;
|
||||
$ret = filter_devs($devices, "");
|
||||
//$ret = array_merge($ret, $ret2);
|
||||
echo json_encode($ret);
|
||||
break;
|
||||
}
|
||||
header('Connection: close');
|
||||
header('Content-Length: '.ob_get_length());
|
||||
ob_end_flush();
|
||||
@ob_flush();
|
||||
flush();
|
||||
fastcgi_finish_request();
|
||||
|
||||
|
||||
//echo postSSLPage($url,json_encode($action));
|
||||
}
|
||||
|
||||
@@ -73,68 +73,62 @@ if (!checkLogin()) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Zonen des Modals. Die Reihenfolge bestimmt die Reihenfolge der Karten, die
|
||||
// Keys muessen zu wateringZones in js/solar/solarMQTT.js passen.
|
||||
// Zonen des Fensters. Die Reihenfolge bestimmt die Reihenfolge der Karten;
|
||||
// die Keys muessen zu wateringZones in js/solar/bewaesserungAnzeige.js passen,
|
||||
// "bild" zu ZONENBILDER in js/solar/energiefluss.js. "dauern" sind die
|
||||
// Schnellwahlen in Minuten, die erste ist die Vorgabe.
|
||||
$zonen = [
|
||||
["key" => "troughs", "label" => "Tröge", "minuten" => 5],
|
||||
["key" => "raisedBeds", "label" => "Beete", "minuten" => 30],
|
||||
["key" => "frontGarden", "label" => "Vorn", "minuten" => 30],
|
||||
["key" => "troughs", "label" => "Tröge", "bild" => "kuebel", "dauern" => [5, 10, 15, 30]],
|
||||
["key" => "raisedBeds", "label" => "Beete", "bild" => "hochbeet", "dauern" => [30, 15, 45, 60]],
|
||||
["key" => "frontGarden", "label" => "Vorn", "bild" => "garten", "dauern" => [30, 15, 45, 60]],
|
||||
];
|
||||
|
||||
// Was die Automatik je Zone vorhat, fuellt solarMQTT.js aus dem RainStatus.
|
||||
$planZeilen = "";
|
||||
foreach ($zonen as $zone) {
|
||||
$planZeilen .= " <div>{$zone['label']}: <span data-watering-plan=\"{$zone['key']}\">--</span></div>\n";
|
||||
}
|
||||
|
||||
echo <<<HTML
|
||||
<div class="p-3">
|
||||
<div id="watering-feedback" class="alert d-none mb-3" role="status"></div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="fw-semibold mb-3">Automatik</div>
|
||||
<div class="d-grid gap-2 mb-3">
|
||||
<button type="button" class="btn btn-outline-primary" data-watering-auto-toggle data-watering-auto="Trog,Hoch" data-watering-topics="Gartenwasser/vorn,Gartenwasser/hinten" data-watering-label="Tröge und Hochbeete">Tröge und Hochbeete</button>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm flex-fill" data-watering-auto-toggle data-watering-auto="Trog" data-watering-topic="Gartenwasser/vorn" data-watering-label="Tröge">Tröge</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm flex-fill" data-watering-auto-toggle data-watering-auto="Hoch" data-watering-topic="Gartenwasser/hinten" data-watering-label="Hochbeete">Hochbeete</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm flex-fill" data-watering-auto-toggle data-watering-auto="Vorn" data-watering-topic="Gartenwasser/vorn" data-watering-label="Garten vorn">Garten vorn</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
$planZeilen <div class="fst-italic" data-watering-plan-updated></div>
|
||||
</div>
|
||||
// Die Abläufe der Ventilsteuerung (Topic .../auto). Mehrere Einträge in
|
||||
// "modi" starten bzw. stoppen gemeinsam.
|
||||
$ablaeufe = [
|
||||
["label" => "Tröge + Beete", "modi" => ["Gartenwasser/vorn" => "Trog", "Gartenwasser/hinten" => "Hoch"]],
|
||||
["label" => "Tröge", "modi" => ["Gartenwasser/vorn" => "Trog"]],
|
||||
["label" => "Beete", "modi" => ["Gartenwasser/hinten" => "Hoch"]],
|
||||
["label" => "Vorn", "modi" => ["Gartenwasser/vorn" => "Vorn"]],
|
||||
];
|
||||
?>
|
||||
<div class="lade wasser">
|
||||
<div class="lade-abschnitt">
|
||||
<h3>Abläufe</h3>
|
||||
<div class="lade-chips wasser-ablaeufe">
|
||||
<?php foreach ($ablaeufe as $ablauf): ?>
|
||||
<button type="button" class="lade-chip" aria-pressed="false"
|
||||
data-ablauf="<?= htmlspecialchars(json_encode($ablauf["modi"], JSON_UNESCAPED_SLASHES)) ?>"><?= htmlspecialchars($ablauf["label"]) ?></button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="lade-erklaerung">Ein Ablauf gießt die Zonen nacheinander – antippen startet, nochmal antippen stoppt. Die Regenautomatik läuft davon unabhängig zum Sonnenaufgang.</div>
|
||||
</div>
|
||||
HTML;
|
||||
|
||||
foreach ($zonen as $zone) {
|
||||
$key = $zone["key"];
|
||||
$label = $zone["label"];
|
||||
$minuten = $zone["minuten"];
|
||||
echo <<<HTML
|
||||
|
||||
<div class="card mb-2">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="fw-semibold">$label</span>
|
||||
<span class="badge bg-secondary" data-watering-state="$key">unbekannt</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div class="input-group input-group-sm" style="max-width: 7.5rem;">
|
||||
<input class="form-control" type="number" min="1" max="120" value="$minuten" data-watering-timer="$key" aria-label="Dauer in Minuten für $label">
|
||||
<span class="input-group-text">Min.</span>
|
||||
<div class="wasser-zonen">
|
||||
<?php foreach ($zonen as $zone):
|
||||
$dauern = $zone["dauern"];
|
||||
sort($dauern); ?>
|
||||
<div class="wasser-zone" data-zone="<?= $zone["key"] ?>" data-dauer="<?= $zone["dauern"][0] ?>">
|
||||
<div class="wasser-kopf">
|
||||
<span class="wasser-bild" data-bild="<?= $zone["bild"] ?>" aria-hidden="true"></span>
|
||||
<div class="wasser-titel">
|
||||
<b><?= htmlspecialchars($zone["label"]) ?></b>
|
||||
<span data-w="zustand">–</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-success px-4 ms-auto" data-watering-toggle data-watering-zone="$key">Ein</button>
|
||||
<span class="lade-pille" data-w="pille"></span>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
Zuletzt <span data-watering-last="$key">--</span> · <span data-watering-duration="$key">--</span><br>
|
||||
Heute <span data-watering-today="$key">--</span> · Vortag <span data-watering-prevday="$key">--</span>
|
||||
<div class="wasser-fuss">
|
||||
<div class="lade-chips" role="group" aria-label="Dauer für <?= htmlspecialchars($zone["label"]) ?>">
|
||||
<?php foreach ($dauern as $min): ?>
|
||||
<button type="button" class="lade-chip" aria-pressed="false" data-min="<?= $min ?>"><?= $min ?> min</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<button type="button" class="lade-start wasser-knopf" data-w="knopf"><span class="lade-punkt"></span><span data-w="knopfText">Gießen</span></button>
|
||||
</div>
|
||||
<div class="wasser-info" data-w="info"></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
echo "\n</div>\n";
|
||||
<div class="lade-meldung" data-w="meldung" aria-live="polite">Befehle gehen sofort an die Ventilsteuerung</div>
|
||||
</div>
|
||||
|
||||
@@ -1,144 +1,144 @@
|
||||
/* devanagari */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
src: url(pxiByp8kv8JHgFVrLFj_Z11lFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
src: url(pxiByp8kv8JHgFVrLFj_Z1JlFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
src: url(pxiByp8kv8JHgFVrLFj_Z1xlFQ.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* devanagari */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url(pxiByp8kv8JHgFVrLDz8Z11lFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url(pxiByp8kv8JHgFVrLDz8Z1JlFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url(pxiByp8kv8JHgFVrLDz8Z1xlFQ.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* devanagari */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(pxiEyp8kv8JHgFVrJJbecmNE.woff2) format('woff2');
|
||||
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(pxiEyp8kv8JHgFVrJJnecmNE.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(pxiEyp8kv8JHgFVrJJfecg.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* devanagari */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url(pxiByp8kv8JHgFVrLEj6Z11lFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url(pxiByp8kv8JHgFVrLEj6Z1JlFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url(pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* devanagari */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url(pxiByp8kv8JHgFVrLCz7Z11lFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url(pxiByp8kv8JHgFVrLCz7Z1JlFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url(pxiByp8kv8JHgFVrLCz7Z1xlFQ.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* devanagari */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
src: url(pxiByp8kv8JHgFVrLDD4Z11lFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
src: url(pxiByp8kv8JHgFVrLDD4Z1JlFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
src: url(pxiByp8kv8JHgFVrLDD4Z1xlFQ.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
/* devanagari */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
src: url(pxiByp8kv8JHgFVrLFj_Z11lFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
src: url(pxiByp8kv8JHgFVrLFj_Z1JlFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
src: url(pxiByp8kv8JHgFVrLFj_Z1xlFQ.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* devanagari */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url(pxiByp8kv8JHgFVrLDz8Z11lFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url(pxiByp8kv8JHgFVrLDz8Z1JlFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url(pxiByp8kv8JHgFVrLDz8Z1xlFQ.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* devanagari */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(pxiEyp8kv8JHgFVrJJbecmNE.woff2) format('woff2');
|
||||
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(pxiEyp8kv8JHgFVrJJnecmNE.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(pxiEyp8kv8JHgFVrJJfecg.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* devanagari */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url(pxiByp8kv8JHgFVrLEj6Z11lFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url(pxiByp8kv8JHgFVrLEj6Z1JlFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url(pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* devanagari */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url(pxiByp8kv8JHgFVrLCz7Z11lFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url(pxiByp8kv8JHgFVrLCz7Z1JlFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url(pxiByp8kv8JHgFVrLCz7Z1xlFQ.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* devanagari */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
src: url(pxiByp8kv8JHgFVrLDD4Z11lFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
src: url(pxiByp8kv8JHgFVrLDD4Z1JlFc-K.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
src: url(pxiByp8kv8JHgFVrLDD4Z1xlFQ.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 874 KiB |
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="#fff"
|
||||
class="bi bi-heart-arrow" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M6.707 9h4.364c-.536 1.573 2.028 3.806 4.929-.5-2.9-4.306-5.465-2.073-4.929-.5H6.707L4.854 6.146a.5.5 0 1 0-.708.708L5.293 8h-.586L2.854 6.146a.5.5 0 1 0-.708.708L3.293 8h-.586L.854 6.146a.5.5 0 1 0-.708.708L1.793 8.5.146 10.146a.5.5 0 0 0 .708.708L2.707 9h.586l-1.147 1.146a.5.5 0 0 0 .708.708L4.707 9h.586l-1.147 1.146a.5.5 0 0 0 .708.708z" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="#fff"
|
||||
class="bi bi-heart-arrow" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M6.707 9h4.364c-.536 1.573 2.028 3.806 4.929-.5-2.9-4.306-5.465-2.073-4.929-.5H6.707L4.854 6.146a.5.5 0 1 0-.708.708L5.293 8h-.586L2.854 6.146a.5.5 0 1 0-.708.708L3.293 8h-.586L.854 6.146a.5.5 0 1 0-.708.708L1.793 8.5.146 10.146a.5.5 0 0 0 .708.708L2.707 9h.586l-1.147 1.146a.5.5 0 0 0 .708.708L4.707 9h.586l-1.147 1.146a.5.5 0 0 0 .708.708z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 495 B After Width: | Height: | Size: 491 B |
|
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,287 +1,287 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="border:0px solid gray;background-color: #212529;" viewBox="0 0 630 750" width= "100%" height= "100%">
|
||||
|
||||
|
||||
<g style="fill:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;stroke:#666666;fill:#666666;" stroke-width="10" transform="scale (2) translate(0 -25)">
|
||||
<path
|
||||
id="Puffer"
|
||||
d="m 227,68 c 0,0 0,-28 -74,-28 -75,0 -75,27 -75,27 V 353 c 0,0 10,31 76,31 66,0 75,-32 75,-32 z"
|
||||
style="fill:#212529;fill-opacity:1;stroke-width:2;" />
|
||||
<ellipse
|
||||
ry="3"
|
||||
rx="1"
|
||||
cy="112"
|
||||
cx="211"
|
||||
id="path902" />
|
||||
<ellipse
|
||||
ry="5"
|
||||
rx="3"
|
||||
cy="163"
|
||||
cx="211"
|
||||
id="path904" />
|
||||
<ellipse
|
||||
ry="5"
|
||||
rx="3"
|
||||
cy="282"
|
||||
cx="210"
|
||||
id="path904-6" />
|
||||
<ellipse
|
||||
ry="5"
|
||||
rx="3"
|
||||
cy="345"
|
||||
cx="213"
|
||||
id="path904-2" />
|
||||
<ellipse
|
||||
ry="3"
|
||||
rx="1"
|
||||
cy="332"
|
||||
cx="213"
|
||||
id="path902-4" />
|
||||
<ellipse
|
||||
ry="3"
|
||||
rx="1"
|
||||
cy="121"
|
||||
cx="100"
|
||||
id="path902-0" />
|
||||
<ellipse
|
||||
ry="3"
|
||||
rx="1"
|
||||
cy="164"
|
||||
cx="99" />
|
||||
<ellipse
|
||||
ry="3"
|
||||
rx="1"
|
||||
cy="175"
|
||||
cx="99"
|
||||
id="path902-8" />
|
||||
<ellipse
|
||||
ry="3"
|
||||
rx="1"
|
||||
cy="223"
|
||||
cx="98"
|
||||
id="path902-2" />
|
||||
<ellipse
|
||||
ry="3"
|
||||
rx="1"
|
||||
cy="257"
|
||||
cx="98"
|
||||
id="path902-07" />
|
||||
<path
|
||||
id="heatweRLtube1"
|
||||
d="m 211,282 h 95 v -44"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="path976"
|
||||
d="m 212,163 h 58"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="path978"
|
||||
d="M 32,140 H 2 v 61 H 32 Z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path980"
|
||||
d="M 99,121 H 9 v 18"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="path982"
|
||||
d="M 33,175 H 98"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="path984"
|
||||
d="m 8,201 v 21 H 98"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="fbVLtube"
|
||||
d="m 99,164 h 7 V 244 H 8 v 23"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="fbRLtube"
|
||||
d="m 8,302 v 32 H 107 v -79 h -9"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="path990"
|
||||
d="m 211,112 h 39 V 68"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="path992"
|
||||
d="M 26,151 H 8 v 2 H 6 v 4 l -1,1 v 30 H 28 v -34 l -1,-1 v -4 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path994"
|
||||
d="M 28,167 H 5"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path996"
|
||||
d="M 5,161 H 28"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path998"
|
||||
d="M 27,156 H 7"
|
||||
style="fill:none;stroke-width:1px;s" />
|
||||
<path
|
||||
id="path1000"
|
||||
d="m 10,152 1,2 H 24 l 1,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1002"
|
||||
d="m 24,167 v 5 h 4"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1004"
|
||||
d="M 47,268 H 1 v 34 H 47 v -34 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1006"
|
||||
d="m 8,269 v 2 c 0,0 -1,0 -1,1 0,1 1,1 1,1 H 40 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 c 0,0 -1,0 -1,1 0,1 1,1 1,1 H 40 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 c 0,0 -1,0 -1,1 0,1 1,1 1,1 H 40 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 c 0,0 -1,0 -1,1 0,1 1,1 1,1 H 40 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 c 0,0 -1,0 -1,1 -0,1 1,1 1,1 h 31 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 v 4"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1008"
|
||||
d="M 294,238 Z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="heizerRLtube2"
|
||||
d="m 295,238 h 10"
|
||||
style="fill:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path
|
||||
id="path1012"
|
||||
d="M 270,163 Z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="heizregler"
|
||||
d="m 282,247 v 7 c 0,0 0,4 7,4 6,0 7,-4 7,-4 v -7 c 0,0 -0,3 -8,3 -7,0 -6,-3 -6,-3 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="Heizstab"
|
||||
d="m 293,246 v -74 c 0,0 -0,-2 -4,-2 -4,0 -4,2 -4,2 v 74 c 0,0 -0,1 4,1 4,-0 4,-1 4,-1 z"
|
||||
style="fill:#212529;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1020"
|
||||
d="m 270,163 h 19 v 5"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="path1022"
|
||||
d="m 282,247 c 0,0 -0,-2 2,-2"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1024"
|
||||
d="m 296,247 c 0,0 0,-2 -3,-3"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path976-2"
|
||||
d="m 213,332 h 75"
|
||||
style="fill:none;stroke-width:4;" />
|
||||
<path
|
||||
id="path1043"
|
||||
d="m 240,338 0,-2 -7,3 6,3 -0,-2 4,-0 -0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1043-9"
|
||||
d="m 240,169 0,-2 -7,3 6,3 -0,-2 4,-0 -0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1043-5"
|
||||
d="m 65,228 0,-2 -7,3 6,3 -0,-2 4,-0 -0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1043-3"
|
||||
d="m 65,250 0,-2 -7,3 6,3 -0,-2 4,-0 -0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1043-94"
|
||||
d="m 253,288 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1043-94-5"
|
||||
d="m 236,103 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1043-94-2"
|
||||
d="m 65,342 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1043-94-6"
|
||||
d="m 65,126 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1043-94-9"
|
||||
d="m 65,180 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1123"
|
||||
d="m 253,52 h 9 c 0,0 3,-0 5,1 1,1 1,5 1,5 l -0,3 c 0,0 2,0 3,1 1,1 1,3 1,3 h -11 c 0,0 -0,-2 1,-4 1,-1 2,-1 2,-1 v -3 c 0,0 0,-2 -1,-3 -1,-0 -2,-0 -2,-0 h -9"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1125"
|
||||
d="m 266,68 -0,2"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1127"
|
||||
d="m 266,73 v 1"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1129"
|
||||
d="m 268,68 v 2"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1131"
|
||||
d="m 268,73 v 1"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1133"
|
||||
d="m 271,69 v 2"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1135"
|
||||
d="M 271,74 V 75"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1137"
|
||||
d="m 285,314 h -4 v 12 h 4"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1139"
|
||||
d="m 280,318 h -4 v -5 h -4 v 5 h -4 c 0,0 -1,-0 -2,1 -0,1 -0,4 -0,4 0,0 -0,3 2,3 2,0 2,-2 2,-3 0,-1 2,-1 2,-1 l 9,0"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1141"
|
||||
d="m 278,312 h -9 c -1,0 -2,-1 0,-1 2,0 2,1 4,1 2,0 3,-1 5,-1 2,0 0,1 -0,1 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
</g>
|
||||
<g style="font-size: 28px; font-weight: 600; font-family: sans-serif; text-anchor: middle; fill: rgb(200, 210, 210); letter-spacing: 1px;" >
|
||||
<text id="PufferOtxt" dy="200" dx="320">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="PufferMtxt" dy="400" dx="320">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="PufferUtxt" dy="600" dx="320">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="heaterRL" dy="505" dx="530" style="font-size: 22px;">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="heaterVL" dy="260" dx="530" style="font-size: 22px;">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="fbVL" dy="465" dx="70" style="font-size: 22px;">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="fbRL" dy="605" dx="70" style="font-size: 22px;">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="thermeRL" dy="385" dx="70" style="font-size: 22px;">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="thermeVLfb" dy="290" dx="110" style="font-size: 22px;">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="thermeVLww" dy="180" dx="70" style="font-size: 22px;">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="triacLbl" dy="350" dx="515" style="font-size: 22px;">
|
||||
Triac:
|
||||
</text>
|
||||
<text id="triac" dy="375" dx="515" style="font-size: 22px;">
|
||||
--.- °C
|
||||
</text>
|
||||
</g>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="border:0px solid gray;background-color: #212529;" viewBox="0 0 630 750" width= "100%" height= "100%">
|
||||
|
||||
|
||||
<g style="fill:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;stroke:#666666;fill:#666666;" stroke-width="10" transform="scale (2) translate(0 -25)">
|
||||
<path
|
||||
id="Puffer"
|
||||
d="m 227,68 c 0,0 0,-28 -74,-28 -75,0 -75,27 -75,27 V 353 c 0,0 10,31 76,31 66,0 75,-32 75,-32 z"
|
||||
style="fill:#212529;fill-opacity:1;stroke-width:2;" />
|
||||
<ellipse
|
||||
ry="3"
|
||||
rx="1"
|
||||
cy="112"
|
||||
cx="211"
|
||||
id="path902" />
|
||||
<ellipse
|
||||
ry="5"
|
||||
rx="3"
|
||||
cy="163"
|
||||
cx="211"
|
||||
id="path904" />
|
||||
<ellipse
|
||||
ry="5"
|
||||
rx="3"
|
||||
cy="282"
|
||||
cx="210"
|
||||
id="path904-6" />
|
||||
<ellipse
|
||||
ry="5"
|
||||
rx="3"
|
||||
cy="345"
|
||||
cx="213"
|
||||
id="path904-2" />
|
||||
<ellipse
|
||||
ry="3"
|
||||
rx="1"
|
||||
cy="332"
|
||||
cx="213"
|
||||
id="path902-4" />
|
||||
<ellipse
|
||||
ry="3"
|
||||
rx="1"
|
||||
cy="121"
|
||||
cx="100"
|
||||
id="path902-0" />
|
||||
<ellipse
|
||||
ry="3"
|
||||
rx="1"
|
||||
cy="164"
|
||||
cx="99" />
|
||||
<ellipse
|
||||
ry="3"
|
||||
rx="1"
|
||||
cy="175"
|
||||
cx="99"
|
||||
id="path902-8" />
|
||||
<ellipse
|
||||
ry="3"
|
||||
rx="1"
|
||||
cy="223"
|
||||
cx="98"
|
||||
id="path902-2" />
|
||||
<ellipse
|
||||
ry="3"
|
||||
rx="1"
|
||||
cy="257"
|
||||
cx="98"
|
||||
id="path902-07" />
|
||||
<path
|
||||
id="heatweRLtube1"
|
||||
d="m 211,282 h 95 v -44"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="path976"
|
||||
d="m 212,163 h 58"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="path978"
|
||||
d="M 32,140 H 2 v 61 H 32 Z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path980"
|
||||
d="M 99,121 H 9 v 18"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="path982"
|
||||
d="M 33,175 H 98"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="path984"
|
||||
d="m 8,201 v 21 H 98"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="fbVLtube"
|
||||
d="m 99,164 h 7 V 244 H 8 v 23"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="fbRLtube"
|
||||
d="m 8,302 v 32 H 107 v -79 h -9"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="path990"
|
||||
d="m 211,112 h 39 V 68"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="path992"
|
||||
d="M 26,151 H 8 v 2 H 6 v 4 l -1,1 v 30 H 28 v -34 l -1,-1 v -4 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path994"
|
||||
d="M 28,167 H 5"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path996"
|
||||
d="M 5,161 H 28"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path998"
|
||||
d="M 27,156 H 7"
|
||||
style="fill:none;stroke-width:1px;s" />
|
||||
<path
|
||||
id="path1000"
|
||||
d="m 10,152 1,2 H 24 l 1,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1002"
|
||||
d="m 24,167 v 5 h 4"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1004"
|
||||
d="M 47,268 H 1 v 34 H 47 v -34 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1006"
|
||||
d="m 8,269 v 2 c 0,0 -1,0 -1,1 0,1 1,1 1,1 H 40 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 c 0,0 -1,0 -1,1 0,1 1,1 1,1 H 40 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 c 0,0 -1,0 -1,1 0,1 1,1 1,1 H 40 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 c 0,0 -1,0 -1,1 0,1 1,1 1,1 H 40 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 c 0,0 -1,0 -1,1 -0,1 1,1 1,1 h 31 c 0,0 1,0 1,1 0,1 -1,1 -1,1 H 8 v 4"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1008"
|
||||
d="M 294,238 Z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="heizerRLtube2"
|
||||
d="m 295,238 h 10"
|
||||
style="fill:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path
|
||||
id="path1012"
|
||||
d="M 270,163 Z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="heizregler"
|
||||
d="m 282,247 v 7 c 0,0 0,4 7,4 6,0 7,-4 7,-4 v -7 c 0,0 -0,3 -8,3 -7,0 -6,-3 -6,-3 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="Heizstab"
|
||||
d="m 293,246 v -74 c 0,0 -0,-2 -4,-2 -4,0 -4,2 -4,2 v 74 c 0,0 -0,1 4,1 4,-0 4,-1 4,-1 z"
|
||||
style="fill:#212529;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1020"
|
||||
d="m 270,163 h 19 v 5"
|
||||
style="fill:none;" />
|
||||
<path
|
||||
id="path1022"
|
||||
d="m 282,247 c 0,0 -0,-2 2,-2"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1024"
|
||||
d="m 296,247 c 0,0 0,-2 -3,-3"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path976-2"
|
||||
d="m 213,332 h 75"
|
||||
style="fill:none;stroke-width:4;" />
|
||||
<path
|
||||
id="path1043"
|
||||
d="m 240,338 0,-2 -7,3 6,3 -0,-2 4,-0 -0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1043-9"
|
||||
d="m 240,169 0,-2 -7,3 6,3 -0,-2 4,-0 -0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1043-5"
|
||||
d="m 65,228 0,-2 -7,3 6,3 -0,-2 4,-0 -0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1043-3"
|
||||
d="m 65,250 0,-2 -7,3 6,3 -0,-2 4,-0 -0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1043-94"
|
||||
d="m 253,288 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1043-94-5"
|
||||
d="m 236,103 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1043-94-2"
|
||||
d="m 65,342 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1043-94-6"
|
||||
d="m 65,126 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1043-94-9"
|
||||
d="m 65,180 -0,-2 7,3 -6,3 0,-2 -4,-0 0,-2 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1123"
|
||||
d="m 253,52 h 9 c 0,0 3,-0 5,1 1,1 1,5 1,5 l -0,3 c 0,0 2,0 3,1 1,1 1,3 1,3 h -11 c 0,0 -0,-2 1,-4 1,-1 2,-1 2,-1 v -3 c 0,0 0,-2 -1,-3 -1,-0 -2,-0 -2,-0 h -9"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1125"
|
||||
d="m 266,68 -0,2"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1127"
|
||||
d="m 266,73 v 1"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1129"
|
||||
d="m 268,68 v 2"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1131"
|
||||
d="m 268,73 v 1"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1133"
|
||||
d="m 271,69 v 2"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1135"
|
||||
d="M 271,74 V 75"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1137"
|
||||
d="m 285,314 h -4 v 12 h 4"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1139"
|
||||
d="m 280,318 h -4 v -5 h -4 v 5 h -4 c 0,0 -1,-0 -2,1 -0,1 -0,4 -0,4 0,0 -0,3 2,3 2,0 2,-2 2,-3 0,-1 2,-1 2,-1 l 9,0"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
<path
|
||||
id="path1141"
|
||||
d="m 278,312 h -9 c -1,0 -2,-1 0,-1 2,0 2,1 4,1 2,0 3,-1 5,-1 2,0 0,1 -0,1 z"
|
||||
style="fill:none;stroke-width:1px;" />
|
||||
</g>
|
||||
<g style="font-size: 28px; font-weight: 600; font-family: sans-serif; text-anchor: middle; fill: rgb(200, 210, 210); letter-spacing: 1px;" >
|
||||
<text id="PufferOtxt" dy="200" dx="320">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="PufferMtxt" dy="400" dx="320">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="PufferUtxt" dy="600" dx="320">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="heaterRL" dy="505" dx="530" style="font-size: 22px;">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="heaterVL" dy="260" dx="530" style="font-size: 22px;">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="fbVL" dy="465" dx="70" style="font-size: 22px;">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="fbRL" dy="605" dx="70" style="font-size: 22px;">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="thermeRL" dy="385" dx="70" style="font-size: 22px;">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="thermeVLfb" dy="290" dx="110" style="font-size: 22px;">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="thermeVLww" dy="180" dx="70" style="font-size: 22px;">
|
||||
--.- °C
|
||||
</text>
|
||||
<text id="triacLbl" dy="350" dx="515" style="font-size: 22px;">
|
||||
Triac:
|
||||
</text>
|
||||
<text id="triac" dy="375" dx="515" style="font-size: 22px;">
|
||||
--.- °C
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 7.8 KiB |
@@ -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>
|
||||
@@ -1,366 +1,366 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2022 Lukas Buchs
|
||||
* license https://github.com/lbuchs/WebAuthn/blob/master/LICENSE MIT
|
||||
*
|
||||
* Server test script for WebAuthn library. Saves new registrations in session.
|
||||
*
|
||||
* JAVASCRIPT | SERVER
|
||||
* ------------------------------------------------------------
|
||||
*
|
||||
* REGISTRATION
|
||||
*
|
||||
* window.fetch -----------------> getCreateArgs
|
||||
* |
|
||||
* navigator.credentials.create <-------------'
|
||||
* |
|
||||
* '-------------------------> processCreate
|
||||
* |
|
||||
* alert ok or fail <----------------'
|
||||
*
|
||||
* ------------------------------------------------------------
|
||||
*
|
||||
* VALIDATION
|
||||
*
|
||||
* window.fetch ------------------> getGetArgs
|
||||
* |
|
||||
* navigator.credentials.get <----------------'
|
||||
* |
|
||||
* '-------------------------> processGet
|
||||
* |
|
||||
* alert ok or fail <----------------'
|
||||
*
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
require_once './restricted/WebAuthn/src/WebAuthn.php';
|
||||
require_once("./helper.php"); // startet die Session, liefert isLocal() und mysql.php
|
||||
try {
|
||||
|
||||
// read get argument and post body
|
||||
$fn = filter_input(INPUT_GET, 'fn');
|
||||
|
||||
// Registrierung und Loeschen von Anmeldedaten nur nach einem gueltigen
|
||||
// Einmalschluessel aus addUser.php (checkAdduser setzt mayRegister) oder
|
||||
// aus dem lokalen Netz. Ohne diese Wache genuegen zwei Aufrufe von
|
||||
// beliebiger Stelle, um einen eigenen Passkey fuer das fest hinterlegte
|
||||
// Konto anzulegen - der Einmalschluessel schuetzte nur die Anzeige in
|
||||
// index.php, nicht diesen Endpunkt.
|
||||
if ($fn === 'getCreateArgs' || $fn === 'processCreate' || $fn === 'clearRegistrations') {
|
||||
$freigegeben = isset($_SESSION['mayRegister'])
|
||||
&& (time() - $_SESSION['mayRegister']) < 600;
|
||||
if (!$freigegeben && !isLocal()) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
print(json_encode(['success' => false, 'msg' => 'Registrierung nicht freigegeben.']));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
$requireResidentKey = false;
|
||||
$userVerification = false;
|
||||
$formats = [];
|
||||
$msg="";
|
||||
|
||||
$formats[] = 'android-key';
|
||||
$formats[] = 'android-safetynet';
|
||||
$formats[] = 'apple';
|
||||
$formats[] = 'fido-u2f';
|
||||
$formats[] = 'none';
|
||||
$formats[] = 'packed';
|
||||
$formats[] = 'tpm';
|
||||
$userId = "E071229F004A4CDE";
|
||||
$userName = "SmartHomeWagner";
|
||||
$userDisplayName = "2025Bym0";
|
||||
|
||||
$post = trim(file_get_contents('php://input'));
|
||||
if ($post) {
|
||||
$post = json_decode($post, null, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
$rpId = "nas.el-wa.org";
|
||||
if ($rpId === false) {
|
||||
throw new Exception('invalid relying party ID');
|
||||
}
|
||||
|
||||
// cross-platform: true, if type internal is not allowed
|
||||
// false, if only internal is allowed
|
||||
// null, if internal and cross-platform is allowed
|
||||
$crossPlatformAttachment = null;
|
||||
|
||||
// new Instance of the server library.
|
||||
// make sure that $rpId is the domain name.
|
||||
$WebAuthn = new lbuchs\WebAuthn\WebAuthn('WebAuthn Library', $rpId, $formats);
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/isrg-root-x2.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/solo.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/solokey_f1.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/solokey_r1.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/apple.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/yubico.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/hypersecu.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/globalSign.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/googleHardware.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/microsoftTpmCollection.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/mds');
|
||||
|
||||
// ------------------------------------
|
||||
// request for create arguments
|
||||
// ------------------------------------
|
||||
|
||||
if ($fn === 'getCreateArgs') {
|
||||
$createArgs = $WebAuthn->getCreateArgs(\hex2bin($userId), $userName, $userDisplayName, 60*4, $requireResidentKey, $userVerification, $crossPlatformAttachment);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
print(json_encode($createArgs));
|
||||
|
||||
// save challange to session. you have to deliver it to processGet later.
|
||||
$_SESSION['challenge'] = $WebAuthn->getChallenge();
|
||||
|
||||
|
||||
|
||||
// ------------------------------------
|
||||
// request for get arguments
|
||||
// ------------------------------------
|
||||
|
||||
} else if ($fn === 'getGetArgs') {
|
||||
$ids = [];
|
||||
$mysql = new mysqli($mysql_server,$mysql_user,$mysql_pass,$mysql_db);
|
||||
$result = mysqli_query($mysql,"SELECT credentialId FROM users WHERE userId = '".base64_encode($userId)."';");
|
||||
if(!$result){
|
||||
$msg = "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
if ($result->num_rows > 0) {
|
||||
while($row = $result->fetch_assoc()) {
|
||||
$ids[] = base64_decode($row["credentialId"]);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
if ($requireResidentKey) {
|
||||
if (!isset($_SESSION['registrations']) || !is_array($_SESSION['registrations']) || count($_SESSION['registrations']) === 0) {
|
||||
throw new Exception('we do not have any registrations in session to check the registration');
|
||||
}
|
||||
|
||||
} else {
|
||||
// load registrations from session stored there by processCreate.
|
||||
// normaly you have to load the credential Id's for a username
|
||||
// from the database.
|
||||
if (isset($_SESSION['registrations']) && is_array($_SESSION['registrations'])) {
|
||||
foreach ($_SESSION['registrations'] as $reg) {
|
||||
if ($reg->userId === $userId) {
|
||||
$ids[] = $reg->credentialId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
if (count($ids) === 0) {
|
||||
throw new Exception('no registrations in session for userId ' . $userId);
|
||||
}
|
||||
|
||||
$getArgs = $WebAuthn->getGetArgs($ids, 60*4, true, true, true, true, true, $userVerification);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
print(json_encode($getArgs));
|
||||
|
||||
// save challange to session. you have to deliver it to processGet later.
|
||||
$_SESSION['challenge'] = $WebAuthn->getChallenge();
|
||||
|
||||
|
||||
|
||||
// ------------------------------------
|
||||
// process create
|
||||
// ------------------------------------
|
||||
|
||||
} else if ($fn === 'processCreate') {
|
||||
$mysql = new mysqli($mysql_server,$mysql_user,$mysql_pass,$mysql_db);
|
||||
$clientDataJSON = !empty($post->clientDataJSON) ? base64_decode($post->clientDataJSON) : null;
|
||||
$attestationObject = !empty($post->attestationObject) ? base64_decode($post->attestationObject) : null;
|
||||
$challenge = $_SESSION['challenge'] ?? null;
|
||||
|
||||
// processCreate returns data to be stored for future logins.
|
||||
// in this example we store it in the php session.
|
||||
// Normally you have to store the data in a database connected
|
||||
// with the username.
|
||||
$data = $WebAuthn->processCreate($clientDataJSON, $attestationObject, $challenge, $userVerification === 'required', true, false);
|
||||
|
||||
// add user infos
|
||||
$data->userId = $userId;
|
||||
$data->userName = $userName;
|
||||
$data->userDisplayName = $userDisplayName;
|
||||
//set Null to 0
|
||||
$data->signatureCounter ??= 0;
|
||||
/*
|
||||
if (!isset($_SESSION['registrations']) || !array_key_exists('registrations', $_SESSION) || !is_array($_SESSION['registrations'])) {
|
||||
$_SESSION['registrations'] = [];
|
||||
}*/
|
||||
if(!mysqli_query($mysql,"INSERT INTO users SET userId = '".base64_encode($data->userId)."', credentialId = '".base64_encode($data->credentialId)."', credentialPublicKey = '".base64_encode($data->credentialPublicKey)."', signatureCounter = '".base64_encode($data->signatureCounter)."', name = '".mysqli_real_escape_string($mysql,filter_input(INPUT_GET, 'name'))."';")){
|
||||
$msg = "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
else{
|
||||
if ($data->rootValid === false) {
|
||||
$msg = 'registration ok, but certificate does not match any of the selected root ca.';
|
||||
}
|
||||
// $msg = "Data: ".json_last_error();//json_encode($data);//'registration success.';
|
||||
}
|
||||
/*
|
||||
$_SESSION['registrations'][] = $data;
|
||||
*/
|
||||
$return = new stdClass();
|
||||
$return->success = true;
|
||||
$return->msg = $msg;
|
||||
|
||||
header('Content-Type: application/json');
|
||||
print(json_encode($return));
|
||||
|
||||
|
||||
|
||||
// ------------------------------------
|
||||
// proccess get
|
||||
// ------------------------------------
|
||||
|
||||
} else if ($fn === 'processGet') {
|
||||
$clientDataJSON = !empty($post->clientDataJSON) ? base64_decode($post->clientDataJSON) : null;
|
||||
$authenticatorData = !empty($post->authenticatorData) ? base64_decode($post->authenticatorData) : null;
|
||||
$signature = !empty($post->signature) ? base64_decode($post->signature) : null;
|
||||
$userHandle = !empty($post->userHandle) ? base64_decode($post->userHandle) : null;
|
||||
$id = !empty($post->id) ? base64_decode($post->id) : null;
|
||||
$challenge = $_SESSION['challenge'] ?? '';
|
||||
$credentialPublicKey = null;
|
||||
|
||||
// looking up correspondending public key of the credential id
|
||||
// you should also validate that only ids of the given user name
|
||||
// are taken for the login.
|
||||
$mysql = new mysqli($mysql_server,$mysql_user,$mysql_pass,$mysql_db);
|
||||
$result = mysqli_query($mysql,"SELECT credentialPublicKey, userId, name FROM users WHERE credentialId = '".base64_encode($id)."';");
|
||||
if(!$result){
|
||||
$msg = "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
if ($result->num_rows > 0) {
|
||||
$row = $result->fetch_assoc();
|
||||
$credentialPublicKey = base64_decode($row["credentialPublicKey"]);
|
||||
$reg = (object) ['userId' => base64_decode($row["userId"])];
|
||||
$username = $row["name"];
|
||||
} /*else {
|
||||
if (isset($_SESSION['registrations']) && is_array($_SESSION['registrations'])) {
|
||||
foreach ($_SESSION['registrations'] as $reg) {
|
||||
if ($reg->credentialId === $id) {
|
||||
$credentialPublicKey = $reg->credentialPublicKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}*/
|
||||
if ($credentialPublicKey === null) {
|
||||
throw new Exception('Public Key for credential ID not found!');
|
||||
}
|
||||
|
||||
// if we have resident key, we have to verify that the userHandle is the provided userId at registration
|
||||
if ($requireResidentKey && $userHandle !== hex2bin($reg->userId)) {
|
||||
throw new \Exception('userId doesnt match (is ' . bin2hex($userHandle) . ' but expect ' . $reg->userId . ')');
|
||||
}
|
||||
|
||||
// process the get request. throws WebAuthnException if it fails
|
||||
$WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge, null, $userVerification === 'required');
|
||||
|
||||
$return = new stdClass();
|
||||
$return->success = true;
|
||||
$authKey = strval(random_int(0,99999999));
|
||||
$result = mysqli_query($mysql,"UPDATE users SET authKey=".$authKey.", lastAuth=NOW() WHERE credentialId = '".base64_encode($id)."';");
|
||||
$_SESSION["Logged"] = true;
|
||||
$_SESSION["user"] = $username;
|
||||
$_SESSION["authKey"] = $authKey;
|
||||
header('Content-Type: application/json');
|
||||
print(json_encode($return));
|
||||
|
||||
// ------------------------------------
|
||||
// proccess clear registrations
|
||||
// ------------------------------------
|
||||
|
||||
} else if ($fn === 'clearRegistrations') {
|
||||
$_SESSION['registrations'] = null;
|
||||
$_SESSION['challenge'] = null;
|
||||
|
||||
$return = new stdClass();
|
||||
$return->success = true;
|
||||
$return->msg = 'all registrations deleted';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
print(json_encode($return));
|
||||
|
||||
// ------------------------------------
|
||||
// display stored data as HTML
|
||||
// ------------------------------------
|
||||
|
||||
} /*else if ($fn === 'getStoredDataHtml') {
|
||||
$html = '<!DOCTYPE html>' . "\n";
|
||||
$html .= '<html><head><style>tr:nth-child(even){background-color: #f2f2f2;}</style></head>';
|
||||
$html .= '<body style="font-family:sans-serif">';
|
||||
if (isset($_SESSION['registrations']) && is_array($_SESSION['registrations'])) {
|
||||
$html .= '<p>There are ' . count($_SESSION['registrations']) . ' registrations in this session:</p>';
|
||||
foreach ($_SESSION['registrations'] as $reg) {
|
||||
$html .= '<table style="border:1px solid black;margin:10px 0;">';
|
||||
foreach ($reg as $key => $value) {
|
||||
|
||||
if (is_bool($value)) {
|
||||
$value = $value ? 'yes' : 'no';
|
||||
|
||||
} else if (is_null($value)) {
|
||||
$value = 'null';
|
||||
|
||||
} else if (is_object($value)) {
|
||||
$value = chunk_split(strval($value), 64);
|
||||
|
||||
} else if (is_string($value) && strlen($value) > 0 && htmlspecialchars($value, ENT_QUOTES) === '') {
|
||||
$value = chunk_split(bin2hex($value), 64);
|
||||
}
|
||||
$html .= '<tr><td>' . htmlspecialchars($key) . '</td><td style="font-family:monospace;">' . nl2br(htmlspecialchars($value)) . '</td>';
|
||||
}
|
||||
$html .= '</table>';
|
||||
}
|
||||
} else {
|
||||
$html .= '<p>There are no registrations.</p>';
|
||||
}
|
||||
$html .= '</body></html>';
|
||||
|
||||
header('Content-Type: text/html');
|
||||
print $html;
|
||||
|
||||
// ------------------------------------
|
||||
// get root certs from FIDO Alliance Metadata Service
|
||||
// ------------------------------------
|
||||
|
||||
} */else if ($fn === 'queryFidoMetaDataService') {
|
||||
|
||||
$mdsFolder = './restricted/WebAuthn/rootCertificates/mds';
|
||||
$success = false;
|
||||
$msg = null;
|
||||
|
||||
// fetch only 1x / 24h
|
||||
$lastFetch = \is_file($mdsFolder . '/lastMdsFetch.txt') ? \strtotime(\file_get_contents($mdsFolder . '/lastMdsFetch.txt')) : 0;
|
||||
if ($lastFetch + (3600*48) < \time()) {
|
||||
$cnt = $WebAuthn->queryFidoMetaDataService($mdsFolder);
|
||||
$success = true;
|
||||
\file_put_contents($mdsFolder . '/lastMdsFetch.txt', date('r'));
|
||||
$msg = 'successfully queried FIDO Alliance Metadata Service - ' . $cnt . ' certificates downloaded.';
|
||||
|
||||
} else {
|
||||
$msg = 'Fail: last fetch was at ' . date('r', $lastFetch) . ' - fetch only 1x every 48h';
|
||||
}
|
||||
|
||||
$return = new stdClass();
|
||||
$return->success = $success;
|
||||
$return->msg = $msg;
|
||||
|
||||
header('Content-Type: application/json');
|
||||
print(json_encode($return));
|
||||
}
|
||||
|
||||
} catch (Throwable $ex) {
|
||||
$return = new stdClass();
|
||||
$return->success = false;
|
||||
$return->msg = $ex->getMessage();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
print(json_encode($return));
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2022 Lukas Buchs
|
||||
* license https://github.com/lbuchs/WebAuthn/blob/master/LICENSE MIT
|
||||
*
|
||||
* Server test script for WebAuthn library. Saves new registrations in session.
|
||||
*
|
||||
* JAVASCRIPT | SERVER
|
||||
* ------------------------------------------------------------
|
||||
*
|
||||
* REGISTRATION
|
||||
*
|
||||
* window.fetch -----------------> getCreateArgs
|
||||
* |
|
||||
* navigator.credentials.create <-------------'
|
||||
* |
|
||||
* '-------------------------> processCreate
|
||||
* |
|
||||
* alert ok or fail <----------------'
|
||||
*
|
||||
* ------------------------------------------------------------
|
||||
*
|
||||
* VALIDATION
|
||||
*
|
||||
* window.fetch ------------------> getGetArgs
|
||||
* |
|
||||
* navigator.credentials.get <----------------'
|
||||
* |
|
||||
* '-------------------------> processGet
|
||||
* |
|
||||
* alert ok or fail <----------------'
|
||||
*
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
require_once './restricted/WebAuthn/src/WebAuthn.php';
|
||||
require_once("./helper.php"); // startet die Session, liefert isLocal() und mysql.php
|
||||
try {
|
||||
|
||||
// read get argument and post body
|
||||
$fn = filter_input(INPUT_GET, 'fn');
|
||||
|
||||
// Registrierung und Loeschen von Anmeldedaten nur nach einem gueltigen
|
||||
// Einmalschluessel aus addUser.php (checkAdduser setzt mayRegister) oder
|
||||
// aus dem lokalen Netz. Ohne diese Wache genuegen zwei Aufrufe von
|
||||
// beliebiger Stelle, um einen eigenen Passkey fuer das fest hinterlegte
|
||||
// Konto anzulegen - der Einmalschluessel schuetzte nur die Anzeige in
|
||||
// index.php, nicht diesen Endpunkt.
|
||||
if ($fn === 'getCreateArgs' || $fn === 'processCreate' || $fn === 'clearRegistrations') {
|
||||
$freigegeben = isset($_SESSION['mayRegister'])
|
||||
&& (time() - $_SESSION['mayRegister']) < 600;
|
||||
if (!$freigegeben && !isLocal()) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
print(json_encode(['success' => false, 'msg' => 'Registrierung nicht freigegeben.']));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
$requireResidentKey = false;
|
||||
$userVerification = false;
|
||||
$formats = [];
|
||||
$msg="";
|
||||
|
||||
$formats[] = 'android-key';
|
||||
$formats[] = 'android-safetynet';
|
||||
$formats[] = 'apple';
|
||||
$formats[] = 'fido-u2f';
|
||||
$formats[] = 'none';
|
||||
$formats[] = 'packed';
|
||||
$formats[] = 'tpm';
|
||||
$userId = "E071229F004A4CDE";
|
||||
$userName = "SmartHomeWagner";
|
||||
$userDisplayName = "2025Bym0";
|
||||
|
||||
$post = trim(file_get_contents('php://input'));
|
||||
if ($post) {
|
||||
$post = json_decode($post, null, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
$rpId = "nas.el-wa.org";
|
||||
if ($rpId === false) {
|
||||
throw new Exception('invalid relying party ID');
|
||||
}
|
||||
|
||||
// cross-platform: true, if type internal is not allowed
|
||||
// false, if only internal is allowed
|
||||
// null, if internal and cross-platform is allowed
|
||||
$crossPlatformAttachment = null;
|
||||
|
||||
// new Instance of the server library.
|
||||
// make sure that $rpId is the domain name.
|
||||
$WebAuthn = new lbuchs\WebAuthn\WebAuthn('WebAuthn Library', $rpId, $formats);
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/isrg-root-x2.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/solo.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/solokey_f1.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/solokey_r1.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/apple.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/yubico.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/hypersecu.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/globalSign.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/googleHardware.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/microsoftTpmCollection.pem');
|
||||
$WebAuthn->addRootCertificates('./restricted/WebAuthn/rootCertificates/mds');
|
||||
|
||||
// ------------------------------------
|
||||
// request for create arguments
|
||||
// ------------------------------------
|
||||
|
||||
if ($fn === 'getCreateArgs') {
|
||||
$createArgs = $WebAuthn->getCreateArgs(\hex2bin($userId), $userName, $userDisplayName, 60*4, $requireResidentKey, $userVerification, $crossPlatformAttachment);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
print(json_encode($createArgs));
|
||||
|
||||
// save challange to session. you have to deliver it to processGet later.
|
||||
$_SESSION['challenge'] = $WebAuthn->getChallenge();
|
||||
|
||||
|
||||
|
||||
// ------------------------------------
|
||||
// request for get arguments
|
||||
// ------------------------------------
|
||||
|
||||
} else if ($fn === 'getGetArgs') {
|
||||
$ids = [];
|
||||
$mysql = new mysqli($mysql_server,$mysql_user,$mysql_pass,$mysql_db);
|
||||
$result = mysqli_query($mysql,"SELECT credentialId FROM users WHERE userId = '".base64_encode($userId)."';");
|
||||
if(!$result){
|
||||
$msg = "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
if ($result->num_rows > 0) {
|
||||
while($row = $result->fetch_assoc()) {
|
||||
$ids[] = base64_decode($row["credentialId"]);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
if ($requireResidentKey) {
|
||||
if (!isset($_SESSION['registrations']) || !is_array($_SESSION['registrations']) || count($_SESSION['registrations']) === 0) {
|
||||
throw new Exception('we do not have any registrations in session to check the registration');
|
||||
}
|
||||
|
||||
} else {
|
||||
// load registrations from session stored there by processCreate.
|
||||
// normaly you have to load the credential Id's for a username
|
||||
// from the database.
|
||||
if (isset($_SESSION['registrations']) && is_array($_SESSION['registrations'])) {
|
||||
foreach ($_SESSION['registrations'] as $reg) {
|
||||
if ($reg->userId === $userId) {
|
||||
$ids[] = $reg->credentialId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
if (count($ids) === 0) {
|
||||
throw new Exception('no registrations in session for userId ' . $userId);
|
||||
}
|
||||
|
||||
$getArgs = $WebAuthn->getGetArgs($ids, 60*4, true, true, true, true, true, $userVerification);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
print(json_encode($getArgs));
|
||||
|
||||
// save challange to session. you have to deliver it to processGet later.
|
||||
$_SESSION['challenge'] = $WebAuthn->getChallenge();
|
||||
|
||||
|
||||
|
||||
// ------------------------------------
|
||||
// process create
|
||||
// ------------------------------------
|
||||
|
||||
} else if ($fn === 'processCreate') {
|
||||
$mysql = new mysqli($mysql_server,$mysql_user,$mysql_pass,$mysql_db);
|
||||
$clientDataJSON = !empty($post->clientDataJSON) ? base64_decode($post->clientDataJSON) : null;
|
||||
$attestationObject = !empty($post->attestationObject) ? base64_decode($post->attestationObject) : null;
|
||||
$challenge = $_SESSION['challenge'] ?? null;
|
||||
|
||||
// processCreate returns data to be stored for future logins.
|
||||
// in this example we store it in the php session.
|
||||
// Normally you have to store the data in a database connected
|
||||
// with the username.
|
||||
$data = $WebAuthn->processCreate($clientDataJSON, $attestationObject, $challenge, $userVerification === 'required', true, false);
|
||||
|
||||
// add user infos
|
||||
$data->userId = $userId;
|
||||
$data->userName = $userName;
|
||||
$data->userDisplayName = $userDisplayName;
|
||||
//set Null to 0
|
||||
$data->signatureCounter ??= 0;
|
||||
/*
|
||||
if (!isset($_SESSION['registrations']) || !array_key_exists('registrations', $_SESSION) || !is_array($_SESSION['registrations'])) {
|
||||
$_SESSION['registrations'] = [];
|
||||
}*/
|
||||
if(!mysqli_query($mysql,"INSERT INTO users SET userId = '".base64_encode($data->userId)."', credentialId = '".base64_encode($data->credentialId)."', credentialPublicKey = '".base64_encode($data->credentialPublicKey)."', signatureCounter = '".base64_encode($data->signatureCounter)."', name = '".mysqli_real_escape_string($mysql,filter_input(INPUT_GET, 'name'))."';")){
|
||||
$msg = "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
else{
|
||||
if ($data->rootValid === false) {
|
||||
$msg = 'registration ok, but certificate does not match any of the selected root ca.';
|
||||
}
|
||||
// $msg = "Data: ".json_last_error();//json_encode($data);//'registration success.';
|
||||
}
|
||||
/*
|
||||
$_SESSION['registrations'][] = $data;
|
||||
*/
|
||||
$return = new stdClass();
|
||||
$return->success = true;
|
||||
$return->msg = $msg;
|
||||
|
||||
header('Content-Type: application/json');
|
||||
print(json_encode($return));
|
||||
|
||||
|
||||
|
||||
// ------------------------------------
|
||||
// proccess get
|
||||
// ------------------------------------
|
||||
|
||||
} else if ($fn === 'processGet') {
|
||||
$clientDataJSON = !empty($post->clientDataJSON) ? base64_decode($post->clientDataJSON) : null;
|
||||
$authenticatorData = !empty($post->authenticatorData) ? base64_decode($post->authenticatorData) : null;
|
||||
$signature = !empty($post->signature) ? base64_decode($post->signature) : null;
|
||||
$userHandle = !empty($post->userHandle) ? base64_decode($post->userHandle) : null;
|
||||
$id = !empty($post->id) ? base64_decode($post->id) : null;
|
||||
$challenge = $_SESSION['challenge'] ?? '';
|
||||
$credentialPublicKey = null;
|
||||
|
||||
// looking up correspondending public key of the credential id
|
||||
// you should also validate that only ids of the given user name
|
||||
// are taken for the login.
|
||||
$mysql = new mysqli($mysql_server,$mysql_user,$mysql_pass,$mysql_db);
|
||||
$result = mysqli_query($mysql,"SELECT credentialPublicKey, userId, name FROM users WHERE credentialId = '".base64_encode($id)."';");
|
||||
if(!$result){
|
||||
$msg = "Error:<br>".mysqli_error($mysql)."<br />";
|
||||
}
|
||||
if ($result->num_rows > 0) {
|
||||
$row = $result->fetch_assoc();
|
||||
$credentialPublicKey = base64_decode($row["credentialPublicKey"]);
|
||||
$reg = (object) ['userId' => base64_decode($row["userId"])];
|
||||
$username = $row["name"];
|
||||
} /*else {
|
||||
if (isset($_SESSION['registrations']) && is_array($_SESSION['registrations'])) {
|
||||
foreach ($_SESSION['registrations'] as $reg) {
|
||||
if ($reg->credentialId === $id) {
|
||||
$credentialPublicKey = $reg->credentialPublicKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}*/
|
||||
if ($credentialPublicKey === null) {
|
||||
throw new Exception('Public Key for credential ID not found!');
|
||||
}
|
||||
|
||||
// if we have resident key, we have to verify that the userHandle is the provided userId at registration
|
||||
if ($requireResidentKey && $userHandle !== hex2bin($reg->userId)) {
|
||||
throw new \Exception('userId doesnt match (is ' . bin2hex($userHandle) . ' but expect ' . $reg->userId . ')');
|
||||
}
|
||||
|
||||
// process the get request. throws WebAuthnException if it fails
|
||||
$WebAuthn->processGet($clientDataJSON, $authenticatorData, $signature, $credentialPublicKey, $challenge, null, $userVerification === 'required');
|
||||
|
||||
$return = new stdClass();
|
||||
$return->success = true;
|
||||
$authKey = strval(random_int(0,99999999));
|
||||
$result = mysqli_query($mysql,"UPDATE users SET authKey=".$authKey.", lastAuth=NOW() WHERE credentialId = '".base64_encode($id)."';");
|
||||
$_SESSION["Logged"] = true;
|
||||
$_SESSION["user"] = $username;
|
||||
$_SESSION["authKey"] = $authKey;
|
||||
header('Content-Type: application/json');
|
||||
print(json_encode($return));
|
||||
|
||||
// ------------------------------------
|
||||
// proccess clear registrations
|
||||
// ------------------------------------
|
||||
|
||||
} else if ($fn === 'clearRegistrations') {
|
||||
$_SESSION['registrations'] = null;
|
||||
$_SESSION['challenge'] = null;
|
||||
|
||||
$return = new stdClass();
|
||||
$return->success = true;
|
||||
$return->msg = 'all registrations deleted';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
print(json_encode($return));
|
||||
|
||||
// ------------------------------------
|
||||
// display stored data as HTML
|
||||
// ------------------------------------
|
||||
|
||||
} /*else if ($fn === 'getStoredDataHtml') {
|
||||
$html = '<!DOCTYPE html>' . "\n";
|
||||
$html .= '<html><head><style>tr:nth-child(even){background-color: #f2f2f2;}</style></head>';
|
||||
$html .= '<body style="font-family:sans-serif">';
|
||||
if (isset($_SESSION['registrations']) && is_array($_SESSION['registrations'])) {
|
||||
$html .= '<p>There are ' . count($_SESSION['registrations']) . ' registrations in this session:</p>';
|
||||
foreach ($_SESSION['registrations'] as $reg) {
|
||||
$html .= '<table style="border:1px solid black;margin:10px 0;">';
|
||||
foreach ($reg as $key => $value) {
|
||||
|
||||
if (is_bool($value)) {
|
||||
$value = $value ? 'yes' : 'no';
|
||||
|
||||
} else if (is_null($value)) {
|
||||
$value = 'null';
|
||||
|
||||
} else if (is_object($value)) {
|
||||
$value = chunk_split(strval($value), 64);
|
||||
|
||||
} else if (is_string($value) && strlen($value) > 0 && htmlspecialchars($value, ENT_QUOTES) === '') {
|
||||
$value = chunk_split(bin2hex($value), 64);
|
||||
}
|
||||
$html .= '<tr><td>' . htmlspecialchars($key) . '</td><td style="font-family:monospace;">' . nl2br(htmlspecialchars($value)) . '</td>';
|
||||
}
|
||||
$html .= '</table>';
|
||||
}
|
||||
} else {
|
||||
$html .= '<p>There are no registrations.</p>';
|
||||
}
|
||||
$html .= '</body></html>';
|
||||
|
||||
header('Content-Type: text/html');
|
||||
print $html;
|
||||
|
||||
// ------------------------------------
|
||||
// get root certs from FIDO Alliance Metadata Service
|
||||
// ------------------------------------
|
||||
|
||||
} */else if ($fn === 'queryFidoMetaDataService') {
|
||||
|
||||
$mdsFolder = './restricted/WebAuthn/rootCertificates/mds';
|
||||
$success = false;
|
||||
$msg = null;
|
||||
|
||||
// fetch only 1x / 24h
|
||||
$lastFetch = \is_file($mdsFolder . '/lastMdsFetch.txt') ? \strtotime(\file_get_contents($mdsFolder . '/lastMdsFetch.txt')) : 0;
|
||||
if ($lastFetch + (3600*48) < \time()) {
|
||||
$cnt = $WebAuthn->queryFidoMetaDataService($mdsFolder);
|
||||
$success = true;
|
||||
\file_put_contents($mdsFolder . '/lastMdsFetch.txt', date('r'));
|
||||
$msg = 'successfully queried FIDO Alliance Metadata Service - ' . $cnt . ' certificates downloaded.';
|
||||
|
||||
} else {
|
||||
$msg = 'Fail: last fetch was at ' . date('r', $lastFetch) . ' - fetch only 1x every 48h';
|
||||
}
|
||||
|
||||
$return = new stdClass();
|
||||
$return->success = $success;
|
||||
$return->msg = $msg;
|
||||
|
||||
header('Content-Type: application/json');
|
||||
print(json_encode($return));
|
||||
}
|
||||
|
||||
} catch (Throwable $ex) {
|
||||
$return = new stdClass();
|
||||
$return->success = false;
|
||||
$return->msg = $ex->getMessage();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
print(json_encode($return));
|
||||
}
|
||||
@@ -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 |
|
||||
@@ -1,96 +1,96 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once("restricted/mysql.php");
|
||||
$_SESSION["local"] =false;
|
||||
|
||||
// Netze, die als lokal gelten und damit ohne Anmeldung Zugriff bekommen.
|
||||
// Bewusst eine sichtbare Liste statt eines Zeichenkettenvergleichs.
|
||||
const LOCAL_NETWORKS = ["192.168.179.0/24"];
|
||||
|
||||
/**
|
||||
* Liegt $ip im Netz $cidr? Verglichen wird binaer (inet_pton), also
|
||||
* unabhaengig von der Schreibweise: "2001:db8::" und "2001:0db8:0000::"
|
||||
* sind dieselbe Adresse.
|
||||
*/
|
||||
function ipInNetwork($ip, $cidr)
|
||||
{
|
||||
$teile = explode("/", $cidr, 2);
|
||||
$adresse = @inet_pton($ip);
|
||||
$netz = @inet_pton($teile[0]);
|
||||
if ($adresse === false || $netz === false || strlen($adresse) !== strlen($netz)) {
|
||||
return false; // ungueltig oder verschiedene Adressfamilien
|
||||
}
|
||||
// Praefixlaenge streng pruefen. Ohne das macht ein Tippfehler in
|
||||
// LOCAL_NETWORKS die Funktion fail-open: "/-5" etwa liefert eine
|
||||
// Nullmaske, und dann liegt jede Adresse in jedem Netz.
|
||||
if (isset($teile[1])) {
|
||||
if (!ctype_digit($teile[1])) {
|
||||
return false;
|
||||
}
|
||||
$bits = intval($teile[1]);
|
||||
if ($bits > strlen($adresse) * 8) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
$bits = strlen($adresse) * 8;
|
||||
}
|
||||
$ganzeBytes = intdiv($bits, 8);
|
||||
$restBits = $bits % 8;
|
||||
if ($ganzeBytes > 0 && strncmp($adresse, $netz, $ganzeBytes) !== 0) {
|
||||
return false;
|
||||
}
|
||||
if ($restBits === 0) {
|
||||
return true;
|
||||
}
|
||||
$maske = chr((0xFF << (8 - $restBits)) & 0xFF);
|
||||
return ($adresse[$ganzeBytes] & $maske) === ($netz[$ganzeBytes] & $maske);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kommt die Anfrage aus dem Heimnetz? Nur dann gilt sie ohne Anmeldung als
|
||||
* berechtigt.
|
||||
*
|
||||
* REMOTE_ADDR stammt aus der TCP-Verbindung und ist nicht faelschbar.
|
||||
* Weiterleitungs-Header wie X-Forwarded-For duerfen hier bewusst NICHT
|
||||
* herangezogen werden - sie kann jeder Aufrufer frei setzen.
|
||||
*/
|
||||
function isLocal()
|
||||
{
|
||||
$_SESSION["local"] = false;
|
||||
$remote = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
|
||||
$server = isset($_SERVER['SERVER_ADDR']) ? $_SERVER['SERVER_ADDR'] : '';
|
||||
|
||||
if (filter_var($remote, FILTER_VALIDATE_IP) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auf IPv6 abgebildete IPv4-Adressen (::ffff:192.168.179.44) auf ihre
|
||||
// IPv4-Form zurueckfuehren. Ein Server mit Dual-Stack-Socket meldet
|
||||
// LAN-Clients so - ohne diesen Schritt faellt das Heimnetz durch.
|
||||
if (preg_match('/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i', $remote, $treffer)) {
|
||||
$remote = $treffer[1];
|
||||
}
|
||||
|
||||
$lokal = false;
|
||||
foreach (LOCAL_NETWORKS as $netz) {
|
||||
if (ipInNetwork($remote, $netz)) {
|
||||
$lokal = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Gleiches IPv6-/64 wie der Server. Das Praefix vergibt der Provider und
|
||||
// es kann sich aendern, deshalb aus SERVER_ADDR abgeleitet statt fest.
|
||||
if (!$lokal
|
||||
&& filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false
|
||||
&& filter_var($server, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
|
||||
$lokal = substr(inet_pton($remote), 0, 8) === substr(inet_pton($server), 0, 8);
|
||||
}
|
||||
|
||||
$_SESSION["local"] = $lokal;
|
||||
return $lokal;
|
||||
}
|
||||
|
||||
<?php
|
||||
session_start();
|
||||
require_once("restricted/mysql.php");
|
||||
$_SESSION["local"] =false;
|
||||
|
||||
// Netze, die als lokal gelten und damit ohne Anmeldung Zugriff bekommen.
|
||||
// Bewusst eine sichtbare Liste statt eines Zeichenkettenvergleichs.
|
||||
const LOCAL_NETWORKS = ["192.168.179.0/24"];
|
||||
|
||||
/**
|
||||
* Liegt $ip im Netz $cidr? Verglichen wird binaer (inet_pton), also
|
||||
* unabhaengig von der Schreibweise: "2001:db8::" und "2001:0db8:0000::"
|
||||
* sind dieselbe Adresse.
|
||||
*/
|
||||
function ipInNetwork($ip, $cidr)
|
||||
{
|
||||
$teile = explode("/", $cidr, 2);
|
||||
$adresse = @inet_pton($ip);
|
||||
$netz = @inet_pton($teile[0]);
|
||||
if ($adresse === false || $netz === false || strlen($adresse) !== strlen($netz)) {
|
||||
return false; // ungueltig oder verschiedene Adressfamilien
|
||||
}
|
||||
// Praefixlaenge streng pruefen. Ohne das macht ein Tippfehler in
|
||||
// LOCAL_NETWORKS die Funktion fail-open: "/-5" etwa liefert eine
|
||||
// Nullmaske, und dann liegt jede Adresse in jedem Netz.
|
||||
if (isset($teile[1])) {
|
||||
if (!ctype_digit($teile[1])) {
|
||||
return false;
|
||||
}
|
||||
$bits = intval($teile[1]);
|
||||
if ($bits > strlen($adresse) * 8) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
$bits = strlen($adresse) * 8;
|
||||
}
|
||||
$ganzeBytes = intdiv($bits, 8);
|
||||
$restBits = $bits % 8;
|
||||
if ($ganzeBytes > 0 && strncmp($adresse, $netz, $ganzeBytes) !== 0) {
|
||||
return false;
|
||||
}
|
||||
if ($restBits === 0) {
|
||||
return true;
|
||||
}
|
||||
$maske = chr((0xFF << (8 - $restBits)) & 0xFF);
|
||||
return ($adresse[$ganzeBytes] & $maske) === ($netz[$ganzeBytes] & $maske);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kommt die Anfrage aus dem Heimnetz? Nur dann gilt sie ohne Anmeldung als
|
||||
* berechtigt.
|
||||
*
|
||||
* REMOTE_ADDR stammt aus der TCP-Verbindung und ist nicht faelschbar.
|
||||
* Weiterleitungs-Header wie X-Forwarded-For duerfen hier bewusst NICHT
|
||||
* herangezogen werden - sie kann jeder Aufrufer frei setzen.
|
||||
*/
|
||||
function isLocal()
|
||||
{
|
||||
$_SESSION["local"] = false;
|
||||
$remote = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
|
||||
$server = isset($_SERVER['SERVER_ADDR']) ? $_SERVER['SERVER_ADDR'] : '';
|
||||
|
||||
if (filter_var($remote, FILTER_VALIDATE_IP) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auf IPv6 abgebildete IPv4-Adressen (::ffff:192.168.179.44) auf ihre
|
||||
// IPv4-Form zurueckfuehren. Ein Server mit Dual-Stack-Socket meldet
|
||||
// LAN-Clients so - ohne diesen Schritt faellt das Heimnetz durch.
|
||||
if (preg_match('/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i', $remote, $treffer)) {
|
||||
$remote = $treffer[1];
|
||||
}
|
||||
|
||||
$lokal = false;
|
||||
foreach (LOCAL_NETWORKS as $netz) {
|
||||
if (ipInNetwork($remote, $netz)) {
|
||||
$lokal = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Gleiches IPv6-/64 wie der Server. Das Praefix vergibt der Provider und
|
||||
// es kann sich aendern, deshalb aus SERVER_ADDR abgeleitet statt fest.
|
||||
if (!$lokal
|
||||
&& filter_var($remote, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false
|
||||
&& filter_var($server, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
|
||||
$lokal = substr(inet_pton($remote), 0, 8) === substr(inet_pton($server), 0, 8);
|
||||
}
|
||||
|
||||
$_SESSION["local"] = $lokal;
|
||||
return $lokal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kleine HTTP-GET-Anfrage im Heimnetz, Antwort als JSON-Array.
|
||||
* Ersetzt die frueher in carOG.php, heater.php und AutoAction.php je einzeln
|
||||
@@ -106,43 +106,43 @@ function httpGetJson($url){
|
||||
return json_decode($response, true);
|
||||
}
|
||||
|
||||
function checkLogin(){
|
||||
$mysql = new mysqli($GLOBALS["mysql_server"],$GLOBALS["mysql_user"],$GLOBALS["mysql_pass"],$GLOBALS["mysql_db"]);
|
||||
if(isLocal()){
|
||||
$_SESSION["local"] =true;
|
||||
return true;
|
||||
}
|
||||
if(isset($_SESSION["authKey"])){
|
||||
$res = mysqli_query($mysql,"SELECT id FROM users WHERE lastAuth > DATE_SUB(NOW(), INTERVAL 2 DAY) AND authKey = '".mysqli_real_escape_string($mysql,$_SESSION["authKey"])."' AND name = '".mysqli_real_escape_string($mysql,$_SESSION["user"])."';");
|
||||
if(!$res){
|
||||
echo mysqli_error($mysql);
|
||||
return false;
|
||||
}
|
||||
if(mysqli_num_rows($res) == 1){
|
||||
return isset($_SESSION["Logged"]);
|
||||
}
|
||||
}else{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function checkAdduser(){
|
||||
$mysql = new mysqli($GLOBALS["mysql_server"],$GLOBALS["mysql_user"],$GLOBALS["mysql_pass"],$GLOBALS["mysql_db"]);
|
||||
if(!mysqli_query($mysql,"DELETE FROM addUser WHERE datetime < DATE_SUB(NOW(), INTERVAL 1 MINUTE);")){
|
||||
echo mysqli_error($mysql);
|
||||
}
|
||||
$result = mysqli_query($mysql,"SELECT * FROM addUser WHERE accesskey='".mysqli_real_escape_string($mysql,$_GET["addUser"])."';");
|
||||
if(!$result){
|
||||
return false;
|
||||
}
|
||||
if ($result->num_rows > 0) {
|
||||
// Freigabe fuer die Passkey-Registrierung vermerken. authServer.php
|
||||
// prueft dieses Merkmal; ohne das waeren getCreateArgs/processCreate
|
||||
// direkt aufrufbar und der Einmalschluessel wirkungslos.
|
||||
$_SESSION["mayRegister"] = time();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkLogin(){
|
||||
$mysql = new mysqli($GLOBALS["mysql_server"],$GLOBALS["mysql_user"],$GLOBALS["mysql_pass"],$GLOBALS["mysql_db"]);
|
||||
if(isLocal()){
|
||||
$_SESSION["local"] =true;
|
||||
return true;
|
||||
}
|
||||
if(isset($_SESSION["authKey"])){
|
||||
$res = mysqli_query($mysql,"SELECT id FROM users WHERE lastAuth > DATE_SUB(NOW(), INTERVAL 2 DAY) AND authKey = '".mysqli_real_escape_string($mysql,$_SESSION["authKey"])."' AND name = '".mysqli_real_escape_string($mysql,$_SESSION["user"])."';");
|
||||
if(!$res){
|
||||
echo mysqli_error($mysql);
|
||||
return false;
|
||||
}
|
||||
if(mysqli_num_rows($res) == 1){
|
||||
return isset($_SESSION["Logged"]);
|
||||
}
|
||||
}else{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function checkAdduser(){
|
||||
$mysql = new mysqli($GLOBALS["mysql_server"],$GLOBALS["mysql_user"],$GLOBALS["mysql_pass"],$GLOBALS["mysql_db"]);
|
||||
if(!mysqli_query($mysql,"DELETE FROM addUser WHERE datetime < DATE_SUB(NOW(), INTERVAL 1 MINUTE);")){
|
||||
echo mysqli_error($mysql);
|
||||
}
|
||||
$result = mysqli_query($mysql,"SELECT * FROM addUser WHERE accesskey='".mysqli_real_escape_string($mysql,$_GET["addUser"])."';");
|
||||
if(!$result){
|
||||
return false;
|
||||
}
|
||||
if ($result->num_rows > 0) {
|
||||
// Freigabe fuer die Passkey-Registrierung vermerken. authServer.php
|
||||
// prueft dieses Merkmal; ohne das waeren getCreateArgs/processCreate
|
||||
// direkt aufrufbar und der Einmalschluessel wirkungslos.
|
||||
$_SESSION["mayRegister"] = time();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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') NOT NULL DEFAULT 'OG' COMMENT 'Reiter in der Uebersicht. Ohne Etage waere die Automatik in keinem davon zu sehen, deshalb gibt es keinen leeren Wert',
|
||||
`floor` varchar(4) NOT NULL COMMENT 'Reiter in der Uebersicht (floors.code, Fremdschluessel legt homeMesh_grundriss.sql an). Ohne Etage waere die Automatik in keinem Reiter zu sehen, deshalb kein leerer Wert',
|
||||
`enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Aktiv oder pausiert',
|
||||
`window_from` time NOT NULL DEFAULT '00:00:00' COMMENT 'Aktiver Zeitraum, Beginn',
|
||||
`window_to` time NOT NULL DEFAULT '23:59:00' COMMENT 'Aktiver Zeitraum, Ende. Kleiner als window_from = ueber Mitternacht',
|
||||
`weekdays` tinyint(3) unsigned NOT NULL DEFAULT 127 COMMENT 'Bitmaske: Bit 0 = Montag ... Bit 6 = Sonntag',
|
||||
`on_vacation` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch in den Ferien ausfuehren',
|
||||
`on_holiday` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch an Feiertagen ausfuehren',
|
||||
`next_day` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Wochentage, Ferien und Feiertage gelten fuer morgen (Vorabend). Nachgeruestet mit vorabend.sql im SolarManager',
|
||||
`force_once` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Am Ende des Zeitraums auf jeden Fall ausfuehren',
|
||||
`lockout_secs` int(11) NOT NULL DEFAULT 60 COMMENT 'Sperrzeit: so lange nach einer Ausloesung wird nicht erneut geschaltet. Gegen Messwerte, die um die Schwelle pendeln - jedes Ueberschreiten waere sonst eine echte Flanke. Der Editor bietet 0, 60 und 900 an',
|
||||
`cond_met` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'War die Bedingung beim letzten Durchlauf erfuellt? Nur die steigende Flanke loest aus, sonst wuerde Temperatur groesser 22 im Sekundentakt feuern',
|
||||
|
||||
@@ -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;
|
||||