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 | ||
|
|
d285ec27a0 | ||
|
|
c9a9e16b21 | ||
|
|
cbddd664e3 | ||
|
|
79a55aeafd | ||
|
|
9f52b095b3 | ||
|
|
08e8e68a61 | ||
|
|
e8612bebb8 | ||
|
|
18557021b0 | ||
|
|
e7fd7715ef | ||
|
|
f6e0414866 | ||
|
|
17f972d081 | ||
|
|
131d7cc46d | ||
|
|
d557a1651f | ||
|
|
1b9bbc9857 | ||
|
|
98a1da6cd5 | ||
|
|
bed1884b04 | ||
|
|
4b47372015 | ||
|
|
2ac504ca29 | ||
|
|
00f5944a7a |
@@ -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,7 +44,16 @@ 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
|
||||
*.backup
|
||||
|
||||
# Kartenkacheln des Zwischenspeichers (ajax/tile.php).
|
||||
# Wachsen mit dem, was angesehen wurde - gehoeren nicht ins Repository.
|
||||
tiles/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
?>
|
||||
@@ -10,7 +10,9 @@
|
||||
* GET ?action=editor&floor=OG Editor fuer eine neue Automatik
|
||||
* GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen"
|
||||
* POST ?action=save JSON-Rumpf, legt an oder ueberschreibt
|
||||
* POST ?action=delete {"id": N}
|
||||
* GET ?action=followers&id=N JSON: wer haengt an dieser Automatik?
|
||||
* GET ?action=werte&ids=1,2,3 JSON: was diese Messwerte gerade sagen
|
||||
* POST ?action=delete {"id": N, "mode": "abhaengen"|"mitloeschen"}
|
||||
* POST ?action=toggle {"id": N} - pausieren / fortsetzen
|
||||
*/
|
||||
|
||||
@@ -31,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") {
|
||||
@@ -45,7 +47,12 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
$id = saveAutomation($body);
|
||||
jsonAntwort(["id" => $id]);
|
||||
case "delete":
|
||||
deleteAutomation(intval($body["id"] ?? 0));
|
||||
// Der Modus kommt aus der Rueckfrage im Browser. Unbekanntes
|
||||
// faellt auf das schonendere "abhaengen" zurueck - lieber eine
|
||||
// pausierte Automatik zu viel als eine geloeschte zu viel.
|
||||
$modus = ($body["mode"] ?? "") === "mitloeschen"
|
||||
? "mitloeschen" : "abhaengen";
|
||||
deleteAutomation(intval($body["id"] ?? 0), $modus);
|
||||
jsonAntwort(["ok" => true]);
|
||||
case "toggle":
|
||||
jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]);
|
||||
@@ -59,6 +66,34 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wer haengt an dieser Automatik? -------------------------------------
|
||||
// Wird vor dem Loeschen gefragt, damit die Rueckfrage im Browser die Namen
|
||||
// nennen kann und nicht nur eine Zahl.
|
||||
if ($action === "followers") {
|
||||
jsonAntwort(["followers" => automationFollowers(intval($_GET["id"] ?? 0))]);
|
||||
}
|
||||
|
||||
// --- Was sagen diese Messwerte gerade? -----------------------------------
|
||||
// Der offene Editor fragt das im Takt nach, damit neben jeder Bedingung der
|
||||
// laufende Wert steht. Absichtlich ein eigener, winziger Endpunkt: den
|
||||
// ganzen Geraetekatalog neu zu holen waeren je Runde ueber hundert
|
||||
// Kilobyte fuer ein paar Zahlen.
|
||||
if ($action === "werte") {
|
||||
$ids = array_slice(explode(",", $_GET["ids"] ?? ""), 0, 200);
|
||||
jsonAntwort(["werte" => stateValues($ids)]);
|
||||
}
|
||||
|
||||
// --- Zeitleiste ----------------------------------------------------------
|
||||
// Alle Automatiken eines Tages mit ihrer Lage, siehe restricted/zeitleiste.php.
|
||||
if ($action === "zeitleiste") {
|
||||
require_once("../restricted/zeitleiste.php");
|
||||
try {
|
||||
jsonAntwort(zeitleiste(strval($_GET["datum"] ?? date("Y-m-d"))));
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => "Zeitleiste nicht abrufbar: " . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Uebersichtstabelle --------------------------------------------------
|
||||
if ($action === "list") {
|
||||
$tage = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
@@ -74,7 +109,77 @@ if ($action === "list") {
|
||||
. "<th class='d-none d-sm-table-cell'>Aktion</th>"
|
||||
. "<th class='d-none d-md-table-cell'>Wann</th>"
|
||||
. "<th class='d-none d-md-table-cell'>Zuletzt</th><th></th></tr></thead><tbody>";
|
||||
|
||||
/*
|
||||
* Eine Automatik, die von einer anderen ausgeloest wird, steht unter
|
||||
* ihr statt alphabetisch irgendwo dazwischen - sonst liest man
|
||||
* "Rollladen Magdalena morgens" und sieht nicht, woran es haengt.
|
||||
*
|
||||
* Eingeklappt, weil die Uebersicht sonst mit jeder Verkettung laenger
|
||||
* wird, ohne mehr zu sagen: der Ausloeser ist das, was man sucht. Der
|
||||
* Zaehler am Pfeil verraet, dass da noch etwas ist.
|
||||
*
|
||||
* Gruppiert wird nur die Darstellung. Jeder Nachfolger bleibt eine
|
||||
* vollwertige Automatik mit eigener Pause, eigenen Rahmenbedingungen
|
||||
* und eigener Zeile im Protokoll - sonst waere zu klaeren, wem die
|
||||
* Pause gehoert und was das Protokoll zeigt.
|
||||
*/
|
||||
$nachFolgern = [];
|
||||
foreach ($list as $a) {
|
||||
foreach ($a["triggers"] as $davor) {
|
||||
$nachFolgern[intval($davor)][] = intval($a["id"]);
|
||||
}
|
||||
}
|
||||
$vorhanden = [];
|
||||
foreach ($list as $a) {
|
||||
$vorhanden[intval($a["id"])] = $a;
|
||||
}
|
||||
// Wessen Ausloeser nicht in dieser Etage steht, faengt selbst oben an.
|
||||
$wurzeln = [];
|
||||
foreach ($list as $a) {
|
||||
$hatVater = false;
|
||||
foreach ($a["triggers"] as $davor) {
|
||||
if (isset($vorhanden[intval($davor)])) {
|
||||
$hatVater = true;
|
||||
}
|
||||
}
|
||||
if (!$hatVater) {
|
||||
$wurzeln[] = intval($a["id"]);
|
||||
}
|
||||
}
|
||||
$reihe = [];
|
||||
$einordnen = function ($id, $tiefe, $vater) use (&$einordnen, &$reihe, $nachFolgern, $vorhanden) {
|
||||
if (!isset($vorhanden[$id]) || isset($reihe[$id])) {
|
||||
return;
|
||||
}
|
||||
$reihe[$id] = ["auto" => $vorhanden[$id], "tiefe" => $tiefe,
|
||||
"vater" => $vater, "kinder" => 0];
|
||||
foreach ($nachFolgern[$id] ?? [] as $kind) {
|
||||
$einordnen($kind, $tiefe + 1, $id);
|
||||
}
|
||||
};
|
||||
foreach ($wurzeln as $id) {
|
||||
$einordnen($id, 0, 0);
|
||||
}
|
||||
// Was durch einen Kreis nicht erreicht wurde, kommt trotzdem mit.
|
||||
foreach ($list as $a) {
|
||||
$einordnen(intval($a["id"]), 0, 0);
|
||||
}
|
||||
// Gezaehlt wird erst jetzt, und nur was wirklich eingeordnet wurde:
|
||||
// wer zwei Ausloeser hat, steht nur unter dem ersten. Aus der rohen
|
||||
// Nachfolgerliste gezaehlt verspraeche der zweite Pfeil eine Zeile,
|
||||
// die er nicht aufklappen kann.
|
||||
foreach ($reihe as $eintrag) {
|
||||
if ($eintrag["vater"]) {
|
||||
$reihe[$eintrag["vater"]]["kinder"]++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($reihe as $eintrag) {
|
||||
$a = $eintrag["auto"];
|
||||
$tiefe = $eintrag["tiefe"];
|
||||
$kinder = $eintrag["kinder"];
|
||||
$vater = $eintrag["vater"];
|
||||
// Nur die aktiven Tage nennen. Sieben Haekchen untereinander
|
||||
// haben die Zeile frueher unnoetig hoch gemacht.
|
||||
$aktiveTage = [];
|
||||
@@ -83,16 +188,52 @@ if ($action === "list") {
|
||||
$aktiveTage[] = $tage[$i];
|
||||
}
|
||||
}
|
||||
$wann = count($aktiveTage) === 7 ? "täglich" : implode(" ", $aktiveTage);
|
||||
// Kalendertage auf "immer" gehoeren zur Tagesangabe, nicht in
|
||||
// die Nachbemerkung: sie fuegen Tage hinzu. Ohne angehakten
|
||||
// Wochentag tragen sie die Automatik sogar allein - "nur
|
||||
// Feiertage" ist damit eine gueltige Einstellung.
|
||||
$dazu = [];
|
||||
if ($a["on_vacation"] == 2) {
|
||||
$dazu[] = "Ferientage";
|
||||
}
|
||||
if ($a["on_holiday"] == 2) {
|
||||
$dazu[] = "Feiertage";
|
||||
}
|
||||
if (count($aktiveTage) === 7) {
|
||||
$wann = "täglich";
|
||||
} elseif ($aktiveTage) {
|
||||
$wann = implode(" ", $aktiveTage);
|
||||
if ($dazu) {
|
||||
$wann .= " + " . implode(" + ", $dazu);
|
||||
}
|
||||
} elseif ($dazu) {
|
||||
$wann = "nur " . implode(" + ", $dazu);
|
||||
} else {
|
||||
$wann = "nie – kein Tag ausgewählt";
|
||||
}
|
||||
// Vorabend: alle Tagesangaben meinen morgen. Vorne, damit auch
|
||||
// "nicht an Feiertagen" weiter hinten als Folgetag gelesen wird.
|
||||
if ($a["next_day"]) {
|
||||
$wann = "Vorabend von " . $wann;
|
||||
}
|
||||
if ($a["window_from"] !== "00:00" || $a["window_to"] !== "23:59") {
|
||||
$wann .= ", " . $a["window_from"] . "–" . $a["window_to"];
|
||||
}
|
||||
if (!$a["on_holiday"]) {
|
||||
if ($a["on_holiday"] == 0) {
|
||||
$wann .= ", nicht an Feiertagen";
|
||||
}
|
||||
if (!$a["on_vacation"]) {
|
||||
if ($a["on_vacation"] == 0) {
|
||||
$wann .= ", nicht in den Ferien";
|
||||
}
|
||||
if ($a["once_per_day"]) {
|
||||
$wann .= ", nur einmal am Tag";
|
||||
}
|
||||
// Vor der Sperre, weil es auch zeitlich davor liegt: erst muss
|
||||
// die Bedingung stehen bleiben, dann wird ausgeloest.
|
||||
if (!empty($a["hold_secs"])) {
|
||||
$wann .= ", erst nach "
|
||||
. haltezeitKurz($a["hold_secs"]) . " ununterbrochen";
|
||||
}
|
||||
if ($a["lockout_secs"] > 0) {
|
||||
$wann .= ", frühestens alle "
|
||||
. ($a["lockout_secs"] >= 60
|
||||
@@ -101,13 +242,43 @@ if ($action === "list") {
|
||||
}
|
||||
|
||||
$zuletzt = $a["last_run"] ? date("d.m. H:i", strtotime($a["last_run"])) : "–";
|
||||
$klasse = $a["enabled"] ? "" : " class='opacity-50'";
|
||||
$pause = $a["enabled"] ? "bi-pause-fill" : "bi-play-fill";
|
||||
$titel = $a["enabled"] ? "Pausieren" : "Fortsetzen";
|
||||
$id = intval($a["id"]);
|
||||
|
||||
echo "<tr" . $klasse . ">";
|
||||
echo "<td>" . htmlspecialchars($a["name"]) . "</td>";
|
||||
$klassen = [];
|
||||
if (!$a["enabled"]) {
|
||||
$klassen[] = "opacity-50";
|
||||
}
|
||||
if ($tiefe > 0) {
|
||||
// Eingeklappt starten: die Zeile gehoert dem Ausloeser, und
|
||||
// der steht direkt darueber.
|
||||
$klassen[] = "auto-folge";
|
||||
$klassen[] = "auto-von-" . $vater;
|
||||
$klassen[] = "d-none";
|
||||
}
|
||||
$klasse = $klassen ? " class='" . implode(" ", $klassen) . "'" : "";
|
||||
|
||||
// Der Pfeil klappt die Nachfolger auf. Er steht nur da, wo es
|
||||
// welche gibt - ein Pfeil ohne Inhalt waere eine Einladung ins
|
||||
// Leere.
|
||||
$griff = "";
|
||||
if ($kinder > 0) {
|
||||
$griff = "<button type='button' class='btn btn-link btn-sm p-0 me-1 text-decoration-none'"
|
||||
. " title='Abhängige Automatiken zeigen'"
|
||||
. " data-gruppe='" . $id . "'"
|
||||
. " onclick='toggleAutomationGroup(" . $id . ", this)'>"
|
||||
. "<i class='bi bi-chevron-right'></i> " . $kinder . "</button>";
|
||||
}
|
||||
$einzug = $tiefe > 0
|
||||
? "<span class='text-body-secondary me-1'>"
|
||||
. str_repeat(" ", $tiefe)
|
||||
. "<i class='bi bi-arrow-return-right'></i></span> "
|
||||
: "";
|
||||
|
||||
echo "<tr data-auto='" . $id . "'" . $klasse . ">";
|
||||
echo "<td class='text-nowrap'>" . $einzug . $griff
|
||||
. htmlspecialchars($a["name"]) . "</td>";
|
||||
echo "<td>" . htmlspecialchars($a["conditionText"]) . "</td>";
|
||||
echo "<td class='d-none d-sm-table-cell'>" . htmlspecialchars($a["actionText"]) . "</td>";
|
||||
echo "<td class='text-nowrap d-none d-md-table-cell'>" . htmlspecialchars($wann) . "</td>";
|
||||
@@ -141,6 +312,12 @@ $nutzdaten = json_encode([
|
||||
"automation" => $auto,
|
||||
"devices" => deviceCatalog(),
|
||||
"lockouts" => lockoutChoices(),
|
||||
"ferien" => kalendertagChoices("Ferien", "in den Ferien"),
|
||||
"feiertage" => kalendertagChoices("Feiertage", "an Feiertagen"),
|
||||
"tagvorlagen" => tagVorlagen(),
|
||||
// Nur fuer die Reihenfolge der Raum-Gruppen: rooms.php gibt sie vor, und
|
||||
// Etage fuer Etage liest sich besser als alphabetisch.
|
||||
"rooms" => raumListe(),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||
|
||||
?>
|
||||
@@ -160,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]);
|
||||
@@ -4,30 +4,94 @@
|
||||
* Ersetzt getConsData_{month,year,decade}.php und getProdData_{month,year,decade}.php.
|
||||
*
|
||||
* Aufruf: energyHistory.php?series=prod|cons&range=month|year|decade
|
||||
*
|
||||
* Zwei Quellen, je nach Zeitraum:
|
||||
*
|
||||
* month EnergyFlow, also die Rohdaten. Nach Tagen gruppiert, und der
|
||||
* heutige Balken soll bis zur letzten Messung reichen. Das
|
||||
* Stundenarchiv wird nur einmal nachts fortgeschrieben und wäre
|
||||
* für den laufenden Tag noch leer.
|
||||
*
|
||||
* year EnergyFlow_hourly, das Stundenarchiv. Die Rohdaten werden nach
|
||||
* decade zwölf Monaten ausgedünnt (solarlog-rollup.sh thin), diese
|
||||
* Diagramme fänden dort also nichts mehr. Bei Gruppierung nach
|
||||
* Monat bzw. Jahr fällt der fehlende laufende Tag nicht auf.
|
||||
*
|
||||
* Beide Quellen liefern Wattstunden, weil die Diagramme auf der y-Achse durch
|
||||
* 1.000.000 teilen und MWh beschriften. Die Rohdaten sind Momentanleistungen
|
||||
* alle fünf Minuten, daher /12; im Archiv stehen bereits kWh, daher *1000.
|
||||
* Gegengerechnet über einen kompletten Monat stimmen beide auf 0,003 % überein
|
||||
* - der Rest ist die /12-Näherung gegenüber der echten Zeitgewichtung.
|
||||
*
|
||||
* pv_fehlbetrag_kwh wird zur erfassten Erzeugung addiert. Wenn Wechselrichter
|
||||
* die Verbindung zur OpenDTU verlieren, fehlt ihr Beitrag in pvP bzw. pv_kwh -
|
||||
* die Einspeisung, die Batterieladung und der Heizstab sind aber vollständig
|
||||
* gemessen. Ohne die Korrektur wird der Direktverbrauch als Differenz negativ
|
||||
* und der Balken bleibt leer, etwa am 03.08.2026 mit -0,5 statt +15,9 kWh.
|
||||
* Im Rohdaten-Zweig kommt der Betrag über eine nach Tagen vorverdichtete
|
||||
* abgeleitete Tabelle dazu, sonst zählte ihn jede der zwölf Zeilen je Stunde
|
||||
* erneut; für den laufenden Tag gibt es ihn noch nicht, dann bleibt er 0.
|
||||
*
|
||||
* Die Einspeisung kommt in beiden Fällen aus gridPfeed, dem Zählerregister,
|
||||
* nicht aus dem Saldo gridP. Innerhalb eines Fensters kommen Bezug und
|
||||
* Einspeisung gleichzeitig vor; der Saldo löscht kurze Bezüge weg und passt
|
||||
* dann nicht mehr zu dem, was abgerechnet wird.
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
require_once("./chartData.php");
|
||||
|
||||
// Zeitraum -> [Format der Beschriftung, Filter auf datetime]
|
||||
// Zeitraum -> [Format der Beschriftung, Quelle]
|
||||
$ranges = [
|
||||
"month" => ["'%d.%b.'", "DATE_FORMAT(datetime,'%Y%m%d') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 1 MONTH),'%Y%m%d')"],
|
||||
"year" => ["'%b. %y'", "DATE_FORMAT(datetime,'%Y%m') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 13 MONTH),'%Y%m')"],
|
||||
"decade" => ["'%Y'", "DATE_FORMAT(datetime,'%Y') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 10 YEAR),'%Y')"],
|
||||
"month" => ["'%d.%b.'", "roh"],
|
||||
"year" => ["'%b. %y'", "archiv"],
|
||||
"decade" => ["'%Y'", "archiv"],
|
||||
];
|
||||
|
||||
// Filter auf die Zeitspalte. ZEIT wird durch den Spaltennamen der Quelle ersetzt.
|
||||
$filters = [
|
||||
"month" => "DATE_FORMAT(ZEIT,'%Y%m%d') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 1 MONTH),'%Y%m%d')",
|
||||
"year" => "DATE_FORMAT(ZEIT,'%Y%m') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 13 MONTH),'%Y%m')",
|
||||
"decade" => "DATE_FORMAT(ZEIT,'%Y') >= DATE_FORMAT(DATE_SUB(now(),INTERVAL 10 YEAR),'%Y')",
|
||||
];
|
||||
|
||||
// Datenreihen je Diagramm. Beide teilen sich Solarertrag und Heizstab und
|
||||
// unterscheiden sich nur darin, wohin die Energie fließt (prod) bzw. woher
|
||||
// sie kommt (cons).
|
||||
$series = [
|
||||
"prod" => "SUM(pvP/12 - IF(gridP < 0, -gridP/12,0) - IF(battP < 0, -battP/12,0) - heaterPwr/12) AS Direktverbrauch,
|
||||
$quellen = [
|
||||
"roh" => [
|
||||
"tabelle" => "EnergyFlow",
|
||||
"zeit" => "datetime",
|
||||
"join" => "LEFT JOIN (SELECT DATE(stunde) AS tag, SUM(pv_fehlbetrag_kwh) AS fehlt
|
||||
FROM EnergyFlow_hourly GROUP BY 1) f ON f.tag = DATE(datetime)",
|
||||
"reihen" => [
|
||||
"prod" => "SUM(pvP/12 - gridPfeed/12 - IF(battP < 0, -battP/12,0) - heaterPwr/12)
|
||||
+ IFNULL(MAX(f.fehlt),0)*1000 AS Direktverbrauch,
|
||||
SUM(heaterPwr/12) AS Heizstab,
|
||||
SUM(IF(battP < 0, -battP/12,0)) AS Batterieladung,
|
||||
SUM(gridPfeed/12) AS Einspeisung",
|
||||
"cons" => "SUM(pvP/12 - IF(gridP < 0, -gridP/12,0) - IF(battP < 0, -battP/12,0) - heaterPwr/12) AS Solarverbrauch,
|
||||
"cons" => "SUM(pvP/12 - gridPfeed/12 - IF(battP < 0, -battP/12,0) - heaterPwr/12)
|
||||
+ IFNULL(MAX(f.fehlt),0)*1000 AS Solarverbrauch,
|
||||
SUM(heaterPwr/12) AS Heizstab,
|
||||
SUM(IF(battP > 0, battP/12,0)) AS Batteriebezug,
|
||||
SUM(gridPcons/12) AS Netzbezug",
|
||||
],
|
||||
],
|
||||
"archiv" => [
|
||||
"tabelle" => "EnergyFlow_hourly",
|
||||
"zeit" => "stunde",
|
||||
"join" => "",
|
||||
"reihen" => [
|
||||
"prod" => "SUM((pv_kwh + IFNULL(pv_fehlbetrag_kwh,0) - netz_einsp_kwh - batt_laden_kwh - heizstab_kwh)*1000) AS Direktverbrauch,
|
||||
SUM(heizstab_kwh*1000) AS Heizstab,
|
||||
SUM(batt_laden_kwh*1000) AS Batterieladung,
|
||||
SUM(netz_einsp_kwh*1000) AS Einspeisung",
|
||||
"cons" => "SUM((pv_kwh + IFNULL(pv_fehlbetrag_kwh,0) - netz_einsp_kwh - batt_laden_kwh - heizstab_kwh)*1000) AS Solarverbrauch,
|
||||
SUM(heizstab_kwh*1000) AS Heizstab,
|
||||
SUM(batt_entladen_kwh*1000) AS Batteriebezug,
|
||||
SUM(netz_bezug_kwh*1000) AS Netzbezug",
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$linecolors["Direktverbrauch"] = "#FFFF00";
|
||||
@@ -46,14 +110,17 @@ $linecolors["Vorhersage"] = "#2222FF";
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$rangeKey = isset($_GET["range"]) ? $_GET["range"] : "";
|
||||
$rangeKey = isset($_GET["range"]) ? $_GET["range"] : "";
|
||||
$seriesKey = isset($_GET["series"]) ? $_GET["series"] : "";
|
||||
if (!isset($ranges[$rangeKey]) || !isset($series[$seriesKey])) {
|
||||
if (!isset($ranges[$rangeKey]) || !in_array($seriesKey, ["prod", "cons"], true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "Unbekannter Zeitraum oder Datenreihe."]);
|
||||
exit;
|
||||
}
|
||||
list($labelFormat, $filter) = $ranges[$rangeKey];
|
||||
list($labelFormat, $quelleKey) = $ranges[$rangeKey];
|
||||
$quelle = $quellen[$quelleKey];
|
||||
$zeit = $quelle["zeit"];
|
||||
$filter = str_replace("ZEIT", $zeit, $filters[$rangeKey]);
|
||||
|
||||
$obj = (object)[];
|
||||
$obj->labels = [];
|
||||
@@ -62,12 +129,12 @@ $obj->datasets = [];
|
||||
if (checkLogin()) {
|
||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||
$query = "SELECT
|
||||
DATE_FORMAT(datetime, $labelFormat) AS 'time',
|
||||
" . $series[$seriesKey] . "
|
||||
FROM EnergyFlow
|
||||
DATE_FORMAT($zeit, $labelFormat) AS 'time',
|
||||
" . $quelle["reihen"][$seriesKey] . "
|
||||
FROM " . $quelle["tabelle"] . " " . $quelle["join"] . "
|
||||
WHERE $filter
|
||||
GROUP BY DATE_FORMAT(datetime, $labelFormat)
|
||||
ORDER BY datetime ; ";
|
||||
GROUP BY DATE_FORMAT($zeit, $labelFormat)
|
||||
ORDER BY MIN($zeit) ; ";
|
||||
$obj = buildChartData($mysql, $query, $linecolors);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,155 +1,369 @@
|
||||
<?php
|
||||
|
||||
require_once("../helper.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;
|
||||
}
|
||||
}
|
||||
|
||||
$Query = "SELECT
|
||||
SUM(gridPcons/12000) as 'Stromverbrauch',
|
||||
SUM((gridPcons*cost)/12000)+140 as 'Stromkosten (140€ fix)',
|
||||
SUM(pvP - IF(gridP < 0, -gridP,0) - IF(battP < 0, -battP,0)- heaterPwr + IF(battP > 0, battP,0))*cost/12000 as 'Verbrauchsersparnis',
|
||||
SUM((gridPfeed*gain)/12000) as 'Einspeisevergütung',
|
||||
SUM(IF(gridP < 500, heaterPwr, 0)*0.063/12000) as 'Ersparnis Heizung',
|
||||
SUM(IF(battP > 0, battP, 0)*cost/12000) as 'Ersparnis Batterie',
|
||||
AVG(autonomy) AS 'Ø Autarkie',
|
||||
(SUM(PL1_EV+PL2_EV+PL3_EV))/12 AS 'Autoladung ges. EG',
|
||||
(((SUM(IF(gridP>100,0,PL1_EV+PL2_EV+PL3_EV)))/(SUM(PL1_EV+PL2_EV+PL3_EV))))*100 AS 'Autoladung Solar EG',
|
||||
(SUM(IF(gridP>100,0,(PL1_EV+PL2_EV+PL3_EV)*cost))/12) AS 'Ersparnis Solarladung EG',
|
||||
(SUM(PL1_EV+PL2_EV+PL3_EV))/24.5 AS 'Benzin gespart EG',
|
||||
(SUM(PL1_EVog+PL2_EVog+PL3_EVog))/12 AS 'Autoladung ges. OG',
|
||||
(((SUM(IF(gridP>100,0,PL1_EVog+PL2_EVog+PL3_EVog)))/(SUM(PL1_EVog+PL2_EVog+PL3_EVog))))*100 AS 'Autoladung Solar OG',
|
||||
(SUM(IF(gridP>100,0,(PL1_EVog+PL2_EVog+PL3_EVog)*cost))/12) AS 'Ersparnis Solarladung OG',
|
||||
(SUM(PL1_EVog+PL2_EVog+PL3_EVog))/24.5 AS 'Benzin gespart OG'
|
||||
FROM EnergyFlow JOIN gridCosts ON DATE(datetime) >= DATE(gridCosts.active_date) AND DATE(datetime) <= DATE(gridCosts.end_date)
|
||||
WHERE year(datetime) = ".$_GET["year"].";";
|
||||
|
||||
$PrevQuery = "SELECT
|
||||
SUM(gridPcons/12000) as 'Stromverbrauch',
|
||||
SUM((gridPcons*cost)/12000)+140 as 'Stromkosten (140€ fix)',
|
||||
SUM(pvP - IF(gridP < 0, -gridP,0) - IF(battP < 0, -battP,0)- heaterPwr + IF(battP > 0, battP,0))*cost/12000 as 'Verbrauchsersparnis',
|
||||
SUM((gridPfeed*gain)/12000) as 'Einspeisevergütung',
|
||||
SUM(IF(gridP < 500, heaterPwr, 0)*0.063/12000) as 'Ersparnis Heizung',
|
||||
SUM(IF(battP > 0, battP, 0)*cost/12000) as 'Ersparnis Batterie',
|
||||
AVG(autonomy) AS 'Ø Autarkie',
|
||||
(SUM(PL1_EV+PL2_EV+PL3_EV))/12 AS 'Autoladung ges. EG',
|
||||
(((SUM(IF(gridP>100,0,PL1_EV+PL2_EV+PL3_EV)))/(SUM(PL1_EV+PL2_EV+PL3_EV))))*100 AS 'Autoladung Solar EG',
|
||||
(SUM(IF(gridP>100,0,(PL1_EV+PL2_EV+PL3_EV)*cost))/12) AS 'Ersparnis Solarladung EG',
|
||||
(SUM(PL1_EV+PL2_EV+PL3_EV))/24.5 AS 'Benzin gespart EG',
|
||||
(SUM(PL1_EVog+PL2_EVog+PL3_EVog))/12 AS 'Autoladung ges. OG',
|
||||
(((SUM(IF(gridP>100,0,PL1_EVog+PL2_EVog+PL3_EVog)))/(SUM(PL1_EVog+PL2_EVog+PL3_EVog))))*100 AS 'Autoladung Solar OG',
|
||||
(SUM(IF(gridP>100,0,(PL1_EVog+PL2_EVog+PL3_EVog)*cost))/12) AS 'Ersparnis Solarladung OG',
|
||||
(SUM(PL1_EVog+PL2_EVog+PL3_EVog))/24.5 AS 'Benzin gespart OG'
|
||||
FROM EnergyFlow JOIN gridCosts ON DATE(datetime) >= DATE(gridCosts.active_date) AND DATE(datetime) <= DATE(gridCosts.end_date)
|
||||
WHERE year(datetime) = ".($_GET["year"]-1).";";
|
||||
|
||||
|
||||
$units = Array("kWh","€","€","€","€","€","€","%","kWh","%","€","L","kWh","%","€","L");
|
||||
$LessIsBetter = Array(true,true,false,false,false,false,false,false,false,false,false,false,false,false,false,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 = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $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();
|
||||
//$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-2 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);
|
||||
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
/**
|
||||
* Raum-Modal: alles, was in einem Raum haengt, auf einer Seite.
|
||||
*
|
||||
* Loest das frühere Thermostat-Modal ab. Der Grundriss zeigt Raeume, also
|
||||
* sollte ein Klick darauf den Raum oeffnen und nicht nur seine Heizung - die
|
||||
* Rollladen im selben Zimmer waren bisher gar nicht erreichbar.
|
||||
*
|
||||
* Welche Geraete zu einem Raum gehoeren, sagt die Spalte actors.room_id
|
||||
* (Einstellungen -> Geraete).
|
||||
*
|
||||
* GET ?room=12 Inhalt des Modals, 12 = rooms.id
|
||||
* POST ?action=command {"command_id": N, "params": {...}} - ein Kommando
|
||||
* POST ?action=temp {"room": 12, "temp": 21} - Solltemperatur
|
||||
* POST ?action=jalousie {"actor": 5} - Stand einer Jalousie
|
||||
* POST ?action=my {"actor": 5} - aktuellen Stand als
|
||||
* my-Position speichern
|
||||
*
|
||||
* Frueher hiess der Raum "EG_Bad" und wurde am Unterstrich zerlegt. Das hielt
|
||||
* weder ein Leerzeichen im Namen aus (erst nachgeruestet) noch eine
|
||||
* Umbenennung - die feste Nummer haelt beides.
|
||||
*/
|
||||
|
||||
/** Ein Raum nach seiner festen Nummer, oder null. */
|
||||
function raumNachNummer($nr)
|
||||
{
|
||||
foreach (allRooms() as $r) {
|
||||
if ($r["nr"] === intval($nr)) {
|
||||
return $r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ein "Zu" an einer Jalousie faehrt herunter, wartet und neigt danach - das
|
||||
// dauert in der Summe ueber eine Minute. Die Vorgabe von PHP reicht dafuer
|
||||
// nicht; ajax/tahoma.php macht es an derselben Stelle genauso.
|
||||
set_time_limit(180);
|
||||
|
||||
require_once("../helper.php");
|
||||
require_once("../restricted/automations.php");
|
||||
require_once("../restricted/commands.php");
|
||||
require_once("../restricted/roomControls.php");
|
||||
require_once("./phpMQTT.php");
|
||||
|
||||
if (!checkLogin()) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Die Sitzung wird ab hier nur noch gelesen, und PHP haelt ihre Datei bis
|
||||
// zum Ende der Anfrage exklusiv gesperrt. Ein "Zu" an einer Jalousie wartet
|
||||
// weiter unten ueber eine Minute auf das Ende der Fahrt - solange stand jede
|
||||
// weitere Anfrage desselben Browsers still, auch die, die waehrend der Fahrt
|
||||
// nachsehen will, wie weit sie ist. Alle Schreibzugriffe auf $_SESSION
|
||||
// stecken in checkLogin() und sind hier durch.
|
||||
session_write_close();
|
||||
|
||||
function raumAntwort($data, $code = 200)
|
||||
{
|
||||
http_response_code($code);
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Antwort abschicken, aber weiterarbeiten.
|
||||
*
|
||||
* Nach einem Fahrbefehl muss noch jemand abwarten, wo die Jalousie stehen
|
||||
* bleibt, und das aufschreiben - sonst steht in actor_states bis zum
|
||||
* naechsten Poll des Runners der alte Wert. Das dem Browser zu ueberlassen
|
||||
* ging schief: wer das Modal gleich nach dem Fahrbefehl zumachte, nahm den
|
||||
* einzigen Zuschauer mit.
|
||||
*
|
||||
* Dieselbe Bauart wie in ajax/tahoma.php: Content-Length setzen, ausliefern,
|
||||
* die Verbindung schliessen, weiterlaufen. Ohne FPM bleibt nur, die Puffer zu
|
||||
* leeren - dann hat der Browser seine Daten zwar, wartet aber trotzdem.
|
||||
*/
|
||||
function raumAntwortUndWeiter($data)
|
||||
{
|
||||
http_response_code(200);
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
$text = json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
header("Content-Length: " . strlen($text));
|
||||
echo $text;
|
||||
// Der Aufrufer ist weg, das Skript soll trotzdem zu Ende kommen.
|
||||
ignore_user_abort(true);
|
||||
if (function_exists("fastcgi_finish_request")) {
|
||||
fastcgi_finish_request();
|
||||
return;
|
||||
}
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
// --- Schaltende Aufrufe ---------------------------------------------------
|
||||
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
$body = json_decode(file_get_contents("php://input"), true);
|
||||
if (!is_array($body)) {
|
||||
raumAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
|
||||
}
|
||||
try {
|
||||
if (($_GET["action"] ?? "") === "command") {
|
||||
$id = intval($body["command_id"] ?? 0);
|
||||
$text = executeCommand($id,
|
||||
is_array($body["params"] ?? null) ? $body["params"] : [],
|
||||
!empty($body["probe"]));
|
||||
// Bei einem Tahoma-Geraet ist mit dem Absenden noch nichts
|
||||
// festgehalten: die Box meldet die neue Stellung erst, wenn die
|
||||
// Fahrt zu Ende ist. Also Antwort raus und in Ruhe nachsehen.
|
||||
$url = empty($body["probe"]) ? tahomaUrlZuKommando($id) : null;
|
||||
if ($url === null) {
|
||||
raumAntwort(["ok" => $text]);
|
||||
}
|
||||
raumAntwortUndWeiter(["ok" => $text]);
|
||||
tahomaNachlesen($url);
|
||||
exit;
|
||||
}
|
||||
if (($_GET["action"] ?? "") === "temp") {
|
||||
// Derselbe Weg wie im alten Thermostat-Modal: <Zweig>/changeSetTemp.
|
||||
// Der Zweig steht am Raum (rooms.thermostat) und wird nicht mehr
|
||||
// aus Etage und Name zusammengesetzt - ein Thermostat in einem
|
||||
// anderen Haus meldet vielleicht unter einem ganz anderen.
|
||||
$raum = raumNachNummer($body["room"] ?? 0);
|
||||
if (!$raum || empty($raum["thermostat"])) {
|
||||
raumAntwort(["error" => "Dieser Raum hat kein Thermostat."], 400);
|
||||
}
|
||||
$temp = intval($body["temp"] ?? 0);
|
||||
if ($temp < 10 || $temp > 30) {
|
||||
raumAntwort(["error" => "Temperatur außerhalb 10–30 °C."], 400);
|
||||
}
|
||||
$mqtt = new Bluerhinos\phpMQTT(MQTT_BROKER, MQTT_PORT, "RoomTempSetter_" . getmypid());
|
||||
if (!$mqtt->connect(true, null, "", "")) {
|
||||
raumAntwort(["error" => "Keine Verbindung zum MQTT-Broker."], 500);
|
||||
}
|
||||
$mqtt->publish(rtrim($raum["thermostat"], "/") . "/changeSetTemp",
|
||||
strval($temp), 0, false);
|
||||
$mqtt->close();
|
||||
raumAntwort(["ok" => "Solltemperatur " . $temp . " °C"]);
|
||||
}
|
||||
// Eine Jalousie waehrend ihrer Fahrt nachfuehren. actor_states wird
|
||||
// sonst nur vom Runner fortgeschrieben, und der fragt die Box alle
|
||||
// fuenf Minuten ab - so lange stand nach einem Fahrbefehl der alte
|
||||
// Wert im Modal. Hier wird genau ein Geraet gelesen, das kostet die
|
||||
// Box wenig, und der frische Stand landet gleich in der Tabelle.
|
||||
if (($_GET["action"] ?? "") === "jalousie") {
|
||||
$url = tahomaAktorUrl(intval($body["actor"] ?? 0));
|
||||
if ($url === null) {
|
||||
raumAntwort(["error" => "Kein Tahoma-Gerät mit dieser Nummer."], 404);
|
||||
}
|
||||
$zustaende = tahomaZustaende($url);
|
||||
if ($zustaende === null) {
|
||||
raumAntwort(["error" => "Tahoma-Box antwortet nicht."], 503);
|
||||
}
|
||||
tahomaZustaendeSchreiben($url, $zustaende);
|
||||
$zahl = function ($feld) use ($zustaende) {
|
||||
return isset($zustaende[$feld]) && is_numeric($zustaende[$feld])
|
||||
? intval($zustaende[$feld]) : null;
|
||||
};
|
||||
raumAntwort([
|
||||
"faehrt" => ($zustaende["core:MovingState"] ?? null) === true,
|
||||
"position" => $zahl("core:ClosureState"),
|
||||
"neigung" => $zahl("core:SlateOrientationState"),
|
||||
"myPosition" => $zahl("core:Memorized1PositionState"),
|
||||
"myNeigung" => $zahl("core:Memorized1OrientationState"),
|
||||
]);
|
||||
}
|
||||
// Den Zustand einer WLED-Lampe live lesen: an/aus, Helligkeit, Farbe,
|
||||
// Effekt und Preset. In actor_states stehen nur die ersten drei, und
|
||||
// ohne Effekt und Preset koennte das Raum-Modal nicht zeigen, was
|
||||
// gerade laeuft.
|
||||
if (($_GET["action"] ?? "") === "wled") {
|
||||
$db = commandDb();
|
||||
$stmt = $db->prepare("SELECT url FROM actors WHERE id = ?");
|
||||
$id = intval($body["actor"] ?? 0);
|
||||
$stmt->bind_param("i", $id);
|
||||
$stmt->execute();
|
||||
$zeile = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
if (!$zeile || strpos(strval($zeile["url"]), "wled://") !== 0) {
|
||||
raumAntwort(["error" => "Keine WLED-Lampe mit dieser Nummer."], 404);
|
||||
}
|
||||
$ch = curl_init("http://" . rtrim(substr($zeile["url"], strlen("wled://")), "/") . "/json/state");
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 3);
|
||||
$stand = json_decode(strval(curl_exec($ch)), true);
|
||||
curl_close($ch);
|
||||
if (!is_array($stand)) {
|
||||
raumAntwort(["error" => "Die Lampe antwortet nicht."], 503);
|
||||
}
|
||||
$segment = $stand["seg"][0] ?? [];
|
||||
$farbe = $segment["col"][0] ?? null;
|
||||
raumAntwort([
|
||||
"an" => !empty($stand["on"]),
|
||||
"bri" => isset($stand["bri"]) ? intval($stand["bri"]) : null,
|
||||
"farbe" => is_array($farbe) && count($farbe) >= 3 ? array_map("intval", array_slice($farbe, 0, 3)) : null,
|
||||
"effect" => isset($segment["fx"]) ? intval($segment["fx"]) : null,
|
||||
"preset" => isset($stand["ps"]) ? intval($stand["ps"]) : null,
|
||||
]);
|
||||
}
|
||||
// Den Stand, auf dem die Jalousie gerade steht, als my-Position in den
|
||||
// Motor schreiben. Die Box kennt dafuer setMemorized1Position und bei
|
||||
// Raffstores zusaetzlich setMemorized1Orientation; die Geraetesuche
|
||||
// uebernimmt beide nicht als Kommando, deshalb geht es hier direkt.
|
||||
// Gespeichert wird, was die Box meldet - nicht, was im Browser steht:
|
||||
// der koennte einer Fahrt hinterherhinken.
|
||||
if (($_GET["action"] ?? "") === "my") {
|
||||
$url = tahomaAktorUrl(intval($body["actor"] ?? 0));
|
||||
if ($url === null) {
|
||||
raumAntwort(["error" => "Kein Tahoma-Gerät mit dieser Nummer."], 404);
|
||||
}
|
||||
$vorher = tahomaZustaende($url);
|
||||
if ($vorher === null) {
|
||||
raumAntwort(["error" => "Tahoma-Box antwortet nicht."], 503);
|
||||
}
|
||||
if (($vorher["core:MovingState"] ?? null) === true) {
|
||||
raumAntwort(["error" => "Die Jalousie fährt noch – bitte warten, bis sie steht."], 409);
|
||||
}
|
||||
if (!isset($vorher["core:Memorized1PositionState"]) || !is_numeric($vorher["core:ClosureState"] ?? null)) {
|
||||
raumAntwort(["error" => "Dieses Gerät kann keine my-Position speichern."], 400);
|
||||
}
|
||||
$position = intval($vorher["core:ClosureState"]);
|
||||
$neigung = null;
|
||||
tahomaKommando($url, "setMemorized1Position", [$position]);
|
||||
$text = "my-Position " . $position . " %";
|
||||
if (isset($vorher["core:Memorized1OrientationState"]) && is_numeric($vorher["core:SlateOrientationState"] ?? null)) {
|
||||
$neigung = intval($vorher["core:SlateOrientationState"]);
|
||||
tahomaKommando($url, "setMemorized1Orientation", [$neigung]);
|
||||
$text .= ", Lamellen " . $neigung . " %";
|
||||
}
|
||||
// Die Box braucht einen Moment, bis sie den neuen Wert meldet.
|
||||
sleep(2);
|
||||
$nachher = tahomaZustaende($url);
|
||||
if ($nachher !== null) {
|
||||
tahomaZustaendeSchreiben($url, $nachher);
|
||||
}
|
||||
raumAntwort([
|
||||
"ok" => $text . " gespeichert",
|
||||
"myPosition" => $position,
|
||||
"myNeigung" => $neigung,
|
||||
]);
|
||||
}
|
||||
raumAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
raumAntwort(["error" => $e->getMessage()], 400);
|
||||
} catch (Throwable $e) {
|
||||
raumAntwort(["error" => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Inhalt des Modals ----------------------------------------------------
|
||||
$raum = raumNachNummer($_GET["room"] ?? 0);
|
||||
if (!$raum) {
|
||||
echo "<p class='text-body-secondary mb-0'>Diesen Raum gibt es nicht (mehr).</p>";
|
||||
exit;
|
||||
}
|
||||
// Derselbe Schluessel wie actors.room_id im Katalog - als Text, siehe raumListe().
|
||||
$raumKey = strval($raum["nr"]);
|
||||
|
||||
// Alles im Raum, sortiert nach Bedienform. Das Thermostat selbst wird nicht
|
||||
// als Karte gezeichnet: es wird zum Schieberegler im Heizungs-Reiter und
|
||||
// braucht daneben keine zweite Bedienung - "Solltemperatur" stand sonst
|
||||
// zweimal im Modal.
|
||||
//
|
||||
// Ob es einen Heizungs-Reiter gibt, entscheidet genau das: haengt in diesem
|
||||
// Raum ein Thermostat? Frueher stand hier "hat der Raum eine Kachel" - das
|
||||
// stimmte, solange nur Raeume mit Thermostat eine bekamen. Seit auch Kueche
|
||||
// und Technik eine haben, bekamen sie einen Heizungsregler fuer eine
|
||||
// Heizung, die es dort nicht gibt.
|
||||
$katalog = deviceCatalog();
|
||||
$hatThermostat = false;
|
||||
$gruppen = [];
|
||||
foreach ($katalog as $d) {
|
||||
if ($d["room"] !== $raumKey) {
|
||||
continue;
|
||||
}
|
||||
$form = bedienform($d);
|
||||
if ($form === "heizung") {
|
||||
$hatThermostat = true;
|
||||
continue;
|
||||
}
|
||||
$gruppen[$form][] = $d;
|
||||
}
|
||||
|
||||
// Die Heizung ist eine Gruppe wie die anderen, nur baut sie sich nicht aus
|
||||
// einer Geraeteliste: ein Regler fuer den Raum statt einer Karte je Geraet.
|
||||
$fertig = [];
|
||||
if ($hatThermostat) {
|
||||
// Ring mit Ist-Temperatur und ziehbarem Soll-Punkt, daneben - und + und
|
||||
// die Schnellwahl. Alle Zahlen kommen live ueber MQTT (der Browser hoert
|
||||
// ohnehin auf den Thermostat-Zweig) und werden von js/solar/raumfenster.js
|
||||
// eingetragen. Das Thermostat nimmt ganze Grad von 10 bis 30 °C, siehe
|
||||
// action=temp.
|
||||
$fertig["heizung"] = "<div class='rf-heiz' data-rf-heiz-teil data-min='10' data-max='30'>"
|
||||
. "<div class='rf-ring'><svg viewBox='0 0 170 170'>"
|
||||
. "<defs><linearGradient id='rfHeizVerlauf' gradientUnits='userSpaceOnUse' gradientTransform='rotate(-135 85 85)' x1='15' y1='0' x2='155' y2='0'>"
|
||||
. "<stop offset='0' stop-color='#4b9fd6'/><stop offset='.55' stop-color='#e8a33a'/><stop offset='1' stop-color='#e5624f'/>"
|
||||
. "</linearGradient></defs>"
|
||||
. "<circle class='rf-ring-spur' cx='85' cy='85' r='70' transform='rotate(135 85 85)'/>"
|
||||
. "<circle class='rf-ring-ist' cx='85' cy='85' r='70' transform='rotate(135 85 85)'/>"
|
||||
. "<circle class='rf-ring-griff' cx='85' cy='85' r='70' transform='rotate(135 85 85)' data-rf-ringzug/>"
|
||||
. "<circle class='rf-ring-soll' cx='155' cy='85' r='10' data-rf-ringzug tabindex='0' role='slider'"
|
||||
. " aria-label='Solltemperatur' aria-valuemin='10' aria-valuemax='30'/></svg>"
|
||||
. "<div class='rf-ring-mitte'><small>im Raum</small><b data-rf-heiz='ist'>–</b><span data-rf-heiz='soll'>Soll –</span></div></div>"
|
||||
. "<div class='rf-heiz-rechts'><h3>Solltemperatur</h3>"
|
||||
. "<div class='rf-stepper'><button type='button' data-rf-schritt='-1' aria-label='1 Grad kälter'>−</button>"
|
||||
. "<output data-rf-heiz='wert'>–</output>"
|
||||
. "<button type='button' data-rf-schritt='1' aria-label='1 Grad wärmer'>+</button></div>"
|
||||
. "<div class='rf-chips'>"
|
||||
. "<button type='button' class='rf-chip' data-rf-soll='10' aria-pressed='false'>Minimum <span>10°</span></button>"
|
||||
. "<button type='button' class='rf-chip' data-rf-soll='17' aria-pressed='false'>Eco <span>17°</span></button>"
|
||||
. "<button type='button' class='rf-chip' data-rf-soll='21' aria-pressed='false'>Komfort <span>21°</span></button>"
|
||||
. "<button type='button' class='rf-chip' data-rf-soll='23' aria-pressed='false'>Warm <span>23°</span></button></div>"
|
||||
. "<div class='rf-hinweis' data-rf-heiz='hinweis' hidden><i class='bi bi-info-circle'></i><span></span></div>"
|
||||
. "<div class='rf-pillen'><span class='rf-pille'><i class='bi bi-droplet'></i><span data-rf-heiz='feuchte'>–</span></span>"
|
||||
. "<span class='rf-pille' data-rf-heiz='puffer' hidden><i class='bi bi-fire'></i>Pufferbetrieb</span></div>"
|
||||
. "</div></div>";
|
||||
}
|
||||
|
||||
// Der Thermostat-Zweig reist mit: das Modal fuellt Ist-Werte und Heizsymbol
|
||||
// aus dem laufenden MQTT-Strom und muss wissen, wo es nachsehen soll.
|
||||
echo "<div class='rf' id='roomView' data-room='" . $raum["nr"] . "'"
|
||||
. " data-thermostat='" . htmlspecialchars((string)$raum["thermostat"], ENT_QUOTES) . "'>";
|
||||
|
||||
if (!$gruppen && !$fertig) {
|
||||
echo "<p class='rf-leer'>Diesem Raum ist noch nichts zugeordnet. "
|
||||
. "Das geht über das Haus-Symbol in der Karte „Automatismen“.</p>";
|
||||
}
|
||||
|
||||
echo zeichneGruppen($gruppen, $fertig);
|
||||
|
||||
echo "<div id='roomFeedback' class='rf-meldung' aria-live='polite'>Änderungen gelten sofort</div>";
|
||||
echo "</div>";
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
require_once("./phpMQTT.php");
|
||||
require_once("../helper.php");
|
||||
|
||||
|
||||
if (checkLogin()) {
|
||||
$close = 0;
|
||||
$hostname = "192.168.179.169";
|
||||
$server = 'localhost'; // change if necessary
|
||||
$port = 1883; // change if necessary
|
||||
$username = ''; // set your username
|
||||
$password = ''; // set your password
|
||||
$client_id = 'RoomtempSetter'; // make sure this is unique for connecting to sever - you could use uniqid()
|
||||
|
||||
if (isset($_POST["setTemp"]) && isset($_GET["heater"])) {
|
||||
$topic = "Raumtemp/".str_replace("_","/",$_GET["heater"])."/changeSetTemp";
|
||||
$mqtt = new Bluerhinos\phpMQTT($server, $port, $client_id);
|
||||
//send new value
|
||||
$temp = intval($_POST["setTemp"]);
|
||||
if($temp > 9 && $temp < 31){
|
||||
if ($mqtt->connect(true, NULL, $username, $password)) {
|
||||
$mqtt->publish($topic, $temp, 0, false);
|
||||
$mqtt->close();
|
||||
}
|
||||
}
|
||||
$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">Temperatur: </label>
|
||||
<div style="font-size: 15px;color: #297195;display: inline-block;" id="modal-slider-label">0</div>
|
||||
<input type="range" id="modal-slider" name="setTemp" class="form-range range-color-track" min="10" max="30" />
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="max-amount f-16 font-weight-normal darkGray-color text-right mt-1">10 °C</span>
|
||||
<span class="max-amount f-16 font-weight-normal darkGray-color text-right mt-1">30 °C</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
ENDE;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
/**
|
||||
* Endpunkt der Einstellungsseite.
|
||||
*
|
||||
* GET ?action=list&reihe=strom Zeitraeume einer Preistabelle
|
||||
* POST ?action=save {"reihe": "strom", "zeilen": [ ... ]}
|
||||
* GET ?action=kacheln Anzeigewerte aller Home-Kacheln
|
||||
* POST ?action=kacheln-save {"floor": "OG", "room": "bad",
|
||||
* "werte": [ ... ] oder null}
|
||||
* GET ?action=geraete Geraete mit Raum, Verwendung und Alter
|
||||
* POST ?action=geraete-raeume {"rooms": {"123": "11", ...}} Geraet -> Raumnummer
|
||||
* POST ?action=geraet-loeschen {"id": 12}
|
||||
* GET ?action=skoda-keys Stand der Fahrzeug-Schluessel (ohne Werte!)
|
||||
* POST ?action=skoda-keys-save {"felder": {"API_KEY2": "..."},
|
||||
* "loeschen": ["API_KEY3"]}
|
||||
*
|
||||
* GET ?action=grundriss Etagen, Raeume, Kachelpositionen
|
||||
* POST ?action=etage-speichern {"code": "EG", "label": "...", "standard": true, "neu": false}
|
||||
* POST ?action=etagen-reihenfolge {"codes": ["OG", "EG", ...]}
|
||||
* POST ?action=etage-loeschen {"code": "EG"}
|
||||
* POST ?action=etage-bild multipart: code, bild (Datei)
|
||||
* POST ?action=raum-speichern {"nr": 0|12, "floor": "EG", "name": "...", "kuerzel": "", "thermostat": ""}
|
||||
* POST ?action=raum-position {"nr": 12, "x": 120, "y": 80} (x/y null = vom Plan)
|
||||
* POST ?action=raum-loeschen {"nr": 12}
|
||||
* Die Grundriss-Aktionen antworten mit dem ganzen neuen Stand.
|
||||
*
|
||||
* GET ?action=energiefluss Abweichungen der Übersicht vom Katalog
|
||||
* POST ?action=energiefluss-save {"knoten": [{id, aktiv, name, x, y, optionen}, ...]}
|
||||
*
|
||||
* Ein reiner Verteiler: geprueft und geschrieben wird in
|
||||
* restricted/costs.php, kacheln.php bzw. grundriss.php, damit Maske und
|
||||
* Endpunkt dieselbe Wahrheit ueber einen Zeitraum oder eine Kachel haben.
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
require_once("../restricted/costs.php");
|
||||
require_once("../restricted/kacheln.php");
|
||||
require_once("../restricted/automations.php");
|
||||
require_once("../restricted/skodaKeys.php");
|
||||
require_once("../restricted/grundriss.php");
|
||||
require_once("../restricted/energiefluss.php");
|
||||
|
||||
if (!checkLogin()) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
function jsonAntwort($data, $code = 200)
|
||||
{
|
||||
http_response_code($code);
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$action = $_GET["action"] ?? "list";
|
||||
|
||||
// Das Grundrissbild kommt als Formular mit Datei, nicht als JSON - deshalb
|
||||
// vor dem JSON-Teil.
|
||||
if ($_SERVER["REQUEST_METHOD"] === "POST" && $action === "etage-bild") {
|
||||
try {
|
||||
$bild = etageBildSpeichern($_POST["code"] ?? "", $_FILES["bild"] ?? null);
|
||||
grundriss(true);
|
||||
jsonAntwort(["bild" => $bild, "stand" => grundrissStand()]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
jsonAntwort(["error" => $e->getMessage()], 400);
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
$body = json_decode(file_get_contents("php://input"), true);
|
||||
if (!is_array($body)) {
|
||||
jsonAntwort(["error" => "Konnte die Anfrage nicht lesen."], 400);
|
||||
}
|
||||
try {
|
||||
switch ($action) {
|
||||
case "save":
|
||||
$anzahl = preiseSpeichern($body["reihe"] ?? "", $body["zeilen"] ?? []);
|
||||
// Die gespeicherte Fassung zurueckgeben: die Maske zeigt danach
|
||||
// die abgeleiteten Enddaten, nicht die eingetippten.
|
||||
jsonAntwort([
|
||||
"gespeichert" => $anzahl,
|
||||
"zeilen" => preiseLaden($body["reihe"]),
|
||||
]);
|
||||
|
||||
case "kacheln-save":
|
||||
/*
|
||||
* Fehlt "werte" oder steht dort null, faellt die Kachel auf
|
||||
* ihre Vorgabe zurueck - dann kommt diese zurueck, damit die
|
||||
* Maske zeigt, was jetzt gilt, statt zu leeren Zeilen.
|
||||
*/
|
||||
$floor = $body["floor"] ?? "";
|
||||
$room = $body["room"] ?? "";
|
||||
$werte = array_key_exists("werte", $body) ? $body["werte"] : null;
|
||||
$gespeichert = kachelWerteSpeichern($floor, $room, $werte);
|
||||
|
||||
$vorgabe = [];
|
||||
foreach (allRooms() as $r) {
|
||||
if ($r["floor"] === $floor && $r["id"] === $room) {
|
||||
$vorgabe = kachelVorgabe($r);
|
||||
}
|
||||
}
|
||||
jsonAntwort([
|
||||
"eigen" => $gespeichert !== null,
|
||||
"werte" => $gespeichert !== null ? $gespeichert : $vorgabe,
|
||||
]);
|
||||
case "geraete-raeume":
|
||||
jsonAntwort(["gespeichert" => saveRooms($body["rooms"] ?? [])]);
|
||||
|
||||
case "geraet-loeschen":
|
||||
/*
|
||||
* Loeschen ist endgueltig - abgelehnt wird es, wenn eine
|
||||
* Automatik auf das Geraet zeigt (siehe geraetLoeschen()).
|
||||
* Zurueck kommt die neue Liste, damit die Maske nicht raten
|
||||
* muss, was jetzt gilt.
|
||||
*/
|
||||
$name = geraetLoeschen($body["id"] ?? 0);
|
||||
jsonAntwort(["geloescht" => $name, "geraete" => geraeteListe()]);
|
||||
|
||||
case "etage-speichern":
|
||||
etageSpeichern($body);
|
||||
grundriss(true);
|
||||
jsonAntwort(["stand" => grundrissStand()]);
|
||||
case "etagen-reihenfolge":
|
||||
etagenReihenfolge($body["codes"] ?? null);
|
||||
grundriss(true);
|
||||
jsonAntwort(["stand" => grundrissStand()]);
|
||||
case "etage-loeschen":
|
||||
etageLoeschen($body["code"] ?? "");
|
||||
grundriss(true);
|
||||
jsonAntwort(["stand" => grundrissStand()]);
|
||||
case "raum-speichern":
|
||||
$nr = raumSpeichern($body);
|
||||
grundriss(true);
|
||||
jsonAntwort(["nr" => $nr, "stand" => grundrissStand()]);
|
||||
case "raum-position":
|
||||
$pos = raumPosition($body["nr"] ?? 0, $body["x"] ?? null, $body["y"] ?? null);
|
||||
grundriss(true);
|
||||
jsonAntwort(["position" => $pos, "stand" => grundrissStand()]);
|
||||
case "raum-loeschen":
|
||||
raumLoeschen($body["nr"] ?? 0);
|
||||
grundriss(true);
|
||||
jsonAntwort(["stand" => grundrissStand()]);
|
||||
|
||||
case "energiefluss-save":
|
||||
// Zurück kommt der gespeicherte Stand - ohne die Einträge,
|
||||
// die dem Katalog entsprechen.
|
||||
energieflussSpeichern($body["knoten"] ?? null);
|
||||
jsonAntwort(["einstellungen" => (object)energieflussLaden()]);
|
||||
|
||||
case "skoda-keys-save":
|
||||
/*
|
||||
* Neue Schluessel eintragen. Zurueck kommt der neue Stand -
|
||||
* aber nie ein Wert, siehe restricted/skodaKeys.php.
|
||||
*/
|
||||
jsonAntwort(skodaSchluesselSpeichern(
|
||||
$body["felder"] ?? [], $body["loeschen"] ?? []));
|
||||
|
||||
default:
|
||||
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||
}
|
||||
} catch (InvalidArgumentException $e) {
|
||||
jsonAntwort(["error" => $e->getMessage()], 400);
|
||||
} catch (RuntimeException $e) {
|
||||
// Nicht die Eingabe, sondern die Umgebung - Datei fehlt, Rechte
|
||||
// fehlen. Die Meldung sagt schon, was zu tun ist; kein Vorspann.
|
||||
jsonAntwort(["error" => $e->getMessage()], 500);
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === "kacheln") {
|
||||
try {
|
||||
jsonAntwort([
|
||||
"kacheln" => kachelListe(),
|
||||
// Alle Symbolnamen - die üblichen zuerst und mit Beschriftung.
|
||||
"symbole" => kachelIconNamen(),
|
||||
// Auswahlliste je Geraet - dieselbe Quelle wie im Automatik-Editor.
|
||||
"katalog" => kachelKatalog(),
|
||||
// Fuer die Eingabe von Hand, wenn etwas nicht im Katalog steht.
|
||||
"topics" => kachelTopicVorschlaege(),
|
||||
"tabelleDa" => grundrissDa(),
|
||||
"max" => KACHEL_MAX,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => "Kacheln nicht abrufbar: " . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === "grundriss") {
|
||||
try {
|
||||
jsonAntwort(grundrissStand());
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => "Grundriss nicht abrufbar: " . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === "energiefluss") {
|
||||
try {
|
||||
jsonAntwort(["einstellungen" => (object)energieflussLaden()]);
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => "Übersicht nicht abrufbar: " . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === "skoda-keys") {
|
||||
try {
|
||||
jsonAntwort(skodaStand());
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => "Schlüssel nicht abrufbar: " . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === "geraete") {
|
||||
try {
|
||||
jsonAntwort(["geraete" => geraeteListe(), "raeume" => raumListe()]);
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => "Geräte nicht abrufbar: " . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === "list") {
|
||||
try {
|
||||
jsonAntwort(["zeilen" => preiseLaden($_GET["reihe"] ?? "")]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
jsonAntwort(["error" => $e->getMessage()], 400);
|
||||
} catch (Throwable $e) {
|
||||
jsonAntwort(["error" => "Laden fehlgeschlagen: " . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||
@@ -0,0 +1,610 @@
|
||||
<?php
|
||||
/*
|
||||
* Daten der Škoda-Seite. Ein Endpunkt, mehrere Abschnitte:
|
||||
*
|
||||
* ?was=live letzter bekannter Stand, alle Spalten der jüngsten Zeile
|
||||
* ?was=ladungen Ladevorgänge der letzten N Tage, aus Sitzungen gebildet
|
||||
* ?was=kurve Verlauf einer einzelnen Ladung (SoC und kW über Zeit)
|
||||
* ?was=gesundheit kWh je 100 Ladestandsprozent, je Ladung, mit Trend
|
||||
* ?was=fahrten Fahrten zwischen zwei Parkständen, mit Koordinaten
|
||||
* ?was=strecke gefahrene Kilometer über Woche, Monat und Jahr
|
||||
*
|
||||
* Die Fahrzeugdaten stehen in der Tabelle skoda, geschrieben von
|
||||
* gatherSkodaData.py. Der Zeilentakt ist nicht fest: geschrieben wird, wenn
|
||||
* das Fahrzeug etwas Neues meldet, mindestens stündlich. Alle Auswertungen
|
||||
* hier müssen damit umgehen, dass die Abstände zwischen zwei Zeilen von vier
|
||||
* Minuten bis zu einer Stunde reichen.
|
||||
*
|
||||
* Solange kein Fahrzeug angebunden ist, ist die Tabelle leer. Jeder Abschnitt
|
||||
* liefert dann eine leere, aber wohlgeformte Antwort - die Seite zeigt
|
||||
* daraufhin ihre Leerzustände und läuft nicht in Fehler.
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if (!checkLogin()) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Nicht angemeldet."]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$was = isset($_GET["was"]) ? $_GET["was"] : "live";
|
||||
$tage = isset($_GET["tage"]) ? max(1, min(3650, intval($_GET["tage"]))) : 90;
|
||||
|
||||
$mysql = new mysqli($mysql_server, $mysql_solarUser, $mysql_solarPass, $mysql_solarDB);
|
||||
if ($mysql->connect_errno) {
|
||||
http_response_code(500);
|
||||
echo json_encode(["error" => "Datenbank nicht erreichbar."]);
|
||||
exit;
|
||||
}
|
||||
$mysql->set_charset("utf8mb4");
|
||||
|
||||
/*
|
||||
* Ladevorgänge aus den Einzelzeilen schneiden. Wird von mehreren Abschnitten
|
||||
* gebraucht, deshalb einmal hier.
|
||||
*
|
||||
* Eine Ladung ist eine ununterbrochene Folge von Zeilen mit chg_state =
|
||||
* 'CHARGING'. Getrennt wird durch eine Zeile mit einem anderen Zustand, nicht
|
||||
* durch die Zeit dazwischen: gatherSkodaData.py schreibt nur, wenn das
|
||||
* Fahrzeug etwas Neues meldet, und beim Laden an der Wallbox tut es das
|
||||
* mitunter nur einmal in der Stunde. Früher trennte eine Pause über 30
|
||||
* Minuten - eine Ladung von 51 auf 80 % am 13.09.2026 zerfiel dadurch in drei
|
||||
* Stücke mit 0, 4 und 0 Prozentpunkten. Hört das Fahrzeug auf zu laden,
|
||||
* steht das in der nächsten Zeile; die Zeit allein trennt nur noch, wenn sie
|
||||
* so lang ist, dass der Abruf offenbar nicht lief (LADUNG_LUECKE_S).
|
||||
*
|
||||
* Dazu gehören die beiden Zeilen, die die Ladung einrahmen. Die davor sagt,
|
||||
* mit welchem Ladestand und Zählerstand sie begann - die erste CHARGING-Zeile
|
||||
* kommt oft erst Minuten später und hat dann schon geladen. Die danach
|
||||
* (CONNECT_CABLE, READY_FOR_CHARGING, ...) trägt den erreichten Ladestand:
|
||||
* bei einer Ladung bis zum Ziel ist das die einzige Zeile mit 80 %.
|
||||
*
|
||||
* Die Energie kommt aus der Differenz der Wallbox-Zählerstände statt aus einer
|
||||
* Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde.
|
||||
* Ein Zählerstand 0 ist kein Stand, sondern ein ausgefallener Abruf der
|
||||
* Wallbox, und bleibt außen vor. Steht der Zähler still, wurde auswärts
|
||||
* geladen (Schnellladesäule); dann bleibt wh null und die Kapazitätsrechnung
|
||||
* lässt die Ladung aus.
|
||||
*/
|
||||
const LADUNG_LUECKE_S = 3 * 3600;
|
||||
|
||||
function sitzungenSQL($seit)
|
||||
{
|
||||
$luecke = LADUNG_LUECKE_S;
|
||||
return "
|
||||
SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende,
|
||||
MIN(soc_start) AS soc_von, MAX(soc) AS soc_bis,
|
||||
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc_start) AS SIGNED) AS zuwachs,
|
||||
MAX(chg_type) AS art, MAX(target_soc) AS ziel,
|
||||
ROUND(AVG(CASE WHEN laedt THEN chg_kw END), 2) AS kw_mittel,
|
||||
ROUND(MAX(CASE WHEN laedt THEN chg_kw END), 2) AS kw_spitze,
|
||||
COALESCE(MAX(NULLIF(wb_wh_total, 0)) - MIN(wh_start), 0) AS wh,
|
||||
/*
|
||||
* Fahrzeugseitige Energie nur, wenn sie lückenlos integriert werden
|
||||
* konnte - siehe kwh_teil. Sonst NULL: eine halbe Summe sähe aus
|
||||
* wie eine kleine Ladung und verfälschte die Kapazitätsrechnung.
|
||||
*/
|
||||
CASE WHEN SUM(zu_lang) = 0 THEN ROUND(SUM(kwh_teil), 3) END AS kwh_fahrzeug,
|
||||
ROUND(AVG(CASE WHEN laedt THEN pv_kw END), 2) AS pv_kw,
|
||||
COUNT(*) AS punkte
|
||||
FROM (
|
||||
SELECT b.*, SUM(b.neu) OVER (ORDER BY b.datetime) AS sitzung FROM (
|
||||
SELECT a.*,
|
||||
a.chg_state = 'CHARGING' AS laedt,
|
||||
(a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke) AS weiter,
|
||||
CASE WHEN a.chg_state = 'CHARGING'
|
||||
AND NOT (a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke)
|
||||
THEN 1 ELSE 0 END AS neu,
|
||||
-- Beginn aus der Zeile davor, wenn sie nah genug liegt.
|
||||
CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
|
||||
AND a.dt_vor <= $luecke
|
||||
THEN LEAST(a.soc, COALESCE(a.soc_vor, a.soc))
|
||||
ELSE a.soc END AS soc_start,
|
||||
CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
|
||||
AND a.dt_vor <= $luecke
|
||||
THEN COALESCE(NULLIF(a.wh_vor, 0), NULLIF(a.wb_wh_total, 0))
|
||||
ELSE NULLIF(a.wb_wh_total, 0) END AS wh_start,
|
||||
/*
|
||||
* chg_kw ist die Ladeleistung, die das Fahrzeug selbst meldet -
|
||||
* gemessen HINTER dem Ladegerät. Die API kennt keine
|
||||
* Kilowattstunden, deshalb wird über die Zeit integriert, je
|
||||
* Schritt mit dem Mittel aus vorigem und aktuellem Wert.
|
||||
*
|
||||
* Über mehr als 30 Minuten ohne Meldung wäre die Fläche unter
|
||||
* der Kurve erfunden: die Leistung kann in der Zeit von 4 auf
|
||||
* 10 kW gestiegen sein, ohne dass eine Zeile davon weiß.
|
||||
* zu_lang merkt sich das, und die Ladung bekommt dann keinen
|
||||
* fahrzeugseitigen Wert.
|
||||
*/
|
||||
CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor BETWEEN 1 AND $luecke
|
||||
THEN (COALESCE(a.chg_kw, 0) + COALESCE(a.kw_vor, 0)) / 2
|
||||
* a.dt_vor / 3600
|
||||
ELSE 0 END AS kwh_teil,
|
||||
CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor > 1800
|
||||
THEN 1 ELSE 0 END AS zu_lang
|
||||
FROM (
|
||||
SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
|
||||
wb_wh_total, pv_kw,
|
||||
LAG(chg_state) OVER w AS st_vor,
|
||||
TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) AS dt_vor,
|
||||
LAG(soc) OVER w AS soc_vor,
|
||||
LAG(wb_wh_total) OVER w AS wh_vor,
|
||||
LAG(chg_kw) OVER w AS kw_vor
|
||||
FROM skoda WHERE datetime >= $seit
|
||||
WINDOW w AS (ORDER BY datetime)) a) b) t
|
||||
-- Die Ladezeilen und die eine Zeile, mit der die Ladung endet.
|
||||
WHERE laedt OR weiter
|
||||
GROUP BY sitzung";
|
||||
}
|
||||
|
||||
/*
|
||||
* Rückfallwert für den Ladewirkungsgrad, solange er sich nicht messen lässt.
|
||||
* Beim Laden mit Wechselstrom gehen im Ladegerät des Fahrzeugs grob zehn
|
||||
* Prozent verloren.
|
||||
*/
|
||||
const LADE_WIRKUNGSGRAD_ANNAHME = 0.90;
|
||||
|
||||
/** Ab so vielen Ladungen gilt der gemessene Wirkungsgrad als belastbar. */
|
||||
const WIRKUNGSGRAD_MIN_LADUNGEN = 5;
|
||||
|
||||
/**
|
||||
* Wie viele Kilowattstunden ein Ladestandsprozent wert ist.
|
||||
*
|
||||
* Zwei Energien je Ladung stehen zur Verfügung, und der Unterschied zwischen
|
||||
* ihnen ist genau das, was das Ladegerät verheizt:
|
||||
*
|
||||
* wh Differenz der Wallbox-Zählerstände - vor dem Ladegerät
|
||||
* kwh_fahrzeug chg_kw über die Zeit integriert - hinter dem Ladegerät
|
||||
*
|
||||
* Für den Fahrverbrauch zählt, was die Batterie verlässt, also die zweite.
|
||||
* Sie wird bevorzugt; erst wenn zu wenige Ladungen sie hergeben, wird der
|
||||
* Zählerwert mit dem angenommenen Wirkungsgrad heruntergerechnet.
|
||||
*
|
||||
* Der Median statt des Mittels, weil einzelne Ladungen durch die Rundung des
|
||||
* Ladestands weit ausreissen können.
|
||||
*
|
||||
* Gibt null zurück, solange es zu wenige Ladungen gibt. Die Oberfläche zeigt
|
||||
* dann keine Kilowattstunden an, statt eine erfundene Zahl.
|
||||
*/
|
||||
function kapazitaetSchaetzen($mysql)
|
||||
{
|
||||
$sql = "SELECT ROUND(wh/1000/zuwachs*100, 3) AS je100_zaehler,
|
||||
CASE WHEN kwh_fahrzeug > 0
|
||||
THEN ROUND(kwh_fahrzeug/zuwachs*100, 3) END AS je100_fahrzeug
|
||||
FROM (" . sitzungenSQL("NOW() - INTERVAL 365 DAY") . ") v
|
||||
WHERE zuwachs >= 20 AND wh > 1000";
|
||||
$res = $mysql->query($sql);
|
||||
if (!$res) {
|
||||
return null;
|
||||
}
|
||||
$zaehler = [];
|
||||
$fahrzeug = [];
|
||||
$paare = []; // beide Werte derselben Ladung, für den Wirkungsgrad
|
||||
while ($z = $res->fetch_assoc()) {
|
||||
$zaehler[] = floatval($z["je100_zaehler"]);
|
||||
if ($z["je100_fahrzeug"] !== null) {
|
||||
$fahrzeug[] = floatval($z["je100_fahrzeug"]);
|
||||
$paare[] = floatval($z["je100_fahrzeug"]) / floatval($z["je100_zaehler"]);
|
||||
}
|
||||
}
|
||||
if (count($zaehler) < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$gemessen = count($fahrzeug) >= WIRKUNGSGRAD_MIN_LADUNGEN;
|
||||
if ($gemessen) {
|
||||
$netto = median($fahrzeug);
|
||||
$grad = median($paare);
|
||||
} else {
|
||||
$netto = median($zaehler) * LADE_WIRKUNGSGRAD_ANNAHME;
|
||||
$grad = LADE_WIRKUNGSGRAD_ANNAHME;
|
||||
}
|
||||
|
||||
return [
|
||||
"je100_zaehler" => round(median($zaehler), 2),
|
||||
"je100_netto" => round($netto, 2),
|
||||
"wirkungsgrad" => round($grad, 3),
|
||||
"gemessen" => $gemessen,
|
||||
"je_soc_netto" => round($netto / 100, 4),
|
||||
"n" => count($zaehler),
|
||||
"n_fahrzeug" => count($fahrzeug),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verlauf einer Ladung: Wallbox-Leistung dicht, Ladestand dazwischen gefüllt.
|
||||
*
|
||||
* Das Fahrzeug meldet beim Laden an der Wallbox mitunter nur einmal in der
|
||||
* Stunde, die Wallbox steht in skoda_ladepunkte im Minutentakt (siehe
|
||||
* solarLog_skoda_ladepunkte.sql). Der Ladestand zwischen zwei Meldungen wird
|
||||
* nach der geflossenen Energie aufgefüllt, nicht nach der Zeit:
|
||||
*
|
||||
* soc(t) = soc_a + (soc_b - soc_a) * (wh(t) - wh_a) / (wh_b - wh_a)
|
||||
*
|
||||
* Das braucht weder Akkukapazität noch Wirkungsgrad. Gegengeprüft am
|
||||
* 13.09.2026: zwischen 56 % (09:59) und 70 % (11:22) ergibt es für 10:29
|
||||
* 59,6 % - gemeldet waren 60 %. Nach der Zeit wären es 61,1 % gewesen.
|
||||
*
|
||||
* Eine Meldung gilt für den Zeitpunkt, zu dem das Fahrzeug sie erfasst hat
|
||||
* (cap_chg), nicht für den Abruf - dazwischen können Minuten mit 11 kW
|
||||
* liegen. Der Zählerstand zu diesem Zeitpunkt kommt deshalb aus dem
|
||||
* Wallbox-Verlauf selbst und nicht aus der Zeile.
|
||||
*
|
||||
* Vor der ersten und nach der letzten Meldung bleibt der Ladestand leer:
|
||||
* hochgerechnet wird nichts.
|
||||
*
|
||||
* Liefert [verlauf, meldungen, wallbox]; ohne Punkte in skoda_ladepunkte
|
||||
* zwei leere Listen - dann zeichnet die Seite wie früher aus den Zeilen.
|
||||
*/
|
||||
function ladeverlauf($mysql, $von, $bis)
|
||||
{
|
||||
// Welche Wallbox: die, die im Zeitraum Energie abgegeben hat.
|
||||
$stmt = $mysql->prepare(
|
||||
"SELECT wallbox, MAX(wh_total) - MIN(wh_total) AS wh, COUNT(*) AS n
|
||||
FROM skoda_ladepunkte
|
||||
WHERE datetime BETWEEN (? - INTERVAL 30 MINUTE) AND (? + INTERVAL 30 MINUTE)
|
||||
GROUP BY wallbox ORDER BY wh DESC, n DESC LIMIT 1");
|
||||
if (!$stmt) {
|
||||
return [[], [], null]; // Tabelle fehlt noch
|
||||
}
|
||||
$stmt->bind_param("ss", $von, $bis);
|
||||
$stmt->execute();
|
||||
$wahl = $stmt->get_result()->fetch_assoc();
|
||||
if (!$wahl) {
|
||||
return [[], [], null];
|
||||
}
|
||||
$wallbox = $wahl["wallbox"];
|
||||
$spalte = $wallbox === "og" ? "wbog_wh_total" : "wb_wh_total";
|
||||
|
||||
$stmt = $mysql->prepare(
|
||||
"SELECT datetime, kw, wh_total, quelle FROM skoda_ladepunkte
|
||||
WHERE wallbox = ? AND datetime BETWEEN (? - INTERVAL 30 MINUTE) AND (? + INTERVAL 30 MINUTE)
|
||||
ORDER BY datetime");
|
||||
$stmt->bind_param("sss", $wallbox, $von, $bis);
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
$verlauf = [];
|
||||
while ($z = $res->fetch_assoc()) {
|
||||
$verlauf[] = [
|
||||
"datetime" => $z["datetime"],
|
||||
"t" => strtotime($z["datetime"]),
|
||||
"kw" => floatval($z["kw"]),
|
||||
"wh" => $z["wh_total"] === null ? null : intval($z["wh_total"]),
|
||||
"quelle" => $z["quelle"],
|
||||
];
|
||||
}
|
||||
if (!$verlauf) {
|
||||
return [[], [], null];
|
||||
}
|
||||
|
||||
// Zählerstand zu einem beliebigen Zeitpunkt, aus dem Verlauf. Vor dem
|
||||
// ersten Punkt ruhte die Wallbox - dann gilt der Stand, den die Zeile
|
||||
// selbst trägt.
|
||||
$zaehlerBei = function ($t, $ersatz) use ($verlauf) {
|
||||
$vor = null;
|
||||
foreach ($verlauf as $p) {
|
||||
if ($p["wh"] === null) {
|
||||
continue;
|
||||
}
|
||||
if ($p["t"] >= $t) {
|
||||
if ($vor === null) {
|
||||
return $ersatz ?: $p["wh"];
|
||||
}
|
||||
$spanne = $p["t"] - $vor["t"];
|
||||
return $spanne > 0
|
||||
? $vor["wh"] + ($p["wh"] - $vor["wh"]) * ($t - $vor["t"]) / $spanne
|
||||
: $p["wh"];
|
||||
}
|
||||
$vor = $p;
|
||||
}
|
||||
return $vor ? $vor["wh"] : $ersatz;
|
||||
};
|
||||
|
||||
// Die Meldungen des Fahrzeugs, die den Verlauf einrahmen: die letzte vor
|
||||
// dem ersten Punkt, alle dazwischen, die erste nach dem letzten.
|
||||
$erster = $verlauf[0]["datetime"];
|
||||
$letzter = end($verlauf)["datetime"];
|
||||
$stmt = $mysql->prepare(
|
||||
"(SELECT COALESCE(cap_chg, datetime) AS erfasst, soc, chg_kw, chg_state, $spalte AS wh
|
||||
FROM skoda WHERE COALESCE(cap_chg, datetime) <= ? AND soc IS NOT NULL
|
||||
ORDER BY erfasst DESC LIMIT 1)
|
||||
UNION
|
||||
(SELECT COALESCE(cap_chg, datetime), soc, chg_kw, chg_state, $spalte
|
||||
FROM skoda WHERE COALESCE(cap_chg, datetime) > ? AND COALESCE(cap_chg, datetime) < ?
|
||||
AND soc IS NOT NULL)
|
||||
UNION
|
||||
(SELECT COALESCE(cap_chg, datetime), soc, chg_kw, chg_state, $spalte
|
||||
FROM skoda WHERE COALESCE(cap_chg, datetime) >= ? AND soc IS NOT NULL
|
||||
ORDER BY 1 LIMIT 1)
|
||||
ORDER BY erfasst");
|
||||
$stmt->bind_param("ssss", $erster, $erster, $letzter, $letzter);
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
$meldungen = [];
|
||||
while ($z = $res->fetch_assoc()) {
|
||||
$t = strtotime($z["erfasst"]);
|
||||
$meldungen[] = [
|
||||
"datetime" => $z["erfasst"],
|
||||
"t" => $t,
|
||||
"soc" => intval($z["soc"]),
|
||||
"chg_kw" => $z["chg_kw"] === null ? null : floatval($z["chg_kw"]),
|
||||
"chg_state"=> $z["chg_state"],
|
||||
"wh" => $zaehlerBei($t, intval($z["wh"])),
|
||||
];
|
||||
}
|
||||
|
||||
// Ladestand je Punkt, zwischen den beiden Meldungen, die ihn einrahmen.
|
||||
foreach ($verlauf as &$p) {
|
||||
$p["soc"] = null;
|
||||
for ($i = 1; $p["wh"] !== null && $i < count($meldungen); $i++) {
|
||||
$a = $meldungen[$i - 1];
|
||||
$b = $meldungen[$i];
|
||||
if ($p["t"] < $a["t"] || $p["t"] > $b["t"]) {
|
||||
continue;
|
||||
}
|
||||
$spanne = $b["wh"] - $a["wh"];
|
||||
$p["soc"] = $spanne > 0
|
||||
? round($a["soc"] + ($b["soc"] - $a["soc"]) * ($p["wh"] - $a["wh"]) / $spanne, 1)
|
||||
: floatval($a["soc"]);
|
||||
break;
|
||||
}
|
||||
unset($p["t"]);
|
||||
}
|
||||
unset($p);
|
||||
foreach ($meldungen as &$m) {
|
||||
unset($m["t"], $m["wh"]);
|
||||
}
|
||||
unset($m);
|
||||
|
||||
return [$verlauf, $meldungen, $wallbox];
|
||||
}
|
||||
|
||||
/** Median eines nicht leeren Zahlenfeldes. */
|
||||
function median($werte)
|
||||
{
|
||||
sort($werte);
|
||||
return $werte[intdiv(count($werte), 2)];
|
||||
}
|
||||
|
||||
$antwort = [];
|
||||
|
||||
switch ($was) {
|
||||
|
||||
/* ------------------------------------------------------------------ live -- */
|
||||
case "live":
|
||||
$res = $mysql->query("SELECT * FROM skoda ORDER BY id DESC LIMIT 1");
|
||||
$zeile = $res ? $res->fetch_assoc() : null;
|
||||
$antwort = ["stand" => $zeile];
|
||||
|
||||
// Wie lange schweigt das Fahrzeug schon? Sagt mehr als der Zeitstempel
|
||||
// allein, weil die Seite auch aus dem Zwischenspeicher kommen kann.
|
||||
if ($zeile) {
|
||||
$res = $mysql->query("SELECT TIMESTAMPDIFF(SECOND, '"
|
||||
. $mysql->real_escape_string($zeile["datetime"]) . "', NOW()) AS alter_s");
|
||||
$r = $res ? $res->fetch_assoc() : null;
|
||||
$antwort["alter_s"] = $r ? intval($r["alter_s"]) : null;
|
||||
}
|
||||
|
||||
// Kennzahlen über den gesamten Bestand, für die Kopfzeile.
|
||||
$res = $mysql->query("SELECT COUNT(*) AS zeilen, MIN(datetime) AS seit,
|
||||
MAX(odo_km) AS km FROM skoda");
|
||||
$antwort["bestand"] = $res ? $res->fetch_assoc() : null;
|
||||
break;
|
||||
|
||||
/* -------------------------------------------------------------- ladungen -- */
|
||||
case "ladungen":
|
||||
$sql = "SELECT * FROM (" . sitzungenSQL("NOW() - INTERVAL $tage DAY") . ") v
|
||||
ORDER BY beginn DESC LIMIT 200";
|
||||
$res = $mysql->query($sql);
|
||||
$antwort["ladungen"] = [];
|
||||
while ($res && $z = $res->fetch_assoc()) {
|
||||
$antwort["ladungen"][] = $z;
|
||||
}
|
||||
break;
|
||||
|
||||
/* ----------------------------------------------------------------- kurve -- */
|
||||
case "kurve":
|
||||
// Verlauf einer Ladung. Ohne Parameter die jüngste.
|
||||
$von = isset($_GET["von"]) ? $_GET["von"] : null;
|
||||
$bis = isset($_GET["bis"]) ? $_GET["bis"] : null;
|
||||
if (!$von || !$bis) {
|
||||
$sql = "SELECT beginn, ende FROM (" . sitzungenSQL("NOW() - INTERVAL 365 DAY") . ") v
|
||||
ORDER BY beginn DESC LIMIT 1";
|
||||
$res = $mysql->query($sql);
|
||||
$z = $res ? $res->fetch_assoc() : null;
|
||||
if (!$z) { $antwort["punkte"] = []; break; }
|
||||
$von = $z["beginn"];
|
||||
$bis = $z["ende"];
|
||||
}
|
||||
// Eine Zeile Vorlauf und Nachlauf, damit die Kurve nicht in der Luft endet.
|
||||
$stmt = $mysql->prepare(
|
||||
"SELECT datetime, soc, chg_kw, wb_kw, pv_kw, chg_state, wb_wh_total
|
||||
FROM skoda
|
||||
WHERE datetime BETWEEN (? - INTERVAL 15 MINUTE) AND (? + INTERVAL 15 MINUTE)
|
||||
ORDER BY datetime");
|
||||
$stmt->bind_param("ss", $von, $bis);
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
$antwort["punkte"] = [];
|
||||
while ($z = $res->fetch_assoc()) { $antwort["punkte"][] = $z; }
|
||||
$antwort["von"] = $von;
|
||||
$antwort["bis"] = $bis;
|
||||
|
||||
list($antwort["verlauf"], $antwort["meldungen"], $antwort["wallbox"])
|
||||
= ladeverlauf($mysql, $von, $bis);
|
||||
break;
|
||||
|
||||
/* ------------------------------------------------------------ gesundheit -- */
|
||||
case "gesundheit":
|
||||
/*
|
||||
* Kilowattstunden je hundert Ladestandsprozent. Der Wert liegt über der
|
||||
* nutzbaren Kapazität, weil die Ladeverluste mitgezählt werden - absolut
|
||||
* also nur eine Näherung, sein Verlauf über die Jahre dagegen die
|
||||
* eigentliche Aussage.
|
||||
*
|
||||
* Nur lange Ladungen an der eigenen Wallbox taugen dazu: bei zwanzig
|
||||
* Prozentpunkten Zuwachs wiegt ein gerundeter Ladestandspunkt fünf
|
||||
* Prozent, und eine Schnellladung unterwegs hat auf unserer Seite keinen
|
||||
* Zähler.
|
||||
*/
|
||||
$sql = "SELECT beginn, soc_von, soc_bis, zuwachs, art, wh,
|
||||
ROUND(wh/1000/zuwachs*100, 2) AS je100
|
||||
FROM (" . sitzungenSQL("NOW() - INTERVAL $tage DAY") . ") v
|
||||
WHERE zuwachs >= 20 AND wh > 1000
|
||||
ORDER BY beginn";
|
||||
$res = $mysql->query($sql);
|
||||
$antwort["ladungen"] = [];
|
||||
while ($res && $z = $res->fetch_assoc()) { $antwort["ladungen"][] = $z; }
|
||||
|
||||
// Ausgleichsgerade über die Einzelwerte, x in Jahren. Die Steigung sagt,
|
||||
// ob der Wert wandert; ihr Fehler, ab welcher Alterung man überhaupt
|
||||
// etwas sehen könnte.
|
||||
$n = count($antwort["ladungen"]);
|
||||
$antwort["trend"] = null;
|
||||
if ($n >= 6) {
|
||||
$t0 = strtotime($antwort["ladungen"][0]["beginn"]);
|
||||
$xs = []; $ys = [];
|
||||
foreach ($antwort["ladungen"] as $l) {
|
||||
$xs[] = (strtotime($l["beginn"]) - $t0) / (365.25 * 86400);
|
||||
$ys[] = floatval($l["je100"]);
|
||||
}
|
||||
$mx = array_sum($xs) / $n;
|
||||
$my = array_sum($ys) / $n;
|
||||
$sxx = 0; $sxy = 0;
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$sxx += ($xs[$i] - $mx) ** 2;
|
||||
$sxy += ($xs[$i] - $mx) * ($ys[$i] - $my);
|
||||
}
|
||||
if ($sxx > 0) {
|
||||
$steigung = $sxy / $sxx;
|
||||
$achse = $my - $steigung * $mx;
|
||||
$rest = 0;
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$rest += ($ys[$i] - ($achse + $steigung * $xs[$i])) ** 2;
|
||||
}
|
||||
$streu = $n > 2 ? sqrt($rest / ($n - 2)) : 0;
|
||||
$fehler = $streu / sqrt($sxx);
|
||||
$antwort["trend"] = [
|
||||
"steigung" => round($steigung, 3),
|
||||
"fehler" => round($fehler, 3),
|
||||
"achse" => round($achse, 2),
|
||||
"prozent" => $achse != 0 ? round(100 * $steigung / $achse, 2) : null,
|
||||
"nachweisbar" => $achse != 0 ? round(100 * 2 * $fehler / $achse, 1) : null,
|
||||
"spanne_jahre" => round(max($xs), 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Median je Jahr - der Stand, um den die Wolke streut.
|
||||
$jahre = [];
|
||||
foreach ($antwort["ladungen"] as $l) {
|
||||
$j = substr($l["beginn"], 0, 4);
|
||||
$jahre[$j][] = floatval($l["je100"]);
|
||||
}
|
||||
$antwort["jahre"] = [];
|
||||
foreach ($jahre as $j => $werte) {
|
||||
sort($werte);
|
||||
$k = count($werte);
|
||||
$antwort["jahre"][] = [
|
||||
"jahr" => $j,
|
||||
"n" => $k,
|
||||
"q1" => round($werte[intdiv($k, 4)], 2),
|
||||
"median" => round($werte[intdiv($k, 2)], 2),
|
||||
"q3" => round($werte[intdiv(3 * $k, 4)], 2),
|
||||
];
|
||||
}
|
||||
break;
|
||||
|
||||
/* --------------------------------------------------------------- fahrten -- */
|
||||
case "fahrten":
|
||||
/*
|
||||
* Eine Fahrt liegt zwischen zwei Parkständen. Die API liefert nur die
|
||||
* letzte bekannte PARKposition - fährt das Fahrzeug, kommt allein
|
||||
* IN_MOTION ohne Koordinaten. Start und Ziel sind damit bekannt, die
|
||||
* Strecke dazwischen nicht.
|
||||
*
|
||||
* CAST(... AS SIGNED) ist nötig: soc und odo_km sind vorzeichenlos, und
|
||||
* eine Differenz unter null bricht die Abfrage sonst ab - genau das
|
||||
* passiert, sobald zwischen zwei Ständen geladen wurde.
|
||||
*/
|
||||
$sql = "SELECT * FROM (
|
||||
SELECT datetime AS ankunft, address AS nach, lat, lon, odo_km, soc,
|
||||
LAG(datetime) OVER w AS abfahrt,
|
||||
LAG(address) OVER w AS von,
|
||||
LAG(lat) OVER w AS von_lat,
|
||||
LAG(lon) OVER w AS von_lon,
|
||||
CAST(odo_km AS SIGNED) - LAG(CAST(odo_km AS SIGNED)) OVER w AS km,
|
||||
LAG(CAST(soc AS SIGNED)) OVER w - CAST(soc AS SIGNED) AS soc_ab
|
||||
FROM skoda
|
||||
WHERE park_state = 'PARKED' AND odo_km IS NOT NULL
|
||||
AND datetime >= NOW() - INTERVAL $tage DAY
|
||||
WINDOW w AS (ORDER BY datetime)) f
|
||||
WHERE km > 0
|
||||
ORDER BY ankunft DESC LIMIT 100";
|
||||
$res = $mysql->query($sql);
|
||||
$antwort["fahrten"] = [];
|
||||
while ($res && $z = $res->fetch_assoc()) { $antwort["fahrten"][] = $z; }
|
||||
|
||||
// Aktueller Standort, für die Karte.
|
||||
$res = $mysql->query("SELECT datetime, lat, lon, address, park_state
|
||||
FROM skoda WHERE lat IS NOT NULL
|
||||
ORDER BY id DESC LIMIT 1");
|
||||
$antwort["standort"] = $res ? $res->fetch_assoc() : null;
|
||||
|
||||
$antwort["kapazitaet"] = kapazitaetSchaetzen($mysql);
|
||||
break;
|
||||
|
||||
/* --------------------------------------------------------------- strecke -- */
|
||||
case "strecke":
|
||||
/*
|
||||
* Gefahrene Kilometer über mehrere Zeiträume, gerechnet aus dem
|
||||
* Kilometerstand: der Zähler läuft nur vorwärts, also ist die Differenz
|
||||
* zwischen dem ältesten Stand im Zeitraum und dem jüngsten überhaupt die
|
||||
* gefahrene Strecke. Das ist belastbarer als eine Summe über die
|
||||
* erkannten Fahrten - fällt eine Zeile zwischendurch aus, fehlt die
|
||||
* Fahrt in der Liste, im Zählerstand aber nicht.
|
||||
*
|
||||
* CAST(... AS SIGNED) ist nötig: odo_km ist vorzeichenlos, und eine
|
||||
* Differenz unter null bräche die Abfrage ab.
|
||||
*/
|
||||
$zeitraeume = ["woche" => 7, "monat" => 30, "jahr" => 365];
|
||||
|
||||
$spalten = [];
|
||||
foreach ($zeitraeume as $name => $spanne) {
|
||||
$spalten[] = "MIN(CASE WHEN datetime >= NOW() - INTERVAL $spanne DAY
|
||||
THEN CAST(odo_km AS SIGNED) END) AS von_$name";
|
||||
}
|
||||
$sql = "SELECT MAX(CAST(odo_km AS SIGNED)) AS jetzt, MIN(datetime) AS seit, "
|
||||
. implode(", ", $spalten) . " FROM skoda WHERE odo_km IS NOT NULL";
|
||||
$res = $mysql->query($sql);
|
||||
$z = $res ? $res->fetch_assoc() : null;
|
||||
|
||||
$jetzt = ($z && $z["jetzt"] !== null) ? intval($z["jetzt"]) : null;
|
||||
$antwort["odo"] = $jetzt;
|
||||
$antwort["seit"] = $z ? $z["seit"] : null;
|
||||
$antwort["strecke"] = [];
|
||||
|
||||
foreach ($zeitraeume as $name => $spanne) {
|
||||
$von = ($z && $z["von_$name"] !== null) ? intval($z["von_$name"]) : null;
|
||||
$antwort["strecke"][$name] = [
|
||||
"tage" => $spanne,
|
||||
"km" => ($von === null || $jetzt === null) ? null : $jetzt - $von,
|
||||
/*
|
||||
* Reicht die Aufzeichnung überhaupt so weit zurück? Ein Jahreswert
|
||||
* aus drei Monaten Daten ist kein Jahreswert - die Anzeige muss
|
||||
* das sagen dürfen, statt eine zu kleine Zahl als Jahr auszugeben.
|
||||
*/
|
||||
"vollstaendig" => ($z && $z["seit"] !== null)
|
||||
&& strtotime($z["seit"]) <= time() - $spanne * 86400
|
||||
];
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(400);
|
||||
$antwort = ["error" => "Unbekannter Abschnitt."];
|
||||
}
|
||||
|
||||
echo json_encode($antwort, JSON_UNESCAPED_UNICODE);
|
||||
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
/*
|
||||
* Befehle an das Fahrzeug.
|
||||
*
|
||||
* Aufruf per POST: befehl=laden_start | laden_stop | klima_start | klima_stop
|
||||
* | lueftung_start | lueftung_stop | ladeziel
|
||||
* temperatur=<Grad Celsius> (nur bei klima_start)
|
||||
* prozent=50..100 in Zehnern (nur bei ladeziel)
|
||||
*
|
||||
* Warum über den Server und nicht unmittelbar aus dem Browser? Der API-
|
||||
* Schlüssel darf die Seite nicht verlassen. Er steht in skoda.conf neben
|
||||
* gatherSkodaData.py, wird hier gelesen und geht nur von hier nach draußen.
|
||||
*
|
||||
* Nur POST. Ein Befehl, der sich über einen Link auslösen liesse, wäre über
|
||||
* ein vorausgeladenes Bild oder einen Klick auf eine fremde Seite fernsteuerbar.
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* Ein Kontingent für alles
|
||||
*
|
||||
* Die API begrenzt auf 20 Anfragen je Stunde, und Befehle zählen genauso mit
|
||||
* wie Abrufe. Die Doku schreibt "je Schlüssel" - gemessen stimmt das nicht,
|
||||
* der Zähler gilt für alle Schlüssel des Kontos zusammen (die Rechnung steht
|
||||
* bei _LIMIT in gatherSkodaData.py). Mehrere Schlüssel bringen also kein
|
||||
* zusätzliches Kontingent.
|
||||
*
|
||||
* Abruf und Steuerung laufen in getrennten Prozessen ohne gemeinsamen Zähler,
|
||||
* deshalb ist fest aufgeteilt:
|
||||
*
|
||||
* Abruf 14 / Stunde (gatherSkodaData.py)
|
||||
* Befehle 4 / Stunde
|
||||
* Reserve 2 / Stunde
|
||||
*
|
||||
* Ein eigener CMD_API_KEY trennt damit nicht die Kontingente, sondern nur,
|
||||
* wer womit fragt - er lässt sich einzeln widerrufen, ohne den Abruf
|
||||
* mitzunehmen. Welchen dieses Skript nimmt, entscheidet
|
||||
* skodaBefehlsSchluessel(): den eigenen, wenn es ihn gibt, sonst API_KEY.
|
||||
* ---------------------------------------------------------------------------
|
||||
*
|
||||
* Nicht angebunden ist die Standheizung. Ihr Start verlangt laut Spec die
|
||||
* Sicherheits-PIN des Fahrzeugs ("spin"). Die dauerhaft auf dem NAS zu
|
||||
* hinterlegen, nur damit ein Knopf im Dashboard funktioniert, ist eine andere
|
||||
* Größenordnung als ein API-Schlüssel - das sollte bewusst entschieden werden,
|
||||
* nicht nebenbei.
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
// Wo skoda.conf liegt, wie sie aussieht, welcher Schlüssel die Befehle
|
||||
// schickt und wie viele je Stunde erlaubt sind: alles dort, damit der Reiter
|
||||
// in den Einstellungen und dieser Endpunkt dieselbe Wahrheit benutzen.
|
||||
require_once("../restricted/skodaKeys.php");
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
/*
|
||||
* Die erlaubten Befehle. Der Schlüssel kommt aus dem Formular, der Pfad
|
||||
* niemals - so kann über den Parameter kein beliebiger Endpunkt der API
|
||||
* angesprochen werden.
|
||||
*/
|
||||
const BEFEHLE = [
|
||||
"laden_start" => ["pfad" => "charging/start", "text" => "Laden gestartet"],
|
||||
"laden_stop" => ["pfad" => "charging/stop", "text" => "Laden gestoppt"],
|
||||
"klima_start" => ["pfad" => "air-conditioning/start", "text" => "Klimatisierung gestartet"],
|
||||
"klima_stop" => ["pfad" => "air-conditioning/stop", "text" => "Klimatisierung gestoppt"],
|
||||
"lueftung_start" => ["pfad" => "active-ventilation/start", "text" => "Lüftung gestartet"],
|
||||
"lueftung_stop" => ["pfad" => "active-ventilation/stop", "text" => "Lüftung gestoppt"],
|
||||
// Die API nimmt das Ladeziel per PUT, alle anderen Befehle per POST.
|
||||
"ladeziel" => ["pfad" => "charging/limit", "methode" => "PUT", "text" => "Ladeziel gesetzt"],
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ Zugang */
|
||||
|
||||
if (!checkLogin()) {
|
||||
antwort(403, false, "Nicht angemeldet.");
|
||||
}
|
||||
if ($_SERVER["REQUEST_METHOD"] !== "POST") {
|
||||
antwort(405, false, "Nur per POST.");
|
||||
}
|
||||
|
||||
$name = isset($_POST["befehl"]) ? $_POST["befehl"] : "";
|
||||
if (!isset(BEFEHLE[$name])) {
|
||||
antwort(400, false, "Unbekannter Befehl.");
|
||||
}
|
||||
$befehl = BEFEHLE[$name];
|
||||
|
||||
// Das Ladeziel vor dem Kontingent pruefen: eine falsche Zahl soll keinen der
|
||||
// vier Befehle je Stunde kosten. Laut Spec nehmen die Fahrzeuge
|
||||
// "typischerweise" 50 bis 100 in Zehnern und lehnen alles andere ab.
|
||||
$prozent = null;
|
||||
if ($name === "ladeziel") {
|
||||
$prozent = isset($_POST["prozent"]) ? intval($_POST["prozent"]) : 0;
|
||||
if ($prozent < 50 || $prozent > 100 || $prozent % 10 !== 0) {
|
||||
antwort(400, false, "Das Ladeziel geht von 50 bis 100 Prozent in Zehnerschritten.");
|
||||
}
|
||||
}
|
||||
|
||||
$konfig = skodaBefehlsSchluessel();
|
||||
if (!$konfig) {
|
||||
antwort(503, false, "Kein API-Schlüssel hinterlegt - unter Einstellungen "
|
||||
. "▸ Fahrzeug lässt sich einer eintragen.");
|
||||
}
|
||||
|
||||
if (!budgetFrei(SKODA_BEFEHLE)) {
|
||||
antwort(429, false, "Stündliches Kontingent für Befehle erschöpft ("
|
||||
. SKODA_BEFEHLE . " je Stunde). Es teilt sich die 20 Anfragen der API"
|
||||
. " mit dem Abruf des Fahrzeugzustands. Später erneut versuchen.");
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Ausführen */
|
||||
|
||||
$rumpf = null;
|
||||
$text = $befehl["text"];
|
||||
if ($name === "ladeziel") {
|
||||
$rumpf = ["targetStateOfChargeInPercent" => $prozent];
|
||||
$text = "Ladeziel " . $prozent . " % gesetzt";
|
||||
}
|
||||
if ($name === "klima_start") {
|
||||
// Die Zieltemperatur kommt aus dem Formular und geht in einen Rumpf, den
|
||||
// die API auswertet - deshalb hart begrenzen statt nur zu vertrauen.
|
||||
$grad = isset($_POST["temperatur"]) ? floatval($_POST["temperatur"]) : 21.0;
|
||||
$grad = max(16.0, min(29.5, round($grad * 2) / 2)); // halbe Grad
|
||||
$rumpf = ["targetTemperature" => ["value" => $grad, "unit" => "CELSIUS"]];
|
||||
}
|
||||
|
||||
list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf, $befehl["methode"] ?? "POST");
|
||||
|
||||
if ($status === 202) {
|
||||
skodaAbrufAnstossen();
|
||||
antwort(200, true, $text . ". Das Fahrzeug führt den Befehl aus; "
|
||||
. "in der Anzeige erscheint es in einer halben Minute.");
|
||||
}
|
||||
antwort(200, false, fehlertext($status, $inhalt));
|
||||
|
||||
/* ------------------------------------------------------------- Bausteine -- */
|
||||
|
||||
/** Antwort ausgeben und beenden. */
|
||||
function antwort($code, $ok, $text)
|
||||
{
|
||||
http_response_code($code);
|
||||
echo json_encode(["ok" => $ok, "text" => $text], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stundenzähler für Befehle. Gleiches Verfahren wie die Bremse in tile.php:
|
||||
* eine kleine Datei, gegen gleichzeitige Zugriffe mit flock abgesichert.
|
||||
*
|
||||
* Das Fenster ist fest: es beginnt mit dem ersten Befehl und läuft eine Stunde.
|
||||
* Die API zählt gleitend, hier stehen also im Grenzfall zwei Fenster
|
||||
* nebeneinander - garantiert sind die $grenze Befehle, mehr geht auf das Risiko
|
||||
* eines 429 von der API selbst.
|
||||
*/
|
||||
function budgetFrei($grenze)
|
||||
{
|
||||
$ort = null;
|
||||
foreach (SKODA_ARBEITSORTE as $kandidat) {
|
||||
if (is_dir($kandidat) && is_writable($kandidat)) {
|
||||
$ort = $kandidat;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$ort) {
|
||||
// Ohne Zähler lieber durchlassen als die Steuerung blockieren; die
|
||||
// API bremst zur Not selbst mit 429.
|
||||
return true;
|
||||
}
|
||||
|
||||
$griff = fopen($ort . "/" . SKODA_ZAEHLER_DATEI, "c+");
|
||||
if (!$griff || !flock($griff, LOCK_EX)) {
|
||||
return true;
|
||||
}
|
||||
$stand = json_decode(stream_get_contents($griff), true);
|
||||
if (!is_array($stand)) {
|
||||
$stand = ["fenster" => 0, "n" => 0];
|
||||
}
|
||||
$jetzt = time();
|
||||
if ($jetzt - $stand["fenster"] >= 3600) {
|
||||
$stand = ["fenster" => $jetzt, "n" => 0];
|
||||
}
|
||||
$frei = $stand["n"] < $grenze;
|
||||
if ($frei) {
|
||||
$stand["n"]++;
|
||||
}
|
||||
ftruncate($griff, 0);
|
||||
rewind($griff);
|
||||
fwrite($griff, json_encode($stand));
|
||||
flock($griff, LOCK_UN);
|
||||
fclose($griff);
|
||||
return $frei;
|
||||
}
|
||||
|
||||
/** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */
|
||||
function senden($konfig, $pfad, $rumpf, $methode = "POST")
|
||||
{
|
||||
$url = SKODA_API . rawurlencode($konfig["vin"]) . "/" . $pfad;
|
||||
$kopf = [
|
||||
"X-API-Key: " . $konfig["schluessel"],
|
||||
"Accept: application/json",
|
||||
];
|
||||
$ch = curl_init($url);
|
||||
$optionen = [
|
||||
CURLOPT_CUSTOMREQUEST => $methode,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_USERAGENT => SKODA_KENNUNG,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
];
|
||||
if ($rumpf !== null) {
|
||||
$kopf[] = "Content-Type: application/json";
|
||||
$optionen[CURLOPT_POSTFIELDS] = json_encode($rumpf);
|
||||
} else {
|
||||
$optionen[CURLOPT_POSTFIELDS] = "";
|
||||
}
|
||||
$optionen[CURLOPT_HTTPHEADER] = $kopf;
|
||||
curl_setopt_array($ch, $optionen);
|
||||
|
||||
$inhalt = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$fehler = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($inhalt === false) {
|
||||
error_log("skodaCmd: $url nicht erreichbar: $fehler");
|
||||
return [0, null];
|
||||
}
|
||||
return [$status, $inhalt];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aus dem Statuscode eine Auskunft machen, mit der man etwas anfangen kann.
|
||||
* Die API liefert dazu einen Problembericht nach RFC 9457; dessen "detail"
|
||||
* ist oft die eigentliche Erklärung.
|
||||
*/
|
||||
function fehlertext($status, $inhalt)
|
||||
{
|
||||
$detail = "";
|
||||
$bericht = json_decode((string)$inhalt, true);
|
||||
if (is_array($bericht) && !empty($bericht["detail"])) {
|
||||
$detail = " (" . $bericht["detail"] . ")";
|
||||
}
|
||||
|
||||
switch ($status) {
|
||||
case 0: return "Die Škoda-API ist nicht erreichbar.";
|
||||
case 400: return "Die API hat den Befehl abgelehnt." . $detail;
|
||||
case 401: return "Der API-Schlüssel ist abgelaufen - in der MyŠkoda-App "
|
||||
. "erneuern und unter Einstellungen ▸ Fahrzeug eintragen.";
|
||||
case 403: return "Der Schlüssel darf diesen Befehl nicht ausführen." . $detail;
|
||||
case 404: return "Fahrzeug nicht gefunden - stimmt die VIN in skoda.conf?";
|
||||
case 422: return "Das Fahrzeug kann das gerade nicht." . $detail;
|
||||
case 429: return "Kontingent der API erschöpft - später erneut versuchen.";
|
||||
case 503:
|
||||
case 504: return "Die API antwortet nicht rechtzeitig. Später erneut versuchen.";
|
||||
default: return "Unerwartete Antwort der API (HTTP " . $status . ")." . $detail;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
/*
|
||||
* Kachel-Zwischenspeicher für die Karte.
|
||||
*
|
||||
* Aufruf: tile.php?z=16&x=34567&y=22456
|
||||
*
|
||||
* Liegt die Kachel im Zwischenspeicher, kommt sie von dort. Fehlt sie, holt
|
||||
* das NAS sie einmal bei OpenStreetMap, legt sie ab und liefert aus. Danach
|
||||
* verlässt für diese Kachel nie wieder eine Anfrage das Haus.
|
||||
*
|
||||
* Warum überhaupt ein Zwischenspeicher und nicht der direkte Weg vom Browser
|
||||
* zu tile.openstreetmap.org? Drei Gründe:
|
||||
*
|
||||
* 1. Die Kachelrichtlinie der OSM Foundation verlangt Zwischenspeicherung
|
||||
* von mindestens sieben Tagen. Der direkte Weg erfüllt das nur, soweit
|
||||
* der Browser mitspielt.
|
||||
* 2. Ohne Zwischenspeicher ginge die Parkposition des Fahrzeugs - also die
|
||||
* Wohnadresse - bei jedem Seitenaufruf als Kachelanfrage nach draußen.
|
||||
* 3. Einmal geholte Gegenden bleiben ohne Internet benutzbar.
|
||||
*
|
||||
* Was die Richtlinie ausdrücklich verbietet, ist Vorratsspeicherung: Kacheln
|
||||
* auf Verdacht holen, Gebiete vorab abgrasen, Offline-Archive anlegen. Dieses
|
||||
* Skript holt deshalb ausschließlich das, was gerade jemand ansieht, und die
|
||||
* Bremse unten begrenzt zusätzlich, wie viel das pro Minute sein darf.
|
||||
*
|
||||
* Richtlinie: https://operations.osmfoundation.org/policies/tiles/
|
||||
*/
|
||||
|
||||
require_once("../helper.php");
|
||||
|
||||
/* ------------------------------------------------------------ Einstellungen */
|
||||
|
||||
/** Woher die Kacheln kommen. {z}/{x}/{y} werden ersetzt. */
|
||||
const KACHEL_QUELLE = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||
|
||||
/*
|
||||
* Die Richtlinie verlangt einen eigenen, kontaktierbaren User-Agent. Vorgaben
|
||||
* von Bibliotheken werden ohne Vorwarnung geblockt. Die Domain genügt als
|
||||
* Kontakt; wer lieber eine Mailadresse hinterlegt, ersetzt sie hier.
|
||||
*/
|
||||
const KENNUNG = "SmartController-SkodaKarte/1.0 (+https://nas.el-wa.org/smart)";
|
||||
|
||||
/*
|
||||
* Wo die Kacheln liegen.
|
||||
*
|
||||
* Bevorzugt neben der Seite unter tiles/ - dort bleiben sie über Neustarts
|
||||
* erhalten und liegen im Backup. PHP läuft auf dem NAS aber unter einem
|
||||
* eigenen Benutzer, und DSM vergibt im Web-Verzeichnis ACLs, die ihm das
|
||||
* Schreiben verwehren. Der POSIX-Modus täuscht: drwxrwxrwx mit einem "+"
|
||||
* dahinter heisst, dass die ACL entscheidet, nicht die Rechtebits.
|
||||
*
|
||||
* Damit die Karte auch ohne Eingriff funktioniert, wird ersatzweise das
|
||||
* Systemverzeichnis für flüchtige Dateien genommen. Das kostet nur den
|
||||
* Bestand: es wird bei Gelegenheit geleert, die Kacheln müssen dann erneut
|
||||
* geholt werden. Dauerhaft wird es mit einem Befehl als root:
|
||||
*
|
||||
* synoacltool -add /volume1/web/smart/tiles \
|
||||
* "user:http:allow:rwxpdDaARWc--:fd--"
|
||||
*
|
||||
* Welcher Ort gerade benutzt wird, sagt speicherOrt() im Fehlerprotokoll,
|
||||
* sobald zum ersten Mal eine Kachel geholt wird.
|
||||
*/
|
||||
const SPEICHER_ORTE = [
|
||||
__DIR__ . "/../tiles", // dauerhaft, bevorzugt
|
||||
"/var/services/tmp/smart-tiles", // flüchtig, aber immer beschreibbar
|
||||
];
|
||||
|
||||
/** Zoomstufen, die ausgeliefert werden. Feiner als Hausnummern braucht es nicht. */
|
||||
const ZOOM_MIN = 6;
|
||||
const ZOOM_MAX = 18;
|
||||
|
||||
/** Wie lange der Browser eine Kachel behalten darf (Sekunden). */
|
||||
const BROWSER_FRIST = 7 * 24 * 3600;
|
||||
|
||||
/**
|
||||
* Nach dieser Zeit wird eine Kachel beim nächsten Ansehen neu geholt. Die
|
||||
* Karte ändert sich, aber nicht schnell. Scheitert das Nachholen, bleibt die
|
||||
* alte Kachel - die Karte ist dann veraltet, aber nie kaputt.
|
||||
*/
|
||||
const AUFFRISCHEN_NACH = 60 * 24 * 3600;
|
||||
|
||||
/*
|
||||
* Bremse gegen den eigenen Fehler. Ein Kartenfeld dieser Größe braucht rund
|
||||
* zwölf Kacheln je Ansicht; wer sich durch mehrere Zoomstufen klickt, kommt
|
||||
* auf einige Dutzend. Alles weit darüber wäre eine Schleife im Frontend - und
|
||||
* die sähe für OSM wie Vorratsspeicherung aus. Ist die Grenze erreicht, wird
|
||||
* eine graue Ersatzkachel geliefert statt weiter nach draußen zu gehen.
|
||||
*/
|
||||
const HOLEN_JE_MINUTE = 150;
|
||||
const HOLEN_JE_STUNDE = 2000;
|
||||
|
||||
/* ------------------------------------------------------------------ Zugriff */
|
||||
|
||||
if (!checkLogin()) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- Prüfung */
|
||||
|
||||
$z = filter_input(INPUT_GET, "z", FILTER_VALIDATE_INT);
|
||||
$x = filter_input(INPUT_GET, "x", FILTER_VALIDATE_INT);
|
||||
$y = filter_input(INPUT_GET, "y", FILTER_VALIDATE_INT);
|
||||
|
||||
// Ohne diese Prüfung liesse sich über z/x/y aus dem Speicherverzeichnis
|
||||
// heraus schreiben - die Werte gehen schliesslich in einen Dateipfad.
|
||||
$grenze = ($z === false || $z === null) ? 0 : (1 << $z);
|
||||
if ($z === false || $z === null || $z < ZOOM_MIN || $z > ZOOM_MAX
|
||||
|| $x === false || $x === null || $x < 0 || $x >= $grenze
|
||||
|| $y === false || $y === null || $y < 0 || $y >= $grenze) {
|
||||
http_response_code(400);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pfad = speicherOrt() . "/$z/$x/$y.png";
|
||||
|
||||
/* -------------------------------------------------------------- Ausliefern */
|
||||
|
||||
$vorhanden = is_file($pfad);
|
||||
$veraltet = $vorhanden && (time() - filemtime($pfad) > AUFFRISCHEN_NACH);
|
||||
|
||||
if (!$vorhanden || $veraltet) {
|
||||
if (holeUndSpeichere($z, $x, $y, $pfad)) {
|
||||
$vorhanden = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($vorhanden) {
|
||||
liefereDatei($pfad);
|
||||
} else {
|
||||
liefereErsatzkachel();
|
||||
}
|
||||
exit;
|
||||
|
||||
/* ------------------------------------------------------------- Bausteine -- */
|
||||
|
||||
/**
|
||||
* Der erste beschreibbare Ort aus SPEICHER_ORTE, einmal je Aufruf ermittelt.
|
||||
* Existiert keiner, wird der letzte angelegt - der liegt im Systemverzeichnis
|
||||
* für flüchtige Dateien und ist immer beschreibbar.
|
||||
*/
|
||||
function speicherOrt()
|
||||
{
|
||||
static $ort = null;
|
||||
if ($ort !== null) {
|
||||
return $ort;
|
||||
}
|
||||
foreach (SPEICHER_ORTE as $kandidat) {
|
||||
if (is_dir($kandidat) && is_writable($kandidat)) {
|
||||
return $ort = $kandidat;
|
||||
}
|
||||
}
|
||||
$ort = SPEICHER_ORTE[count(SPEICHER_ORTE) - 1];
|
||||
if (!is_dir($ort)) {
|
||||
@mkdir($ort, 0755, true);
|
||||
}
|
||||
return $ort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Kachel bei der Quelle holen und ablegen.
|
||||
* Gibt true zurück, wenn danach eine brauchbare Datei vorliegt.
|
||||
*/
|
||||
function holeUndSpeichere($z, $x, $y, $pfad)
|
||||
{
|
||||
if (!bremseErlaubt()) {
|
||||
return is_file($pfad); // notfalls die veraltete Kachel
|
||||
}
|
||||
|
||||
$url = str_replace(["{z}", "{x}", "{y}"], [$z, $x, $y], KACHEL_QUELLE);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_USERAGENT => KENNUNG,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_CONNECTTIMEOUT => 4,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 2,
|
||||
]);
|
||||
$inhalt = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
// Auf den PNG-Kopf prüfen, statt nur auf den Statuscode: eine Fehlerseite
|
||||
// mit Status 200 wäre sonst als Kachel im Speicher gelandet und bliebe
|
||||
// dort für immer.
|
||||
if ($status !== 200 || $inhalt === false || strncmp($inhalt, "\x89PNG", 4) !== 0) {
|
||||
return is_file($pfad);
|
||||
}
|
||||
|
||||
if (!is_dir(dirname($pfad)) && !mkdir(dirname($pfad), 0755, true)) {
|
||||
return is_file($pfad);
|
||||
}
|
||||
|
||||
// Erst daneben schreiben, dann umbenennen. Sonst könnte ein zweiter
|
||||
// Aufruf eine halb geschriebene Datei ausliefern.
|
||||
$temp = $pfad . "." . getmypid() . ".teil";
|
||||
if (file_put_contents($temp, $inhalt) === false) {
|
||||
return is_file($pfad);
|
||||
}
|
||||
if (!rename($temp, $pfad)) {
|
||||
unlink($temp);
|
||||
return is_file($pfad);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf jetzt noch eine Kachel geholt werden?
|
||||
*
|
||||
* Gezählt wird in einer kleinen Datei, gegen gleichzeitige Zugriffe mit
|
||||
* flock abgesichert. Zwei Fenster: eines je Minute, eines je Stunde. Läuft
|
||||
* ein Fenster ab, fängt sein Zähler wieder bei null an.
|
||||
*/
|
||||
function bremseErlaubt()
|
||||
{
|
||||
$datei = speicherOrt() . "/.bremse.json";
|
||||
|
||||
$griff = fopen($datei, "c+");
|
||||
if (!$griff) {
|
||||
return true;
|
||||
}
|
||||
if (!flock($griff, LOCK_EX)) {
|
||||
fclose($griff);
|
||||
return true;
|
||||
}
|
||||
|
||||
$stand = json_decode(stream_get_contents($griff), true);
|
||||
if (!is_array($stand)) {
|
||||
$stand = ["minute" => 0, "min_n" => 0, "stunde" => 0, "std_n" => 0];
|
||||
}
|
||||
|
||||
$jetzt = time();
|
||||
if ($jetzt - $stand["minute"] >= 60) { $stand["minute"] = $jetzt; $stand["min_n"] = 0; }
|
||||
if ($jetzt - $stand["stunde"] >= 3600) { $stand["stunde"] = $jetzt; $stand["std_n"] = 0; }
|
||||
|
||||
$erlaubt = $stand["min_n"] < HOLEN_JE_MINUTE && $stand["std_n"] < HOLEN_JE_STUNDE;
|
||||
if ($erlaubt) {
|
||||
$stand["min_n"]++;
|
||||
$stand["std_n"]++;
|
||||
}
|
||||
|
||||
ftruncate($griff, 0);
|
||||
rewind($griff);
|
||||
fwrite($griff, json_encode($stand));
|
||||
flock($griff, LOCK_UN);
|
||||
fclose($griff);
|
||||
|
||||
if (!$erlaubt) {
|
||||
error_log("tile.php: Bremse greift, keine weiteren Kacheln in diesem Fenster.");
|
||||
}
|
||||
return $erlaubt;
|
||||
}
|
||||
|
||||
/** Eine gespeicherte Kachel ausliefern, mit Unterstützung für 304. */
|
||||
function liefereDatei($pfad)
|
||||
{
|
||||
$zeit = filemtime($pfad);
|
||||
$marke = '"' . md5($pfad . $zeit) . '"';
|
||||
|
||||
header("Content-Type: image/png");
|
||||
header("Cache-Control: public, max-age=" . BROWSER_FRIST);
|
||||
header("ETag: $marke");
|
||||
header("Last-Modified: " . gmdate("D, d M Y H:i:s", $zeit) . " GMT");
|
||||
|
||||
$bekannt = isset($_SERVER["HTTP_IF_NONE_MATCH"]) ? trim($_SERVER["HTTP_IF_NONE_MATCH"]) : "";
|
||||
if ($bekannt === $marke) {
|
||||
http_response_code(304);
|
||||
return;
|
||||
}
|
||||
|
||||
header("Content-Length: " . filesize($pfad));
|
||||
readfile($pfad);
|
||||
}
|
||||
|
||||
/**
|
||||
* Graue Ersatzkachel, wenn nichts zu holen war - kein Internet, Quelle
|
||||
* gestört oder Bremse aktiv. Ein leeres Feld ist besser als ein kaputtes
|
||||
* Bildsymbol, und es wird bewusst nicht zwischengespeichert.
|
||||
*/
|
||||
function liefereErsatzkachel()
|
||||
{
|
||||
header("Content-Type: image/png");
|
||||
header("Cache-Control: no-store");
|
||||
// 1x1 Punkt in Grau, von Leaflet auf Kachelgröße gezogen.
|
||||
echo base64_decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
|
||||
. "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||