Compare commits
32
Commits
22ecc1b50f
...
59d2a4a55e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -44,6 +44,11 @@ logs/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
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
|
# Temporäre Dateien
|
||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
|
|||||||
@@ -0,0 +1,786 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 ← die acht Seitenvorlagen
|
||||||
|
│ ├── rooms.php Räume, Etagen, Kachelwerte, Symbole
|
||||||
|
│ ├── costs.php Preistabellen + solarDb()
|
||||||
|
│ ├── automations.php Automatiken, Gerätekatalog + meshDb()
|
||||||
|
│ ├── 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
|
||||||
|
│ ├── 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/ Grundriss-Renderings OG/EG/UG/AG, Icons
|
||||||
|
├── tiles/ Kartenkacheln-Zwischenspeicher (nicht im Git)
|
||||||
|
└── *.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`, `solarMQTT.js` | `getProdData`, `getConsData`, `getForecastData`, `getSunrise`, `getStats`, `carEG`, `carOG`, `heater`, `watering` | `solarManager/#`, `weatherStation/#`, `wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` |
|
||||||
|
| `home` | `home.php` | `autoActionFuncs.js`, `homeMQTT.js` | `room.php`, `AutoAction.php` | die Topics der Kacheln, `Raumtemp/#` |
|
||||||
|
| `heat` | `heat.php` | `heatMQTT.js` | `getHeaterData`, `getWaterData`, `getSunrise` | `solarManager/#`, `weatherStation/#`, Wallbox-Topics |
|
||||||
|
| `history` | `history.php` | `jahresstatistik.js`, `historyMQTT.js` | `energyHistory` (6 ×), `getStats` | — |
|
||||||
|
| `skoda` | `skoda.php` | `skodaMQTT.js` | `skoda.php?was=…`, `skodaCmd.php`, `tile.php` | `solarManager/#` |
|
||||||
|
| `weather` | `weather.php` | `weatherMQTT.js` | nichts — das Meteogramm ist ein fremdes Dokument im `<iframe>` (`js/meteogram.js`) | `weatherStation/#` |
|
||||||
|
| `settings` | `settings.php` | `settings.js` | `settings.php?action=…` | — |
|
||||||
|
| `logs` | `logs.php` | `logs.js` | `logs.php?action=…` | — |
|
||||||
|
|
||||||
|
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
|
||||||
|
KACH --> ROOMS
|
||||||
|
ROOMS --> COSTS
|
||||||
|
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 |
|
||||||
|
| `submitFormAjax(form)` | Formular abschicken, ohne die Seite zu verlassen |
|
||||||
|
| `bindModalSlider()` | Schieberegler im Modal an ihre Anzeige koppeln |
|
||||||
|
| `mountMeteogram()`, `updateWeatherCards()` | Wetterteile der Solar- und Wetterseite |
|
||||||
|
| `reiterMerken()` | offener Reiter steht in der Adresse und übersteht ein Neuladen |
|
||||||
|
| `loadingHTML(text)` | der eine Ladezustand für alle Modals |
|
||||||
|
|
||||||
|
### 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"`), runden (`stellen`), Einheit anhängen. Klick auf eine Kachel
|
||||||
|
öffnet das Raum-Modal über `ajax/room.php?room=EG_Bad`.
|
||||||
|
|
||||||
|
**`solarMQTT.js`** — die große Anlagenübersicht. `solarSVG` zeichnet den
|
||||||
|
Energiefluss (Breite der Pfeile aus der Leistung), darunter hängen die drei
|
||||||
|
Diagramme Prognose, Verbrauch und Erzeugung über `getData()`. Änderungen am SVG laufen über kleine Setter
|
||||||
|
(`setText`, `setAttr`, `setFlowWidth`) und werden pro Bildrahmen gesammelt
|
||||||
|
(`solarSvgUpdatePending`), damit ein Schwall MQTT-Nachrichten nicht hundertmal
|
||||||
|
Layout auslöst.
|
||||||
|
|
||||||
|
**`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`).
|
||||||
|
|
||||||
|
**`settings.js`** — drei Reiter in einer Datei, deutlich getrennt: Preistabellen,
|
||||||
|
Home-Kacheln, Geräte. Jeder Abschnitt hat sein Modell (`kachelModell`,
|
||||||
|
`geraeteModell`), seine `zeichne…()`-Funktion und eine `…Binden()`-Funktion,
|
||||||
|
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=EG_Bad`, `POST ?action=command\|temp` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden |
|
||||||
|
| `AutoAction.php` | `?action=editor\|list\|followers\|werte`, `POST save\|delete\|toggle` | Automatik-Editor und -Übersicht |
|
||||||
|
| `settings.php` | `?action=list\|kacheln\|geraete`, `POST save\|kacheln-save\|geraete-raeume\|geraet-loeschen` | Einstellungsseite |
|
||||||
|
| `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 |
|
||||||
|
| `skoda.php` | `?was=live\|ladungen\|kurve\|gesundheit\|fahrten\|strecke` | Fahrzeugauswertungen aus `solarLog.skoda` |
|
||||||
|
| `skodaCmd.php` | `POST befehl=…` | Laden/Klima/Lüftung am Fahrzeug |
|
||||||
|
| `tile.php` | `?z=&x=&y=` | Kartenkachel aus dem eigenen Zwischenspeicher, sonst einmalig von OSM |
|
||||||
|
| `logs.php` | `?action=list\|read` | Zustand und Zeilen der Logdateien |
|
||||||
|
| `carEG.php` `carOG.php` `carSteuerung.php` `heater.php` `watering.php` `tahoma.php` | | Wallboxen, Heizstab, Bewässerung, Tahoma-Bedienung |
|
||||||
|
| `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` | Räume, Etagen, Kachelwerte, Symbole | `allRooms()`, `roomsWithTile()`, `tileTopics()`, `kachelVorgabe()`, `kachelUeberschreibungen()`, `bootstrapIcons()`, `kachelIcon()` |
|
||||||
|
| `costs.php` | Preistabellen, Verbindung zu `solarLog` | `solarDb()`, `preiseLaden()`, `preiseSpeichern()`, `grundpreisImZeitraum()` |
|
||||||
|
| `automations.php` | Automatiken, Gerätekatalog, Verbindung zu `homeMesh` | `meshDb()`, `deviceCatalog()`, `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()` |
|
||||||
|
| `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>"]
|
||||||
|
ROOMS["rooms.php"] --> COSTS["costs.php"] --> MYSQL
|
||||||
|
KACH["kacheln.php"] --> ROOMS
|
||||||
|
KACH --> COSTS
|
||||||
|
KACH -. "nur wenn vorhanden" .-> AUTOM["automations.php"]
|
||||||
|
AUTOM --> MYSQL
|
||||||
|
AUTOM --> ROOMS
|
||||||
|
CMD["commands.php"] --> MYSQL
|
||||||
|
CMD --> TAH["tahoma_EG.php"]
|
||||||
|
CMD --> PHPMQTT["ajax/phpMQTT.php"]
|
||||||
|
RCTRL["roomControls.php"] --> REITER["reiter.php"]
|
||||||
|
HOME["home.php"] --> ROOMS
|
||||||
|
HOME --> REITER
|
||||||
|
SET["settings.php"] --> COSTS & KACH & REITER
|
||||||
|
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` |
|
||||||
|
| `weatherStation`, `weatherHours`, `weatherDays`, `daylight`, `simPower` | Wetter, Sonnenzeiten, Ertragsprognose | Wetterbrücke, Open-Meteo, Prognose |
|
||||||
|
| `gridCosts`, `gasCosts`, `fuelCosts` | Preiszeitreihen (Stichtag, kein Enddatum) | Einstellungsseite (`costs.php`) |
|
||||||
|
| `kachelWerte` | Überschreibungen der Home-Kacheln, JSON je Raum | Einstellungsseite (`kacheln.php`) |
|
||||||
|
|
||||||
|
**`Logins`** — `users` (Passkeys) und `addUser` (Einmal-Links).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vier Bausteine im Detail
|
||||||
|
|
||||||
|
### 1. Die Home-Kacheln
|
||||||
|
|
||||||
|
Was auf einer Kachel steht, hat drei Stufen: die Vorgabe in `rooms.php`, die
|
||||||
|
Überschreibung in `kachelWerte`, und die Anzeige im Browser.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
ROOMS["rooms.php<br/><small>$rooms: Vorgabe je Raum</small>"] --> MIT["mitWerten()"]
|
||||||
|
KW[("kachelWerte<br/><small>je Raum, JSON</small>")] --> MIT
|
||||||
|
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
|
||||||
|
|
||||||
|
SETJS["settings.js<br/><small>Reiter Kacheln</small>"] --> SETA["ajax/settings.php"]
|
||||||
|
SETA --> KACHM["kacheln.php<br/><small>prüfen, speichern</small>"] --> KW
|
||||||
|
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; `negativ` dreht das
|
||||||
|
Vorzeichen (für einen Zähler, der verkehrt herum eingebaut ist); `icon` ist
|
||||||
|
der Name **irgendeines** Bootstrap-Icons — die Codepunkte liest
|
||||||
|
`bootstrapIcons()` aus `css/bootstrap-icons.min.css`, gezeichnet wird als
|
||||||
|
SVG-Text in der Icon-Schrift.
|
||||||
|
|
||||||
|
Fehlt die Tabelle `kachelWerte`, zeigt jede Kachel ihre Vorgabe: die
|
||||||
|
Startseite ist keine Einstellung, ohne die man das Haus nicht mehr sieht.
|
||||||
|
|
||||||
|
### 2. Das Raum-Modal
|
||||||
|
|
||||||
|
```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=EG_Bad
|
||||||
|
R->>RC: Geräte des Raums (actors.room) → 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 zwei
|
||||||
|
Reglern; hat es drei Parameter rot/grün/blau, wird es eine Lampe mit
|
||||||
|
Farbwähler. Ein neu gefundenes Gerät bekommt so ohne Codeänderung ein
|
||||||
|
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.
|
||||||
|
|
||||||
|
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, bleibt die Startseite
|
||||||
|
sichtbar; fehlt `kachelWerte`, gilt die Vorgabe.
|
||||||
|
|
||||||
|
**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 kommt dazu oder heißt anders | `restricted/rooms.php`, Tabelle `$rooms` — Kachel, Menü und MQTT-Abo ergeben sich daraus |
|
||||||
|
| Andere Werte auf einer Kachel | Einstellungen → Kacheln (keine Codeänderung). Vorgabe für neue Räume: `kachelVorgabe()` in `rooms.php` |
|
||||||
|
| Neues Symbol auf einer Kachel | Name eines Bootstrap-Icons ins Feld tippen. Vorschlagsliste: `kachelSymbole()` in `kacheln.php` |
|
||||||
|
| 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 |
|
||||||
|
| 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.
|
||||||
|
|
||||||
|
Zwei Repositories, beide auf Gitea (`gitea.nas.el-wa.org`):
|
||||||
|
|
||||||
|
| Repo | Ort | Inhalt |
|
||||||
|
|---|---|---|
|
||||||
|
| `admin/Smart-Dashboard` | `/volume1/web/smart` | dieses hier |
|
||||||
|
| `admin/SolarManager` | `/volume1/homes/wagner/SolarManager` | Sammler, Runner, Wecker |
|
||||||
|
|
||||||
|
Rekursive Suchen (`grep -r`) laufen über die SMB-Freigabe in Zeitüberschreitungen —
|
||||||
|
auf der NAS selbst sind sie sofort fertig.
|
||||||
+301
-152
@@ -9,11 +9,11 @@
|
|||||||
* GET ?action=editor&id=N Editor fuer eine vorhandene Automatik
|
* GET ?action=editor&id=N Editor fuer eine vorhandene Automatik
|
||||||
* GET ?action=editor&floor=OG Editor fuer eine neue Automatik
|
* GET ?action=editor&floor=OG Editor fuer eine neue Automatik
|
||||||
* GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen"
|
* GET ?action=list&floor=OG Tabelle fuer die Karte "Automatismen"
|
||||||
* GET ?action=rooms Zuordnung Geraet -> Raum zum Bearbeiten
|
|
||||||
* POST ?action=save JSON-Rumpf, legt an oder ueberschreibt
|
* POST ?action=save JSON-Rumpf, legt an oder ueberschreibt
|
||||||
* POST ?action=delete {"id": N}
|
* GET ?action=followers&id=N JSON: wer haengt an dieser Automatik?
|
||||||
|
* GET ?action=werte&ids=1,2,3 JSON: was diese Messwerte gerade sagen
|
||||||
|
* POST ?action=delete {"id": N, "mode": "abhaengen"|"mitloeschen"}
|
||||||
* POST ?action=toggle {"id": N} - pausieren / fortsetzen
|
* POST ?action=toggle {"id": N} - pausieren / fortsetzen
|
||||||
* POST ?action=saveRooms {"<actor_id>": "OG/Bad", ...}
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once("../helper.php");
|
require_once("../helper.php");
|
||||||
@@ -47,12 +47,15 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
|||||||
$id = saveAutomation($body);
|
$id = saveAutomation($body);
|
||||||
jsonAntwort(["id" => $id]);
|
jsonAntwort(["id" => $id]);
|
||||||
case "delete":
|
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]);
|
jsonAntwort(["ok" => true]);
|
||||||
case "toggle":
|
case "toggle":
|
||||||
jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]);
|
jsonAntwort(["enabled" => toggleAutomation(intval($body["id"] ?? 0))]);
|
||||||
case "saveRooms":
|
|
||||||
jsonAntwort(["gespeichert" => saveRooms($body["rooms"] ?? [])]);
|
|
||||||
default:
|
default:
|
||||||
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||||
}
|
}
|
||||||
@@ -63,6 +66,23 @@ 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)]);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Uebersichtstabelle --------------------------------------------------
|
// --- Uebersichtstabelle --------------------------------------------------
|
||||||
if ($action === "list") {
|
if ($action === "list") {
|
||||||
$tage = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
$tage = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||||
@@ -78,7 +98,77 @@ if ($action === "list") {
|
|||||||
. "<th class='d-none d-sm-table-cell'>Aktion</th>"
|
. "<th class='d-none d-sm-table-cell'>Aktion</th>"
|
||||||
. "<th class='d-none d-md-table-cell'>Wann</th>"
|
. "<th class='d-none d-md-table-cell'>Wann</th>"
|
||||||
. "<th class='d-none d-md-table-cell'>Zuletzt</th><th></th></tr></thead><tbody>";
|
. "<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 ($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
|
// Nur die aktiven Tage nennen. Sieben Haekchen untereinander
|
||||||
// haben die Zeile frueher unnoetig hoch gemacht.
|
// haben die Zeile frueher unnoetig hoch gemacht.
|
||||||
$aktiveTage = [];
|
$aktiveTage = [];
|
||||||
@@ -87,16 +177,46 @@ if ($action === "list") {
|
|||||||
$aktiveTage[] = $tage[$i];
|
$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") {
|
if ($a["window_from"] !== "00:00" || $a["window_to"] !== "23:59") {
|
||||||
$wann .= ", " . $a["window_from"] . "–" . $a["window_to"];
|
$wann .= ", " . $a["window_from"] . "–" . $a["window_to"];
|
||||||
}
|
}
|
||||||
if (!$a["on_holiday"]) {
|
if ($a["on_holiday"] == 0) {
|
||||||
$wann .= ", nicht an Feiertagen";
|
$wann .= ", nicht an Feiertagen";
|
||||||
}
|
}
|
||||||
if (!$a["on_vacation"]) {
|
if ($a["on_vacation"] == 0) {
|
||||||
$wann .= ", nicht in den Ferien";
|
$wann .= ", nicht in den Ferien";
|
||||||
}
|
}
|
||||||
|
if ($a["once_per_day"]) {
|
||||||
|
$wann .= ", nur einmal am Tag";
|
||||||
|
}
|
||||||
if ($a["lockout_secs"] > 0) {
|
if ($a["lockout_secs"] > 0) {
|
||||||
$wann .= ", frühestens alle "
|
$wann .= ", frühestens alle "
|
||||||
. ($a["lockout_secs"] >= 60
|
. ($a["lockout_secs"] >= 60
|
||||||
@@ -105,13 +225,43 @@ if ($action === "list") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$zuletzt = $a["last_run"] ? date("d.m. H:i", strtotime($a["last_run"])) : "–";
|
$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";
|
$pause = $a["enabled"] ? "bi-pause-fill" : "bi-play-fill";
|
||||||
$titel = $a["enabled"] ? "Pausieren" : "Fortsetzen";
|
$titel = $a["enabled"] ? "Pausieren" : "Fortsetzen";
|
||||||
$id = intval($a["id"]);
|
$id = intval($a["id"]);
|
||||||
|
|
||||||
echo "<tr" . $klasse . ">";
|
$klassen = [];
|
||||||
echo "<td>" . htmlspecialchars($a["name"]) . "</td>";
|
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>" . htmlspecialchars($a["conditionText"]) . "</td>";
|
||||||
echo "<td class='d-none d-sm-table-cell'>" . htmlspecialchars($a["actionText"]) . "</td>";
|
echo "<td class='d-none d-sm-table-cell'>" . htmlspecialchars($a["actionText"]) . "</td>";
|
||||||
echo "<td class='text-nowrap d-none d-md-table-cell'>" . htmlspecialchars($wann) . "</td>";
|
echo "<td class='text-nowrap d-none d-md-table-cell'>" . htmlspecialchars($wann) . "</td>";
|
||||||
@@ -130,73 +280,6 @@ if ($action === "list") {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Geraete den Raeumen zuordnen ---------------------------------------
|
|
||||||
if ($action === "rooms") {
|
|
||||||
$labels = geraeteBeschriftungen();
|
|
||||||
$raeume = geraeteRaeume();
|
|
||||||
$vorschlag = raumVorschlaege();
|
|
||||||
$auswahl = raumListe();
|
|
||||||
|
|
||||||
$res = meshDb()->query("SELECT id, type FROM actors");
|
|
||||||
$geraete = [];
|
|
||||||
while ($row = $res->fetch_assoc()) {
|
|
||||||
$id = intval($row["id"]);
|
|
||||||
$geraete[] = [
|
|
||||||
"id" => $id,
|
|
||||||
"label" => $labels[$id] ?? "?",
|
|
||||||
"type" => $row["type"],
|
|
||||||
"room" => $raeume[$id] ?? null,
|
|
||||||
"tipp" => $vorschlag[$id] ?? null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
// Unzugeordnete nach oben: das ist die Arbeit, die noch zu tun ist.
|
|
||||||
usort($geraete, function ($a, $b) {
|
|
||||||
$offen = (int)!empty($b["room"]) - (int)!empty($a["room"]);
|
|
||||||
return $offen !== 0 ? $offen : strcoll($a["label"], $b["label"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
$offen = count(array_filter($geraete, function ($g) { return empty($g["room"]); }));
|
|
||||||
$tipps = count(array_filter($geraete, function ($g) {
|
|
||||||
return empty($g["room"]) && !empty($g["tipp"]);
|
|
||||||
}));
|
|
||||||
|
|
||||||
echo "<div class='p-2'>";
|
|
||||||
echo "<p class='small text-body-secondary'>Die Zuordnung ordnet die Geräteliste im Editor "
|
|
||||||
. "nach Räumen statt nach Geräteart. Sie wird von Hand vergeben und bleibt bei "
|
|
||||||
. "einem neuen Geräte-Suchlauf erhalten.</p>";
|
|
||||||
if ($tipps) {
|
|
||||||
echo "<div class='alert alert-secondary py-2 small d-flex justify-content-between align-items-center'>"
|
|
||||||
. "<span>" . $tipps . " Gerät(e) verraten ihren Raum im MQTT-Topic.</span>"
|
|
||||||
. "<button type='button' class='btn btn-outline-secondary btn-sm' id='btnRoomTipps'>"
|
|
||||||
. "Vorschläge übernehmen</button></div>";
|
|
||||||
}
|
|
||||||
echo "<div class='table-responsive' style='max-height:60vh;overflow-y:auto'>";
|
|
||||||
echo "<table class='table table-sm table-hover align-middle'><thead><tr>"
|
|
||||||
. "<th>Gerät</th><th class='d-none d-sm-table-cell'>Art</th><th>Raum</th>"
|
|
||||||
. "</tr></thead><tbody>";
|
|
||||||
foreach ($geraete as $g) {
|
|
||||||
echo "<tr" . (empty($g["room"]) ? " class='table-active'" : "") . ">";
|
|
||||||
echo "<td>" . htmlspecialchars($g["label"]) . "</td>";
|
|
||||||
echo "<td class='d-none d-sm-table-cell small text-body-secondary'>"
|
|
||||||
. htmlspecialchars($g["type"]) . "</td>";
|
|
||||||
echo "<td><select class='form-select form-select-sm' data-actor='" . $g["id"] . "'"
|
|
||||||
. ($g["tipp"] ? " data-tipp='" . htmlspecialchars($g["tipp"], ENT_QUOTES) . "'" : "")
|
|
||||||
. ">";
|
|
||||||
echo "<option value=''" . (empty($g["room"]) ? " selected" : "") . ">–</option>";
|
|
||||||
foreach ($auswahl as $raum) {
|
|
||||||
echo "<option value='" . htmlspecialchars($raum["key"], ENT_QUOTES) . "'"
|
|
||||||
. ($g["room"] === $raum["key"] ? " selected" : "") . ">"
|
|
||||||
. htmlspecialchars($raum["label"]) . "</option>";
|
|
||||||
}
|
|
||||||
echo "</select></td></tr>";
|
|
||||||
}
|
|
||||||
echo "</tbody></table></div>";
|
|
||||||
echo "<p class='small text-body-secondary mb-0'>" . $offen . " von " . count($geraete)
|
|
||||||
. " Geräten noch ohne Raum.</p>";
|
|
||||||
echo "</div>";
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Editor --------------------------------------------------------------
|
// --- Editor --------------------------------------------------------------
|
||||||
$id = intval($_GET["id"] ?? 0);
|
$id = intval($_GET["id"] ?? 0);
|
||||||
$auto = $id > 0 ? loadAutomation($id) : null;
|
$auto = $id > 0 ? loadAutomation($id) : null;
|
||||||
@@ -212,6 +295,9 @@ $nutzdaten = json_encode([
|
|||||||
"automation" => $auto,
|
"automation" => $auto,
|
||||||
"devices" => deviceCatalog(),
|
"devices" => deviceCatalog(),
|
||||||
"lockouts" => lockoutChoices(),
|
"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
|
// Nur fuer die Reihenfolge der Raum-Gruppen: rooms.php gibt sie vor, und
|
||||||
// Etage fuer Etage liest sich besser als alphabetisch.
|
// Etage fuer Etage liest sich besser als alphabetisch.
|
||||||
"rooms" => raumListe(),
|
"rooms" => raumListe(),
|
||||||
@@ -243,90 +329,153 @@ $nutzdaten = json_encode([
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="accordion" id="actionAccordion">
|
<!--
|
||||||
<div class="accordion-item">
|
Drei Baender statt eines Akkordeons: Wenn - Wann - Dann.
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="accordion-item">
|
Das Akkordeon schloss beim Oeffnen die anderen Faecher, Ausloeser und
|
||||||
<h2 class="accordion-header">
|
Aktion waren also nie gleichzeitig zu sehen - ausgerechnet bei einer
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
Regel, die genau daraus besteht. Und das mittlere Fach hiess
|
||||||
data-bs-target="#collapseTwo" aria-expanded="false">
|
"Bedingungen", enthielt aber die *Rahmen*bedingungen; die echten
|
||||||
<i class="bi bi-calendar-check-fill"></i> Bedingungen
|
Bedingungen standen im ersten.
|
||||||
</button>
|
|
||||||
</h2>
|
Gebaut aus .card mit farbigem Rand und .card-header - kein eigenes
|
||||||
<div id="collapseTwo" class="accordion-collapse collapse" data-bs-parent="#actionAccordion">
|
Bauteil. Die Ueberschriften tragen .card-title und bekommen darueber die
|
||||||
<div class="accordion-body">
|
Anzeigeschrift aus solar.css.
|
||||||
<div class="input-group mb-3">
|
-->
|
||||||
<span class="input-group-text">Aktiver Zeitraum</span>
|
|
||||||
<div class="form-floating">
|
<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">
|
<input type="time" class="form-control" id="tSpanFrom" value="00:00">
|
||||||
<label for="tSpanFrom">Von</label>
|
<span class="input-group-text">bis</span>
|
||||||
</div>
|
|
||||||
<div class="form-floating">
|
|
||||||
<input type="time" class="form-control" id="tSpanTo" value="23:59">
|
<input type="time" class="form-control" id="tSpanTo" value="23:59">
|
||||||
<label for="tSpanTo">Bis</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
Aktive Wochentage:
|
<div class="col-sm-6">
|
||||||
<div class="input-group mb-3" id="weekdayGroup"></div>
|
<div class="input-group input-group-sm">
|
||||||
<div class="input-group mb-3">
|
<span class="input-group-text">Sperre</span>
|
||||||
<div class="input-group-text">
|
<select class="form-select" id="lockoutSecs"></select>
|
||||||
<input class="form-check-input mt-0 me-2" type="checkbox" id="actFerien">
|
|
||||||
<label class="form-check-label" for="actFerien">In den Ferien ausführen</label>
|
|
||||||
</div>
|
|
||||||
<div class="input-group-text">
|
|
||||||
<input class="form-check-input mt-0 me-2" type="checkbox" id="actFeier">
|
|
||||||
<label class="form-check-label" for="actFeier">An Feiertagen ausführen</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<span class="input-group-text">Sperrzeit nach dem Auslösen</span>
|
|
||||||
<select class="form-select" id="lockoutSecs"></select>
|
|
||||||
</div>
|
|
||||||
<div class="input-group mb-0">
|
|
||||||
<div class="input-group-text">
|
|
||||||
<input class="form-check-input mt-0 me-2" type="checkbox" id="runOnce">
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="accordion-item">
|
<div class="form-check">
|
||||||
<h2 class="accordion-header">
|
<input class="form-check-input" type="checkbox" id="oncePerDay">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
<label class="form-check-label" for="oncePerDay">
|
||||||
data-bs-target="#collapseThree" aria-expanded="false">
|
Nur einmal am Tag – danach bis Mitternacht gesperrt
|
||||||
<i class="bi bi-play-fill"></i> Aktionen
|
</label>
|
||||||
</button>
|
</div>
|
||||||
</h2>
|
<div class="form-check">
|
||||||
<div id="collapseThree" class="accordion-collapse collapse" data-bs-parent="#actionAccordion">
|
<input class="form-check-input" type="checkbox" id="runOnce">
|
||||||
<div class="accordion-body">
|
<label class="form-check-label" for="runOnce">
|
||||||
<div id="actionList"></div>
|
Am Ende des Zeitraums auf jeden Fall ausführen, falls der Auslöser nie zutraf
|
||||||
<button class="btn btn-outline-success btn-sm mt-2" type="button" id="btnAddActor">
|
</label>
|
||||||
<i class="bi bi-plus-lg"></i> Aktion
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</form>
|
||||||
|
|||||||
+332
-174
@@ -1,73 +1,177 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
require_once("../helper.php");
|
* Jahresstatistik der Anlage - alle Jahre, alle Zeitraeume, als JSON.
|
||||||
require_once("../restricted/costs.php");
|
|
||||||
if(!isset($_GET["year"])){
|
|
||||||
$_GET["year"] = date("Y");
|
|
||||||
}else{
|
|
||||||
$_GET["year"] = intval($_GET["year"]);
|
|
||||||
}
|
|
||||||
if($_GET["year"] < 2018 || $_GET["year"] > date("Y")){
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
if(isset($_GET["type"])){
|
|
||||||
if(strtolower($_GET["type"]) =="lastyear"){
|
|
||||||
$_GET["year"] = date("Y")-1;
|
|
||||||
}elseif(strtolower($_GET["type"]) =="prelastyear"){
|
|
||||||
$_GET["year"] = date("Y")-2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Die Jahresstatistik als eine Abfrage - jetzt auf stats_daily.
|
|
||||||
*
|
*
|
||||||
* Gerechnet wird nicht mehr ueber die Rohdaten aus EnergyFlow, sondern ueber
|
* Frueher lieferte diese Datei fertiges HTML: je Aufruf ein Jahr, 18 Kacheln,
|
||||||
* die Tageswerte. Zwei Gruende:
|
* 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
|
* 1. Die Rohdaten werden nach zwoelf Monaten ausgeduennt bzw. ins
|
||||||
* Stundenarchiv verschoben (solarlog-rollup.sh). Eine Kachel, die auf
|
* Stundenarchiv verschoben (solarlog-rollup.sh). Wer auf EnergyFlow
|
||||||
* EnergyFlow rechnet, zeigt fuer alte Jahre danach zu wenig an.
|
* rechnet, zeigt fuer alte Jahre zu wenig an.
|
||||||
* 2. Ein Jahr sind 365 Zeilen statt rund 105.000.
|
* 2. Ein Jahr sind 365 Zeilen statt rund 105.000.
|
||||||
*
|
*
|
||||||
* Die Tarife bleiben unveraendert: je Zeile ein Stichtag, das Ende holt
|
* Die Tarife bleiben unveraendert: je Zeile ein Stichtag, das Ende holt LEAD()
|
||||||
* LEAD() beim naechsten. Weil ein Stichtag ein DATUM ist, kann ein Tag nie
|
* beim naechsten. Weil ein Stichtag ein DATUM ist, kann ein Tag nie zwei
|
||||||
* zwei Tarife haben - die Verdichtung auf Tage verliert hier also nichts.
|
* Tarife haben - die Verdichtung auf Tage verliert hier also nichts.
|
||||||
*
|
*
|
||||||
* Was in stats_daily vorberechnet sein MUSS, weil es eine Bedingung je
|
* Was in stats_daily vorberechnet sein MUSS, weil es eine Bedingung je
|
||||||
* Messwert auswertet und sich aus Tagessummen nicht rekonstruieren laesst:
|
* Messwert auswertet und sich aus Tagessummen nicht rekonstruieren laesst:
|
||||||
*
|
*
|
||||||
* eigenverbrauch_kwh pv - Einspeisung - Batterieladung
|
* eigenverbrauch_kwh pv - Einspeisung - Batterieladung - Heizstab
|
||||||
* - Heizstab + Batterieentladung. Dazu addiert
|
* + Batterieentladung. Dazu addiert wird
|
||||||
* wird pv_fehlbetrag_kwh: bei Ausfall einzelner
|
* pv_fehlbetrag_kwh: bei Ausfall einzelner
|
||||||
* Wechselrichter fehlt deren Beitrag in pv, alle
|
* Wechselrichter fehlt deren Beitrag in pv, alle
|
||||||
* Senken sind dagegen gemessen - die Differenz
|
* Senken sind dagegen gemessen - die Differenz
|
||||||
* faellt sonst zu klein oder negativ aus.
|
* faellt sonst zu klein oder negativ aus.
|
||||||
* heizstab_solar_kwh Heizstab, anteilig nach autonomy
|
* heizstab_solar_kwh Heizstab, anteilig nach autonomy
|
||||||
* auto_*_solar_kwh Wallbox, anteilig nach autonomy - dem vom
|
* auto_*_solar_kwh Wallbox, anteilig nach autonomy - dem vom Geraet
|
||||||
* Geraet in feiner Aufloesung gerechneten
|
* in feiner Aufloesung gerechneten Nicht-Netz-
|
||||||
* Nicht-Netz-Anteil der Zufuhr
|
* Anteil der Zufuhr
|
||||||
*
|
*
|
||||||
* Einheiten sind in stats_daily bereits bereinigt: PL*_EV und PL*_EVog
|
* Die Abfragen liefern nur SUMMEN. Jede Verhaeltniszahl - Autarkie, Quoten,
|
||||||
* liefern Kilowatt, alle uebrigen Leistungsspalten Watt. In der alten
|
* effektiver Preis - rechnet kennzahlenAus() daraus, und zwar auf jeder Ebene
|
||||||
* Abfrage stand das als /12 gegen /12000 nebeneinander.
|
* 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.
|
||||||
*
|
*
|
||||||
* Die Solaranteile teilen jetzt durch NULLIF(...): ohne Ladung im Jahr kam
|
* Der Grundpreis faellt unabhaengig vom Verbrauch an und steckt deshalb in
|
||||||
* vorher 0/0 heraus.
|
* keiner Summe, sondern kommt aus grundpreisImZeitraum() dazu.
|
||||||
*
|
*
|
||||||
* "Ersparnis Batterie" rechnet den Nutzen netto: was die entladene Energie
|
* Achtung beim Lesen der Ertragsgruppe: Eigenverbrauch, Einspeisung und
|
||||||
* aus dem Netz gekostet haette, abzueglich der Verguetung, die fuer den zum
|
* Heizung TEILEN den Ertrag auf, sie ergeben ihn zusammen. Der Beitrag der
|
||||||
* Laden verwendeten Strom entgangen ist. Brutto waere die Kachel zu
|
* Batterie und die Solarladung des Autos stecken dagegen IM EIGENVERBRAUCH -
|
||||||
* freundlich - der Speicher ist nicht umsonst zu fuellen.
|
* 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.
|
||||||
*
|
*
|
||||||
* Achtung beim Lesen: "Verbrauchsersparnis" ist der GESAMTE Eigenverbrauch
|
* Beim Beitrag der Batterie ist "darin" der Groesse nach zu lesen: gerechnet
|
||||||
* zum Strompreis. "Ersparnis Batterie" und "Ersparnis Solarladung" sind
|
* ist er netto, also abzueglich der Verguetung, die fuer den zum Laden
|
||||||
* Teilmengen davon, nach Herkunft bzw. Verwendung herausgegriffen. Sie
|
* verwendeten Strom entgangen ist. Brutto waere die Zeile zu freundlich - der
|
||||||
* duerfen nicht addiert werden.
|
* Speicher ist nicht umsonst zu fuellen.
|
||||||
*
|
|
||||||
* Der Grundpreis fehlt weiterhin bewusst - er haengt an den Tagen des
|
|
||||||
* Jahres, nicht an einem Messwert, und kommt aus grundpreisImJahr() dazu.
|
|
||||||
*/
|
*/
|
||||||
function statistikAbfrage($jahr)
|
|
||||||
|
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
|
return "WITH
|
||||||
preis AS (SELECT active_date, cost, gain,
|
preis AS (SELECT active_date, cost, gain,
|
||||||
@@ -76,136 +180,190 @@ function statistikAbfrage($jahr)
|
|||||||
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gasCosts),
|
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM gasCosts),
|
||||||
sprit AS (SELECT active_date, cost, lPer100km, kwhPer100km,
|
sprit AS (SELECT active_date, cost, lPer100km, kwhPer100km,
|
||||||
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM fuelCosts)
|
LEAD(active_date) OVER (ORDER BY active_date) AS folgt FROM fuelCosts)
|
||||||
SELECT
|
SELECT ";
|
||||||
SUM(d.netz_bezug_kwh) AS 'Stromverbrauch',
|
|
||||||
SUM(d.netz_bezug_kwh * preis.cost) AS 'Stromkosten',
|
|
||||||
SUM((d.eigenverbrauch_kwh + IFNULL(d.pv_fehlbetrag_kwh,0)) * preis.cost) AS 'Verbrauchsersparnis',
|
|
||||||
SUM(d.netz_einsp_kwh * preis.gain) AS 'Einspeisevergütung',
|
|
||||||
SUM(d.heizstab_solar_kwh * gas.cost / NULLIF(gas.kwhPerLitre,0)) AS 'Ersparnis Heizung',
|
|
||||||
SUM(d.batt_entladen_kwh * preis.cost - d.batt_laden_kwh * preis.gain) AS 'Ersparnis Batterie',
|
|
||||||
SUM(d.autarkie_avg * d.stunden_erfasst) / NULLIF(SUM(d.stunden_erfasst),0) AS 'Ø Autarkie',
|
|
||||||
SUM(d.auto_eg_kwh) AS 'Autoladung ges. EG',
|
|
||||||
SUM(d.auto_eg_solar_kwh) / NULLIF(SUM(d.auto_eg_kwh),0) * 100 AS 'Autoladung Solar EG',
|
|
||||||
SUM(d.auto_eg_solar_kwh * preis.cost) AS 'Ersparnis Solarladung EG',
|
|
||||||
SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart EG',
|
|
||||||
SUM(d.auto_eg_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin EG',
|
|
||||||
SUM(d.auto_og_kwh) AS 'Autoladung ges. OG',
|
|
||||||
SUM(d.auto_og_solar_kwh) / NULLIF(SUM(d.auto_og_kwh),0) * 100 AS 'Autoladung Solar OG',
|
|
||||||
SUM(d.auto_og_solar_kwh * preis.cost) AS 'Ersparnis Solarladung OG',
|
|
||||||
SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0)) AS 'Benzin gespart OG',
|
|
||||||
SUM(d.auto_og_kwh * sprit.lPer100km / NULLIF(sprit.kwhPer100km,0) * sprit.cost) AS 'Ersparnis Benzin OG'
|
|
||||||
FROM stats_daily d
|
|
||||||
JOIN preis ON d.tag >= preis.active_date AND (preis.folgt IS NULL OR d.tag < preis.folgt)
|
|
||||||
LEFT JOIN gas ON d.tag >= gas.active_date AND (gas.folgt IS NULL OR d.tag < gas.folgt)
|
|
||||||
LEFT JOIN sprit ON d.tag >= sprit.active_date AND (sprit.folgt IS NULL OR d.tag < sprit.folgt)
|
|
||||||
WHERE YEAR(d.tag) = " . intval($jahr) . ";";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$Query = statistikAbfrage($_GET["year"]);
|
/* --------------------------------------------------------------- Rechnung --
|
||||||
$PrevQuery = statistikAbfrage($_GET["year"] - 1);
|
*
|
||||||
|
* 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;
|
||||||
|
};
|
||||||
|
|
||||||
// Reihenfolge wie in der Abfrage, mit "Verg.+Einsp. Strom" an Stelle 4.
|
$bezug = floatval($s["bezug_kwh"]);
|
||||||
$units = Array("kWh","€","€","€","€","€","€","%",
|
$kosten = floatval($s["bezug_eur"]) + $grundpreis;
|
||||||
"kWh","%","€","L","€",
|
$geladen = floatval($s["eg_kwh"]) + floatval($s["og_kwh"]);
|
||||||
"kWh","%","€","L","€");
|
$solar = floatval($s["eg_solar_kwh"]) + floatval($s["og_solar_kwh"]);
|
||||||
// Nur bei Verbrauch und Kosten ist weniger besser.
|
|
||||||
$LessIsBetter = array_merge([true, true], array_fill(0, count($units) - 2, false));
|
|
||||||
|
|
||||||
function array_insert($array,$values,$offset) {
|
return [
|
||||||
return array_slice($array, 0, $offset, true) + $values + array_slice($array, $offset, NULL, true);
|
"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. */
|
||||||
if (checkLogin()) {
|
function summenPlus($a, $b)
|
||||||
|
{
|
||||||
$html = "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
|
if ($a === null) return $b;
|
||||||
$mysql = solarDb();
|
foreach ($b as $k => $v) {
|
||||||
$Res = mysqli_query($mysql,$Query);
|
if ($v === null) continue;
|
||||||
$ResPrev = mysqli_query($mysql,$PrevQuery);
|
$a[$k] = ($a[$k] === null ? 0 : $a[$k]) + $v;
|
||||||
if(!$Res || !$ResPrev){
|
|
||||||
echo "Error:<br>".mysqli_error($mysql)."<br />";
|
|
||||||
}
|
|
||||||
if ($Res->num_rows > 0) {
|
|
||||||
$i = 0;
|
|
||||||
$row = $Res->fetch_assoc();
|
|
||||||
$rowPrev = $ResPrev->fetch_assoc();
|
|
||||||
// Der Grundpreis faellt unabhaengig vom Verbrauch an und laesst sich
|
|
||||||
// deshalb nicht aus den Messwerten summieren.
|
|
||||||
$row["Stromkosten"] += grundpreisImJahr($_GET["year"]);
|
|
||||||
$rowPrev["Stromkosten"] += grundpreisImJahr($_GET["year"] - 1);
|
|
||||||
//$row["Vergütung+Einsparung Strom"] = $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"];
|
|
||||||
$row = array_insert($row,["Verg.+Einsp. Strom" => $row["Einspeisevergütung"]+$row["Verbrauchsersparnis"]],4);
|
|
||||||
$rowPrev = array_insert($rowPrev,["Verg.+Einsp. Strom" => $rowPrev["Einspeisevergütung"]+$rowPrev["Verbrauchsersparnis"]],4);
|
|
||||||
$i = 0;
|
|
||||||
foreach ($row as $name => $value) {
|
|
||||||
if(str_starts_with($name,"Autoladung ges.")){
|
|
||||||
$html .= "</div><hr class='mt-3 mb-3 border-light' />";
|
|
||||||
$html .= "<div class='row row-cols-2 row-cols-md-4 g-2 row-info'>";
|
|
||||||
}
|
|
||||||
/*$html .= "<div class='col-12 col-sm-4 col-md-2 col-xl-1'>
|
|
||||||
<div class='info-box'>
|
|
||||||
<div class='info-box-content'>
|
|
||||||
<span class='info-box-text'>".$name."</span>
|
|
||||||
<span class='info-box-number'>".
|
|
||||||
number_format(floatval($value),2,",",".")
|
|
||||||
."<small>".$units[$i++]."</small>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- /.info-box-content -->
|
|
||||||
</div>
|
|
||||||
</div>";*/
|
|
||||||
$html .= "<div class='col'>
|
|
||||||
<div class='card stats border-0'>
|
|
||||||
<div class='card-body bg-dark bg-gradient rounded-top pb-1 pt-1'>
|
|
||||||
".$name."
|
|
||||||
</div>
|
|
||||||
<div class='card-footer text-center'>";
|
|
||||||
if($_GET["year"] != date("Y")){
|
|
||||||
if($LessIsBetter[$i]){
|
|
||||||
if($value == 0)
|
|
||||||
$dev=100;
|
|
||||||
else
|
|
||||||
$dev = $rowPrev[$name]/$value;
|
|
||||||
//$first = round($rowPrev[$name]*10/$rowPrev[$name]); //allow for 10% deviation for displaying no tendency
|
|
||||||
//$second = round($value); //allow for 10% deviation for displaying no tendency
|
|
||||||
$arrowGood = "down";
|
|
||||||
$arrowBad = "up";
|
|
||||||
}else{
|
|
||||||
if($rowPrev[$name] == 0)
|
|
||||||
$dev=100;
|
|
||||||
else
|
|
||||||
$dev = $value/$rowPrev[$name];
|
|
||||||
//$first = round($value); //allow for 10% deviation for displaying no tendency
|
|
||||||
//$second = round($rowPrev[$name]); //allow for 10% deviation for displaying no tendency
|
|
||||||
$arrowGood = "up";
|
|
||||||
$arrowBad = "down";
|
|
||||||
}
|
|
||||||
if($dev > 1.05){
|
|
||||||
$html .= "<span class='float-right text-success-emphasis'>
|
|
||||||
<i class='bi bi-arrow-".$arrowGood."' style='font-size: 0.9em;'></i> ";
|
|
||||||
}elseif($dev < 0.95){
|
|
||||||
$html .= "<span class='float-right text-danger-emphasis'>
|
|
||||||
<i class='bi bi-arrow-".$arrowBad."' style='font-size: 0.9em;'></i> ";
|
|
||||||
}else{
|
|
||||||
$html .= "<span class='float-right'>
|
|
||||||
<i class='bi bi-arrow-right' style='font-size: 0.9em;'></i> ";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
$html .= "<span class='float-right'>";
|
|
||||||
}
|
|
||||||
$html .= number_format(floatval($value),2,",",".")
|
|
||||||
."<small> ".$units[$i]."</small></span></div>
|
|
||||||
<!-- /.info-box-content -->
|
|
||||||
</div>
|
|
||||||
</div>";
|
|
||||||
$i++;
|
|
||||||
}
|
}
|
||||||
}
|
return $a;
|
||||||
$html .= "</div>";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Abruf --*/
|
||||||
|
|
||||||
//header('Content-Type: application/json');
|
$db = solarDb();
|
||||||
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]}]}';
|
// 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);
|
||||||
|
|||||||
+95
-2
@@ -4,14 +4,26 @@
|
|||||||
*
|
*
|
||||||
* GET ?action=list&reihe=strom Zeitraeume einer Preistabelle
|
* GET ?action=list&reihe=strom Zeitraeume einer Preistabelle
|
||||||
* POST ?action=save {"reihe": "strom", "zeilen": [ ... ]}
|
* 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": {"12": "OG/Bad", ...}}
|
||||||
|
* 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"]}
|
||||||
*
|
*
|
||||||
* Ein reiner Verteiler: geprueft und geschrieben wird in
|
* Ein reiner Verteiler: geprueft und geschrieben wird in
|
||||||
* restricted/costs.php, damit Maske und Endpunkt dieselbe Wahrheit ueber
|
* restricted/costs.php bzw. restricted/kacheln.php, damit Maske und Endpunkt
|
||||||
* einen Zeitraum haben.
|
* dieselbe Wahrheit ueber einen Zeitraum oder eine Kachel haben.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once("../helper.php");
|
require_once("../helper.php");
|
||||||
require_once("../restricted/costs.php");
|
require_once("../restricted/costs.php");
|
||||||
|
require_once("../restricted/kacheln.php");
|
||||||
|
require_once("../restricted/automations.php");
|
||||||
|
require_once("../restricted/skodaKeys.php");
|
||||||
|
|
||||||
if (!checkLogin()) {
|
if (!checkLogin()) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
@@ -43,16 +55,97 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
|||||||
"gespeichert" => $anzahl,
|
"gespeichert" => $anzahl,
|
||||||
"zeilen" => preiseLaden($body["reihe"]),
|
"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 "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:
|
default:
|
||||||
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
jsonAntwort(["error" => "Unbekannte Aktion."], 400);
|
||||||
}
|
}
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
jsonAntwort(["error" => $e->getMessage()], 400);
|
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) {
|
} catch (Throwable $e) {
|
||||||
jsonAntwort(["error" => "Speichern fehlgeschlagen: " . $e->getMessage()], 500);
|
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" => kachelTabelleDa(),
|
||||||
|
"max" => KACHEL_MAX,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
jsonAntwort(["error" => "Kacheln 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") {
|
if ($action === "list") {
|
||||||
try {
|
try {
|
||||||
jsonAntwort(["zeilen" => preiseLaden($_GET["reihe"] ?? "")]);
|
jsonAntwort(["zeilen" => preiseLaden($_GET["reihe"] ?? "")]);
|
||||||
|
|||||||
+279
-35
@@ -7,6 +7,7 @@
|
|||||||
* ?was=kurve Verlauf einer einzelnen Ladung (SoC und kW über Zeit)
|
* ?was=kurve Verlauf einer einzelnen Ladung (SoC und kW über Zeit)
|
||||||
* ?was=gesundheit kWh je 100 Ladestandsprozent, je Ladung, mit Trend
|
* ?was=gesundheit kWh je 100 Ladestandsprozent, je Ladung, mit Trend
|
||||||
* ?was=fahrten Fahrten zwischen zwei Parkständen, mit Koordinaten
|
* ?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
|
* Die Fahrzeugdaten stehen in der Tabelle skoda, geschrieben von
|
||||||
* gatherSkodaData.py. Der Zeilentakt ist nicht fest: geschrieben wird, wenn
|
* gatherSkodaData.py. Der Zeilentakt ist nicht fest: geschrieben wird, wenn
|
||||||
@@ -41,54 +42,100 @@ if ($mysql->connect_errno) {
|
|||||||
$mysql->set_charset("utf8mb4");
|
$mysql->set_charset("utf8mb4");
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Ladevorgänge aus den Einzelzeilen schneiden. Aufeinanderfolgende Zeilen mit
|
* Ladevorgänge aus den Einzelzeilen schneiden. Wird von mehreren Abschnitten
|
||||||
* chg_state = 'CHARGING' gehören zusammen, eine Pause über 30 Minuten trennt
|
* gebraucht, deshalb einmal hier.
|
||||||
* zwei Vorgänge. 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
|
* Die Energie kommt aus der Differenz der Wallbox-Zählerstände statt aus einer
|
||||||
* Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde.
|
* Summe über Leistungswerte - das trifft den Ladevorgang auf die Wattstunde.
|
||||||
* Steht der Zähler still, wurde auswärts geladen (Schnellladesäule); dann
|
* Ein Zählerstand 0 ist kein Stand, sondern ein ausgefallener Abruf der
|
||||||
* bleibt wh null und die Kapazitätsrechnung lässt die Ladung aus.
|
* 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)
|
function sitzungenSQL($seit)
|
||||||
{
|
{
|
||||||
|
$luecke = LADUNG_LUECKE_S;
|
||||||
return "
|
return "
|
||||||
SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende,
|
SELECT MIN(datetime) AS beginn, MAX(datetime) AS ende,
|
||||||
MIN(soc) AS soc_von, MAX(soc) AS soc_bis,
|
MIN(soc_start) AS soc_von, MAX(soc) AS soc_bis,
|
||||||
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc) AS SIGNED) AS zuwachs,
|
CAST(MAX(soc) AS SIGNED) - CAST(MIN(soc_start) AS SIGNED) AS zuwachs,
|
||||||
MAX(chg_type) AS art, MAX(target_soc) AS ziel,
|
MAX(chg_type) AS art, MAX(target_soc) AS ziel,
|
||||||
ROUND(AVG(chg_kw), 2) AS kw_mittel, ROUND(MAX(chg_kw), 2) AS kw_spitze,
|
ROUND(AVG(CASE WHEN laedt THEN chg_kw END), 2) AS kw_mittel,
|
||||||
MAX(wb_wh_total) - MIN(wb_wh_total) AS wh,
|
ROUND(MAX(CASE WHEN laedt THEN chg_kw END), 2) AS kw_spitze,
|
||||||
ROUND(SUM(kwh_teil), 3) AS kwh_fahrzeug,
|
COALESCE(MAX(NULLIF(wb_wh_total, 0)) - MIN(wh_start), 0) AS wh,
|
||||||
ROUND(AVG(pv_kw), 2) AS pv_kw, COUNT(*) AS punkte
|
/*
|
||||||
|
* 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 (
|
FROM (
|
||||||
SELECT s.*, SUM(s.neu) OVER (ORDER BY s.datetime) AS sitzung FROM (
|
SELECT b.*, SUM(b.neu) OVER (ORDER BY b.datetime) AS sitzung FROM (
|
||||||
SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
|
SELECT a.*,
|
||||||
wb_wh_total, pv_kw,
|
a.chg_state = 'CHARGING' AS laedt,
|
||||||
CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
|
(a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke) AS weiter,
|
||||||
AND TIMESTAMPDIFF(MINUTE, LAG(datetime) OVER w, datetime) <= 30
|
CASE WHEN a.chg_state = 'CHARGING'
|
||||||
THEN 0 ELSE 1 END AS neu,
|
AND NOT (a.st_vor <=> 'CHARGING' AND a.dt_vor <= $luecke)
|
||||||
|
THEN 1 ELSE 0 END AS neu,
|
||||||
|
-- Beginn aus der Zeile davor, wenn sie nah genug liegt.
|
||||||
|
CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
|
||||||
|
AND a.dt_vor <= $luecke
|
||||||
|
THEN LEAST(a.soc, COALESCE(a.soc_vor, a.soc))
|
||||||
|
ELSE a.soc END AS soc_start,
|
||||||
|
CASE WHEN a.chg_state = 'CHARGING' AND NOT (a.st_vor <=> 'CHARGING')
|
||||||
|
AND a.dt_vor <= $luecke
|
||||||
|
THEN COALESCE(NULLIF(a.wh_vor, 0), NULLIF(a.wb_wh_total, 0))
|
||||||
|
ELSE NULLIF(a.wb_wh_total, 0) END AS wh_start,
|
||||||
/*
|
/*
|
||||||
* Fahrzeugseitige Energie: chg_kw ist die Ladeleistung, die
|
* chg_kw ist die Ladeleistung, die das Fahrzeug selbst meldet -
|
||||||
* das Fahrzeug selbst meldet - gemessen HINTER dem Ladegerät.
|
* gemessen HINTER dem Ladegerät. Die API kennt keine
|
||||||
* Die API kennt keine Kilowattstunden, deshalb wird hier über
|
* Kilowattstunden, deshalb wird über die Zeit integriert, je
|
||||||
* die Zeit integriert, je Schritt mit dem Mittel aus vorigem
|
* Schritt mit dem Mittel aus vorigem und aktuellem Wert.
|
||||||
* und aktuellem Wert (Trapez). Beim Laden mit Wechselstrom
|
|
||||||
* liegt die Leistung fast konstant an, da ist das genau genug.
|
|
||||||
*
|
*
|
||||||
* Die Bedingung hält die Summe innerhalb einer Ladung: über
|
* Über mehr als 30 Minuten ohne Meldung wäre die Fläche unter
|
||||||
* eine Lücke von mehr als 30 Minuten hinweg wäre die Fläche
|
* der Kurve erfunden: die Leistung kann in der Zeit von 4 auf
|
||||||
* unter der Kurve frei erfunden.
|
* 10 kW gestiegen sein, ohne dass eine Zeile davon weiß.
|
||||||
|
* zu_lang merkt sich das, und die Ladung bekommt dann keinen
|
||||||
|
* fahrzeugseitigen Wert.
|
||||||
*/
|
*/
|
||||||
CASE WHEN LAG(chg_state) OVER w <=> 'CHARGING'
|
CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor BETWEEN 1 AND $luecke
|
||||||
AND TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime)
|
THEN (COALESCE(a.chg_kw, 0) + COALESCE(a.kw_vor, 0)) / 2
|
||||||
BETWEEN 1 AND 1800
|
* a.dt_vor / 3600
|
||||||
THEN (COALESCE(chg_kw, 0) + COALESCE(LAG(chg_kw) OVER w, 0)) / 2
|
ELSE 0 END AS kwh_teil,
|
||||||
* TIMESTAMPDIFF(SECOND, LAG(datetime) OVER w, datetime) / 3600
|
CASE WHEN a.st_vor <=> 'CHARGING' AND a.dt_vor > 1800
|
||||||
ELSE 0 END AS kwh_teil
|
THEN 1 ELSE 0 END AS zu_lang
|
||||||
FROM skoda WHERE datetime >= $seit
|
FROM (
|
||||||
WINDOW w AS (ORDER BY datetime)) s) t
|
SELECT datetime, soc, chg_state, chg_type, chg_kw, target_soc,
|
||||||
WHERE chg_state = 'CHARGING'
|
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";
|
GROUP BY sitzung";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +213,154 @@ function kapazitaetSchaetzen($mysql)
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verlauf einer Ladung: Wallbox-Leistung dicht, Ladestand dazwischen gefüllt.
|
||||||
|
*
|
||||||
|
* Das Fahrzeug meldet beim Laden an der Wallbox mitunter nur einmal in der
|
||||||
|
* Stunde, die Wallbox steht in skoda_ladepunkte im Minutentakt (siehe
|
||||||
|
* solarLog_skoda_ladepunkte.sql). Der Ladestand zwischen zwei Meldungen wird
|
||||||
|
* nach der geflossenen Energie aufgefüllt, nicht nach der Zeit:
|
||||||
|
*
|
||||||
|
* soc(t) = soc_a + (soc_b - soc_a) * (wh(t) - wh_a) / (wh_b - wh_a)
|
||||||
|
*
|
||||||
|
* Das braucht weder Akkukapazität noch Wirkungsgrad. Gegengeprüft am
|
||||||
|
* 13.09.2026: zwischen 56 % (09:59) und 70 % (11:22) ergibt es für 10:29
|
||||||
|
* 59,6 % - gemeldet waren 60 %. Nach der Zeit wären es 61,1 % gewesen.
|
||||||
|
*
|
||||||
|
* Eine Meldung gilt für den Zeitpunkt, zu dem das Fahrzeug sie erfasst hat
|
||||||
|
* (cap_chg), nicht für den Abruf - dazwischen können Minuten mit 11 kW
|
||||||
|
* liegen. Der Zählerstand zu diesem Zeitpunkt kommt deshalb aus dem
|
||||||
|
* Wallbox-Verlauf selbst und nicht aus der Zeile.
|
||||||
|
*
|
||||||
|
* Vor der ersten und nach der letzten Meldung bleibt der Ladestand leer:
|
||||||
|
* hochgerechnet wird nichts.
|
||||||
|
*
|
||||||
|
* Liefert [verlauf, meldungen, wallbox]; ohne Punkte in skoda_ladepunkte
|
||||||
|
* zwei leere Listen - dann zeichnet die Seite wie früher aus den Zeilen.
|
||||||
|
*/
|
||||||
|
function ladeverlauf($mysql, $von, $bis)
|
||||||
|
{
|
||||||
|
// Welche Wallbox: die, die im Zeitraum Energie abgegeben hat.
|
||||||
|
$stmt = $mysql->prepare(
|
||||||
|
"SELECT wallbox, MAX(wh_total) - MIN(wh_total) AS wh, COUNT(*) AS n
|
||||||
|
FROM skoda_ladepunkte
|
||||||
|
WHERE datetime BETWEEN (? - INTERVAL 30 MINUTE) AND (? + INTERVAL 30 MINUTE)
|
||||||
|
GROUP BY wallbox ORDER BY wh DESC, n DESC LIMIT 1");
|
||||||
|
if (!$stmt) {
|
||||||
|
return [[], [], null]; // Tabelle fehlt noch
|
||||||
|
}
|
||||||
|
$stmt->bind_param("ss", $von, $bis);
|
||||||
|
$stmt->execute();
|
||||||
|
$wahl = $stmt->get_result()->fetch_assoc();
|
||||||
|
if (!$wahl) {
|
||||||
|
return [[], [], null];
|
||||||
|
}
|
||||||
|
$wallbox = $wahl["wallbox"];
|
||||||
|
$spalte = $wallbox === "og" ? "wbog_wh_total" : "wb_wh_total";
|
||||||
|
|
||||||
|
$stmt = $mysql->prepare(
|
||||||
|
"SELECT datetime, kw, wh_total, quelle FROM skoda_ladepunkte
|
||||||
|
WHERE wallbox = ? AND datetime BETWEEN (? - INTERVAL 30 MINUTE) AND (? + INTERVAL 30 MINUTE)
|
||||||
|
ORDER BY datetime");
|
||||||
|
$stmt->bind_param("sss", $wallbox, $von, $bis);
|
||||||
|
$stmt->execute();
|
||||||
|
$res = $stmt->get_result();
|
||||||
|
$verlauf = [];
|
||||||
|
while ($z = $res->fetch_assoc()) {
|
||||||
|
$verlauf[] = [
|
||||||
|
"datetime" => $z["datetime"],
|
||||||
|
"t" => strtotime($z["datetime"]),
|
||||||
|
"kw" => floatval($z["kw"]),
|
||||||
|
"wh" => $z["wh_total"] === null ? null : intval($z["wh_total"]),
|
||||||
|
"quelle" => $z["quelle"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!$verlauf) {
|
||||||
|
return [[], [], null];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zählerstand zu einem beliebigen Zeitpunkt, aus dem Verlauf. Vor dem
|
||||||
|
// ersten Punkt ruhte die Wallbox - dann gilt der Stand, den die Zeile
|
||||||
|
// selbst trägt.
|
||||||
|
$zaehlerBei = function ($t, $ersatz) use ($verlauf) {
|
||||||
|
$vor = null;
|
||||||
|
foreach ($verlauf as $p) {
|
||||||
|
if ($p["wh"] === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($p["t"] >= $t) {
|
||||||
|
if ($vor === null) {
|
||||||
|
return $ersatz ?: $p["wh"];
|
||||||
|
}
|
||||||
|
$spanne = $p["t"] - $vor["t"];
|
||||||
|
return $spanne > 0
|
||||||
|
? $vor["wh"] + ($p["wh"] - $vor["wh"]) * ($t - $vor["t"]) / $spanne
|
||||||
|
: $p["wh"];
|
||||||
|
}
|
||||||
|
$vor = $p;
|
||||||
|
}
|
||||||
|
return $vor ? $vor["wh"] : $ersatz;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Die Meldungen des Fahrzeugs, die den Verlauf einrahmen: die letzte vor
|
||||||
|
// dem ersten Punkt, alle dazwischen, die erste nach dem letzten.
|
||||||
|
$erster = $verlauf[0]["datetime"];
|
||||||
|
$letzter = end($verlauf)["datetime"];
|
||||||
|
$stmt = $mysql->prepare(
|
||||||
|
"(SELECT COALESCE(cap_chg, datetime) AS erfasst, soc, chg_kw, chg_state, $spalte AS wh
|
||||||
|
FROM skoda WHERE COALESCE(cap_chg, datetime) <= ? AND soc IS NOT NULL
|
||||||
|
ORDER BY erfasst DESC LIMIT 1)
|
||||||
|
UNION
|
||||||
|
(SELECT COALESCE(cap_chg, datetime), soc, chg_kw, chg_state, $spalte
|
||||||
|
FROM skoda WHERE COALESCE(cap_chg, datetime) > ? AND COALESCE(cap_chg, datetime) < ?
|
||||||
|
AND soc IS NOT NULL)
|
||||||
|
UNION
|
||||||
|
(SELECT COALESCE(cap_chg, datetime), soc, chg_kw, chg_state, $spalte
|
||||||
|
FROM skoda WHERE COALESCE(cap_chg, datetime) >= ? AND soc IS NOT NULL
|
||||||
|
ORDER BY 1 LIMIT 1)
|
||||||
|
ORDER BY erfasst");
|
||||||
|
$stmt->bind_param("ssss", $erster, $erster, $letzter, $letzter);
|
||||||
|
$stmt->execute();
|
||||||
|
$res = $stmt->get_result();
|
||||||
|
$meldungen = [];
|
||||||
|
while ($z = $res->fetch_assoc()) {
|
||||||
|
$t = strtotime($z["erfasst"]);
|
||||||
|
$meldungen[] = [
|
||||||
|
"datetime" => $z["erfasst"],
|
||||||
|
"t" => $t,
|
||||||
|
"soc" => intval($z["soc"]),
|
||||||
|
"chg_kw" => $z["chg_kw"] === null ? null : floatval($z["chg_kw"]),
|
||||||
|
"chg_state"=> $z["chg_state"],
|
||||||
|
"wh" => $zaehlerBei($t, intval($z["wh"])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ladestand je Punkt, zwischen den beiden Meldungen, die ihn einrahmen.
|
||||||
|
foreach ($verlauf as &$p) {
|
||||||
|
$p["soc"] = null;
|
||||||
|
for ($i = 1; $p["wh"] !== null && $i < count($meldungen); $i++) {
|
||||||
|
$a = $meldungen[$i - 1];
|
||||||
|
$b = $meldungen[$i];
|
||||||
|
if ($p["t"] < $a["t"] || $p["t"] > $b["t"]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$spanne = $b["wh"] - $a["wh"];
|
||||||
|
$p["soc"] = $spanne > 0
|
||||||
|
? round($a["soc"] + ($b["soc"] - $a["soc"]) * ($p["wh"] - $a["wh"]) / $spanne, 1)
|
||||||
|
: floatval($a["soc"]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
unset($p["t"]);
|
||||||
|
}
|
||||||
|
unset($p);
|
||||||
|
foreach ($meldungen as &$m) {
|
||||||
|
unset($m["t"], $m["wh"]);
|
||||||
|
}
|
||||||
|
unset($m);
|
||||||
|
|
||||||
|
return [$verlauf, $meldungen, $wallbox];
|
||||||
|
}
|
||||||
|
|
||||||
/** Median eines nicht leeren Zahlenfeldes. */
|
/** Median eines nicht leeren Zahlenfeldes. */
|
||||||
function median($werte)
|
function median($werte)
|
||||||
{
|
{
|
||||||
@@ -236,6 +431,9 @@ case "kurve":
|
|||||||
while ($z = $res->fetch_assoc()) { $antwort["punkte"][] = $z; }
|
while ($z = $res->fetch_assoc()) { $antwort["punkte"][] = $z; }
|
||||||
$antwort["von"] = $von;
|
$antwort["von"] = $von;
|
||||||
$antwort["bis"] = $bis;
|
$antwort["bis"] = $bis;
|
||||||
|
|
||||||
|
list($antwort["verlauf"], $antwort["meldungen"], $antwort["wallbox"])
|
||||||
|
= ladeverlauf($mysql, $von, $bis);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
/* ------------------------------------------------------------ gesundheit -- */
|
/* ------------------------------------------------------------ gesundheit -- */
|
||||||
@@ -358,6 +556,52 @@ case "fahrten":
|
|||||||
$antwort["kapazitaet"] = kapazitaetSchaetzen($mysql);
|
$antwort["kapazitaet"] = kapazitaetSchaetzen($mysql);
|
||||||
break;
|
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:
|
default:
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
$antwort = ["error" => "Unbekannter Abschnitt."];
|
$antwort = ["error" => "Unbekannter Abschnitt."];
|
||||||
|
|||||||
+39
-73
@@ -14,28 +14,25 @@
|
|||||||
* ein vorausgeladenes Bild oder einen Klick auf eine fremde Seite fernsteuerbar.
|
* ein vorausgeladenes Bild oder einen Klick auf eine fremde Seite fernsteuerbar.
|
||||||
*
|
*
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
* Zwei Schlüssel, zwei Kontingente
|
* Ein Kontingent für alles
|
||||||
*
|
*
|
||||||
* Die API begrenzt auf 20 Anfragen je Stunde **und Schlüssel**, und Befehle
|
* Die API begrenzt auf 20 Anfragen je Stunde, und Befehle zählen genauso mit
|
||||||
* zählen genauso mit wie Abrufe. Steht in skoda.conf ein eigener
|
* wie Abrufe. Die Doku schreibt "je Schlüssel" - gemessen stimmt das nicht,
|
||||||
* CMD_API_KEY, hat die Steuerung damit ihr eigenes Kontingent und nimmt dem
|
* der Zähler gilt für alle Schlüssel des Kontos zusammen (die Rechnung steht
|
||||||
* Abruf des Fahrzeugzustands nichts weg. Beide Schlüssel werden in der
|
* bei _LIMIT in gatherSkodaData.py). Mehrere Schlüssel bringen also kein
|
||||||
* MyŠkoda-App angelegt.
|
* zusätzliches Kontingent.
|
||||||
*
|
*
|
||||||
* Ohne eigenen Schlüssel greift dieses Skript auf API_KEY zurück - dann
|
* Abruf und Steuerung laufen in getrennten Prozessen ohne gemeinsamen Zähler,
|
||||||
* teilen sich beide ein Kontingent, und weil sie in getrennten Prozessen
|
* deshalb ist fest aufgeteilt:
|
||||||
* laufen und keinen gemeinsamen Zähler haben, ist es fest aufgeteilt:
|
|
||||||
*
|
*
|
||||||
* eigener CMD_API_KEY nur API_KEY
|
* Abruf 14 / Stunde (gatherSkodaData.py)
|
||||||
* Abruf 18 / Stunde 14 / Stunde
|
* Befehle 4 / Stunde
|
||||||
* Befehle 15 / Stunde 4 / Stunde
|
* Reserve 2 / Stunde
|
||||||
* Reserve 2 / Stunde 2 / Stunde
|
|
||||||
*
|
*
|
||||||
* gatherSkodaData.py stellt sich selbst um, sobald der zweite Schlüssel in
|
* Ein eigener CMD_API_KEY trennt damit nicht die Kontingente, sondern nur,
|
||||||
* skoda.conf auftaucht - dort ist nichts von Hand nachzuziehen.
|
* wer womit fragt - er lässt sich einzeln widerrufen, ohne den Abruf
|
||||||
*
|
* mitzunehmen. Welchen dieses Skript nimmt, entscheidet
|
||||||
* Der zweite Schlüssel ist kein Freibrief, den Abruf zu beschleunigen: die
|
* skodaBefehlsSchluessel(): den eigenen, wenn es ihn gibt, sonst API_KEY.
|
||||||
* Kachelrichtlinie zielt auf die Last, nicht auf die Buchführung.
|
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
*
|
*
|
||||||
* Nicht angebunden ist die Standheizung. Ihr Start verlangt laut Spec die
|
* Nicht angebunden ist die Standheizung. Ihr Start verlangt laut Spec die
|
||||||
@@ -46,24 +43,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require_once("../helper.php");
|
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');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
const API_BASIS = "https://public.api.connect.skoda-auto.cz/api/v1/vehicles/";
|
|
||||||
const KONFIG = "/volume1/homes/wagner/SolarManager/skoda.conf";
|
|
||||||
const KENNUNG = "SmartController-SkodaSteuerung/1.0 (+https://nas.el-wa.org/smart)";
|
|
||||||
|
|
||||||
/** Befehle je Stunde - siehe Kopfkommentar. Hängt davon ab, ob die
|
|
||||||
* Steuerung einen eigenen Schlüssel hat oder ihn mit dem Abruf teilt. */
|
|
||||||
const BEFEHLE_EIGENER_SCHLUESSEL = 15;
|
|
||||||
const BEFEHLE_GETEILT = 4;
|
|
||||||
|
|
||||||
/** Wo der Stundenzähler liegt. Muss für den Webserver beschreibbar sein. */
|
|
||||||
const ZAEHLER_ORTE = [
|
|
||||||
"/volume1/web/smart/tiles", // falls per synoacltool freigegeben
|
|
||||||
"/var/services/tmp/smart-tiles", // sonst
|
|
||||||
];
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Die erlaubten Befehle. Der Schlüssel kommt aus dem Formular, der Pfad
|
* Die erlaubten Befehle. Der Schlüssel kommt aus dem Formular, der Pfad
|
||||||
* niemals - so kann über den Parameter kein beliebiger Endpunkt der API
|
* niemals - so kann über den Parameter kein beliebiger Endpunkt der API
|
||||||
@@ -93,18 +79,16 @@ if (!isset(BEFEHLE[$name])) {
|
|||||||
}
|
}
|
||||||
$befehl = BEFEHLE[$name];
|
$befehl = BEFEHLE[$name];
|
||||||
|
|
||||||
$konfig = konfigLesen();
|
$konfig = skodaBefehlsSchluessel();
|
||||||
if (!$konfig) {
|
if (!$konfig) {
|
||||||
antwort(503, false, "Kein API-Schlüssel hinterlegt - skoda.conf fehlt.");
|
antwort(503, false, "Kein API-Schlüssel hinterlegt - unter Einstellungen "
|
||||||
|
. "▸ Fahrzeug lässt sich einer eintragen.");
|
||||||
}
|
}
|
||||||
|
|
||||||
$grenze = $konfig["EIGENER"] ? BEFEHLE_EIGENER_SCHLUESSEL : BEFEHLE_GETEILT;
|
if (!budgetFrei(SKODA_BEFEHLE)) {
|
||||||
if (!budgetFrei($grenze)) {
|
|
||||||
antwort(429, false, "Stündliches Kontingent für Befehle erschöpft ("
|
antwort(429, false, "Stündliches Kontingent für Befehle erschöpft ("
|
||||||
. $grenze . " je Stunde)."
|
. SKODA_BEFEHLE . " je Stunde). Es teilt sich die 20 Anfragen der API"
|
||||||
. ($konfig["EIGENER"] ? "" : " Ein eigener CMD_API_KEY in skoda.conf"
|
. " mit dem Abruf des Fahrzeugzustands. Später erneut versuchen.");
|
||||||
. " würde der Steuerung ein eigenes Kontingent geben.")
|
|
||||||
. " Später erneut versuchen.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- Ausführen */
|
/* ---------------------------------------------------------------- Ausführen */
|
||||||
@@ -121,8 +105,9 @@ if ($name === "klima_start") {
|
|||||||
list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf);
|
list($status, $inhalt) = senden($konfig, $befehl["pfad"], $rumpf);
|
||||||
|
|
||||||
if ($status === 202) {
|
if ($status === 202) {
|
||||||
|
skodaAbrufAnstossen();
|
||||||
antwort(200, true, $befehl["text"] . ". Das Fahrzeug führt den Befehl aus; "
|
antwort(200, true, $befehl["text"] . ". Das Fahrzeug führt den Befehl aus; "
|
||||||
. "in der Anzeige erscheint es beim nächsten Abruf.");
|
. "in der Anzeige erscheint es in einer halben Minute.");
|
||||||
}
|
}
|
||||||
antwort(200, false, fehlertext($status, $inhalt));
|
antwort(200, false, fehlertext($status, $inhalt));
|
||||||
|
|
||||||
@@ -136,39 +121,19 @@ function antwort($code, $ok, $text)
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** API-Schlüssel und VIN aus skoda.conf lesen. Format: NAME=wert je Zeile. */
|
|
||||||
function konfigLesen()
|
|
||||||
{
|
|
||||||
if (!is_readable(KONFIG)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$werte = [];
|
|
||||||
foreach (file(KONFIG, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $zeile) {
|
|
||||||
$zeile = trim($zeile);
|
|
||||||
if ($zeile === "" || $zeile[0] === "#" || strpos($zeile, "=") === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
list($k, $v) = explode("=", $zeile, 2);
|
|
||||||
$werte[strtoupper(trim($k))] = trim(trim($v), "\"'");
|
|
||||||
}
|
|
||||||
if (empty($werte["API_KEY"]) || empty($werte["VIN"])) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Eigener Schluessel fuer die Steuerung, wenn hinterlegt - sonst der
|
|
||||||
// gemeinsame. Welcher es wurde, entscheidet unten ueber das Budget.
|
|
||||||
$werte["EIGENER"] = !empty($werte["CMD_API_KEY"]);
|
|
||||||
$werte["SCHLUESSEL"] = $werte["EIGENER"] ? $werte["CMD_API_KEY"] : $werte["API_KEY"];
|
|
||||||
return $werte;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stundenzähler für Befehle. Gleiches Verfahren wie die Bremse in tile.php:
|
* Stundenzähler für Befehle. Gleiches Verfahren wie die Bremse in tile.php:
|
||||||
* eine kleine Datei, gegen gleichzeitige Zugriffe mit flock abgesichert.
|
* 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)
|
function budgetFrei($grenze)
|
||||||
{
|
{
|
||||||
$ort = null;
|
$ort = null;
|
||||||
foreach (ZAEHLER_ORTE as $kandidat) {
|
foreach (SKODA_ARBEITSORTE as $kandidat) {
|
||||||
if (is_dir($kandidat) && is_writable($kandidat)) {
|
if (is_dir($kandidat) && is_writable($kandidat)) {
|
||||||
$ort = $kandidat;
|
$ort = $kandidat;
|
||||||
break;
|
break;
|
||||||
@@ -180,7 +145,7 @@ function budgetFrei($grenze)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$griff = fopen($ort . "/.befehle.json", "c+");
|
$griff = fopen($ort . "/" . SKODA_ZAEHLER_DATEI, "c+");
|
||||||
if (!$griff || !flock($griff, LOCK_EX)) {
|
if (!$griff || !flock($griff, LOCK_EX)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -207,16 +172,16 @@ function budgetFrei($grenze)
|
|||||||
/** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */
|
/** Den Befehl an die API schicken. Liefert [HTTP-Status, Rumpf]. */
|
||||||
function senden($konfig, $pfad, $rumpf)
|
function senden($konfig, $pfad, $rumpf)
|
||||||
{
|
{
|
||||||
$url = API_BASIS . rawurlencode($konfig["VIN"]) . "/" . $pfad;
|
$url = SKODA_API . rawurlencode($konfig["vin"]) . "/" . $pfad;
|
||||||
$kopf = [
|
$kopf = [
|
||||||
"X-API-Key: " . $konfig["SCHLUESSEL"],
|
"X-API-Key: " . $konfig["schluessel"],
|
||||||
"Accept: application/json",
|
"Accept: application/json",
|
||||||
];
|
];
|
||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
$optionen = [
|
$optionen = [
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_USERAGENT => KENNUNG,
|
CURLOPT_USERAGENT => SKODA_KENNUNG,
|
||||||
CURLOPT_TIMEOUT => 20,
|
CURLOPT_TIMEOUT => 20,
|
||||||
CURLOPT_CONNECTTIMEOUT => 5,
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
];
|
];
|
||||||
@@ -257,7 +222,8 @@ function fehlertext($status, $inhalt)
|
|||||||
switch ($status) {
|
switch ($status) {
|
||||||
case 0: return "Die Škoda-API ist nicht erreichbar.";
|
case 0: return "Die Škoda-API ist nicht erreichbar.";
|
||||||
case 400: return "Die API hat den Befehl abgelehnt." . $detail;
|
case 400: return "Die API hat den Befehl abgelehnt." . $detail;
|
||||||
case 401: return "Der API-Schlüssel ist abgelaufen - in der MyŠkoda-App erneuern.";
|
case 401: return "Der API-Schlüssel ist abgelaufen - in der MyŠkoda-App "
|
||||||
|
. "erneuern und unter Einstellungen ▸ Fahrzeug eintragen.";
|
||||||
case 403: return "Der Schlüssel darf diesen Befehl nicht ausführen." . $detail;
|
case 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 404: return "Fahrzeug nicht gefunden - stimmt die VIN in skoda.conf?";
|
||||||
case 422: return "Das Fahrzeug kann das gerade nicht." . $detail;
|
case 422: return "Das Fahrzeug kann das gerade nicht." . $detail;
|
||||||
|
|||||||
+1732
-127
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS `automations` (
|
|||||||
`weekdays` tinyint(3) unsigned NOT NULL DEFAULT 127 COMMENT 'Bitmaske: Bit 0 = Montag ... Bit 6 = Sonntag',
|
`weekdays` tinyint(3) unsigned NOT NULL DEFAULT 127 COMMENT 'Bitmaske: Bit 0 = Montag ... Bit 6 = Sonntag',
|
||||||
`on_vacation` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch in den Ferien ausfuehren',
|
`on_vacation` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch in den Ferien ausfuehren',
|
||||||
`on_holiday` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch an Feiertagen ausfuehren',
|
`on_holiday` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Auch an Feiertagen ausfuehren',
|
||||||
|
`next_day` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Wochentage, Ferien und Feiertage gelten fuer morgen (Vorabend). Nachgeruestet mit vorabend.sql im SolarManager',
|
||||||
`force_once` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Am Ende des Zeitraums auf jeden Fall ausfuehren',
|
`force_once` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Am Ende des Zeitraums auf jeden Fall ausfuehren',
|
||||||
`lockout_secs` int(11) NOT NULL DEFAULT 60 COMMENT 'Sperrzeit: so lange nach einer Ausloesung wird nicht erneut geschaltet. Gegen Messwerte, die um die Schwelle pendeln - jedes Ueberschreiten waere sonst eine echte Flanke. Der Editor bietet 0, 60 und 900 an',
|
`lockout_secs` int(11) NOT NULL DEFAULT 60 COMMENT 'Sperrzeit: so lange nach einer Ausloesung wird nicht erneut geschaltet. Gegen Messwerte, die um die Schwelle pendeln - jedes Ueberschreiten waere sonst eine echte Flanke. Der Editor bietet 0, 60 und 900 an',
|
||||||
`cond_met` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'War die Bedingung beim letzten Durchlauf erfuellt? Nur die steigende Flanke loest aus, sonst wuerde Temperatur groesser 22 im Sekundentakt feuern',
|
`cond_met` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'War die Bedingung beim letzten Durchlauf erfuellt? Nur die steigende Flanke loest aus, sonst wuerde Temperatur groesser 22 im Sekundentakt feuern',
|
||||||
|
|||||||
@@ -100,13 +100,13 @@ if (isset($_GET["addUser"])) {
|
|||||||
*/
|
*/
|
||||||
$pages = [
|
$pages = [
|
||||||
"solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, "leaflet" => false,
|
"solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, "leaflet" => false,
|
||||||
"scripts" => ["js/solar/solarMQTT.js"]],
|
"scripts" => ["js/solar/jahresstatistik.js", "js/solar/solarMQTT.js"]],
|
||||||
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false,
|
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false,
|
||||||
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]],
|
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]],
|
||||||
"heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false,
|
"heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false,
|
||||||
"scripts" => ["js/solar/heatMQTT.js"]],
|
"scripts" => ["js/solar/heatMQTT.js"]],
|
||||||
"history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, "leaflet" => false,
|
"history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, "leaflet" => false,
|
||||||
"scripts" => ["js/solar/historyMQTT.js"]],
|
"scripts" => ["js/solar/jahresstatistik.js", "js/solar/historyMQTT.js"]],
|
||||||
"skoda" => ["template" => "skoda.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => true,
|
"skoda" => ["template" => "skoda.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => true,
|
||||||
"scripts" => ["js/solar/skodaMQTT.js"]],
|
"scripts" => ["js/solar/skodaMQTT.js"]],
|
||||||
"weather" => ["template" => "weather.php", "mqtt" => true, "charts" => false, "meteogram" => true, "leaflet" => false,
|
"weather" => ["template" => "weather.php", "mqtt" => true, "charts" => false, "meteogram" => true, "leaflet" => false,
|
||||||
|
|||||||
+841
-169
File diff suppressed because it is too large
Load Diff
+33
-13
@@ -73,19 +73,6 @@ async function getData(chart, url, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vorgerendertes HTML-Schnipsel in ein Element laden. */
|
|
||||||
async function getStats(elem_id, url) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Response status: ${response.status}`);
|
|
||||||
}
|
|
||||||
document.getElementById(elem_id).innerHTML = await response.text();
|
|
||||||
} catch (error) {
|
|
||||||
console.log("getStats " + url + ": " + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formular des Modals per POST abschicken.
|
* Formular des Modals per POST abschicken.
|
||||||
* Der Start/Stop-Knopf der Ladesteuerung schickt nur sich selbst.
|
* Der Start/Stop-Knopf der Ladesteuerung schickt nur sich selbst.
|
||||||
@@ -228,3 +215,36 @@ function updateWeatherCards(value) {
|
|||||||
document.getElementById("windGust").innerHTML = value("maxgust");
|
document.getElementById("windGust").innerHTML = value("maxgust");
|
||||||
document.getElementById("ambPress").innerHTML = Math.round(value("qff") * 10) / 10;
|
document.getElementById("ambPress").innerHTML = Math.round(value("qff") * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offenen Reiter in der Adresse merken.
|
||||||
|
*
|
||||||
|
* Gilt fuer jede Leiste, die restricted/reiter.php mit "merken" gebaut hat -
|
||||||
|
* erkennbar an data-reiter-merken. Ohne das landet man nach jedem Neuladen
|
||||||
|
* wieder im ersten Reiter, obwohl man gerade woanders gearbeitet hat.
|
||||||
|
*
|
||||||
|
* In der Adresse steht nur das Kuerzel des Reiters (#kacheln), nicht der Name
|
||||||
|
* der Leiste: es ist eine Adresse, die man auch von Hand tippt. Zwei merkende
|
||||||
|
* Leisten auf einer Seite waeren damit nicht zu unterscheiden - dann muesste
|
||||||
|
* hier der Name davor, und die Adresse waere haesslicher als noetig.
|
||||||
|
*/
|
||||||
|
function reiterMerken() {
|
||||||
|
if (typeof bootstrap === "undefined") return;
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-reiter-merken]").forEach(function (leiste) {
|
||||||
|
const knoepfe = leiste.querySelectorAll("[data-reiter]");
|
||||||
|
const gewuenscht = location.hash.replace("#", "");
|
||||||
|
|
||||||
|
knoepfe.forEach(function (knopf) {
|
||||||
|
knopf.addEventListener("shown.bs.tab", function () {
|
||||||
|
history.replaceState(null, "", "#" + knopf.dataset.reiter);
|
||||||
|
});
|
||||||
|
if (gewuenscht && knopf.dataset.reiter === gewuenscht) {
|
||||||
|
new bootstrap.Tab(knopf).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", reiterMerken);
|
||||||
|
|||||||
@@ -145,8 +145,7 @@ Chart.defaults.plugins.legend.labels.generateLabels = legendLabels;
|
|||||||
getData(consChartYear, 'ajax/energyHistory.php?series=cons&range=year');
|
getData(consChartYear, 'ajax/energyHistory.php?series=cons&range=year');
|
||||||
getData(prodChartDecade, 'ajax/energyHistory.php?series=prod&range=decade');
|
getData(prodChartDecade, 'ajax/energyHistory.php?series=prod&range=decade');
|
||||||
getData(consChartDecade, 'ajax/energyHistory.php?series=cons&range=decade');
|
getData(consChartDecade, 'ajax/energyHistory.php?series=cons&range=decade');
|
||||||
getStats("Stats-Year","ajax/getStats.php?type=ThisYear");
|
baueJahresstatistik(document.getElementById("jahresvergleich"),
|
||||||
getStats("Stats-Lastyear","ajax/getStats.php?type=LastYear");
|
{ spalten: 3, werkzeuge: true, fussnoten: true });
|
||||||
getStats("Stats-Prelastyear","ajax/getStats.php?type=PreLastYear");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+19
-15
@@ -122,6 +122,12 @@ const homeSVG = {
|
|||||||
* Wie er dasteht, sagt entweder "format" oder das Paar aus "einheit" und
|
* Wie er dasteht, sagt entweder "format" oder das Paar aus "einheit" und
|
||||||
* "stellen": "stellen" schneidet Nachkommastellen ab, ohne Angabe bleibt
|
* "stellen": "stellen" schneidet Nachkommastellen ab, ohne Angabe bleibt
|
||||||
* der Text so, wie das Geraet ihn meldet.
|
* der Text so, wie das Geraet ihn meldet.
|
||||||
|
*
|
||||||
|
* "negativ" dreht das Vorzeichen. Ein Zaehler zaehlt in der Richtung, in
|
||||||
|
* der er eingebaut ist - einer der beiden meldet den Bezug deshalb mit
|
||||||
|
* Minus. Das gehoert an die Anzeige und nicht an den Zaehler: die
|
||||||
|
* Automatiken und die Statistik lesen dieselbe Nachricht und rechnen mit
|
||||||
|
* dem Vorzeichen, das dort steht.
|
||||||
*/
|
*/
|
||||||
kachelWert: function (mqttData, wert) {
|
kachelWert: function (mqttData, wert) {
|
||||||
const roh = wert.wechselrichter
|
const roh = wert.wechselrichter
|
||||||
@@ -138,13 +144,24 @@ const homeSVG = {
|
|||||||
return null; // noch keine vollstaendige Nachricht
|
return null; // noch keine vollstaendige Nachricht
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (wert.negativ) {
|
||||||
|
const zahl = Number(text);
|
||||||
|
if (text === "" || isNaN(zahl)) return null;
|
||||||
|
// "+ 0" macht aus -0 wieder 0: "-0 W" hat noch niemanden erfreut.
|
||||||
|
text = String(-zahl + 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Leistungen skalieren mit: unter tausend Watt in W, darueber in kW.
|
// Leistungen skalieren mit: unter tausend Watt in W, darueber in kW.
|
||||||
// powerToString() aus common.js macht genau das und wird auf der
|
// powerToString() aus common.js macht genau das und wird auf der
|
||||||
// Solarseite fuer dieselben Zahlen benutzt - eine eigene Regel hier
|
// Solarseite fuer dieselben Zahlen benutzt - eine eigene Regel hier
|
||||||
// haette bedeutet, dass dieselbe Wallbox an zwei Stellen verschieden
|
// haette bedeutet, dass dieselbe Wallbox an zwei Stellen verschieden
|
||||||
// gerundet dasteht.
|
// gerundet dasteht.
|
||||||
if (wert.format === "leistung") {
|
//
|
||||||
const zahl = Number(text);
|
// "leistung_kw" ist dasselbe fuer Quellen, die schon Kilowatt melden -
|
||||||
|
// die Wallboxen im SolarManager. Umgerechnet wird hier in Watt, damit
|
||||||
|
// es eine Regel fuer beide bleibt.
|
||||||
|
if (wert.format === "leistung" || wert.format === "leistung_kw") {
|
||||||
|
const zahl = Number(text) * (wert.format === "leistung_kw" ? 1000 : 1);
|
||||||
if (text === "" || isNaN(zahl)) return null;
|
if (text === "" || isNaN(zahl)) return null;
|
||||||
// Ersetzt wird nur das Trennzeichen: auf der Kachel steht ein
|
// Ersetzt wird nur das Trennzeichen: auf der Kachel steht ein
|
||||||
// schmales Leerzeichen, im Rest der Anwendung ein gewoehnliches.
|
// schmales Leerzeichen, im Rest der Anwendung ein gewoehnliches.
|
||||||
@@ -203,19 +220,6 @@ function removeClass(el, classNameToRemove){
|
|||||||
// Die Etagen stehen nicht mehr im Code: was es gibt, sagen die Reiter, die
|
// Die Etagen stehen nicht mehr im Code: was es gibt, sagen die Reiter, die
|
||||||
// home.php aus der Raumtabelle gebaut hat. Vorher standen OG, EG und UG hier
|
// home.php aus der Raumtabelle gebaut hat. Vorher standen OG, EG und UG hier
|
||||||
// sechsmal, und eine vierte Etage haette sechs neue Zeilen gebraucht.
|
// sechsmal, und eine vierte Etage haette sechs neue Zeilen gebraucht.
|
||||||
function switchTab(newtab){
|
|
||||||
const newContent = document.getElementById(newtab);
|
|
||||||
const newTabBtn = document.getElementById(newtab+"-tab");
|
|
||||||
document.querySelectorAll("#actions-tab .nav-link").forEach(function(btn){
|
|
||||||
removeClass(btn,"active");
|
|
||||||
});
|
|
||||||
document.querySelectorAll("#actions-tabContent .tab-pane").forEach(function(pane){
|
|
||||||
removeClass(pane,"active show");
|
|
||||||
});
|
|
||||||
addClass(newTabBtn,"active");
|
|
||||||
addClass(newContent,"active show");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function switchFloor(floor){
|
function switchFloor(floor){
|
||||||
if(currentFloor == floor)
|
if(currentFloor == floor)
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
/*
|
||||||
|
* Jahresvergleich der Anlage - ein Baustein, zwei Seiten.
|
||||||
|
*
|
||||||
|
* Auf der Historienseite steht er mit allem: drei Jahresspalten, umschaltbarem
|
||||||
|
* Zeitraum, verschiebbarem Jahrfenster. Auf der Solarseite genügt das laufende
|
||||||
|
* Jahr. Beide bauen dieselbe Tabelle aus derselben Antwort - was sich
|
||||||
|
* unterscheidet, sind Einstellungen, nicht zwei Fassungen desselben Codes.
|
||||||
|
*
|
||||||
|
* baueJahresstatistik(wurzel, { spalten: 3, werkzeuge: true, fussnoten: true })
|
||||||
|
*
|
||||||
|
* Alles Inhaltliche kommt von ajax/getStats.php, und zwar auch die Beschreibung
|
||||||
|
* der Zeilen: welche Kennzahlen es gibt, wie sie heißen, welche Einheit sie
|
||||||
|
* tragen, ob weniger besser ist und wie sie zusammenhängen. Hier steht nur, wie
|
||||||
|
* das aussieht. Eine neue Kennzahl braucht deshalb keine Zeile in dieser Datei.
|
||||||
|
*
|
||||||
|
* Der Abruf liefert alle Jahre auf einmal. Das Verschieben des Fensters und das
|
||||||
|
* Umschalten des Zeitraums brauchen darum keine weitere Anfrage - und die
|
||||||
|
* Antwort wird geteilt, falls eine Seite den Baustein zweimal zeigt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MONATSNAMEN = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
|
||||||
|
|
||||||
|
/* Ein Abruf für alle Bausteine der Seite. */
|
||||||
|
let statistikAbruf = null;
|
||||||
|
function holeJahresstatistik() {
|
||||||
|
if (!statistikAbruf) {
|
||||||
|
statistikAbruf = fetch("ajax/getStats.php", { cache: "no-store" })
|
||||||
|
.then(function (antwort) {
|
||||||
|
if (!antwort.ok) throw new Error("HTTP " + antwort.status);
|
||||||
|
return antwort.json();
|
||||||
|
})
|
||||||
|
.then(function (daten) {
|
||||||
|
if (daten.error) throw new Error(daten.error);
|
||||||
|
return daten;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return statistikAbruf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Format */
|
||||||
|
|
||||||
|
const statZahl = (wert, dez) => (wert === null || wert === undefined || !isFinite(wert))
|
||||||
|
? "–" : Number(wert).toLocaleString("de-DE",
|
||||||
|
{ minimumFractionDigits: dez, maximumFractionDigits: dez });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Veränderung zweier Werte.
|
||||||
|
*
|
||||||
|
* Prozentwerte vergleichen sich in Prozentpunkten: "die Autarkie stieg um 5 %"
|
||||||
|
* ließe offen, ob 70 auf 75 oder auf 73,5 gemeint ist. Bei allem anderen ist
|
||||||
|
* die relative Veränderung die Aussage. Was unter einem Prozent bzw. drei
|
||||||
|
* Zehntel Prozentpunkten liegt, gilt als unverändert - sonst zeigt die Tabelle
|
||||||
|
* Bewegung, wo keine ist.
|
||||||
|
*/
|
||||||
|
function statDelta(jetzt, vorher, kz) {
|
||||||
|
if (jetzt === null || jetzt === undefined || !vorher) return null;
|
||||||
|
const roh = kz.punkte ? jetzt - vorher : (jetzt - vorher) / Math.abs(vorher) * 100;
|
||||||
|
const klein = Math.abs(roh) < (kz.punkte ? 0.3 : 1);
|
||||||
|
const besser = kz.invers ? roh < 0 : roh > 0;
|
||||||
|
return {
|
||||||
|
text: (roh >= 0 ? "+" : "−") + statZahl(Math.abs(roh), 1) + (kz.punkte ? " Pp" : " %"),
|
||||||
|
art: klein ? "gleich" : (besser ? "gut" : "schlecht"),
|
||||||
|
zeichen: klein ? "→" : (roh > 0 ? "↑" : "↓")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- Baustein */
|
||||||
|
|
||||||
|
function baueJahresstatistik(wurzel, einstellungen) {
|
||||||
|
if (!wurzel) return null;
|
||||||
|
|
||||||
|
const opt = Object.assign({
|
||||||
|
spalten: 3, // Jahresspalten nebeneinander
|
||||||
|
werkzeuge: true, // Zeitraum, Jahrfenster, Verlauf
|
||||||
|
fussnoten: true,
|
||||||
|
zeitraum: "gleich", // "gleich" (bis zum Stichtag) oder "voll"
|
||||||
|
verlauf: true
|
||||||
|
}, einstellungen || {});
|
||||||
|
|
||||||
|
let daten = null;
|
||||||
|
let zeitraum = opt.zeitraum;
|
||||||
|
let verlauf = opt.verlauf;
|
||||||
|
let fenster = 0; // 0 = die jüngsten Jahre
|
||||||
|
const offen = {}; // aufgeklappte Summenzeilen
|
||||||
|
|
||||||
|
/* --- Gerüst ------------------------------------------------------------ */
|
||||||
|
wurzel.classList.add("jahresstatistik");
|
||||||
|
wurzel.innerHTML =
|
||||||
|
'<div class="leitzahlen"></div>'
|
||||||
|
+ (opt.werkzeuge ? '<div class="werkzeuge">'
|
||||||
|
+ '<span class="umschalter" data-rolle="zeitraum" role="group" aria-label="Zeitraum des Vergleichs">'
|
||||||
|
+ '<button type="button" data-zeitraum="gleich">Gleicher Zeitraum</button>'
|
||||||
|
+ '<button type="button" data-zeitraum="voll">Ganze Jahre</button></span>'
|
||||||
|
+ '<span class="jahrwahl">'
|
||||||
|
+ '<button type="button" data-schritt="-1" aria-label="Frühere Jahre zeigen">'
|
||||||
|
+ '<i class="bi bi-chevron-left"></i></button>'
|
||||||
|
+ '<span class="fenster">–</span>'
|
||||||
|
+ '<button type="button" data-schritt="1" aria-label="Spätere Jahre zeigen">'
|
||||||
|
+ '<i class="bi bi-chevron-right"></i></button></span>'
|
||||||
|
+ '<label class="kasten"><input type="checkbox" data-rolle="verlauf" checked>'
|
||||||
|
+ "Monatsverlauf zeigen</label></div>" : "")
|
||||||
|
+ '<div class="tafel-scroll"><table class="tafel">'
|
||||||
|
+ "<thead><tr></tr></thead>"
|
||||||
|
+ '<tbody><tr><td class="leer">Statistik wird geladen …</td></tr></tbody>'
|
||||||
|
+ "</table></div>"
|
||||||
|
+ (opt.fussnoten ? '<div class="fussnoten"><p data-rolle="zeitraumtext"></p>'
|
||||||
|
+ "<p><b>Davon</b> teilt den Ertrag auf: Eigenverbrauch, Einspeisung und Heizung "
|
||||||
|
+ "ergeben ihn zusammen. <b>Darin</b> greift aus einer dieser Zeilen etwas heraus – "
|
||||||
|
+ "der Beitrag der Batterie und die Solarladung des Autos stecken beide im "
|
||||||
|
+ "Eigenverbrauch, denn Strom aus der Batterie und Strom ins Auto ist Strom, der im "
|
||||||
|
+ "Haus geblieben ist. Sie dürfen deshalb nicht dazugezählt werden. Der Beitrag der "
|
||||||
|
+ "Batterie ist dabei netto gerechnet: abzüglich der Vergütung, die für den zum "
|
||||||
|
+ "Laden verwendeten Strom entgangen ist.</p>"
|
||||||
|
+ "<p>Verhältniszahlen – Autarkie, Quoten, effektiver Strompreis – sind für jeden "
|
||||||
|
+ "Zeitraum neu aus seinen Summen gerechnet und nicht über Jahre gemittelt. Der "
|
||||||
|
+ "effektive Strompreis ist Netzkosten samt Grundpreis geteilt durch den "
|
||||||
|
+ "Netzbezug.</p></div>" : "");
|
||||||
|
|
||||||
|
const teil = (auswahl) => wurzel.querySelector(auswahl);
|
||||||
|
const kopf = teil("thead tr");
|
||||||
|
const koerper = teil("tbody");
|
||||||
|
|
||||||
|
/* --- Auswahl ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** Die Jahre ab dem Fensteranfang, jüngstes zuerst. */
|
||||||
|
function jahreAbFenster() {
|
||||||
|
const alle = daten.jahre.slice().sort((a, b) => b - a);
|
||||||
|
const start = Math.max(0, Math.min(fenster, Math.max(0, alle.length - opt.spalten)));
|
||||||
|
return alle.slice(start);
|
||||||
|
}
|
||||||
|
function spaltenJahre() { return jahreAbFenster().slice(0, opt.spalten); }
|
||||||
|
|
||||||
|
function werteVon(jahr) {
|
||||||
|
const satz = daten[zeitraum] || {};
|
||||||
|
return satz[jahr] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welche beiden Jahre die Δ-Spalte vergleicht. Bei ganzen Jahren bleibt das
|
||||||
|
* laufende außen vor: es ist noch nicht fertig, ein Vergleich mit einem
|
||||||
|
* vollen Jahr wäre keiner. Das Paar hängt am Fenster, nicht an den
|
||||||
|
* sichtbaren Spalten - so vergleicht auch eine einspaltige Ansicht.
|
||||||
|
*/
|
||||||
|
function vergleichsPaar() {
|
||||||
|
const laufend = new Date().getFullYear();
|
||||||
|
const jahre = jahreAbFenster();
|
||||||
|
const brauchbar = zeitraum === "gleich" ? jahre : jahre.filter(j => j !== laufend);
|
||||||
|
return brauchbar.length >= 2 ? [brauchbar[0], brauchbar[1]] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das Jahr, dessen Monatsverlauf die letzte Spalte zeigt. */
|
||||||
|
function verlaufsJahr() {
|
||||||
|
const paar = vergleichsPaar();
|
||||||
|
return paar ? paar[0] : (spaltenJahre()[0] || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Stichtag als "7. September" - so, wie man ihn ausspricht. */
|
||||||
|
function stichtagText() {
|
||||||
|
const d = new Date(daten.stichtag + "T00:00:00");
|
||||||
|
return d.toLocaleDateString("de-DE", { day: "numeric", month: "long" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Monatsbalken ------------------------------------------------------ */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Zwölf Balken, eine Grundlinie, sonst nichts: der Verlauf sagt "wann", die
|
||||||
|
* Zahlen daneben sagen "wieviel". Der letzte Monat mit Wert ist hervorgehoben,
|
||||||
|
* damit erkennbar bleibt, wo das Jahr steht.
|
||||||
|
*/
|
||||||
|
function sparkline(kz, jahr) {
|
||||||
|
const reihe = (daten.monate[jahr] || {})[kz.id];
|
||||||
|
if (!reihe) return "";
|
||||||
|
const zahlen = reihe.map(w => (w === null || !isFinite(w)) ? null : Number(w));
|
||||||
|
const gueltig = zahlen.filter(w => w !== null);
|
||||||
|
if (!gueltig.length) return "";
|
||||||
|
|
||||||
|
const hoch = Math.max.apply(null, gueltig.map(Math.abs)) || 1;
|
||||||
|
const letzter = zahlen.reduce((idx, w, i) => (w === null ? idx : i), -1);
|
||||||
|
const b = 7, luecke = 2, H = 24;
|
||||||
|
const breite = zahlen.length * b + (zahlen.length - 1) * luecke;
|
||||||
|
|
||||||
|
let s = '<svg class="spark" width="' + breite + '" height="' + H + '" viewBox="0 0 '
|
||||||
|
+ breite + " " + H + '" role="img" aria-label="Monatsverlauf ' + jahr + '">'
|
||||||
|
+ '<line class="basis" x1="0" y1="' + (H - .5) + '" x2="' + breite
|
||||||
|
+ '" y2="' + (H - .5) + '"/>';
|
||||||
|
zahlen.forEach(function (w, i) {
|
||||||
|
if (w === null) return;
|
||||||
|
const x = i * (b + luecke);
|
||||||
|
const h = Math.max(1.5, Math.abs(w) / hoch * (H - 4));
|
||||||
|
s += '<rect class="balken' + (i === letzter ? " jetzt" : "") + '" x="' + x + '" y="'
|
||||||
|
+ (H - h - 1) + '" width="' + b + '" height="' + h + '" rx="1.5"'
|
||||||
|
+ ' data-platz="' + i + '"'
|
||||||
|
+ ' data-monat="' + MONATSNAMEN[i] + " " + jahr + '"'
|
||||||
|
+ ' data-wert="' + statZahl(w, kz.dez) + " " + kz.einheit + '"></rect>';
|
||||||
|
});
|
||||||
|
return s + "</svg>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Zeichnen ---------------------------------------------------------- */
|
||||||
|
|
||||||
|
function zeichneKopf() {
|
||||||
|
const gleich = zeitraum === "gleich";
|
||||||
|
const laufend = new Date().getFullYear();
|
||||||
|
const paar = vergleichsPaar();
|
||||||
|
|
||||||
|
let h = '<th scope="col">Kennzahl</th>';
|
||||||
|
spaltenJahre().forEach(function (jahr, i) {
|
||||||
|
const unten = gleich ? "bis " + stichtagText()
|
||||||
|
: (jahr === laufend ? "läuft noch" : "ganzes Jahr");
|
||||||
|
h += '<th scope="col"' + (i === 0 ? ' class="jetzt"' : "") + ">" + jahr
|
||||||
|
+ '<span class="zeitraum">' + unten + "</span></th>";
|
||||||
|
});
|
||||||
|
h += '<th scope="col">Δ<span class="zeitraum">'
|
||||||
|
+ (paar ? paar[0] + " zu " + paar[1] : "–") + "</span></th>"
|
||||||
|
+ '<th scope="col" class="gesamt">Gesamt<span class="zeitraum">seit '
|
||||||
|
+ daten.jahre[0] + "</span></th>";
|
||||||
|
if (verlauf) {
|
||||||
|
h += '<th scope="col" class="verlauf">Monatsverlauf<span class="zeitraum">'
|
||||||
|
+ verlaufsJahr() + "</span></th>";
|
||||||
|
}
|
||||||
|
kopf.innerHTML = h;
|
||||||
|
|
||||||
|
const text = teil('[data-rolle="zeitraumtext"]');
|
||||||
|
if (text) {
|
||||||
|
text.innerHTML = gleich
|
||||||
|
? "<b>Gleicher Zeitraum:</b> jedes Jahr vom 1. Januar bis zum " + stichtagText()
|
||||||
|
+ " – so vergleicht sich das laufende Jahr mit dem, was zum selben Stichtag im "
|
||||||
|
+ "Vorjahr zusammengekommen war."
|
||||||
|
: "<b>Ganze Jahre:</b> abgeschlossene Jahre vollständig; das laufende Jahr steht "
|
||||||
|
+ "so weit, wie es ist, und trägt deshalb keine Tendenz.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeichneZeilen() {
|
||||||
|
const jahre = spaltenJahre();
|
||||||
|
const paar = vergleichsPaar();
|
||||||
|
const jahrDesVerlaufs = verlaufsJahr();
|
||||||
|
const spalten = jahre.length + 3 + (verlauf ? 1 : 0);
|
||||||
|
let h = "";
|
||||||
|
|
||||||
|
daten.kennzahlen.forEach(function (kz) {
|
||||||
|
if (kz.gruppe) {
|
||||||
|
h += '<tr class="gruppe"><td colspan="' + spalten + '">' + kz.gruppe + "</td></tr>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const versteckt = kz.unter && !offen[kz.unter];
|
||||||
|
const klassen = [kz.summe ? "summe" : "", kz.teilmenge ? "teilmenge" : "",
|
||||||
|
versteckt ? "versteckt" : ""].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Beschriftung. Die Einheit steht einmal an der Zeile statt in jeder
|
||||||
|
* Spalte - sie ändert sich ja nicht von Jahr zu Jahr. Sie sitzt INNERHALB
|
||||||
|
* der Einrückung, damit sie beim Umbruch dem Namen folgt und nicht
|
||||||
|
* allein am linken Zellenrand landet.
|
||||||
|
*/
|
||||||
|
const text = kz.name + '<span class="einheit-klein">' + kz.einheit + "</span>";
|
||||||
|
let name;
|
||||||
|
if (kz.klapp) {
|
||||||
|
/*
|
||||||
|
* Zusammengefasste Zeilen stehen zunaechst zu - die Tabelle soll mit
|
||||||
|
* zehn Zeilen aufmachen und nicht mit siebzehn. Damit das Zugeklappte
|
||||||
|
* nicht verschwindet, sagt die Zeile, wieviel darin steckt.
|
||||||
|
*/
|
||||||
|
const posten = daten.kennzahlen.filter(k => k.unter === kz.id).length;
|
||||||
|
name = '<button type="button" class="klapp" data-klapp="' + kz.id + '" aria-expanded="'
|
||||||
|
+ (offen[kz.id] ? "true" : "false") + '"><span class="pfeil">›</span>'
|
||||||
|
// Der Name als eigener Baustein: bei knapper Breite rutscht dann
|
||||||
|
// die Anzahl in die naechste Zeile, statt den Namen zu zerreissen.
|
||||||
|
+ "<span>" + text + "</span>"
|
||||||
|
+ (offen[kz.id] || !posten ? ""
|
||||||
|
: '<span class="posten">' + posten + " Posten</span>") + "</button>";
|
||||||
|
} else if (kz.teilmenge) {
|
||||||
|
// "darin" allein liesse offen, worin - deshalb steht der Name der
|
||||||
|
// uebergeordneten Zeile daneben. Wer die Tabelle ueberfliegt, soll
|
||||||
|
// nicht raten muessen, ob die Zeile direkt darueber gemeint ist.
|
||||||
|
const traeger = daten.kennzahlen.find(k => k.id === kz.teilmenge);
|
||||||
|
name = '<span class="stufe-2"><span class="darin">'
|
||||||
|
+ (traeger ? "im " + traeger.name : "darin") + "</span>" + text + "</span>";
|
||||||
|
} else if (kz.teil) {
|
||||||
|
name = '<span class="stufe-1">' + text + "</span>";
|
||||||
|
} else {
|
||||||
|
name = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
h += '<tr class="' + klassen + '"><td class="name">' + name + "</td>";
|
||||||
|
|
||||||
|
jahre.forEach(function (jahr, i) {
|
||||||
|
const w = werteVon(jahr);
|
||||||
|
h += '<td class="' + (i === 0 ? "wert-jetzt" : "wert-alt") + '">'
|
||||||
|
+ statZahl(w ? w[kz.id] : null, kz.dez) + "</td>";
|
||||||
|
});
|
||||||
|
|
||||||
|
const d = paar ? statDelta((werteVon(paar[0]) || {})[kz.id],
|
||||||
|
(werteVon(paar[1]) || {})[kz.id], kz) : null;
|
||||||
|
h += "<td>" + (d ? '<span class="delta ' + d.art + '">' + d.zeichen + " " + d.text
|
||||||
|
+ "</span>" : '<span class="leer">–</span>') + "</td>";
|
||||||
|
|
||||||
|
// Die Summe über alles. Verhältniszahlen sind darin über den ganzen
|
||||||
|
// Zeitraum gerechnet, nicht über die Jahre gemittelt.
|
||||||
|
h += '<td class="gesamt">'
|
||||||
|
+ statZahl(daten.gesamt ? daten.gesamt[kz.id] : null, kz.dez) + "</td>";
|
||||||
|
|
||||||
|
if (verlauf) h += '<td class="verlauf">' + sparkline(kz, jahrDesVerlaufs) + "</td>";
|
||||||
|
h += "</tr>";
|
||||||
|
});
|
||||||
|
|
||||||
|
koerper.innerHTML = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeichneLeitzahlen() {
|
||||||
|
const paar = vergleichsPaar();
|
||||||
|
const jahr = paar ? paar[0] : spaltenJahre()[0];
|
||||||
|
const gegen = paar ? paar[1] : null;
|
||||||
|
const werte = werteVon(jahr) || {};
|
||||||
|
const vorher = gegen ? (werteVon(gegen) || {}) : {};
|
||||||
|
|
||||||
|
teil(".leitzahlen").innerHTML = daten.leitzahlen.map(function (id, i) {
|
||||||
|
const kz = daten.kennzahlen.find(k => k.id === id);
|
||||||
|
if (!kz) return "";
|
||||||
|
const d = statDelta(werte[id], vorher[id], kz);
|
||||||
|
return '<div class="leitzahl' + (i === 0 ? " betont" : "") + '">'
|
||||||
|
+ '<div class="name">' + kz.name + "</div>"
|
||||||
|
+ '<div class="zahl">' + statZahl(werte[id], kz.dez)
|
||||||
|
+ '<span class="einheit">' + kz.einheit + "</span></div>"
|
||||||
|
+ '<div class="fuss">'
|
||||||
|
+ (d ? '<span class="delta ' + d.art + '">' + d.zeichen + " " + d.text + "</span>" : "")
|
||||||
|
+ "<span>" + jahr + (zeitraum === "gleich" ? " bis " + stichtagText() : "")
|
||||||
|
+ (gegen ? " gegen " + gegen : "") + "</span></div></div>";
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeichneJahrwahl() {
|
||||||
|
const wahl = teil(".jahrwahl");
|
||||||
|
if (!wahl) return;
|
||||||
|
const jahre = spaltenJahre();
|
||||||
|
wahl.querySelector(".fenster").textContent =
|
||||||
|
jahre.length > 1 ? jahre[jahre.length - 1] + "–" + jahre[0] : String(jahre[0] || "–");
|
||||||
|
// Links liegt die Vergangenheit: dort ist Schluss beim ersten
|
||||||
|
// aufgezeichneten Jahr, rechts beim laufenden.
|
||||||
|
wahl.querySelector('[data-schritt="-1"]').disabled =
|
||||||
|
fenster >= daten.jahre.length - opt.spalten;
|
||||||
|
wahl.querySelector('[data-schritt="1"]').disabled = fenster <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeichne() {
|
||||||
|
if (!daten || !daten.jahre.length) return;
|
||||||
|
zeichneLeitzahlen();
|
||||||
|
zeichneKopf();
|
||||||
|
zeichneZeilen();
|
||||||
|
zeichneJahrwahl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Bedienung --------------------------------------------------------- */
|
||||||
|
|
||||||
|
wurzel.querySelectorAll("[data-zeitraum]").forEach(function (b) {
|
||||||
|
b.setAttribute("aria-pressed", String(b.dataset.zeitraum === zeitraum));
|
||||||
|
b.addEventListener("click", function () {
|
||||||
|
zeitraum = b.dataset.zeitraum;
|
||||||
|
wurzel.querySelectorAll("[data-zeitraum]").forEach(function (x) {
|
||||||
|
x.setAttribute("aria-pressed", String(x === b));
|
||||||
|
});
|
||||||
|
zeichne();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wurzel.querySelectorAll("[data-schritt]").forEach(function (b) {
|
||||||
|
b.addEventListener("click", function () {
|
||||||
|
const grenze = Math.max(0, daten.jahre.length - opt.spalten);
|
||||||
|
fenster = Math.max(0, Math.min(grenze, fenster - Number(b.dataset.schritt)));
|
||||||
|
zeichne();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const schalter = teil('[data-rolle="verlauf"]');
|
||||||
|
if (schalter) {
|
||||||
|
schalter.checked = verlauf;
|
||||||
|
schalter.addEventListener("change", function (e) {
|
||||||
|
verlauf = e.target.checked;
|
||||||
|
zeichneKopf();
|
||||||
|
zeichneZeilen();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
koerper.addEventListener("click", function (e) {
|
||||||
|
const knopf = e.target.closest("[data-klapp]");
|
||||||
|
if (!knopf) return;
|
||||||
|
offen[knopf.dataset.klapp] = !offen[knopf.dataset.klapp];
|
||||||
|
zeichneZeilen();
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sprechblase am Monatsverlauf.
|
||||||
|
*
|
||||||
|
* Nicht je Balken, sondern über die ganze Reihe: ein Balken ist sieben Pixel
|
||||||
|
* breit, eine Fingerkuppe deckt rund vierzig - einzeln zu treffen wären sie
|
||||||
|
* nicht, und ein hoher Balken deckte seine eigene Trefferfläche zu. Deshalb
|
||||||
|
* entscheidet die Stelle, an der Finger oder Zeiger die Reihe berühren,
|
||||||
|
* welcher Monat gemeint ist; Wischen fährt sie ab. Damit ist jeder Monat
|
||||||
|
* erreichbar, unabhängig von seiner Höhe.
|
||||||
|
*/
|
||||||
|
const tipp = document.createElement("div");
|
||||||
|
tipp.className = "spark-tipp";
|
||||||
|
tipp.setAttribute("role", "status");
|
||||||
|
document.body.appendChild(tipp);
|
||||||
|
|
||||||
|
/** Der Balken, über dem die Stelle x liegt - auch wenn sie daneben trifft. */
|
||||||
|
function balkenBei(svg, x) {
|
||||||
|
const r = svg.getBoundingClientRect();
|
||||||
|
if (!r.width) return null;
|
||||||
|
const platz = Math.max(0, Math.min(MONATSNAMEN.length - 1,
|
||||||
|
Math.floor((x - r.left) / r.width * MONATSNAMEN.length)));
|
||||||
|
// Monate ohne Wert haben keinen Balken - dort gibt es nichts zu zeigen.
|
||||||
|
return svg.querySelector('rect.balken[data-platz="' + platz + '"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeTipp(balken) {
|
||||||
|
koerper.querySelectorAll("rect.balken.aktiv").forEach(b => b.classList.remove("aktiv"));
|
||||||
|
balken.classList.add("aktiv");
|
||||||
|
tipp.textContent = balken.dataset.monat + " · " + balken.dataset.wert;
|
||||||
|
const r = balken.getBoundingClientRect();
|
||||||
|
tipp.style.left = Math.max(8, Math.min(window.innerWidth - 180, r.left - 60)) + "px";
|
||||||
|
tipp.style.top = Math.max(8, r.top - 34) + "px";
|
||||||
|
tipp.classList.add("an");
|
||||||
|
}
|
||||||
|
|
||||||
|
function verbergeTipp() {
|
||||||
|
koerper.querySelectorAll("rect.balken.aktiv").forEach(b => b.classList.remove("aktiv"));
|
||||||
|
tipp.classList.remove("an");
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Gemeint ist die ganze Zelle, nicht die Zeichnung darin: die Balkenreihe
|
||||||
|
* ist 24 Pixel hoch, die Zelle gut dreissig - wer knapp darueber oder
|
||||||
|
* darunter tippt, meint trotzdem diesen Monat.
|
||||||
|
*/
|
||||||
|
function zeigeAnStelle(e) {
|
||||||
|
const zelle = e.target.closest("td.verlauf");
|
||||||
|
const svg = zelle && zelle.querySelector("svg.spark");
|
||||||
|
if (!svg) { verbergeTipp(); return; }
|
||||||
|
const balken = balkenBei(svg, e.clientX);
|
||||||
|
if (balken) zeigeTipp(balken); else verbergeTipp();
|
||||||
|
}
|
||||||
|
|
||||||
|
koerper.addEventListener("pointerdown", zeigeAnStelle);
|
||||||
|
koerper.addEventListener("pointermove", zeigeAnStelle);
|
||||||
|
koerper.addEventListener("pointerleave", verbergeTipp);
|
||||||
|
|
||||||
|
/* --- Laden ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
holeJahresstatistik().then(function (antwort) {
|
||||||
|
daten = antwort;
|
||||||
|
if (!daten.jahre.length) {
|
||||||
|
koerper.innerHTML = '<tr><td class="leer">Noch keine ausgewerteten Tage.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
zeichne();
|
||||||
|
}).catch(function (e) {
|
||||||
|
koerper.innerHTML = '<tr><td class="leer">Statistik nicht abrufbar.</td></tr>';
|
||||||
|
console.log("Jahresstatistik: " + e.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { zeichne: zeichne };
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+480
-79
@@ -15,6 +15,11 @@
|
|||||||
* Abrufen liegen im Ruhezustand bis zu einer Stunde. Die Anzeige nennt deshalb
|
* Abrufen liegen im Ruhezustand bis zu einer Stunde. Die Anzeige nennt deshalb
|
||||||
* immer auch das Alter des Standes, statt einen alten Wert als aktuell
|
* immer auch das Alter des Standes, statt einen alten Wert als aktuell
|
||||||
* auszugeben.
|
* auszugeben.
|
||||||
|
*
|
||||||
|
* Der Ladestand hat keine eigene Anzeige mehr: er steckt in der Akkuplatte der
|
||||||
|
* Fahrzeuggrafik - Füllhöhe, Zielmarke, Prozent und Reichweite setzt
|
||||||
|
* zeichneAkku(). Was sich nicht zeichnen lässt, steht in den Kennzahlen
|
||||||
|
* daneben (zeichneKennzahlen).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
var mqttData = {};
|
var mqttData = {};
|
||||||
@@ -86,6 +91,70 @@ function dauer(minuten) {
|
|||||||
return Math.floor(m / 60) + " h " + (m % 60) + " min";
|
return Math.floor(m / 60) + " h " + (m % 60) + " min";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- Hinweise */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Erklärungen zu einer Zahl - woher sie stammt, was sie nicht abdeckt.
|
||||||
|
*
|
||||||
|
* Im title stehen sie am Zeiger gut, auf einem Berührungsgerät aber nirgends:
|
||||||
|
* Überfahren gibt es dort nicht, und der title erscheint nie. Das übliche
|
||||||
|
* Mittel dagegen ist, aus der Erklärung etwas Anfassbares zu machen - einen
|
||||||
|
* Auslöser, der Fokus annehmen kann, und ein Fenster, das beim nächsten
|
||||||
|
* Tippen daneben oder mit Esc wieder verschwindet. Genau das leistet ein
|
||||||
|
* Popover mit den Auslösern "focus hover": tippen öffnet, danebentippen
|
||||||
|
* schliesst, für Maus und Tastatur bleibt es beim Überfahren und Tabben.
|
||||||
|
*
|
||||||
|
* Damit das gefunden wird, trägt der Auslöser sichtbar ein Zeichen - die
|
||||||
|
* Klasse hat-hinweis setzt es. Eine Erklärung, die niemand vermutet, ist so
|
||||||
|
* gut wie keine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Erklärung an ein Element hängen; ohne Text wird sie wieder entfernt. */
|
||||||
|
function setzeHinweis(el, text) {
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// Ohne Bootstrap bleibt der title. Weniger gut, aber nicht nichts.
|
||||||
|
if (typeof bootstrap === "undefined" || !bootstrap.Popover) {
|
||||||
|
if (text) el.title = text; else el.removeAttribute("title");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alt = bootstrap.Popover.getInstance(el);
|
||||||
|
if (alt) alt.dispose();
|
||||||
|
el.removeAttribute("title"); // sonst zeigt der Browser beides
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
el.classList.remove("hat-hinweis");
|
||||||
|
["tabindex", "role", "aria-label"].forEach(a => el.removeAttribute(a));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.classList.add("hat-hinweis");
|
||||||
|
el.setAttribute("tabindex", "0"); // ohne Fokus kein Antippen auf iOS
|
||||||
|
el.setAttribute("role", "button");
|
||||||
|
el.setAttribute("aria-label", "Erklärung anzeigen");
|
||||||
|
new bootstrap.Popover(el, {
|
||||||
|
content: text,
|
||||||
|
trigger: "focus hover",
|
||||||
|
placement: "top",
|
||||||
|
container: "body", // sonst schneidet die Kachel ab
|
||||||
|
customClass: "skoda-erklaerung"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erklärungen unterhalb eines Knotens auflösen, bevor er neu gebaut wird.
|
||||||
|
* Ein offenes Fenster gehört zu seinem Auslöser - verschwindet der aus dem
|
||||||
|
* Dokument, bliebe es sonst am Bildschirm stehen.
|
||||||
|
*/
|
||||||
|
function loeseHinweise(wurzel) {
|
||||||
|
if (!wurzel || typeof bootstrap === "undefined" || !bootstrap.Popover) return;
|
||||||
|
wurzel.querySelectorAll(".hat-hinweis").forEach(function (el) {
|
||||||
|
const p = bootstrap.Popover.getInstance(el);
|
||||||
|
if (p) p.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function hole(was, extra = "") {
|
async function hole(was, extra = "") {
|
||||||
const antwort = await fetch("ajax/skoda.php?was=" + was + extra, { cache: "no-store" });
|
const antwort = await fetch("ajax/skoda.php?was=" + was + extra, { cache: "no-store" });
|
||||||
if (!antwort.ok) throw new Error("HTTP " + antwort.status);
|
if (!antwort.ok) throw new Error("HTTP " + antwort.status);
|
||||||
@@ -115,7 +184,6 @@ const ZUSTANDSWORT = {
|
|||||||
const TEILE = [
|
const TEILE = [
|
||||||
{ id: "teil-doors", feld: "doors", name: "Türen", zu: "CLOSED" },
|
{ id: "teil-doors", feld: "doors", name: "Türen", zu: "CLOSED" },
|
||||||
{ id: "teil-windows", feld: "windows", name: "Fenster", zu: "CLOSED" },
|
{ id: "teil-windows", feld: "windows", name: "Fenster", zu: "CLOSED" },
|
||||||
{ id: "teil-sunroof", feld: "sunroof", name: "Panoramadach", zu: "CLOSED" },
|
|
||||||
{ id: "teil-trunk", feld: "trunk", name: "Heckklappe", zu: "CLOSED" },
|
{ id: "teil-trunk", feld: "trunk", name: "Heckklappe", zu: "CLOSED" },
|
||||||
{ id: "teil-bonnet", feld: "bonnet", name: "Motorhaube", zu: "CLOSED" },
|
{ id: "teil-bonnet", feld: "bonnet", name: "Motorhaube", zu: "CLOSED" },
|
||||||
{ id: "teil-lights", feld: "lights", name: "Licht", zu: "OFF" }
|
{ id: "teil-lights", feld: "lights", name: "Licht", zu: "OFF" }
|
||||||
@@ -201,6 +269,17 @@ function zeichneTeile(stand) {
|
|||||||
|
|
||||||
/* -------------------------------------------------------------- Live-Block */
|
/* -------------------------------------------------------------- Live-Block */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Der Live-Block ist die Fahrzeugkachel: die Draufsicht mit dem Ladestand in
|
||||||
|
* der Akkuplatte und die Kennzahlen daneben.
|
||||||
|
*
|
||||||
|
* Er arbeitet in drei Schritten. liveWerte() sammelt aus MQTT und Tabelle
|
||||||
|
* einen Satz fertiger Werte, jede zeichne*-Funktion zeigt genau einen Bereich
|
||||||
|
* davon, und zeichneLive() ruft sie der Reihe nach auf. Keine der
|
||||||
|
* Zeichenfunktionen holt sich selbst etwas - sie bekommen alles übergeben und
|
||||||
|
* bleiben dadurch einzeln prüfbar und für sich verwendbar.
|
||||||
|
*/
|
||||||
|
|
||||||
let livePlanung = null;
|
let livePlanung = null;
|
||||||
function planeLiveAnzeige() {
|
function planeLiveAnzeige() {
|
||||||
if (livePlanung) return; // höchstens fünfmal je Sekunde
|
if (livePlanung) return; // höchstens fünfmal je Sekunde
|
||||||
@@ -209,108 +288,350 @@ function planeLiveAnzeige() {
|
|||||||
|
|
||||||
let letzterStand = null; // letzte Zeile aus der Tabelle
|
let letzterStand = null; // letzte Zeile aus der Tabelle
|
||||||
|
|
||||||
function zeichneLive() {
|
/*
|
||||||
|
* Maße der Akkuplatte in Einheiten der viewBox. Sie stehen genau einmal hier
|
||||||
|
* und einmal im Markup (restricted/skoda.php); wer die Platte verschiebt,
|
||||||
|
* ändert beides.
|
||||||
|
*/
|
||||||
|
const AKKU = { oben: 108, hoehe: 270 };
|
||||||
|
|
||||||
|
/* Zustand der Platte. Die Klasse sitzt am <svg>, nicht an der Gruppe: der
|
||||||
|
Farbverlauf steht in <defs> und muss dieselbe Farbe erben. */
|
||||||
|
const AKKU_KLASSEN = ["akku-laedt", "akku-niedrig", "akku-unbekannt"];
|
||||||
|
|
||||||
|
/* Ladestand, unter dem die Platte rot wird. Reichweitenangst beginnt nicht
|
||||||
|
bei null, sondern dort, wo eine Fahrt zu planen ist. */
|
||||||
|
const AKKU_NIEDRIG = 15;
|
||||||
|
|
||||||
|
const LADEART = { AC: "Wechselstrom", DC: "Gleichstrom" };
|
||||||
|
|
||||||
|
const KLIMA = {
|
||||||
|
OFF: "aus", COOLING: "kühlt", HEATING: "heizt",
|
||||||
|
HEATING_AUXILIARY: "Zuheizer", VENTILATION: "lüftet",
|
||||||
|
COMPLETED: "fertig", UNKNOWN: "unbekannt", UNSUPPORTED: "–"
|
||||||
|
};
|
||||||
|
/* Zustände, in denen die Klimatisierung tatsächlich arbeitet - nur dann hebt
|
||||||
|
sich ihre Kachel hervor. */
|
||||||
|
const KLIMA_LAEUFT = ["COOLING", "HEATING", "HEATING_AUXILIARY", "VENTILATION"];
|
||||||
|
|
||||||
|
/* Das Zeichen der Klimakachel sagt, was gerade geschieht, statt nur "Klima" zu
|
||||||
|
bebildern: Schneeflocke fürs Kühlen, Sonne fürs Heizen, Wind fürs Lüften.
|
||||||
|
Der Lüfter steht für alles Übrige - aus, fertig, unbekannt. */
|
||||||
|
const KLIMA_ZEICHEN = {
|
||||||
|
COOLING: "bi-snow",
|
||||||
|
HEATING: "bi-thermometer-sun",
|
||||||
|
HEATING_AUXILIARY: "bi-fire",
|
||||||
|
VENTILATION: "bi-wind",
|
||||||
|
COMPLETED: "bi-check2-circle"
|
||||||
|
};
|
||||||
|
const KLIMA_ZEICHEN_SONST = "bi-fan";
|
||||||
|
|
||||||
|
const LADEZUSTAND = {
|
||||||
|
CHARGING: ["lädt", "text-bg-success"],
|
||||||
|
CONSERVING: ["hält Ladung", "text-bg-secondary"],
|
||||||
|
READY_FOR_CHARGING: ["bereit", "text-bg-info"],
|
||||||
|
CONNECT_CABLE: ["nicht angesteckt", "text-bg-secondary"],
|
||||||
|
CHARGING_INTERRUPTED: ["unterbrochen", "text-bg-warning"],
|
||||||
|
DISCHARGING: ["entlädt", "text-bg-warning"]
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------- kleine Werkzeuge */
|
||||||
|
|
||||||
|
/** Zahl aus einem Tabellenfeld; leer, null und "" ergeben null statt 0. */
|
||||||
|
function feldZahl(wert) {
|
||||||
|
if (wert === null || wert === undefined || wert === "") return null;
|
||||||
|
const z = Number(wert);
|
||||||
|
return Number.isFinite(z) ? z : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text eines Elements setzen, sofern die Seite es überhaupt hat. */
|
||||||
|
function setzeText(id, text) {
|
||||||
|
const el = $(id);
|
||||||
|
if (el) el.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Klasse an einem Element setzen oder entfernen. */
|
||||||
|
function schalteKlasse(id, klasse, an) {
|
||||||
|
const el = $(id);
|
||||||
|
if (el) el.classList.toggle(klasse, !!an);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Genau eine Klasse aus einer Gruppe setzen, die übrigen entfernen. */
|
||||||
|
function setzeAusGruppe(el, gruppe, aktiv) {
|
||||||
|
if (!el) return;
|
||||||
|
gruppe.forEach(k => el.classList.toggle(k, k === aktiv));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "bis 14:20 Uhr" - der Zeitpunkt hinter der gemeldeten Restladezeit. */
|
||||||
|
function fertigUm(minuten) {
|
||||||
|
const m = Number(minuten);
|
||||||
|
if (!Number.isFinite(m) || m <= 0) return "";
|
||||||
|
const ende = new Date(Date.now() + m * 60000);
|
||||||
|
return "bis " + ende.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) + " Uhr";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** y-Kante eines Ladestands auf der Platte: 0 % unten, 100 % oben. */
|
||||||
|
function akkuKante(prozent) {
|
||||||
|
const p = Math.max(0, Math.min(100, Number(prozent) || 0));
|
||||||
|
return AKKU.oben + AKKU.hoehe * (1 - p / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Rechteck vom Boden der Platte bis zum Ladestand aufziehen. */
|
||||||
|
function setzeFuellstand(el, prozent) {
|
||||||
|
if (!el) return;
|
||||||
|
const y = akkuKante(prozent);
|
||||||
|
el.setAttribute("y", y.toFixed(1));
|
||||||
|
el.setAttribute("height", (AKKU.oben + AKKU.hoehe - y).toFixed(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Sammelstelle */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alles, was die Fahrzeugkachel zeigt, an einer Stelle zusammengetragen.
|
||||||
|
* MQTT hat überall Vorrang, die Tabelle ist der Rückfall - sie kann eine
|
||||||
|
* Stunde alt sein, MQTT ist von eben.
|
||||||
|
*/
|
||||||
|
function liveWerte() {
|
||||||
const stand = letzterStand;
|
const stand = letzterStand;
|
||||||
|
|
||||||
// Ladestand: MQTT hat Vorrang, die Tabelle ist der Rückfall.
|
|
||||||
const soc = mqZahl("evSOC") ?? (stand ? Number(stand.soc) : null);
|
|
||||||
const ziel = stand && stand.target_soc ? Number(stand.target_soc) : null;
|
|
||||||
const zustand = mqText("evChgState") || (stand ? stand.chg_state : null);
|
const zustand = mqText("evChgState") || (stand ? stand.chg_state : null);
|
||||||
const laedt = zustand === "CHARGING";
|
const laedt = zustand === "CHARGING";
|
||||||
|
|
||||||
// Ring: der Umfang ist 2*pi*r, der Rest ergibt sich aus dem Ladestand.
|
|
||||||
const ring = $("skodaRing");
|
|
||||||
if (ring) {
|
|
||||||
const umfang = 2 * Math.PI * 82;
|
|
||||||
ring.style.strokeDasharray = umfang;
|
|
||||||
ring.style.strokeDashoffset = umfang * (1 - (soc || 0) / 100);
|
|
||||||
ring.style.stroke = laedt ? "#4caf7d" : (soc !== null && soc < 15 ? "#e06060" : "#7aa7c7");
|
|
||||||
}
|
|
||||||
const socText = $("skodaSocText");
|
|
||||||
if (socText) socText.textContent = soc === null ? "–" : nf(soc);
|
|
||||||
|
|
||||||
// Zielmarke auf dem Ring
|
|
||||||
const zielLinie = $("skodaZiel");
|
|
||||||
if (zielLinie) {
|
|
||||||
zielLinie.style.display = ziel ? "" : "none";
|
|
||||||
if (ziel) zielLinie.setAttribute("transform", "rotate(" + (ziel * 3.6) + " 100 100)");
|
|
||||||
}
|
|
||||||
$("skodaZielText").textContent = ziel ? nf(ziel) + " %" : "–";
|
|
||||||
|
|
||||||
// Reichweite. range_m trägt den Rohwert der API; über 1500 kann nur Meter
|
// Reichweite. range_m trägt den Rohwert der API; über 1500 kann nur Meter
|
||||||
// gemeint sein, ein Akku trägt keine 1500 Kilometer.
|
// gemeint sein, ein Akku trägt keine 1500 Kilometer.
|
||||||
let km = mqZahl("evRange");
|
let km = mqZahl("evRange");
|
||||||
if (km === null && stand && stand.range_m !== null && stand.range_m !== undefined) {
|
if (km === null && stand) {
|
||||||
const roh = Number(stand.range_m);
|
const roh = feldZahl(stand.range_m);
|
||||||
km = roh > 1500 ? roh / 1000 : roh;
|
if (roh !== null) km = roh > 1500 ? roh / 1000 : roh;
|
||||||
}
|
}
|
||||||
$("skodaReichweite").textContent = km === null ? "–" : nf(km) + " km";
|
|
||||||
|
|
||||||
// Ladeleistung: das Fahrzeug meldet DC-seitig, die Wallbox zählt am
|
// Ladeleistung: das Fahrzeug meldet DC-seitig, die Wallbox zählt am
|
||||||
// Hausanschluss. Beim Laden ist die Wallbox der belastbarere Wert.
|
// Hausanschluss. Beim Laden ist die Wallbox der belastbarere Wert.
|
||||||
const wbKw = mqZahl("evPower");
|
const wbKw = mqZahl("evPower");
|
||||||
const carKw = stand && stand.chg_kw !== null ? Number(stand.chg_kw) : null;
|
const carKw = stand ? feldZahl(stand.chg_kw) : null;
|
||||||
const kw = laedt ? (wbKw || carKw) : (wbKw && wbKw > 0.2 ? wbKw : null);
|
|
||||||
$("skodaLeistung").textContent = kw ? nf(kw, 1) + " kW" : "–";
|
|
||||||
|
|
||||||
const restMin = mqZahl("evRemChrgTime") ?? (stand ? Number(stand.chg_rem_min) : null);
|
const pvW = mqZahl("P_PV");
|
||||||
$("skodaRestzeit").textContent = laedt ? dauer(restMin) : "–";
|
|
||||||
|
|
||||||
$("skodaKm").textContent = stand && stand.odo_km ? nf(stand.odo_km) + " km" : "–";
|
return {
|
||||||
|
zustand: zustand,
|
||||||
const klima = stand ? stand.ac_state : null;
|
laedt: laedt,
|
||||||
const KLIMA = { OFF: "aus", COOLING: "kühlt", HEATING: "heizt",
|
soc: mqZahl("evSOC") ?? (stand ? feldZahl(stand.soc) : null),
|
||||||
HEATING_AUXILIARY: "Zuheizer", VENTILATION: "lüftet",
|
ziel: stand ? feldZahl(stand.target_soc) : null,
|
||||||
COMPLETED: "fertig", UNKNOWN: "unbekannt", UNSUPPORTED: "–" };
|
km: km,
|
||||||
$("skodaKlima").textContent = klima ? (KLIMA[klima] || klima) : "–";
|
kw: laedt ? (wbKw || carKw) : (wbKw && wbKw > 0.2 ? wbKw : null),
|
||||||
|
pvKw: pvW === null ? null : Math.max(0, pvW / 1000),
|
||||||
// Ladezustand als Kennzeichen
|
restMin: mqZahl("evRemChrgTime") ?? (stand ? feldZahl(stand.chg_rem_min) : null),
|
||||||
const ZUSTAND = {
|
ladeart: stand ? stand.chg_type : null,
|
||||||
CHARGING: ["lädt", "text-bg-success"],
|
odo: stand ? feldZahl(stand.odo_km) : null,
|
||||||
CONSERVING: ["hält Ladung", "text-bg-secondary"],
|
klima: stand ? stand.ac_state : null,
|
||||||
READY_FOR_CHARGING: ["bereit", "text-bg-info"],
|
// null heißt: das Fahrzeug meldet den Zustand des Schlosses selbst nicht.
|
||||||
CONNECT_CABLE: ["nicht angesteckt", "text-bg-secondary"],
|
verriegelt: (stand && stand.locked !== null && stand.locked !== undefined)
|
||||||
CHARGING_INTERRUPTED: ["unterbrochen", "text-bg-warning"],
|
? Number(stand.locked) === 1 : null,
|
||||||
DISCHARGING: ["entlädt", "text-bg-warning"]
|
/*
|
||||||
|
* Fährt gerade. Kommt allein aus der Tabelle - der Broker kennt den
|
||||||
|
* Parkzustand nicht, unter solarManager/ev* stehen nur Ladestand,
|
||||||
|
* Reichweite und Ladung. Der Sammler fragt das Fahrzeug unterwegs alle
|
||||||
|
* vier Minuten, die Seite lädt jede Minute nach: die Anzeige hinkt also
|
||||||
|
* um wenige Minuten hinterher. Für "rollt gerade" reicht das; ein
|
||||||
|
* Tachometer ist es nicht.
|
||||||
|
*/
|
||||||
|
faehrt: !!(stand && stand.park_state === "IN_MOTION")
|
||||||
};
|
};
|
||||||
const kz = $("skodaLadeZustand");
|
}
|
||||||
const eintrag = ZUSTAND[zustand] || [zustand || "–", "text-bg-secondary"];
|
|
||||||
kz.textContent = eintrag[0];
|
/* --------------------------------------------------------- Zeichenschritte */
|
||||||
kz.className = "badge " + eintrag[1];
|
|
||||||
|
/**
|
||||||
|
* Die Akkuplatte im Fahrzeugboden - die Ladestandsanzeige der Seite.
|
||||||
|
*
|
||||||
|
* Gefüllt wird von hinten nach vorn, also im Bild von unten nach oben. Die
|
||||||
|
* Farbe kommt aus einer einzigen CSS-Variablen, die an der Zustandsklasse des
|
||||||
|
* <svg> hängt; hier wird nur die Klasse gewechselt.
|
||||||
|
*/
|
||||||
|
function zeichneAkku(w) {
|
||||||
|
const bild = $("skodaAuto");
|
||||||
|
if (!bild) return;
|
||||||
|
|
||||||
|
const bekannt = w.soc !== null && Number.isFinite(w.soc);
|
||||||
|
setzeAusGruppe(bild, AKKU_KLASSEN, !bekannt ? "akku-unbekannt"
|
||||||
|
: (w.laedt ? "akku-laedt" : (w.soc < AKKU_NIEDRIG ? "akku-niedrig" : null)));
|
||||||
|
|
||||||
|
const stand = bekannt ? w.soc : 0;
|
||||||
|
setzeFuellstand($("akkuFuellung"), stand);
|
||||||
|
setzeFuellstand($("akkuPegelFeld"), stand); // Schnittkante für die Strömung
|
||||||
|
|
||||||
|
// Die helle Kante sitzt auf der Füllung. Bei leerer Platte hat sie nichts
|
||||||
|
// zu begrenzen und bliebe sonst als Strich am Boden liegen.
|
||||||
|
const kante = $("akkuKante");
|
||||||
|
if (kante) {
|
||||||
|
kante.setAttribute("y", (akkuKante(stand) - 1.25).toFixed(1));
|
||||||
|
kante.style.display = (bekannt && stand > 0) ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zielmarke. Gezeichnet ist sie auf 100 Prozent; verschoben wird die ganze
|
||||||
|
// Gruppe, damit Linie und Beschriftung beieinanderbleiben.
|
||||||
|
const marke = $("akkuZielMarke");
|
||||||
|
if (marke) {
|
||||||
|
marke.style.display = w.ziel ? "" : "none";
|
||||||
|
if (w.ziel) {
|
||||||
|
marke.style.transform = "translateY(" + (akkuKante(w.ziel) - AKKU.oben).toFixed(1) + "px)";
|
||||||
|
setzeText("akkuZielText", "Ziel " + nf(w.ziel) + " %");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setzeText("akkuText", bekannt ? nf(w.soc) : "–");
|
||||||
|
setzeText("akkuNeben", w.km === null ? "–" : nf(w.km) + " km");
|
||||||
|
|
||||||
|
// Der Titel der Gruppe ist Sprechblase und Vorlesetext zugleich - er nennt
|
||||||
|
// in Worten, was die Platte zeigt.
|
||||||
|
setzeText("akkuTitel", "Antriebsbatterie: "
|
||||||
|
+ (bekannt ? nf(w.soc) + " Prozent" : "Ladestand unbekannt")
|
||||||
|
+ (w.ziel ? ", Ladeziel " + nf(w.ziel) + " Prozent" : "")
|
||||||
|
+ (w.km === null ? "" : ", Reichweite " + nf(w.km) + " Kilometer")
|
||||||
|
+ (w.laedt ? ", lädt" : ""));
|
||||||
|
|
||||||
|
// Der Ladeanschluss pulst, der Weg zur Platte fließt - beides hängt an
|
||||||
|
// derselben Zustandsklasse wie die Füllung.
|
||||||
|
schalteKlasse("teil-plug", "laedt", w.laedt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Klimaknopf im Fahrzeugbild. Sein Lüfterrad dreht sich, solange die
|
||||||
|
* Klimatisierung arbeitet - so zeigt der Knopf nicht nur, was er kann,
|
||||||
|
* sondern auch, ob es gerade geschieht.
|
||||||
|
*/
|
||||||
|
function zeichneKlimaknopf(w) {
|
||||||
|
schalteKlasse("skodaAuto", "klima-laeuft", KLIMA_LAEUFT.indexOf(w.klima) >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Kacheln neben dem Fahrzeug: alles, was sich nicht zeichnen lässt. */
|
||||||
|
function zeichneKennzahlen(w) {
|
||||||
|
setzeText("skodaLeistung", w.kw ? nf(w.kw, 1) + " kW" : "–");
|
||||||
|
setzeText("skodaLadeart", (w.laedt && w.ladeart && LADEART[w.ladeart]) || "");
|
||||||
|
schalteKlasse("skodaKachelLeistung", "aktiv", w.laedt && w.kw);
|
||||||
|
|
||||||
|
setzeText("skodaRestzeit", w.laedt ? dauer(w.restMin) : "–");
|
||||||
|
setzeText("skodaFertig", w.laedt ? fertigUm(w.restMin) : "");
|
||||||
|
schalteKlasse("skodaKachelRestzeit", "aktiv", w.laedt && w.restMin > 0);
|
||||||
|
|
||||||
|
setzeText("skodaKm", w.odo ? nf(w.odo) + " km" : "–");
|
||||||
|
|
||||||
|
setzeText("skodaKlima", w.klima ? (KLIMA[w.klima] || w.klima) : "–");
|
||||||
|
schalteKlasse("skodaKachelKlima", "aktiv", KLIMA_LAEUFT.indexOf(w.klima) >= 0);
|
||||||
|
const klimaZeichen = $("skodaKlimaZeichen");
|
||||||
|
if (klimaZeichen) {
|
||||||
|
klimaZeichen.className = "bi " + (KLIMA_ZEICHEN[w.klima] || KLIMA_ZEICHEN_SONST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die beiden Kennzeichen in der Kopfzeile: Schloss und Ladezustand. */
|
||||||
|
function zeichneAbzeichen(w) {
|
||||||
|
const zustand = $("skodaLadeZustand");
|
||||||
|
if (zustand) {
|
||||||
|
const eintrag = LADEZUSTAND[w.zustand] || [w.zustand || "–", "text-bg-secondary"];
|
||||||
|
zustand.textContent = eintrag[0];
|
||||||
|
zustand.className = "badge " + eintrag[1];
|
||||||
|
}
|
||||||
|
|
||||||
// Verriegelung. null heißt: das Fahrzeug meldet UNKNOWN.
|
|
||||||
const schloss = $("skodaSchloss");
|
const schloss = $("skodaSchloss");
|
||||||
const zu = stand ? stand.locked : null;
|
if (!schloss) return;
|
||||||
if (zu === null || zu === undefined) {
|
if (w.verriegelt === null) {
|
||||||
schloss.textContent = "Schloss unbekannt";
|
schloss.textContent = "Schloss unbekannt";
|
||||||
schloss.className = "badge text-bg-secondary";
|
schloss.className = "badge text-bg-secondary";
|
||||||
} else if (Number(zu) === 1) {
|
} else if (w.verriegelt) {
|
||||||
schloss.textContent = "verriegelt";
|
schloss.textContent = "verriegelt";
|
||||||
schloss.className = "badge text-bg-success";
|
schloss.className = "badge text-bg-success";
|
||||||
} else {
|
} else {
|
||||||
schloss.textContent = "offen";
|
schloss.textContent = "offen";
|
||||||
schloss.className = "badge text-bg-warning";
|
schloss.className = "badge text-bg-warning";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ladeanschluss pulst beim Laden
|
/* ------------------------------------------------------------ Ostergruß */
|
||||||
const stecker = $("teil-plug");
|
|
||||||
if (stecker) stecker.classList.toggle("laedt", laedt);
|
|
||||||
|
|
||||||
// Herkunft des Ladestroms: was die PV im Augenblick liefert, gegen das,
|
/*
|
||||||
// was die Wallbox zieht. Mehr als die Ladeleistung kann die PV nicht
|
* Ganz selten liegt eine Bananenschale auf der Fahrbahn.
|
||||||
// beitragen, auch wenn gerade mehr Sonne da ist.
|
*
|
||||||
const box = $("skodaQuelleBox");
|
* Geprüft wird alle 20 Sekunden, und nur während der Fahrt; die Chance liegt
|
||||||
const pvW = mqZahl("P_PV");
|
* bei zwei Prozent. Das ist im Mittel einmal in gut sechzehn Minuten Fahrt -
|
||||||
if (laedt && kw && pvW !== null) {
|
* selten genug, dass man es kaum erwartet, und häufig genug, dass es
|
||||||
const pvKw = Math.max(0, Math.min(kw, pvW / 1000));
|
* irgendwann jemand sieht.
|
||||||
const anteil = Math.round(100 * pvKw / kw);
|
*
|
||||||
$("skodaAnteilPV").style.width = anteil + "%";
|
* Der Auftritt dauert anderthalb Sekunden, danach fällt die Klasse wieder ab.
|
||||||
$("skodaAnteilNetz").style.width = (100 - anteil) + "%";
|
* Wer Bewegungen abbestellt hat, bekommt ihn gar nicht erst - ein
|
||||||
$("skodaQuelleText").textContent = anteil + " % Sonne, " + (100 - anteil) + " % Netz";
|
* schleuderndes Auto ist genau das, was damit gemeint ist.
|
||||||
box.style.display = "";
|
*/
|
||||||
} else {
|
const BANANE_TAKT = 20000;
|
||||||
box.style.display = "none";
|
const BANANE_CHANCE = 0.02;
|
||||||
|
const BANANE_DAUER = 1600;
|
||||||
|
|
||||||
|
function bananeVielleicht() {
|
||||||
|
const bild = $("skodaAuto");
|
||||||
|
if (!bild || !bild.classList.contains("faehrt")) return;
|
||||||
|
if (bild.classList.contains("banane")) return; // läuft schon
|
||||||
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||||
|
if (Math.random() >= BANANE_CHANCE) return;
|
||||||
|
|
||||||
|
bild.classList.add("banane");
|
||||||
|
setTimeout(function () { bild.classList.remove("banane"); }, BANANE_DAUER);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fahrt anzeigen: Fahrtwind am Fahrzeug, Abzeichen im Kopf der Kachel.
|
||||||
|
*
|
||||||
|
* Zwei Wege für dieselbe Aussage, und beide werden gebraucht. Die Streifen
|
||||||
|
* sieht man aus dem Augenwinkel, ohne zu lesen - dafür sind sie da. Wer die
|
||||||
|
* Seite vorgelesen bekommt oder Bewegungen abgeschaltet hat, hat von ihnen
|
||||||
|
* nichts; deshalb steht es zusätzlich als Wort neben Schloss und Ladezustand.
|
||||||
|
*/
|
||||||
|
function zeichneFahrt(w) {
|
||||||
|
const bild = $("skodaAuto");
|
||||||
|
if (bild) {
|
||||||
|
bild.classList.toggle("faehrt", w.faehrt);
|
||||||
|
// Hält der Wagen mitten im Schleudern an, bleibt sonst eine Schale auf
|
||||||
|
// der Fahrbahn liegen, die niemand mehr überfährt.
|
||||||
|
if (!w.faehrt) bild.classList.remove("banane");
|
||||||
}
|
}
|
||||||
|
const abzeichen = $("skodaUnterwegs");
|
||||||
|
if (abzeichen) {
|
||||||
|
abzeichen.hidden = !w.faehrt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Herkunft des Ladestroms: was die Anlage im Augenblick liefert, gegen das,
|
||||||
|
* was die Wallbox zieht. Ohne Ladung hat der Balken nichts zu teilen und
|
||||||
|
* bleibt weg.
|
||||||
|
*/
|
||||||
|
function zeichneLadequelle(w) {
|
||||||
|
const box = $("skodaQuelleBox");
|
||||||
|
if (!box) return;
|
||||||
|
|
||||||
|
const zeigen = w.laedt && w.kw > 0 && w.pvKw !== null;
|
||||||
|
box.hidden = !zeigen;
|
||||||
|
if (!zeigen) return;
|
||||||
|
|
||||||
|
// Mehr als die Ladeleistung kann die Anlage nicht beitragen, auch wenn
|
||||||
|
// gerade mehr Sonne da ist - der Überschuss geht ins Haus oder ins Netz.
|
||||||
|
const ausPv = Math.max(0, Math.min(w.kw, w.pvKw));
|
||||||
|
const anteil = Math.round(100 * ausPv / w.kw);
|
||||||
|
|
||||||
|
$("skodaAnteilPV").style.width = anteil + "%";
|
||||||
|
$("skodaAnteilNetz").style.width = (100 - anteil) + "%";
|
||||||
|
setzeText("skodaQuelleText", nf(w.kw, 1) + " kW gesamt");
|
||||||
|
setzeText("skodaQuellePV", nf(ausPv, 1) + " kW");
|
||||||
|
setzeText("skodaQuelleNetz", nf(w.kw - ausPv, 1) + " kW");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Fahrzeugkachel neu zeichnen. */
|
||||||
|
function zeichneLive() {
|
||||||
|
const w = liveWerte();
|
||||||
|
zeichneAkku(w);
|
||||||
|
zeichneKlimaknopf(w);
|
||||||
|
zeichneKennzahlen(w);
|
||||||
|
zeichneAbzeichen(w);
|
||||||
|
zeichneFahrt(w);
|
||||||
|
zeichneLadequelle(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------- Diagramme */
|
/* ------------------------------------------------------------- Diagramme */
|
||||||
@@ -328,7 +649,10 @@ function baueKurve() {
|
|||||||
options: {
|
options: {
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
animation: false,
|
animation: false,
|
||||||
interaction: { mode: "index", intersect: false },
|
// "nearest" statt "index": Wallbox-Verlauf und Fahrzeugmeldungen haben
|
||||||
|
// verschiedene Zeitpunkte, der n-te Wert der einen Reihe gehört nicht zum
|
||||||
|
// n-ten der anderen.
|
||||||
|
interaction: { mode: "nearest", axis: "x", intersect: false },
|
||||||
scales: {
|
scales: {
|
||||||
// 'timestack' ist die Zeitachse, die diese Seite mitbringt
|
// 'timestack' ist die Zeitachse, die diese Seite mitbringt
|
||||||
// (js/chartjs-scale-timestack.min.js). Chart.js' eingebautes 'time'
|
// (js/chartjs-scale-timestack.min.js). Chart.js' eingebautes 'time'
|
||||||
@@ -691,7 +1015,7 @@ function zeigeVerbrauchsbasis() {
|
|||||||
if (!letzteKapazitaet) {
|
if (!letzteKapazitaet) {
|
||||||
// Ohne Kapazität gibt es nichts umzuschalten: dann bleibt nur Prozent.
|
// Ohne Kapazität gibt es nichts umzuschalten: dann bleibt nur Prozent.
|
||||||
feld.textContent = "";
|
feld.textContent = "";
|
||||||
feld.removeAttribute("title");
|
setzeHinweis(feld, null);
|
||||||
if (schalter) schalter.style.display = "none";
|
if (schalter) schalter.style.display = "none";
|
||||||
setzeEinheit("prozent", false);
|
setzeEinheit("prozent", false);
|
||||||
return;
|
return;
|
||||||
@@ -703,7 +1027,7 @@ function zeigeVerbrauchsbasis() {
|
|||||||
|
|
||||||
// Woher der Wirkungsgrad stammt, gehört dazu: gemessen ist er aus dem
|
// Woher der Wirkungsgrad stammt, gehört dazu: gemessen ist er aus dem
|
||||||
// Verhältnis zweier Energien derselben Ladung, angenommen ist er geraten.
|
// Verhältnis zweier Energien derselben Ladung, angenommen ist er geraten.
|
||||||
feld.title = "Aus " + k.n + " Ladungen des letzten Jahres: "
|
setzeHinweis(feld, "Aus " + k.n + " Ladungen des letzten Jahres: "
|
||||||
+ nf(k.je100_zaehler, 1) + " kWh je 100 Prozentpunkte ab Wallbox-Zähler, "
|
+ nf(k.je100_zaehler, 1) + " kWh je 100 Prozentpunkte ab Wallbox-Zähler, "
|
||||||
+ nf(k.je100_netto, 1) + " kWh davon in der Batterie. Ladewirkungsgrad "
|
+ nf(k.je100_netto, 1) + " kWh davon in der Batterie. Ladewirkungsgrad "
|
||||||
+ nf(k.wirkungsgrad * 100, 1) + " %, "
|
+ nf(k.wirkungsgrad * 100, 1) + " %, "
|
||||||
@@ -711,7 +1035,7 @@ function zeigeVerbrauchsbasis() {
|
|||||||
? "gemessen aus " + k.n_fahrzeug + " Ladungen — der Zähler misst vor dem "
|
? "gemessen aus " + k.n_fahrzeug + " Ladungen — der Zähler misst vor dem "
|
||||||
+ "Ladegerät, das Fahrzeug meldet dahinter."
|
+ "Ladegerät, das Fahrzeug meldet dahinter."
|
||||||
: "angenommen, weil noch zu wenige Ladungen die Ladeleistung des "
|
: "angenommen, weil noch zu wenige Ladungen die Ladeleistung des "
|
||||||
+ "Fahrzeugs mitgeliefert haben.");
|
+ "Fahrzeugs mitgeliefert haben."));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Die gewählte Zeile in der Fahrtenliste kenntlich machen. */
|
/** Die gewählte Zeile in der Fahrtenliste kenntlich machen. */
|
||||||
@@ -757,6 +1081,47 @@ async function ladeLive() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fahrleistung. Die Namen der Zeiträume stehen hier, ihre Länge in Tagen
|
||||||
|
* kommt vom Server mit - so muss nur eine Seite geändert werden, wenn ein
|
||||||
|
* Zeitraum hinzukommt.
|
||||||
|
*/
|
||||||
|
const STRECKE_ZEITRAEUME = { woche: "7 Tage", monat: "30 Tage", jahr: "12 Monate" };
|
||||||
|
|
||||||
|
async function ladeStrecke() {
|
||||||
|
const feld = $("skodaFahrleistung");
|
||||||
|
if (!feld) return;
|
||||||
|
loeseHinweise(feld);
|
||||||
|
try {
|
||||||
|
const d = await hole("strecke");
|
||||||
|
feld.innerHTML = Object.keys(STRECKE_ZEITRAEUME).map(function (name) {
|
||||||
|
const p = d.strecke ? d.strecke[name] : null;
|
||||||
|
const km = (p && p.km !== null && p.km !== undefined) ? nf(p.km) + " km" : "–";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Reicht die Aufzeichnung nicht über den ganzen Zeitraum, ist die Zahl
|
||||||
|
* kein Jahreswert, sondern nur das, was aufgezeichnet ist. Sie deshalb
|
||||||
|
* wegzulassen wäre falsch - sie kommentarlos als Jahr auszugeben aber
|
||||||
|
* auch. Also gestrichelt unterstrichen, mit dem Anfang im Titel.
|
||||||
|
*/
|
||||||
|
const knapp = p && p.km !== null && !p.vollstaendig;
|
||||||
|
return '<div class="posten' + (knapp ? " knapp" : "") + '">'
|
||||||
|
+ '<span class="v">' + km + '</span>'
|
||||||
|
+ '<span class="k">' + STRECKE_ZEITRAEUME[name] + "</span></div>";
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
// Die knappen Zeiträume tragen ihre Erklärung selbst - antippbar.
|
||||||
|
feld.querySelectorAll(".posten.knapp").forEach(function (posten) {
|
||||||
|
setzeHinweis(posten, "Aufgezeichnet wird erst seit " + zeitpunkt(d.seit, true)
|
||||||
|
+ ". Der Wert deckt deshalb nicht den ganzen Zeitraum ab, sondern nur "
|
||||||
|
+ "das, was seither aufgezeichnet ist.");
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
feld.innerHTML = '<div class="posten"><span class="v">–</span>'
|
||||||
|
+ '<span class="k">nicht abrufbar</span></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function ladeLadungen() {
|
async function ladeLadungen() {
|
||||||
try {
|
try {
|
||||||
const d = await hole("ladungen", "&tage=180");
|
const d = await hole("ladungen", "&tage=180");
|
||||||
@@ -789,6 +1154,38 @@ async function ladeKurve() {
|
|||||||
// Date-Objekten - die nimmt sie stillschweigend nicht an und zeigt
|
// Date-Objekten - die nimmt sie stillschweigend nicht an und zeigt
|
||||||
// stattdessen den heutigen Tag.
|
// stattdessen den heutigen Tag.
|
||||||
const zeit = (p) => new Date(String(p.datetime).replace(" ", "T")).getTime();
|
const zeit = (p) => new Date(String(p.datetime).replace(" ", "T")).getTime();
|
||||||
|
|
||||||
|
// Mit Wallbox-Verlauf (skoda_ladepunkte): die Leistung im Minutentakt,
|
||||||
|
// der Ladestand dazwischen nach der geflossenen Energie aufgefüllt, und
|
||||||
|
// die echten Meldungen des Fahrzeugs als Punkte darauf - damit sichtbar
|
||||||
|
// bleibt, was gemessen und was abgeleitet ist. Das Fahrzeug meldet beim
|
||||||
|
// Laden mitunter nur einmal in der Stunde; eine Linie durch diese Punkte
|
||||||
|
// allein erfände den Verlauf dazwischen.
|
||||||
|
if (d.verlauf && d.verlauf.length) {
|
||||||
|
const imBild = (m) => zeit(m) >= zeit(d.verlauf[0]) - 600000
|
||||||
|
&& zeit(m) <= zeit(d.verlauf[d.verlauf.length - 1]) + 600000;
|
||||||
|
const meldungen = (d.meldungen || []).filter(imBild);
|
||||||
|
kurveChart.data.datasets = [
|
||||||
|
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
|
||||||
|
borderWidth: 2, pointRadius: 0, fill: "origin", tension: 0,
|
||||||
|
data: d.verlauf.map(p => ({ x: zeit(p), y: +p.kw })) },
|
||||||
|
{ label: "Ladestand, aufgefüllt", yAxisID: "y1", borderColor: "#f0b429", borderWidth: 2,
|
||||||
|
borderDash: [5, 4], pointRadius: 0, tension: 0, spanGaps: false,
|
||||||
|
data: d.verlauf.map(p => ({ x: zeit(p), y: p.soc === null ? null : +p.soc })) },
|
||||||
|
{ label: "Ladestand laut Fahrzeug", yAxisID: "y1", showLine: false,
|
||||||
|
borderColor: "#f0b429", backgroundColor: "#f0b429", pointRadius: 4, pointHoverRadius: 6,
|
||||||
|
data: meldungen.map(m => ({ x: zeit(m), y: +m.soc })) },
|
||||||
|
{ label: "Leistung laut Fahrzeug", yAxisID: "y", showLine: false,
|
||||||
|
borderColor: "#7aa7c7", backgroundColor: "#7aa7c7", pointRadius: 3, pointStyle: "rectRot",
|
||||||
|
data: meldungen.filter(m => m.chg_kw !== null && m.chg_state === "CHARGING")
|
||||||
|
.map(m => ({ x: zeit(m), y: +m.chg_kw })) }
|
||||||
|
];
|
||||||
|
kurveChart.update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne Wallbox-Verlauf - Ladungen von vor der Aufzeichnung - wie bisher
|
||||||
|
// aus den Zeilen der Fahrzeugtabelle.
|
||||||
kurveChart.data.datasets = [
|
kurveChart.data.datasets = [
|
||||||
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
|
{ label: "Wallbox", yAxisID: "y", borderColor: "#4caf7d", backgroundColor: "rgba(76,175,125,.18)",
|
||||||
borderWidth: 2, pointRadius: 0, fill: "origin", tension: .25,
|
borderWidth: 2, pointRadius: 0, fill: "origin", tension: .25,
|
||||||
@@ -1137,6 +1534,7 @@ function verdrahteBefehle(bereich) {
|
|||||||
|
|
||||||
function alleLaden() {
|
function alleLaden() {
|
||||||
ladeLive();
|
ladeLive();
|
||||||
|
ladeStrecke();
|
||||||
ladeLadungen();
|
ladeLadungen();
|
||||||
ladeKurve();
|
ladeKurve();
|
||||||
ladeGesundheit();
|
ladeGesundheit();
|
||||||
@@ -1161,8 +1559,11 @@ document.addEventListener("readystatechange", function () {
|
|||||||
// Die Tabelle wächst im Takt des Fahrzeugs, nicht im Takt des Betrachters:
|
// Die Tabelle wächst im Takt des Fahrzeugs, nicht im Takt des Betrachters:
|
||||||
// beim Parken steht dort stundenlang dasselbe. Einmal pro Minute genügt.
|
// beim Parken steht dort stundenlang dasselbe. Einmal pro Minute genügt.
|
||||||
setInterval(ladeLive, 60000);
|
setInterval(ladeLive, 60000);
|
||||||
setInterval(function () { ladeLadungen(); ladeKurve(); ladeFahrten(); }, 300000);
|
setInterval(function () {
|
||||||
|
ladeLadungen(); ladeKurve(); ladeFahrten(); ladeStrecke();
|
||||||
|
}, 300000);
|
||||||
setInterval(ladeGesundheit, 900000);
|
setInterval(ladeGesundheit, 900000);
|
||||||
|
setInterval(bananeVielleicht, BANANE_TAKT);
|
||||||
|
|
||||||
// Leaflet rechnet die Kachelanordnung selbst neu, sobald es erfährt, dass
|
// Leaflet rechnet die Kachelanordnung selbst neu, sobald es erfährt, dass
|
||||||
// sein Container eine andere Größe hat.
|
// sein Container eine andere Größe hat.
|
||||||
|
|||||||
+12
-2
@@ -339,7 +339,14 @@ const solarSVG = {
|
|||||||
} else {
|
} else {
|
||||||
setDisplay("heatEG", "none");
|
setDisplay("heatEG", "none");
|
||||||
}
|
}
|
||||||
if (plugev != "no car") {
|
// solarManager/evPlug kommt als 1 oder 0, wie evPlugOG. Frueher kam
|
||||||
|
// hier der Text der Wallbox an ("no car", "charging", ...); Number()
|
||||||
|
// machte daraus NaN, und NaN != "no car" ist immer wahr - das
|
||||||
|
// Steckersymbol stand dauerhaft da. Den Text weiter zu verstehen
|
||||||
|
// kostet eine Zeile und haelt die Anzeige richtig, egal ob der
|
||||||
|
// Manager schon neu gestartet ist oder nicht.
|
||||||
|
const plugText = mqttData["solarManager/evPlug"];
|
||||||
|
if (isNaN(plugev) ? (plugText !== undefined && plugText !== "no car") : plugev > 0) {
|
||||||
setDisplay("evPlug", "");
|
setDisplay("evPlug", "");
|
||||||
} else {
|
} else {
|
||||||
setDisplay("evPlug", "none");
|
setDisplay("evPlug", "none");
|
||||||
@@ -593,7 +600,10 @@ var chartSettings = {
|
|||||||
getData(consChart, 'ajax/getConsData.php', { params: dayRange, sunrise: true, updateMode: "none" });
|
getData(consChart, 'ajax/getConsData.php', { params: dayRange, sunrise: true, updateMode: "none" });
|
||||||
getData(prodChart, 'ajax/getProdData.php', { params: dayRange, sunrise: true, updateMode: "none" });
|
getData(prodChart, 'ajax/getProdData.php', { params: dayRange, sunrise: true, updateMode: "none" });
|
||||||
getData(foreChart, 'ajax/getForecastData.php', { params: dayRange, updateMode: "none" });
|
getData(foreChart, 'ajax/getForecastData.php', { params: dayRange, updateMode: "none" });
|
||||||
getStats("Stats-Year","ajax/getStats.php?type=ThisYear");
|
// Dieselbe Statistik wie auf der Historienseite, nur das laufende Jahr:
|
||||||
|
// hier ist sie Beiwerk, dort der Zweck der Seite.
|
||||||
|
baueJahresstatistik(document.getElementById("Stats-Year"),
|
||||||
|
{ spalten: 1, werkzeuge: false, fussnoten: false });
|
||||||
solarSVG.fillElementArray();
|
solarSVG.fillElementArray();
|
||||||
mountMeteogram();
|
mountMeteogram();
|
||||||
}
|
}
|
||||||
|
|||||||
+502
-27
@@ -54,6 +54,11 @@ function meshDb()
|
|||||||
* danach liegen kann - deshalb dieselben drei Faelle noch einmal mal zwei:
|
* danach liegen kann - deshalb dieselben drei Faelle noch einmal mal zwei:
|
||||||
* "+ 00:30" eine halbe Stunde nach Sonnenaufgang, "ab - 00:30" ab einer
|
* "+ 00:30" eine halbe Stunde nach Sonnenaufgang, "ab - 00:30" ab einer
|
||||||
* halben Stunde davor, "vor + 00:30" bis eine halbe Stunde danach.
|
* halben Stunde davor, "vor + 00:30" bis eine halbe Stunde danach.
|
||||||
|
*
|
||||||
|
* Dieselbe Schreibweise traegt die Verkettung. Das gerechnete Geraet
|
||||||
|
* "Automatiken" fuehrt jede Automatik als Messwert, ihr Wert ist der
|
||||||
|
* Zeitpunkt der letzten Ausloesung (Datentyp `elapsed`) - "Wecker Magdalena
|
||||||
|
* + 00:10" heisst zehn Minuten danach. Ein Minus gibt es dort nicht.
|
||||||
*/
|
*/
|
||||||
function operatorsForType($type)
|
function operatorsForType($type)
|
||||||
{
|
{
|
||||||
@@ -67,6 +72,10 @@ function operatorsForType($type)
|
|||||||
return ["=", ">=", "<"];
|
return ["=", ">=", "<"];
|
||||||
case "deltatime": // Sonnenauf-/-untergang: Wert ist ein Versatz
|
case "deltatime": // Sonnenauf-/-untergang: Wert ist ein Versatz
|
||||||
return ["+", "-", ">=+", ">=-", "<+", "<-"];
|
return ["+", "-", ">=+", ">=-", "<+", "<-"];
|
||||||
|
case "elapsed": // andere Automatik: Wert ist ein Versatz danach
|
||||||
|
// Nur die drei Formen "danach". Ein Minus gibt es nicht: vor dem
|
||||||
|
// Auslöser kann nichts liegen, das erst der Auslöser anstößt.
|
||||||
|
return ["+", ">=+", "<+"];
|
||||||
case "bool":
|
case "bool":
|
||||||
return ["="]; // JA/NEIN steht im Wertfeld, nicht im Operator
|
return ["="]; // JA/NEIN steht im Wertfeld, nicht im Operator
|
||||||
default:
|
default:
|
||||||
@@ -84,6 +93,115 @@ function operatorChoices($type)
|
|||||||
return $liste;
|
return $liste;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die aktuellen Werte zu einer Liste von Messwerten.
|
||||||
|
*
|
||||||
|
* Der Editor zeigt neben jeder Bedingung, was der Messwert gerade sagt, und
|
||||||
|
* holt sich das alle paar Sekunden nach. Bewusst aus actor_states und nicht
|
||||||
|
* aus MQTT: ueber den Broker kommt nur ein Teil der Geraete: die Jalousien
|
||||||
|
* haengen an der Tahoma-Box, die gerechneten Werte an gar nichts. Der Runner
|
||||||
|
* schreibt dagegen jeden Messwert zurueck, egal ueber welchen Weg er ihn
|
||||||
|
* geholt hat - eine Tabelle, ein Zugriff, alle Geraetearten.
|
||||||
|
*
|
||||||
|
* Die Ids kommen aus dem Browser und werden deshalb zu Zahlen gemacht, bevor
|
||||||
|
* sie in die Abfrage gehen.
|
||||||
|
*/
|
||||||
|
function stateValues(array $ids)
|
||||||
|
{
|
||||||
|
$ids = array_values(array_unique(array_filter(array_map("intval", $ids))));
|
||||||
|
if (!$ids) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$db = meshDb();
|
||||||
|
$liste = implode(",", $ids);
|
||||||
|
$res = $db->query("SELECT id, current_value, unit FROM actor_states
|
||||||
|
WHERE id IN (" . $liste . ")");
|
||||||
|
$werte = [];
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$werte[intval($row["id"])] = [
|
||||||
|
"value" => $row["current_value"],
|
||||||
|
"unit" => $row["unit"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $werte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ferien und Feiertage sind dreiwertig, nicht ja/nein.
|
||||||
|
*
|
||||||
|
* Zwei Zustaende gab es immer: "egal" (der Tag aendert nichts) und "nie"
|
||||||
|
* (an solchen Tagen laeuft die Automatik nicht - so ist "werktags" gebaut).
|
||||||
|
* "Zusaetzlich" ist der dritte und zaehlt wie ein passender Wochentag.
|
||||||
|
*
|
||||||
|
* Ohne ihn liess sich "an Wochenenden und Feiertagen" gar nicht schreiben:
|
||||||
|
* die Wochentagsmaske kennt nur Samstag und Sonntag, und ein Feiertag am
|
||||||
|
* Dienstag ist eben ein Dienstag. Jetzt genuegt Sa+So angehakt und
|
||||||
|
* "Feiertage: zusaetzlich".
|
||||||
|
*
|
||||||
|
* Ein Verbot schlaegt eine Erweiterung: wer in den Ferien nie laeuft und an
|
||||||
|
* Feiertagen zusaetzlich, laeuft an einem Feiertag in den Ferien nicht.
|
||||||
|
* Andersherum liesse sich "nie" nicht mehr verlassen. Der Runner haelt
|
||||||
|
* dieselbe Reihenfolge ein (tag_passt in autoaction_runner.py).
|
||||||
|
*/
|
||||||
|
function kalendertagChoices($was, $wann)
|
||||||
|
{
|
||||||
|
// Drei kurze Worte auf einer Achse, von eng nach weit: nie - egal -
|
||||||
|
// immer. Sie stehen als Schalterreihe neben "Ferien" bzw. "Feiertage",
|
||||||
|
// die Erklaerung haengt als title daran und der ganze Rahmen steht
|
||||||
|
// darunter noch einmal als Satz.
|
||||||
|
//
|
||||||
|
// Vorher stand die Erklaerung *im* Etikett ("zusaetzlich, auch am
|
||||||
|
// falschen Wochentag"). Ein Auswahlfeld, dessen Eintraege Saetze sind,
|
||||||
|
// liest sich wie eine Bedienungsanleitung und zeigt trotzdem nur den
|
||||||
|
// gewaehlten Satz - man sieht nie, was es sonst noch gibt.
|
||||||
|
return [
|
||||||
|
["value" => 0, "label" => "nie",
|
||||||
|
"title" => "Läuft " . $wann . " nicht."],
|
||||||
|
["value" => 1, "label" => "egal",
|
||||||
|
"title" => $was . " ändern nichts."],
|
||||||
|
["value" => 2, "label" => "immer",
|
||||||
|
"title" => "Läuft " . $wann . ", auch wenn kein Wochentag angehakt ist."],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorlagen fuer die Tagesauswahl - die drei Faelle, die man wirklich meint.
|
||||||
|
*
|
||||||
|
* "Werktags" heisst nicht nur Mo-Fr, sondern auch "nicht an Feiertagen", und
|
||||||
|
* "Wochenende" heisst fast immer "Wochenende und Feiertage". Wer das aus
|
||||||
|
* Wochentagsmaske und zwei Dreifachschaltern von Hand zusammensetzt, vergisst
|
||||||
|
* den zweiten Teil - deshalb steht er hier mit drin.
|
||||||
|
*/
|
||||||
|
function tagVorlagen()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
["label" => "täglich", "weekdays" => 127, "on_vacation" => 1, "on_holiday" => 1,
|
||||||
|
"title" => "Jeden Tag, Feiertage eingeschlossen."],
|
||||||
|
["label" => "werktags", "weekdays" => 31, "on_vacation" => 1, "on_holiday" => 0,
|
||||||
|
"title" => "Montag bis Freitag, aber nicht an Feiertagen."],
|
||||||
|
["label" => "Wochenende", "weekdays" => 96, "on_vacation" => 1, "on_holiday" => 2,
|
||||||
|
"title" => "Samstag und Sonntag, dazu alle Feiertage."],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Kalenderwert aus dem Editor - alles Krumme wird zu "egal".
|
||||||
|
*
|
||||||
|
* is_numeric davor, und zwar mit Absicht: intval("") ist 0, und 0 heisst hier
|
||||||
|
* "nie". Eine leere oder verunglueckte Angabe wuerde eine Automatik sonst
|
||||||
|
* stillschweigend an Feiertagen abschalten - eine Regelaenderung, die niemand
|
||||||
|
* angeordnet hat und die man erst am naechsten Feiertag bemerkt. "Egal" ist
|
||||||
|
* die harmlose Vorgabe: der Tag aendert dann nichts.
|
||||||
|
*/
|
||||||
|
function kalendertagWert($wert)
|
||||||
|
{
|
||||||
|
if (!is_numeric($wert)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$zahl = intval($wert);
|
||||||
|
return in_array($zahl, [0, 1, 2], true) ? $zahl : 1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sperrzeit: so lange nach einer Ausloesung wird nicht erneut geschaltet.
|
* Sperrzeit: so lange nach einer Ausloesung wird nicht erneut geschaltet.
|
||||||
*
|
*
|
||||||
@@ -157,6 +275,7 @@ function inputForType($type, $hasOptions)
|
|||||||
return "number";
|
return "number";
|
||||||
case "time":
|
case "time":
|
||||||
case "deltatime":
|
case "deltatime":
|
||||||
|
case "elapsed":
|
||||||
return "time";
|
return "time";
|
||||||
case "date":
|
case "date":
|
||||||
return "date";
|
return "date";
|
||||||
@@ -192,6 +311,14 @@ function operatorLabel($op, $type = "")
|
|||||||
return $beschriftung[$op];
|
return $beschriftung[$op];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ($type === "elapsed") {
|
||||||
|
// Dieselben Wörter wie beim Sonnenstand, damit ein Versatz überall
|
||||||
|
// gleich gelesen wird: "Wecker + 00:10" sind zehn Minuten danach.
|
||||||
|
$beschriftung = ["+" => "+", ">=+" => "ab +", "<+" => "vor +"];
|
||||||
|
if (isset($beschriftung[$op])) {
|
||||||
|
return $beschriftung[$op];
|
||||||
|
}
|
||||||
|
}
|
||||||
return $op === "!=" ? "≠" : $op;
|
return $op === "!=" ? "≠" : $op;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,17 +443,6 @@ function raumVorschlaege()
|
|||||||
return $vorschlag;
|
return $vorschlag;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raum je Geraet, wie gespeichert. */
|
|
||||||
function geraeteRaeume()
|
|
||||||
{
|
|
||||||
$res = meshDb()->query("SELECT id, room FROM actors");
|
|
||||||
$raeume = [];
|
|
||||||
while ($row = $res->fetch_assoc()) {
|
|
||||||
$raeume[intval($row["id"])] = $row["room"];
|
|
||||||
}
|
|
||||||
return $raeume;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Zuordnungen speichern. $zuordnung ist [actor_id => Raumschluessel|""]. */
|
/** Zuordnungen speichern. $zuordnung ist [actor_id => Raumschluessel|""]. */
|
||||||
function saveRooms($zuordnung)
|
function saveRooms($zuordnung)
|
||||||
{
|
{
|
||||||
@@ -352,6 +468,161 @@ function saveRooms($zuordnung)
|
|||||||
return $anzahl;
|
return $anzahl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Geraeteliste fuer die Einstellungsseite.
|
||||||
|
*
|
||||||
|
* Enthaelt alles, was man ueber ein Geraet wissen will, ohne es anzuklicken:
|
||||||
|
* Beschriftung, Art, Raum, ein Vorschlag fuer den Raum (aus dem MQTT-Topic),
|
||||||
|
* wie viele Messwerte und Kommandos daran haengen, wann zuletzt etwas davon
|
||||||
|
* gemeldet wurde - und in welchen Automatiken es steckt.
|
||||||
|
*
|
||||||
|
* Das Letzte ist der Grund, warum die Liste hier steht und nicht in der Maske:
|
||||||
|
* ein Geraet, auf das eine Automatik zeigt, darf nicht verschwinden, und wer
|
||||||
|
* das entscheidet, muss die Verweise kennen.
|
||||||
|
*/
|
||||||
|
function geraeteListe()
|
||||||
|
{
|
||||||
|
$db = meshDb();
|
||||||
|
$labels = geraeteBeschriftungen();
|
||||||
|
$tipps = raumVorschlaege();
|
||||||
|
|
||||||
|
$geraete = [];
|
||||||
|
$res = $db->query("SELECT id, name, type, url, room FROM actors");
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$id = intval($row["id"]);
|
||||||
|
$wo = raumEinordnung($row["room"]);
|
||||||
|
$geraete[$id] = [
|
||||||
|
"id" => $id,
|
||||||
|
"name" => $row["name"],
|
||||||
|
"label" => $labels[$id] ?? $row["name"],
|
||||||
|
"type" => $row["type"],
|
||||||
|
"url" => $row["url"],
|
||||||
|
"room" => $row["room"],
|
||||||
|
"roomLabel" => raumBeschriftung($row["room"]),
|
||||||
|
"floor" => $wo["floor"],
|
||||||
|
"tipp" => $tipps[$id] ?? null,
|
||||||
|
"messwerte" => 0,
|
||||||
|
"kommandos" => 0,
|
||||||
|
"gesehen" => null,
|
||||||
|
"benutzt" => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messwerte: Anzahl und juengste Meldung. Letztere sagt, ob ein Geraet
|
||||||
|
// noch lebt - ein Eintrag von vor einem halben Jahr ist eine Karteileiche.
|
||||||
|
$res = $db->query("SELECT actor_id, COUNT(*) AS n, MAX(last_updated) AS gesehen
|
||||||
|
FROM actor_states GROUP BY actor_id");
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$id = intval($row["actor_id"]);
|
||||||
|
if (isset($geraete[$id])) {
|
||||||
|
$geraete[$id]["messwerte"] = intval($row["n"]);
|
||||||
|
$geraete[$id]["gesehen"] = $row["gesehen"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = $db->query("SELECT actor_id, COUNT(*) AS n FROM actor_commands GROUP BY actor_id");
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$id = intval($row["actor_id"]);
|
||||||
|
if (isset($geraete[$id])) {
|
||||||
|
$geraete[$id]["kommandos"] = intval($row["n"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verweise aus den Automatiken - ueber Messwerte (Bedingungen) und ueber
|
||||||
|
// Kommandos (Aktionen).
|
||||||
|
$sql = "SELECT s.actor_id AS actor_id, a.id AS aid, a.name AS aname
|
||||||
|
FROM automation_conditions c
|
||||||
|
JOIN actor_states s ON s.id = c.state_id
|
||||||
|
JOIN automations a ON a.id = c.automation_id
|
||||||
|
UNION
|
||||||
|
SELECT k.actor_id AS actor_id, a.id AS aid, a.name AS aname
|
||||||
|
FROM automation_actions x
|
||||||
|
JOIN actor_commands k ON k.id = x.command_id
|
||||||
|
JOIN automations a ON a.id = x.automation_id";
|
||||||
|
$res = $db->query($sql);
|
||||||
|
while ($res && $row = $res->fetch_assoc()) {
|
||||||
|
$id = intval($row["actor_id"]);
|
||||||
|
if (isset($geraete[$id])) {
|
||||||
|
$geraete[$id]["benutzt"][intval($row["aid"])] = $row["aname"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($geraete as $id => $g) {
|
||||||
|
$geraete[$id]["benutzt"] = array_values($g["benutzt"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach Etage, Raum und Beschriftung - dieselbe Ordnung wie ueberall.
|
||||||
|
$reihenfolge = array_flip($GLOBALS["floors"]);
|
||||||
|
$liste = array_values($geraete);
|
||||||
|
usort($liste, function ($a, $b) use ($reihenfolge) {
|
||||||
|
$wa = explode("/", (string)$a["room"], 2);
|
||||||
|
$wb = explode("/", (string)$b["room"], 2);
|
||||||
|
// Geraete ohne Raum nach oben: sie sind das, was Arbeit macht.
|
||||||
|
$ra = ($a["room"] === null || $a["room"] === "") ? -1 : ($reihenfolge[$wa[0]] ?? 98);
|
||||||
|
$rb = ($b["room"] === null || $b["room"] === "") ? -1 : ($reihenfolge[$wb[0]] ?? 98);
|
||||||
|
if ($ra !== $rb) {
|
||||||
|
return $ra <=> $rb;
|
||||||
|
}
|
||||||
|
$raum = strcoll($wa[1] ?? "", $wb[1] ?? "");
|
||||||
|
return $raum !== 0 ? $raum : strcoll($a["label"], $b["label"]);
|
||||||
|
});
|
||||||
|
return $liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Geraet samt Messwerten, Kommandos und Parametern loeschen.
|
||||||
|
*
|
||||||
|
* Gedacht fuer Karteileichen: der Suchlauf loescht nichts (clear_tables steht
|
||||||
|
* bewusst auf false, weil die Automatiken auf die Ids zeigen), also bleibt ein
|
||||||
|
* abgebautes Geraet fuer immer in der Liste stehen.
|
||||||
|
*
|
||||||
|
* Zeigt eine Automatik darauf, wird nicht geloescht, sondern abgelehnt - mit
|
||||||
|
* den Namen der Automatiken. Sie stillschweigend mitzuloeschen waere ein
|
||||||
|
* stiller Datenverlust, sie kaputt zurueckzulassen ebenfalls. Wer das Geraet
|
||||||
|
* wirklich loswerden will, raeumt erst die Automatik auf.
|
||||||
|
*
|
||||||
|
* Steht das Geraet noch im Netz, findet der naechste Suchlauf es wieder - das
|
||||||
|
* ist kein Fehler, sondern der Zweck des Suchlaufs.
|
||||||
|
*/
|
||||||
|
function geraetLoeschen($id)
|
||||||
|
{
|
||||||
|
$id = intval($id);
|
||||||
|
if ($id <= 0) {
|
||||||
|
throw new InvalidArgumentException("Kein Gerät angegeben.");
|
||||||
|
}
|
||||||
|
$db = meshDb();
|
||||||
|
|
||||||
|
$res = $db->query("SELECT name FROM actors WHERE id = " . $id);
|
||||||
|
$geraet = $res ? $res->fetch_assoc() : null;
|
||||||
|
if (!$geraet) {
|
||||||
|
throw new InvalidArgumentException("Dieses Gerät gibt es nicht (mehr).");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (geraeteListe() as $g) {
|
||||||
|
if ($g["id"] === $id && $g["benutzt"]) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Das Gerät steckt in " . count($g["benutzt"]) . " Automatik(en): "
|
||||||
|
. implode(", ", $g["benutzt"]) . ". Erst dort entfernen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->begin_transaction();
|
||||||
|
try {
|
||||||
|
// Von innen nach aussen: Parameter haengen an Kommandos, Kommandos und
|
||||||
|
// Messwerte am Geraet.
|
||||||
|
$db->query("DELETE p FROM command_parameters p
|
||||||
|
JOIN actor_commands k ON k.id = p.command_id
|
||||||
|
WHERE k.actor_id = " . $id);
|
||||||
|
$db->query("DELETE FROM actor_commands WHERE actor_id = " . $id);
|
||||||
|
$db->query("DELETE FROM actor_states WHERE actor_id = " . $id);
|
||||||
|
$db->query("DELETE FROM actors WHERE id = " . $id);
|
||||||
|
$db->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$db->rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
return $geraet["name"];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* id => Beschriftung fuer alle Geraete, geputzt und eindeutig.
|
* id => Beschriftung fuer alle Geraete, geputzt und eindeutig.
|
||||||
*
|
*
|
||||||
@@ -425,8 +696,14 @@ function deviceCatalog()
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* value_path kommt mit, seit die Einstellungsseite denselben Katalog fuer
|
||||||
|
* die Home-Kacheln benutzt: bei Geraeten, die ihre Werte als JSON in einer
|
||||||
|
* einzigen Nachricht schicken (der Regensensor etwa), unterscheidet erst
|
||||||
|
* dieser Schluessel die sechs Messwerte unter demselben Topic.
|
||||||
|
*/
|
||||||
$res = $db->query("SELECT s.id, s.actor_id, s.state_name, s.unit, s.current_value,
|
$res = $db->query("SELECT s.id, s.actor_id, s.state_name, s.unit, s.current_value,
|
||||||
s.url, s.possible_values, t.type
|
s.url, s.value_path, s.possible_values, t.type
|
||||||
FROM actor_states s
|
FROM actor_states s
|
||||||
LEFT JOIN state_types t ON s.state_type = t.id
|
LEFT JOIN state_types t ON s.state_type = t.id
|
||||||
ORDER BY s.actor_id, s.id");
|
ORDER BY s.actor_id, s.id");
|
||||||
@@ -447,6 +724,8 @@ function deviceCatalog()
|
|||||||
"unit" => $row["unit"],
|
"unit" => $row["unit"],
|
||||||
"value" => $row["current_value"],
|
"value" => $row["current_value"],
|
||||||
"url" => $row["url"],
|
"url" => $row["url"],
|
||||||
|
// Schluessel in einer JSON-Nachricht, sonst leer - siehe oben.
|
||||||
|
"pfad" => $row["value_path"],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,9 +801,11 @@ function emptyAutomation($floor)
|
|||||||
"window_from" => "00:00",
|
"window_from" => "00:00",
|
||||||
"window_to" => "23:59",
|
"window_to" => "23:59",
|
||||||
"weekdays" => 127,
|
"weekdays" => 127,
|
||||||
"on_vacation" => true,
|
"on_vacation" => 1,
|
||||||
"on_holiday" => true,
|
"on_holiday" => 1,
|
||||||
|
"next_day" => false,
|
||||||
"force_once" => false,
|
"force_once" => false,
|
||||||
|
"once_per_day" => false,
|
||||||
"lockout_secs" => 60,
|
"lockout_secs" => 60,
|
||||||
"last_run" => null,
|
"last_run" => null,
|
||||||
"conditions" => [],
|
"conditions" => [],
|
||||||
@@ -553,9 +834,15 @@ function loadAutomation($id)
|
|||||||
"window_from" => substr($row["window_from"], 0, 5),
|
"window_from" => substr($row["window_from"], 0, 5),
|
||||||
"window_to" => substr($row["window_to"], 0, 5),
|
"window_to" => substr($row["window_to"], 0, 5),
|
||||||
"weekdays" => intval($row["weekdays"]),
|
"weekdays" => intval($row["weekdays"]),
|
||||||
"on_vacation" => (bool)$row["on_vacation"],
|
"on_vacation" => intval($row["on_vacation"]),
|
||||||
"on_holiday" => (bool)$row["on_holiday"],
|
"on_holiday" => intval($row["on_holiday"]),
|
||||||
|
// Vorabend: der Rahmen gilt fuer morgen. Siehe vorabend.sql im
|
||||||
|
// SolarManager; ?? 0 wie bei once_per_day, falls die Spalte fehlt.
|
||||||
|
"next_day" => (bool)($row["next_day"] ?? 0),
|
||||||
"force_once" => (bool)$row["force_once"],
|
"force_once" => (bool)$row["force_once"],
|
||||||
|
// ?? 0, damit der Editor auch vor der Migration noch aufgeht:
|
||||||
|
// rahmen_erweitern.sql legt die Spalte erst an.
|
||||||
|
"once_per_day" => (bool)($row["once_per_day"] ?? 0),
|
||||||
"lockout_secs" => intval($row["lockout_secs"]),
|
"lockout_secs" => intval($row["lockout_secs"]),
|
||||||
"last_run" => $row["last_run"],
|
"last_run" => $row["last_run"],
|
||||||
"conditions" => [],
|
"conditions" => [],
|
||||||
@@ -633,15 +920,18 @@ function saveAutomation($data)
|
|||||||
if (!$actions) {
|
if (!$actions) {
|
||||||
throw new InvalidArgumentException("Ohne Aktion haette die Automatik nichts zu tun.");
|
throw new InvalidArgumentException("Ohne Aktion haette die Automatik nichts zu tun.");
|
||||||
}
|
}
|
||||||
|
pruefeKreis($data["id"] ?? 0, $conditions);
|
||||||
|
|
||||||
$id = intval($data["id"] ?? 0);
|
$id = intval($data["id"] ?? 0);
|
||||||
$enabled = !empty($data["enabled"]) ? 1 : 0;
|
$enabled = !empty($data["enabled"]) ? 1 : 0;
|
||||||
$windowFrom = normalizeTime($data["window_from"] ?? "00:00", "00:00:00");
|
$windowFrom = normalizeTime($data["window_from"] ?? "00:00", "00:00:00");
|
||||||
$windowTo = normalizeTime($data["window_to"] ?? "23:59", "23:59:00");
|
$windowTo = normalizeTime($data["window_to"] ?? "23:59", "23:59:00");
|
||||||
$weekdays = intval($data["weekdays"] ?? 127) & 127;
|
$weekdays = intval($data["weekdays"] ?? 127) & 127;
|
||||||
$onVacation = !empty($data["on_vacation"]) ? 1 : 0;
|
$onVacation = kalendertagWert($data["on_vacation"] ?? 1);
|
||||||
$onHoliday = !empty($data["on_holiday"]) ? 1 : 0;
|
$onHoliday = kalendertagWert($data["on_holiday"] ?? 1);
|
||||||
|
$nextDay = !empty($data["next_day"]) ? 1 : 0;
|
||||||
$forceOnce = !empty($data["force_once"]) ? 1 : 0;
|
$forceOnce = !empty($data["force_once"]) ? 1 : 0;
|
||||||
|
$oncePerDay = !empty($data["once_per_day"]) ? 1 : 0;
|
||||||
// Nur die angebotenen Stufen, sonst die Vorgabe. Eine krumme Zahl kaeme
|
// Nur die angebotenen Stufen, sonst die Vorgabe. Eine krumme Zahl kaeme
|
||||||
// hier nur aus einer selbstgebauten Anfrage.
|
// hier nur aus einer selbstgebauten Anfrage.
|
||||||
$lockout = intval($data["lockout_secs"] ?? 60);
|
$lockout = intval($data["lockout_secs"] ?? 60);
|
||||||
@@ -672,11 +962,12 @@ function saveAutomation($data)
|
|||||||
// Zeitstempel, dass er sein Regelwerk neu laden muss.
|
// Zeitstempel, dass er sein Regelwerk neu laden muss.
|
||||||
$stmt = $db->prepare("UPDATE automations SET name = ?, floor = ?, enabled = ?,
|
$stmt = $db->prepare("UPDATE automations SET name = ?, floor = ?, enabled = ?,
|
||||||
window_from = ?, window_to = ?, weekdays = ?,
|
window_from = ?, window_to = ?, weekdays = ?,
|
||||||
on_vacation = ?, on_holiday = ?, force_once = ?,
|
on_vacation = ?, on_holiday = ?, next_day = ?, force_once = ?,
|
||||||
lockout_secs = ?, changed = NOW()
|
once_per_day = ?, lockout_secs = ?, changed = NOW()
|
||||||
WHERE id = ?");
|
WHERE id = ?");
|
||||||
$stmt->bind_param("ssissiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
|
$stmt->bind_param("ssissiiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
|
||||||
$weekdays, $onVacation, $onHoliday, $forceOnce, $lockout, $id);
|
$weekdays, $onVacation, $onHoliday, $nextDay, $forceOnce,
|
||||||
|
$oncePerDay, $lockout, $id);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
$db->query("DELETE FROM automation_conditions WHERE automation_id = " . $id);
|
$db->query("DELETE FROM automation_conditions WHERE automation_id = " . $id);
|
||||||
@@ -684,10 +975,12 @@ function saveAutomation($data)
|
|||||||
} else {
|
} else {
|
||||||
$stmt = $db->prepare("INSERT INTO automations
|
$stmt = $db->prepare("INSERT INTO automations
|
||||||
(name, floor, enabled, window_from, window_to, weekdays,
|
(name, floor, enabled, window_from, window_to, weekdays,
|
||||||
on_vacation, on_holiday, force_once, lockout_secs)
|
on_vacation, on_holiday, next_day, force_once,
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?)");
|
once_per_day, lockout_secs)
|
||||||
$stmt->bind_param("ssissiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
|
||||||
$weekdays, $onVacation, $onHoliday, $forceOnce, $lockout);
|
$stmt->bind_param("ssissiiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
|
||||||
|
$weekdays, $onVacation, $onHoliday, $nextDay, $forceOnce,
|
||||||
|
$oncePerDay, $lockout);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$id = $db->insert_id;
|
$id = $db->insert_id;
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
@@ -773,9 +1066,186 @@ function normalizeTime($value, $fallback)
|
|||||||
return $fallback;
|
return $fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteAutomation($id)
|
/**
|
||||||
|
* Welcher Messwert des gerechneten Geraets "Automatiken" auf welche Automatik
|
||||||
|
* zeigt: state_id => automation_id.
|
||||||
|
*
|
||||||
|
* Die Zeilen legt der Runner an und fuehrt sie nach (ausloeser_nachfuehren()),
|
||||||
|
* hier werden sie nur gelesen. Gefuehrt wird nach der Kennung in der URL
|
||||||
|
* ("auto:15") und nicht nach dem Namen - wer umbenennt, soll die abhaengigen
|
||||||
|
* Bedingungen behalten.
|
||||||
|
*/
|
||||||
|
function ausloeserStates()
|
||||||
{
|
{
|
||||||
|
static $karte = null;
|
||||||
|
if ($karte !== null) {
|
||||||
|
return $karte;
|
||||||
|
}
|
||||||
|
$karte = [];
|
||||||
$db = meshDb();
|
$db = meshDb();
|
||||||
|
$res = $db->query("SELECT s.id, s.url FROM actor_states s
|
||||||
|
JOIN actors a ON a.id = s.actor_id
|
||||||
|
WHERE a.url = 'Automatik'");
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
if (preg_match('/^auto:(\d+)$/', strval($row["url"]), $t)) {
|
||||||
|
$karte[intval($row["id"])] = intval($t[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $karte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** automation_id => Liste der Automatiken, die sie ausloesen. */
|
||||||
|
function automationTriggers()
|
||||||
|
{
|
||||||
|
$states = ausloeserStates();
|
||||||
|
if (!$states) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$db = meshDb();
|
||||||
|
$res = $db->query("SELECT automation_id, state_id FROM automation_conditions");
|
||||||
|
$karte = [];
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$wer = intval($row["automation_id"]);
|
||||||
|
$davor = $states[intval($row["state_id"])] ?? null;
|
||||||
|
if ($davor !== null && $davor !== $wer) {
|
||||||
|
$karte[$wer][$davor] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_map("array_keys", $karte);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Automatiken, die diese hier als Ausloeser benutzen. */
|
||||||
|
function automationFollowers($id)
|
||||||
|
{
|
||||||
|
$id = intval($id);
|
||||||
|
$liste = [];
|
||||||
|
foreach (automationTriggers() as $wer => $davor) {
|
||||||
|
if (in_array($id, $davor, true)) {
|
||||||
|
$liste[] = $wer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$liste) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$db = meshDb();
|
||||||
|
$platz = implode(",", array_fill(0, count($liste), "?"));
|
||||||
|
$stmt = $db->prepare("SELECT id, name, floor, enabled FROM automations
|
||||||
|
WHERE id IN ($platz) ORDER BY name");
|
||||||
|
$stmt->bind_param(str_repeat("i", count($liste)), ...$liste);
|
||||||
|
$stmt->execute();
|
||||||
|
$res = $stmt->get_result();
|
||||||
|
$nachfolger = [];
|
||||||
|
while ($row = $res->fetch_assoc()) {
|
||||||
|
$nachfolger[] = [
|
||||||
|
"id" => intval($row["id"]),
|
||||||
|
"name" => $row["name"],
|
||||||
|
"floor" => $row["floor"],
|
||||||
|
"enabled" => (bool)$row["enabled"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$stmt->close();
|
||||||
|
return $nachfolger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wuerde diese Automatik sich mittelbar selbst ausloesen?
|
||||||
|
*
|
||||||
|
* Ein Kreis (A loest B loest A) ist kein Fehler, den man im Betrieb bemerkt:
|
||||||
|
* die Sperrzeit begrenzt ihn auf eine Ausloesung je lockout_secs, und im
|
||||||
|
* Protokoll sieht das aus wie eine Automatik, die halt oft laeuft. Deshalb
|
||||||
|
* wird er hier abgelehnt und nicht dort geheilt. Der Runner meldet einen
|
||||||
|
* Kreis trotzdem - falls doch einer an der Datenbank vorbei entsteht.
|
||||||
|
*/
|
||||||
|
function pruefeKreis($id, $conditions)
|
||||||
|
{
|
||||||
|
$id = intval($id);
|
||||||
|
if ($id <= 0) {
|
||||||
|
return; // eine neue Automatik kennt noch niemand
|
||||||
|
}
|
||||||
|
$states = ausloeserStates();
|
||||||
|
$davor = [];
|
||||||
|
foreach ($conditions as $c) {
|
||||||
|
$wer = $states[intval($c["state_id"] ?? 0)] ?? null;
|
||||||
|
if ($wer !== null) {
|
||||||
|
$davor[] = $wer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$davor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (in_array($id, $davor, true)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Eine Automatik kann sich nicht selbst auslösen.");
|
||||||
|
}
|
||||||
|
$kanten = automationTriggers();
|
||||||
|
$kanten[$id] = $davor;
|
||||||
|
$offen = $davor;
|
||||||
|
$gesehen = [];
|
||||||
|
while ($offen) {
|
||||||
|
$aktuell = array_pop($offen);
|
||||||
|
if ($aktuell === $id) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Das ergäbe einen Kreis: die Automatik würde sich mittelbar "
|
||||||
|
. "selbst auslösen.");
|
||||||
|
}
|
||||||
|
if (isset($gesehen[$aktuell])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$gesehen[$aktuell] = true;
|
||||||
|
foreach ($kanten[$aktuell] ?? [] as $weiter) {
|
||||||
|
$offen[] = $weiter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loeschen - und was mit den Automatiken geschieht, die davon abhaengen.
|
||||||
|
*
|
||||||
|
* Nichts zu tun waere die schlechteste Wahl: `fk_cond_state` steht auf
|
||||||
|
* ON DELETE CASCADE, mit dem Messwert verschwaende also still die Bedingung
|
||||||
|
* des Nachfolgers. Bei der LETZTEN Bedingung faengt der Runner das ab (eine
|
||||||
|
* Automatik ohne Bedingung loest nie aus), bei einer von zweien niemand: aus
|
||||||
|
* "zehn Minuten nach dem Wecker UND es ist hell" wuerde ein blosses "es ist
|
||||||
|
* hell", und der Rollladen fuehre ab morgen jeden Tag bei Sonnenaufgang hoch.
|
||||||
|
*
|
||||||
|
* abhaengen die Bedingung wird entfernt und der Nachfolger pausiert.
|
||||||
|
* Eine pausierte Automatik mit sichtbarer Luecke ist besser
|
||||||
|
* als eine stille Regelaenderung.
|
||||||
|
* mitloeschen der Nachfolger geht mit, und dessen Nachfolger auch.
|
||||||
|
*/
|
||||||
|
function deleteAutomation($id, $modus = "abhaengen", &$gesehen = [])
|
||||||
|
{
|
||||||
|
$id = intval($id);
|
||||||
|
if (isset($gesehen[$id])) {
|
||||||
|
return; // Schutz gegen einen Kreis, den es nicht geben sollte
|
||||||
|
}
|
||||||
|
$gesehen[$id] = true;
|
||||||
|
|
||||||
|
$db = meshDb();
|
||||||
|
$nachfolger = automationFollowers($id);
|
||||||
|
$eigeneStates = array_keys(ausloeserStates(), $id, true);
|
||||||
|
|
||||||
|
if ($nachfolger && $modus === "mitloeschen") {
|
||||||
|
foreach ($nachfolger as $n) {
|
||||||
|
deleteAutomation($n["id"], "mitloeschen", $gesehen);
|
||||||
|
}
|
||||||
|
} elseif ($nachfolger && $eigeneStates) {
|
||||||
|
$platz = implode(",", array_fill(0, count($eigeneStates), "?"));
|
||||||
|
foreach ($nachfolger as $n) {
|
||||||
|
$args = array_merge([$n["id"]], $eigeneStates);
|
||||||
|
$stmt = $db->prepare("DELETE FROM automation_conditions
|
||||||
|
WHERE automation_id = ? AND state_id IN ($platz)");
|
||||||
|
$stmt->bind_param(str_repeat("i", count($args)), ...$args);
|
||||||
|
$stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
$stmt = $db->prepare("UPDATE automations SET enabled = 0, changed = NOW()
|
||||||
|
WHERE id = ?");
|
||||||
|
$stmt->bind_param("i", $n["id"]);
|
||||||
|
$stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = $db->prepare("DELETE FROM automations WHERE id = ?");
|
$stmt = $db->prepare("DELETE FROM automations WHERE id = ?");
|
||||||
$stmt->bind_param("i", $id);
|
$stmt->bind_param("i", $id);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
@@ -818,6 +1288,7 @@ function listAutomations($floor)
|
|||||||
$namesState = stateNames();
|
$namesState = stateNames();
|
||||||
$namesCommand = commandNames();
|
$namesCommand = commandNames();
|
||||||
$labelsParam = parameterLabels();
|
$labelsParam = parameterLabels();
|
||||||
|
$kanten = automationTriggers();
|
||||||
|
|
||||||
$list = [];
|
$list = [];
|
||||||
foreach ($ids as $id) {
|
foreach ($ids as $id) {
|
||||||
@@ -827,6 +1298,10 @@ function listAutomations($floor)
|
|||||||
}
|
}
|
||||||
$auto["conditionText"] = describeConditions($auto["conditions"], $namesState);
|
$auto["conditionText"] = describeConditions($auto["conditions"], $namesState);
|
||||||
$auto["actionText"] = describeActions($auto["actions"], $namesCommand, $labelsParam);
|
$auto["actionText"] = describeActions($auto["actions"], $namesCommand, $labelsParam);
|
||||||
|
// Woran diese Automatik haengt - die Uebersicht rueckt Nachfolger
|
||||||
|
// unter ihren Ausloeser ein, statt sie alphabetisch dazwischen zu
|
||||||
|
// streuen.
|
||||||
|
$auto["triggers"] = $kanten[$id] ?? [];
|
||||||
$list[] = $auto;
|
$list[] = $auto;
|
||||||
}
|
}
|
||||||
return $list;
|
return $list;
|
||||||
|
|||||||
+26
-3
@@ -325,9 +325,32 @@ function preiseSpeichern($key, $zeilen)
|
|||||||
function grundpreisImJahr($jahr)
|
function grundpreisImJahr($jahr)
|
||||||
{
|
{
|
||||||
$jahr = intval($jahr);
|
$jahr = intval($jahr);
|
||||||
$von = $jahr . "-01-01";
|
return grundpreisImZeitraum($jahr . "-01-01", $jahr . "-12-31");
|
||||||
$bis = $jahr . "-12-31";
|
}
|
||||||
$tage = intval((new DateTimeImmutable($bis))->format("z")) + 1; // 365 oder 366
|
|
||||||
|
/**
|
||||||
|
* Grundpreis Strom fuer einen Zeitraum innerhalb eines Kalenderjahres.
|
||||||
|
*
|
||||||
|
* Der Grundpreis steht als Jahresbetrag in der Tarifzeile; auf einen Zeitraum
|
||||||
|
* kommt er anteilig nach Tagen. Geteilt wird deshalb durch die Tage des
|
||||||
|
* JAHRES, nicht durch die des Zeitraums - sonst zahlte ein halbes Jahr den
|
||||||
|
* vollen Betrag.
|
||||||
|
*
|
||||||
|
* Von und bis muessen im selben Jahr liegen: die Statistik fragt entweder ein
|
||||||
|
* ganzes Jahr oder dessen Anfang bis zu einem Stichtag, und ein Zeitraum ueber
|
||||||
|
* den Jahreswechsel haette zwei verschiedene Nenner.
|
||||||
|
*/
|
||||||
|
function grundpreisImZeitraum($von, $bis)
|
||||||
|
{
|
||||||
|
$vonTag = new DateTimeImmutable($von);
|
||||||
|
$bisTag = new DateTimeImmutable($bis);
|
||||||
|
if ($vonTag->format("Y") !== $bisTag->format("Y")) {
|
||||||
|
throw new InvalidArgumentException("Zeitraum muss innerhalb eines Jahres liegen.");
|
||||||
|
}
|
||||||
|
$von = $vonTag->format("Y-m-d");
|
||||||
|
$bis = $bisTag->format("Y-m-d");
|
||||||
|
// Tage des Jahres, 365 oder 366 - der Nenner des Jahresbetrags.
|
||||||
|
$tage = intval((new DateTimeImmutable($vonTag->format("Y") . "-12-31"))->format("z")) + 1;
|
||||||
|
|
||||||
$stmt = solarDb()->prepare(
|
$stmt = solarDb()->prepare(
|
||||||
"WITH p AS (SELECT active_date, base,
|
"WITH p AS (SELECT active_date, base,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from modules.mqtt_module import MQTTModule
|
|||||||
from modules.shelly_module import ShellyModule
|
from modules.shelly_module import ShellyModule
|
||||||
from modules.gartenwasser_module import GartenwasserModule
|
from modules.gartenwasser_module import GartenwasserModule
|
||||||
from modules.solarmanager_module import SolarManagerModule
|
from modules.solarmanager_module import SolarManagerModule
|
||||||
|
from modules.wetterstation_module import WetterstationModule
|
||||||
from modules.logic_module import LogicModule #nicht Vergessen neue Module auch unten in die Liste "modules" einzufügen!!
|
from modules.logic_module import LogicModule #nicht Vergessen neue Module auch unten in die Liste "modules" einzufügen!!
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
@@ -166,6 +167,13 @@ class Config:
|
|||||||
return False
|
return False
|
||||||
return self._get_bool('solarmanager', 'enable', False)
|
return self._get_bool('solarmanager', 'enable', False)
|
||||||
|
|
||||||
|
# WETTERSTATION
|
||||||
|
@property
|
||||||
|
def wetterstation_enable(self) -> bool:
|
||||||
|
if not self.config.has_section('wetterstation'):
|
||||||
|
return False
|
||||||
|
return self._get_bool('wetterstation', 'enable', False)
|
||||||
|
|
||||||
|
|
||||||
# MQTT
|
# MQTT
|
||||||
@property
|
@property
|
||||||
@@ -431,7 +439,14 @@ class DatabaseManager:
|
|||||||
-- Wert zufaellig einmal aenderte.
|
-- Wert zufaellig einmal aenderte.
|
||||||
current_value = COALESCE(VALUES(current_value), current_value),
|
current_value = COALESCE(VALUES(current_value), current_value),
|
||||||
possible_values = VALUES(possible_values),
|
possible_values = VALUES(possible_values),
|
||||||
unit = VALUES(unit),
|
-- Dieselbe Ueberlegung wie bei current_value: ein Modul,
|
||||||
|
-- das keine Einheit mitliefert, meint "weiss ich nicht"
|
||||||
|
-- und nicht "loesch die vorhandene". Ohne COALESCE hat
|
||||||
|
-- ein Suchlauf am 10.09.2026 den Sonnensensoren ihr
|
||||||
|
-- "Lux" genommen, weil das Tahoma-Modul keine Einheiten
|
||||||
|
-- kennt - im Automatik-Editor stand danach "877" ohne
|
||||||
|
-- Angabe, wovon.
|
||||||
|
unit = COALESCE(VALUES(unit), unit),
|
||||||
url = VALUES(url),
|
url = VALUES(url),
|
||||||
value_path = VALUES(value_path)
|
value_path = VALUES(value_path)
|
||||||
"""
|
"""
|
||||||
@@ -504,7 +519,14 @@ class DatabaseManager:
|
|||||||
-- Siehe insert_actor: NULL heisst "kein Startwert",
|
-- Siehe insert_actor: NULL heisst "kein Startwert",
|
||||||
-- nicht "Messwert loeschen".
|
-- nicht "Messwert loeschen".
|
||||||
current_value = COALESCE(VALUES(current_value), current_value),
|
current_value = COALESCE(VALUES(current_value), current_value),
|
||||||
unit = VALUES(unit),
|
-- Dieselbe Ueberlegung wie bei current_value: ein Modul,
|
||||||
|
-- das keine Einheit mitliefert, meint "weiss ich nicht"
|
||||||
|
-- und nicht "loesch die vorhandene". Ohne COALESCE hat
|
||||||
|
-- ein Suchlauf am 10.09.2026 den Sonnensensoren ihr
|
||||||
|
-- "Lux" genommen, weil das Tahoma-Modul keine Einheiten
|
||||||
|
-- kennt - im Automatik-Editor stand danach "877" ohne
|
||||||
|
-- Angabe, wovon.
|
||||||
|
unit = COALESCE(VALUES(unit), unit),
|
||||||
url = VALUES(url),
|
url = VALUES(url),
|
||||||
value_path = VALUES(value_path),
|
value_path = VALUES(value_path),
|
||||||
possible_values = VALUES(possible_values)
|
possible_values = VALUES(possible_values)
|
||||||
@@ -589,7 +611,8 @@ def main():
|
|||||||
ShellyModule(config),
|
ShellyModule(config),
|
||||||
LogicModule(config),
|
LogicModule(config),
|
||||||
GartenwasserModule(config),
|
GartenwasserModule(config),
|
||||||
SolarManagerModule(config)
|
SolarManagerModule(config),
|
||||||
|
WetterstationModule(config)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Jedes Modul durchlaufen
|
# Jedes Modul durchlaufen
|
||||||
|
|||||||
@@ -173,6 +173,32 @@ class ShellyDiscovery:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_mqtt_config(self, device: Dict) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Holt die MQTT-Einstellung eines Gen2-Geräts.
|
||||||
|
|
||||||
|
Nur Gen2 kann MQTT; die alten Geräte werden weiterhin abgefragt. Wer
|
||||||
|
sendet, muss nicht gepollt werden - deshalb interessiert hier nicht
|
||||||
|
nur, ob MQTT eingeschaltet ist, sondern auch das Präfix: daraus ergibt
|
||||||
|
sich das Topic, unter dem die Werte ankommen.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Die Antwort von Mqtt.GetConfig oder None (Gen1, altes Firmware,
|
||||||
|
Gerät nicht erreichbar).
|
||||||
|
"""
|
||||||
|
if device.get('generation') != 2:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"http://{device['ip']}/rpc/Mqtt.GetConfig",
|
||||||
|
timeout=2
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Keine MQTT-Einstellung von {device['ip']}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def discover_devices(self) -> List[Dict]:
|
def discover_devices(self) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Entdeckt alle Shelly-Geräte im Netzwerk
|
Entdeckt alle Shelly-Geräte im Netzwerk
|
||||||
@@ -185,6 +211,7 @@ class ShellyDiscovery:
|
|||||||
for idx, device in enumerate(self.devices):
|
for idx, device in enumerate(self.devices):
|
||||||
status = self.get_device_status(device)
|
status = self.get_device_status(device)
|
||||||
self.devices[idx]['status'] = status
|
self.devices[idx]['status'] = status
|
||||||
|
self.devices[idx]['mqtt'] = self.get_mqtt_config(device)
|
||||||
|
|
||||||
logger.info(f"Insgesamt {len(self.devices)} Shelly-Geräte entdeckt")
|
logger.info(f"Insgesamt {len(self.devices)} Shelly-Geräte entdeckt")
|
||||||
return self.devices
|
return self.devices
|
||||||
@@ -205,6 +232,70 @@ class ShellyModule(BaseModule):
|
|||||||
"""Prüft ob Shelly aktiviert ist"""
|
"""Prüft ob Shelly aktiviert ist"""
|
||||||
return self.config.shelly_enable
|
return self.config.shelly_enable
|
||||||
|
|
||||||
|
# Welche Komponenten ihren Zustand von selbst an den Broker schicken.
|
||||||
|
#
|
||||||
|
# "status_ntf" am Geraet heisst nicht, dass jede Komponente etwas sendet -
|
||||||
|
# es heisst nur, dass gesendet werden darf. Nachgemessen am Pro 3EM: unter
|
||||||
|
# Power_EG/# kommen ausschliesslich em:0, emdata:0 und online. Die
|
||||||
|
# Temperatur der Endstufe (temperature:0) steht zwar im RPC-Status, wird
|
||||||
|
# aber nie veroeffentlicht - ein Messwert, der auf dieses Topic zeigte,
|
||||||
|
# bliebe fuer immer auf seinem letzten Wert stehen.
|
||||||
|
#
|
||||||
|
# Deshalb eine ausdrueckliche Liste statt "alles, was MQTT kann". Wer eine
|
||||||
|
# Komponente ergaenzen will, hoert vorher nach:
|
||||||
|
# mosquitto_sub -t '<praefix>/#' -v
|
||||||
|
# und traegt sie hier ein, wenn sie tatsaechlich sendet. Alles andere
|
||||||
|
# bleibt beim Abfragen - langsamer, aber richtig.
|
||||||
|
MQTT_KOMPONENTEN = {"em", "switch"}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mqtt_praefix(device: Dict) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Das Topic-Präfix, unter dem ein Gerät seine Zustände sendet - oder
|
||||||
|
None, wenn es das nicht tut.
|
||||||
|
|
||||||
|
Verlangt beides: MQTT eingeschaltet UND die Statusmeldungen
|
||||||
|
eingeschaltet. Ohne status_ntf hält das Gerät die Verbindung, schickt
|
||||||
|
aber nichts von selbst - dann bliebe ein Messwert auf dem Broker ewig
|
||||||
|
aus, und Abfragen ist die richtige Antwort.
|
||||||
|
"""
|
||||||
|
mqtt = device.get('mqtt') or {}
|
||||||
|
if mqtt.get('enable') and mqtt.get('status_ntf') and mqtt.get('topic_prefix'):
|
||||||
|
return mqtt['topic_prefix']
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _adressiere(states: List[Dict], praefix: Optional[str],
|
||||||
|
komponente: str, index: int) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Die Messwerte einer Komponente adressieren.
|
||||||
|
|
||||||
|
Zwei Wege, und der Unterschied ist nicht Geschmack, sondern Technik:
|
||||||
|
|
||||||
|
ohne MQTT Das Gerät wird abgefragt. Die Adresse steht am Gerät
|
||||||
|
(actors.url = der RPC-Aufruf), der Messwert nennt nur
|
||||||
|
das Feld der Antwort: url = 'total_act_power'.
|
||||||
|
|
||||||
|
mit MQTT Es gibt nichts abzufragen, die Nachricht kommt von
|
||||||
|
selbst. Dann muss die Adresse am Messwert stehen:
|
||||||
|
url = 'Power_EG/status/em:0', und das Feld in der
|
||||||
|
JSON-Nachricht wandert nach value_path - genau so, wie
|
||||||
|
es das MQTT-Modul für alle anderen Geräte hält.
|
||||||
|
|
||||||
|
Damit fällt für die neuen Geräte das Abfragen im Automatik-Runner weg;
|
||||||
|
die alten Shellys ohne MQTT bleiben unverändert beim HTTP-Weg - und
|
||||||
|
ebenso jede Komponente, die zwar am Gerät hängt, aber nichts sendet
|
||||||
|
(siehe MQTT_KOMPONENTEN).
|
||||||
|
"""
|
||||||
|
if not praefix or komponente not in ShellyModule.MQTT_KOMPONENTEN:
|
||||||
|
return states
|
||||||
|
topic = f"{praefix}/status/{komponente}:{index}"
|
||||||
|
for state in states:
|
||||||
|
# Was bisher die Adresse war, ist jetzt der Schlüssel darin.
|
||||||
|
state['value_path'] = state.get('url')
|
||||||
|
state['url'] = topic
|
||||||
|
return states
|
||||||
|
|
||||||
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
||||||
"""
|
"""
|
||||||
Führt Shelly Discovery durch
|
Führt Shelly Discovery durch
|
||||||
@@ -256,6 +347,12 @@ class ShellyModule(BaseModule):
|
|||||||
|
|
||||||
device_name = info.get('name', f"Shelly_{info.get('id', ip)}")
|
device_name = info.get('name', f"Shelly_{info.get('id', ip)}")
|
||||||
device_model = info.get('model', 'Unknown')
|
device_model = info.get('model', 'Unknown')
|
||||||
|
# Sendet das Geraet seine Zustaende selbst? Dann bekommen die
|
||||||
|
# Messwerte Topics statt Feldnamen - siehe _adressiere().
|
||||||
|
praefix = self._mqtt_praefix(device)
|
||||||
|
if praefix:
|
||||||
|
logger.info(f" {device_name}: {', '.join(sorted(self.MQTT_KOMPONENTEN))}"
|
||||||
|
f" ueber MQTT ({praefix}/status/...), der Rest ueber HTTP")
|
||||||
|
|
||||||
# Switches als Aktoren
|
# Switches als Aktoren
|
||||||
switch_count = sum(1 for key in status.keys() if key.startswith('switch:'))
|
switch_count = sum(1 for key in status.keys() if key.startswith('switch:'))
|
||||||
@@ -271,14 +368,14 @@ class ShellyModule(BaseModule):
|
|||||||
{'command': 'turn_off', 'url': 'on=false', 'parameters': []},
|
{'command': 'turn_off', 'url': 'on=false', 'parameters': []},
|
||||||
{'command': 'toggle', 'parameters': []}
|
{'command': 'toggle', 'parameters': []}
|
||||||
],
|
],
|
||||||
'states': [
|
'states': self._adressiere([
|
||||||
{
|
{
|
||||||
'name': 'output',
|
'name': 'output',
|
||||||
'type': 'boolean',
|
'type': 'boolean',
|
||||||
'url': 'output',
|
'url': 'output',
|
||||||
'current_value': switch_data.get('output', False)
|
'current_value': switch_data.get('output', False)
|
||||||
}
|
}
|
||||||
]
|
], praefix, 'switch', i)
|
||||||
})
|
})
|
||||||
|
|
||||||
# Temperatursensoren
|
# Temperatursensoren
|
||||||
@@ -290,7 +387,7 @@ class ShellyModule(BaseModule):
|
|||||||
'type': 'Temperatur',
|
'type': 'Temperatur',
|
||||||
'name': f"{device_name}_Temp_{i}",
|
'name': f"{device_name}_Temp_{i}",
|
||||||
'url': f"http://{ip}/rpc/Temperature.GetStatus?id={i}",
|
'url': f"http://{ip}/rpc/Temperature.GetStatus?id={i}",
|
||||||
'states': [
|
'states': self._adressiere([
|
||||||
{
|
{
|
||||||
'name': 'temperature',
|
'name': 'temperature',
|
||||||
'type': 'number',
|
'type': 'number',
|
||||||
@@ -298,7 +395,7 @@ class ShellyModule(BaseModule):
|
|||||||
'url': f"tC",
|
'url': f"tC",
|
||||||
'unit': '°C'
|
'unit': '°C'
|
||||||
}
|
}
|
||||||
]
|
], praefix, 'temperature', i)
|
||||||
})
|
})
|
||||||
# Energy-Meter
|
# Energy-Meter
|
||||||
em_count = sum(1 for key in status.keys() if key.startswith('em:'))
|
em_count = sum(1 for key in status.keys() if key.startswith('em:'))
|
||||||
@@ -422,6 +519,8 @@ class ShellyModule(BaseModule):
|
|||||||
'url': 'total_act_power',
|
'url': 'total_act_power',
|
||||||
'current_value': em_data.get('total_act_power'),
|
'current_value': em_data.get('total_act_power'),
|
||||||
'unit': 'W'})
|
'unit': 'W'})
|
||||||
|
# Erst hier, weil die Messwerte oben einzeln angehaengt werden.
|
||||||
|
self._adressiere(sensors[len(sensors)-1]['states'], praefix, 'em', i)
|
||||||
|
|
||||||
return actors, sensors
|
return actors, sensors
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Wetterstation Module
|
||||||
|
Die eigene Wetterstation am Haus - fest beschrieben, nicht gesucht
|
||||||
|
KEINE Datenbank-Operationen!
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Tuple
|
||||||
|
from modules.base_module import BaseModule
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Wurzel aller Topics. wsMQTTbridge.py im SolarManager hängt am Websocket der
|
||||||
|
# Station (ws://192.168.179.42/ws) und legt jedes Feld der Nachricht einzeln
|
||||||
|
# unter diese Wurzel - retained, deshalb hat ein frisch verbundener Leser
|
||||||
|
# sofort alle Werte.
|
||||||
|
#
|
||||||
|
# Zwei Eigenheiten der Brücke, die man kennen muss:
|
||||||
|
#
|
||||||
|
# * Die trägen Werte gehen nicht bei jeder Nachricht raus. Die Brücke wirft
|
||||||
|
# tempAmb, hum, qff, dewpt, iaq, avgWindspeed und avgWindDeg aus jeder
|
||||||
|
# Nachricht heraus außer jeder einundzwanzigsten. Wind und Böe kommen
|
||||||
|
# dagegen immer. Für eine Automatik macht das keinen Unterschied - der
|
||||||
|
# Runner hält den zuletzt gesehenen Wert -, für die Fehlersuche schon:
|
||||||
|
# eine Außentemperatur, die minutenlang steht, ist kein Ausfall.
|
||||||
|
#
|
||||||
|
# * Der Zeitstempel kommt von der Station selbst, nicht vom Broker.
|
||||||
|
WURZEL = "weatherStation"
|
||||||
|
|
||||||
|
# Was von der Station als Messwert im Editor auftaucht.
|
||||||
|
#
|
||||||
|
# Nicht dabei sind zwei Topics. "IP" ist keine Messung. Und "QNH [hPa]" ist
|
||||||
|
# der auf Meereshöhe gerechnete Druck nach Standardatmosphäre - er steht bei
|
||||||
|
# uns gut hundert Hektopascal neben qff (1133 gegen 1023) und ist offenbar
|
||||||
|
# nicht auf die Stationshöhe eingestellt. qff rechnet mit der tatsächlichen
|
||||||
|
# Temperatur und ist der Wert, den auch die Wetterkachel auf der Startseite
|
||||||
|
# zeigt. Zwei Drücke nebeneinander, von denen einer falsch ist, wären im
|
||||||
|
# Editor eine Falle.
|
||||||
|
#
|
||||||
|
# Die Windgeschwindigkeiten stehen in km/h - so beschriftet sie
|
||||||
|
# restricted/weather_cards.html, und so passen die Zahlen zur Historie in
|
||||||
|
# solarLog.weatherStation.
|
||||||
|
#
|
||||||
|
# Windrichtung und Böenrichtung sind Grad im Uhrzeigersinn ab Nord, wie beim
|
||||||
|
# Kompass: 0 = aus Norden, 90 = aus Osten, 180 = aus Süden, 270 = aus Westen.
|
||||||
|
# Eine Bedingung darauf ist unhandlich, weil Nord bei 0 und bei 360 liegt -
|
||||||
|
# für "Wind aus Westen" schreibt man zwei Bedingungen (> 225 und < 315).
|
||||||
|
MESSWERTE = [
|
||||||
|
# (Name im Editor, Topic, Typ, Einheit)
|
||||||
|
("Außentemperatur", "tempAmb", "float", "°C"),
|
||||||
|
("Luftfeuchte", "hum", "float", "%"),
|
||||||
|
("Taupunkt", "dewpt", "float", "°C"),
|
||||||
|
("Luftdruck", "qff", "float", "hPa"),
|
||||||
|
("Wind", "windspeed", "float", "km/h"),
|
||||||
|
("Wind im Mittel", "avgWindspeed", "float", "km/h"),
|
||||||
|
("Böe", "maxgust", "float", "km/h"),
|
||||||
|
("Windrichtung", "windDeg", "float", "°"),
|
||||||
|
("Böenrichtung", "gustDeg", "float", "°"),
|
||||||
|
("Luftgüte", "iaq", "float", None),
|
||||||
|
# Kein Wert zum Vergleichen, sondern zum Nachsehen: im Editor steht er
|
||||||
|
# hinter dem Namen und verrät, ob die Brücke noch läuft. Genauso hält es
|
||||||
|
# der Regen mit seinem "Stand".
|
||||||
|
("Stand", "Timestamp", "string", None),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class WetterstationModule(BaseModule):
|
||||||
|
"""
|
||||||
|
Wetterstation - Implementiert BaseModule Interface
|
||||||
|
|
||||||
|
Wie das Gartenwasser-, das Logic- und das SolarManager-Modul sucht dieses
|
||||||
|
nichts, sondern schreibt ein festes Gerät hin. Die Station meldet sich
|
||||||
|
nicht per Home-Assistant-Discovery; das MQTT-Modul findet sie also nicht.
|
||||||
|
|
||||||
|
Warum sie überhaupt hineingehört: für den Hitzeschutz ist die
|
||||||
|
Außentemperatur die bessere Bedingung als die Raumtemperatur. Wenn es
|
||||||
|
drinnen schon warm ist, ist es zum Verschatten zu spät - die Wärme steht
|
||||||
|
dann bereits im Raum und muss wieder heraus. Draußen sieht man sie
|
||||||
|
kommen. Und Wind gab es hier bisher gar nicht: ohne ihn lässt sich für
|
||||||
|
Markise und Sonnensegel kein Sturmschutz bauen.
|
||||||
|
|
||||||
|
Alles hier ist zum Lesen. Zu schalten gibt es an einer Wetterstation
|
||||||
|
nichts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
"""Prüft ob das Wetterstation-Modul aktiviert ist"""
|
||||||
|
return self.config.wetterstation_enable
|
||||||
|
|
||||||
|
def discover(self) -> Tuple[List[Dict], List[Dict]]:
|
||||||
|
"""
|
||||||
|
Erzeugt das eine Gerät aus MESSWERTE - als Sensor, ohne Kommandos.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple (actors, sensors)
|
||||||
|
"""
|
||||||
|
logger.info("\n" + "=" * 60)
|
||||||
|
logger.info("WETTERSTATION WIRD ERZEUGT")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
states = [{
|
||||||
|
"name": name,
|
||||||
|
"url": WURZEL + "/" + topic,
|
||||||
|
"type": typ,
|
||||||
|
"unit": einheit,
|
||||||
|
} for name, topic, typ, einheit in MESSWERTE]
|
||||||
|
|
||||||
|
geraet = {
|
||||||
|
"type": "Wetter",
|
||||||
|
"name": "Wetterstation",
|
||||||
|
# Die URL eines Geräts ist das Topic seines ersten Messwerts, wie
|
||||||
|
# beim SolarManager. Sie muss nur eindeutig sein und mit mqtt://
|
||||||
|
# beginnen, damit der Runner den MQTT-Transport wählt; abonniert
|
||||||
|
# wird über die Topics der Messwerte.
|
||||||
|
"url": "mqtt://" + states[0]["url"],
|
||||||
|
"states": states,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("1 Gerät mit %d Messwerten", len(states))
|
||||||
|
return [], [geraet]
|
||||||
+34
-19
@@ -1,16 +1,24 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/*
|
||||||
|
* Seite "Vergangenheit": sechs Diagramme und darunter der Jahresvergleich.
|
||||||
|
*
|
||||||
|
* Der Vergleich war einmal drei Karten mit je achtzehn Kacheln - dieselben
|
||||||
|
* Beschriftungen dreimal untereinander, und wer zwei Jahre vergleichen wollte,
|
||||||
|
* musste scrollen. Jetzt ist es eine Tabelle: Kennzahlen in Zeilen, Jahre in
|
||||||
|
* Spalten, der Vergleich also eine Zeile weit.
|
||||||
|
*
|
||||||
|
* Gebaut wird der Vergleich von baueJahresstatistik() in
|
||||||
|
* js/solar/jahresstatistik.js, aus einer einzigen Antwort von
|
||||||
|
* ajax/getStats.php - auch die Zeilen selbst, denn welche Kennzahlen es gibt,
|
||||||
|
* weiss der Server. Denselben Baustein zeigt die Solarseite einspaltig.
|
||||||
|
*/
|
||||||
|
|
||||||
// Die sechs Diagramme unterscheiden sich nur in Zeitraum und Canvas-ID.
|
// Die sechs Diagramme unterscheiden sich nur in Zeitraum und Canvas-ID.
|
||||||
$periods = [
|
$periods = [
|
||||||
"month" => "Letzter Monat",
|
"month" => "Letzter Monat",
|
||||||
"year" => "Letzte 13 Monate",
|
"year" => "Letzte 13 Monate",
|
||||||
"decade" => "Letzte 10 Jahre",
|
"decade" => "Letzte 10 Jahre",
|
||||||
];
|
];
|
||||||
// Die Jahresstatistiken darunter folgen demselben Muster.
|
|
||||||
$statistics = [
|
|
||||||
"Stats-Year" => "Statistik dieses Jahr",
|
|
||||||
"Stats-Lastyear" => "Statistik letztes Jahr",
|
|
||||||
"Stats-Prelastyear" => "Statistik vorletztes Jahr",
|
|
||||||
];
|
|
||||||
?>
|
?>
|
||||||
<!--begin::App Main-->
|
<!--begin::App Main-->
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
@@ -63,23 +71,30 @@ $statistics = [
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php foreach ($statistics as $id => $title): ?>
|
<!-- ============================================ Jahresvergleich ==== -->
|
||||||
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
|
<!-- Der Inhalt kommt aus baueJahresstatistik() in
|
||||||
<div class="card p-0 mb-4" style="width:100%">
|
js/solar/jahresstatistik.js: Leitzahlen, Werkzeugleiste, Tabelle und
|
||||||
<div class="card-header pb-0 pt-1">
|
Fussnoten baut der Baustein selbst, damit die Solarseite dieselbe
|
||||||
<h3 class="card-title"><?= $title ?></h3>
|
Statistik in schmaler Form zeigen kann, ohne dass es sie zweimal
|
||||||
<div class="card-tools">
|
gaebe. -->
|
||||||
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
<div class="row">
|
||||||
<i class="bi bi-x-lg"></i>
|
<div class="col-12">
|
||||||
</button>
|
<div class="card mb-4 p-0 w-100">
|
||||||
|
<div class="card-header pb-0 pt-1">
|
||||||
|
<h3 class="card-title">Jahresvergleich</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body statistik">
|
||||||
|
<div id="jahresvergleich"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-cols-1 row-cols-md-1 g-4 p-2" id="<?= $id ?>">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- /.card -->
|
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
|
||||||
<!-- /.row (main row) -->
|
<!-- /.row (main row) -->
|
||||||
</div>
|
</div>
|
||||||
<!--end::Container-->
|
<!--end::Container-->
|
||||||
|
|||||||
+26
-22
@@ -1,4 +1,7 @@
|
|||||||
<?php require_once(__DIR__ . "/rooms.php"); ?>
|
<?php
|
||||||
|
require_once(__DIR__ . "/rooms.php");
|
||||||
|
require_once(__DIR__ . "/reiter.php");
|
||||||
|
?>
|
||||||
<!--begin::App Main-->
|
<!--begin::App Main-->
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<!--begin::App Content Header-->
|
<!--begin::App Content Header-->
|
||||||
@@ -185,12 +188,6 @@
|
|||||||
<div class="card-header pb-0 pt-1">
|
<div class="card-header pb-0 pt-1">
|
||||||
<h3 class="card-title">Automatismen</h3>
|
<h3 class="card-title">Automatismen</h3>
|
||||||
<div class="card-tools">
|
<div class="card-tools">
|
||||||
<!-- Zuordnung Geraet -> Raum: sie ordnet die Geraeteliste im
|
|
||||||
Editor nach Raeumen statt nach Geraeteart. -->
|
|
||||||
<button type="button" class="btn btn-tool" title="Geräte den Räumen zuordnen"
|
|
||||||
onclick="openRoomModal()">
|
|
||||||
<i class="bi bi-house-gear"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
||||||
<i class="bi bi-x-lg"></i>
|
<i class="bi bi-x-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -198,28 +195,35 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body pb-1">
|
<div class="card-body pb-1">
|
||||||
<div class="card-header p-0 pt-1">
|
<?php
|
||||||
<ul class="nav nav-tabs" id="actions-tab" role="tablist">
|
/*
|
||||||
<?php foreach ($floors as $etage): $offen = $_GET["floor"] == $etage; ?>
|
* Eine Etage, ein Reiter. Gezeichnet von reiterLeiste() aus
|
||||||
<li class="nav-item">
|
* restricted/reiter.php - dieselbe Bauart wie im Raum-Modal und
|
||||||
<a class="nav-link <?php if ($offen) echo "active"; ?>" id="actions-<?= $etage ?>-tab"
|
* auf der Einstellungsseite. Frueher stand hier ein eigenes
|
||||||
href="#actions-<?= $etage ?>" onclick="switchTab('actions-<?= $etage ?>')"
|
* Markup samt eigenem switchTab() in homeMQTT.js; umgeschaltet
|
||||||
title="<?= htmlspecialchars(floorLabel($etage)) ?>"
|
* wird jetzt von Bootstrap wie ueberall sonst.
|
||||||
aria-controls="actions-<?= $etage ?>" aria-selected="<?= $offen ? "true" : "false" ?>"><?= $etage ?></a>
|
*
|
||||||
</li>
|
* Der Name "actions" haelt die Element-Ids, an denen
|
||||||
<?php endforeach; ?>
|
* autoActionFuncs.js haengt: actions-OG, actions-OG-list.
|
||||||
</ul>
|
*/
|
||||||
</div>
|
$autoReiter = array_map(function ($etage) {
|
||||||
|
return ["id" => $etage, "titel" => $etage,
|
||||||
|
"titel2" => floorLabel($etage)];
|
||||||
|
}, $floors);
|
||||||
|
$autoOpt = ["name" => "actions", "aktiv" => $_GET["floor"],
|
||||||
|
"klasse" => "mb-2"];
|
||||||
|
$autoOpt["aktiv"] = reiterAktiv($autoReiter, $autoOpt);
|
||||||
|
?>
|
||||||
|
<?= reiterLeiste($autoReiter, $autoOpt) ?>
|
||||||
<div class="tab-content" id="actions-tabContent">
|
<div class="tab-content" id="actions-tabContent">
|
||||||
<?php foreach ($floors as $etage): ?>
|
<?php foreach ($floors as $etage): ?>
|
||||||
<div class="tab-pane fade <?php if ($_GET["floor"] == $etage) echo "active show"; ?>"
|
<?= reiterFlaecheAuf($etage, $autoOpt) ?>
|
||||||
id="actions-<?= $etage ?>" role="tabpanel" aria-labelledby="actions-<?= $etage ?>-tab">
|
|
||||||
<!-- Inhalt kommt aus ajax/AutoAction.php?action=list, gefuellt von
|
<!-- Inhalt kommt aus ajax/AutoAction.php?action=list, gefuellt von
|
||||||
refreshAutomations() in js/solar/autoActionFuncs.js -->
|
refreshAutomations() in js/solar/autoActionFuncs.js -->
|
||||||
<div id="actions-<?= $etage ?>-list">Wird geladen...</div>
|
<div id="actions-<?= $etage ?>-list">Wird geladen...</div>
|
||||||
<button type="button" class="btn btn-success" title="Neue Automatik"
|
<button type="button" class="btn btn-success" title="Neue Automatik"
|
||||||
onclick="openAutoActionModal('?action=editor&floor=<?= $etage ?>')"><i class="bi bi-plus-square"></i></button>
|
onclick="openAutoActionModal('?action=editor&floor=<?= $etage ?>')"><i class="bi bi-plus-square"></i></button>
|
||||||
</div>
|
<?= reiterFlaecheZu() ?>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Modell-Schicht für die Anzeigewerte der Home-Kacheln.
|
||||||
|
*
|
||||||
|
* Was auf einer Kachel steht, stand bisher fest in restricted/rooms.php. Das
|
||||||
|
* ist weiterhin die Vorgabe - hier kommt nur die Möglichkeit dazu, sie zu
|
||||||
|
* überschreiben, ohne die Datei anzufassen. Eine Kachel ohne Zeile in
|
||||||
|
* kachelWerte zeigt also genau das, was sie immer gezeigt hat.
|
||||||
|
*
|
||||||
|
* Die Form einer Wertdefinition ist dieselbe wie in rooms.php, damit zwischen
|
||||||
|
* Maske, Datenbank und Anzeige nichts übersetzt werden muss:
|
||||||
|
*
|
||||||
|
* topic MQTT-Topic, aus dem der Wert kommt
|
||||||
|
* pfad optional: Schlüssel in einer JSON-Nachricht
|
||||||
|
* wechselrichter Liste von Namen unter solarManager/invertersN (statt topic)
|
||||||
|
* feld bei Wechselrichtern das Feld, meist p_AC
|
||||||
|
* format "leistung" skaliert selbst zwischen W und kW, die Quelle
|
||||||
|
* liefert Watt; "leistung_kw" dasselbe fuer Quellen, die
|
||||||
|
* schon in Kilowatt melden (die Wallboxen im SolarManager)
|
||||||
|
* einheit sonst die Einheit, die hinter der Zahl steht
|
||||||
|
* stellen Nachkommastellen (0-3)
|
||||||
|
* negativ das Vorzeichen umdrehen - fuer Zaehler, die verkehrt
|
||||||
|
* herum eingebaut sind
|
||||||
|
* gross die Zahl steht gross auf der Kachel
|
||||||
|
* icon Symbol vor der Zahl: der Name eines Bootstrap-Icons ohne
|
||||||
|
* das "bi-" davor ("thermometer-half"). Erlaubt ist jedes,
|
||||||
|
* das im Stylesheet steht - siehe bootstrapIcons() in
|
||||||
|
* rooms.php; kachelSymbole() ist nur die Vorschlagsliste.
|
||||||
|
*
|
||||||
|
* Die Tabelle darf fehlen. Ist sie nicht angelegt (solarLog_kacheln.sql), so
|
||||||
|
* verhält sich alles wie vorher, statt dass die Startseite mit einem
|
||||||
|
* SQL-Fehler stehenbleibt - die Kacheln sind keine Einstellung, ohne die man
|
||||||
|
* das Haus nicht mehr sieht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Geladen wird nicht hier, sondern in rooms.php: kachelVorgabe() und
|
||||||
|
* kachelUeberschreibungen() stehen dort, weil die Startseite sie braucht und
|
||||||
|
* diese Datei nicht. So bleibt der Weg zur Anzeige kurz - und es gibt keine
|
||||||
|
* Ringabhängigkeit zwischen Raum-Modell und Maske.
|
||||||
|
*/
|
||||||
|
require_once(__DIR__ . "/rooms.php"); // Räume, Vorgaben, Überschreibungen
|
||||||
|
require_once(__DIR__ . "/costs.php"); // solarDb()
|
||||||
|
|
||||||
|
/** Höchstens drei Werte je Kachel - mehr passt nicht auf 24 x 19 Einheiten. */
|
||||||
|
const KACHEL_MAX = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Formate, die selbst zwischen W und kW skalieren - je nachdem, in welcher
|
||||||
|
* Einheit die Quelle meldet. Die meisten Zähler schicken Watt; die Wallboxen
|
||||||
|
* im SolarManager (evPower, evPowerOG) schicken Kilowatt, und als Watt gelesen
|
||||||
|
* stand bei 1 kW Ladeleistung "1 W" auf der Kachel.
|
||||||
|
*/
|
||||||
|
const KACHEL_LEISTUNG = ["leistung", "leistung_kw"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorschläge für das Symbol vor einer Zahl.
|
||||||
|
*
|
||||||
|
* Erlaubt ist jedes Bootstrap-Icon (bootstrapIcons() in rooms.php liest sie
|
||||||
|
* aus dem Stylesheet, es sind gut zweitausend). Diese Liste ist deshalb keine
|
||||||
|
* Beschränkung, sondern eine Abkürzung: sie steht in der Maske obenan, damit
|
||||||
|
* man für die üblichen Messwerte nicht erst einen Namen suchen muss.
|
||||||
|
*
|
||||||
|
* Gruppiert nach dem, was auf den Kacheln steht - nicht nach dem, wie die
|
||||||
|
* Symbole heißen.
|
||||||
|
*/
|
||||||
|
function kachelSymbole()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
"Klima" => [
|
||||||
|
"thermometer-half" => "Temperatur",
|
||||||
|
"thermometer-sun" => "Temperatur draußen",
|
||||||
|
"thermometer-snow" => "Kälte",
|
||||||
|
"droplet-half" => "Feuchte",
|
||||||
|
"moisture" => "Luftfeuchte",
|
||||||
|
"fan" => "Lüftung",
|
||||||
|
"fire" => "Heizung",
|
||||||
|
"wind" => "Wind",
|
||||||
|
],
|
||||||
|
"Strom" => [
|
||||||
|
"lightning-charge-fill" => "Leistung",
|
||||||
|
"plug-fill" => "Verbrauch",
|
||||||
|
"sun" => "Erzeugung",
|
||||||
|
"battery-half" => "Speicher",
|
||||||
|
"battery-charging" => "Ladung",
|
||||||
|
"ev-station" => "Wallbox",
|
||||||
|
"car-front" => "Fahrzeug",
|
||||||
|
"speedometer2" => "Zähler",
|
||||||
|
"graph-up-arrow" => "Ertrag",
|
||||||
|
"arrow-down-up" => "Bezug und Einspeisung",
|
||||||
|
],
|
||||||
|
"Wasser und Wetter" => [
|
||||||
|
"cloud-rain" => "Regen",
|
||||||
|
"cloud-sun" => "Wetter",
|
||||||
|
"snow" => "Schnee",
|
||||||
|
"water" => "Wasserstand",
|
||||||
|
"bucket" => "Menge",
|
||||||
|
"tree" => "Garten",
|
||||||
|
],
|
||||||
|
"Haus" => [
|
||||||
|
"lightbulb" => "Licht",
|
||||||
|
"door-open" => "Tür",
|
||||||
|
"window" => "Fenster",
|
||||||
|
"house-door" => "Haus",
|
||||||
|
"clock-history" => "Zeit",
|
||||||
|
"wifi" => "Verbindung",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Vorschläge flach, wie die Maske sie braucht: Name => Beschriftung. */
|
||||||
|
function kachelSymboleFlach()
|
||||||
|
{
|
||||||
|
$flach = [];
|
||||||
|
foreach (kachelSymbole() as $gruppe) {
|
||||||
|
foreach ($gruppe as $name => $titel) {
|
||||||
|
$flach[$name] = $titel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $flach;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Symbolnamen für die Vorschlagsliste der Maske.
|
||||||
|
*
|
||||||
|
* Die Vorschläge zuerst und mit ihrer Beschriftung, danach der ganze Rest
|
||||||
|
* alphabetisch: so steht das Übliche oben, und wer etwas anderes will, tippt
|
||||||
|
* los und bekommt es angeboten.
|
||||||
|
*/
|
||||||
|
function kachelIconNamen()
|
||||||
|
{
|
||||||
|
$vorschlag = kachelSymboleFlach();
|
||||||
|
$liste = [];
|
||||||
|
foreach ($vorschlag as $name => $titel) {
|
||||||
|
if (isset(bootstrapIcons()[$name])) {
|
||||||
|
$liste[] = ["name" => $name, "titel" => $titel];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (array_keys(bootstrapIcons()) as $name) {
|
||||||
|
if (!isset($vorschlag[$name])) {
|
||||||
|
$liste[] = ["name" => $name, "titel" => ""];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Kacheln mit ihrem aktuellen Stand, für die Einstellungsseite.
|
||||||
|
*
|
||||||
|
* Je Kachel steht dabei, ob die Werte die Vorgabe sind oder von Hand gesetzt -
|
||||||
|
* die Maske zeigt "Standard wiederherstellen" nur dort, wo es etwas
|
||||||
|
* herzustellen gibt.
|
||||||
|
*/
|
||||||
|
function kachelListe()
|
||||||
|
{
|
||||||
|
$ueber = kachelUeberschreibungen();
|
||||||
|
$liste = [];
|
||||||
|
foreach (allRooms() as $room) {
|
||||||
|
if (!isset($room["x"])) {
|
||||||
|
continue; // kein Platz im Grundriss, keine Kachel
|
||||||
|
}
|
||||||
|
$key = $room["floor"] . "|" . $room["id"];
|
||||||
|
$liste[] = [
|
||||||
|
"floor" => $room["floor"],
|
||||||
|
"floorLabel" => floorLabel($room["floor"]),
|
||||||
|
"room" => $room["id"],
|
||||||
|
"name" => $room["mqtt"],
|
||||||
|
"eigen" => isset($ueber[$key]),
|
||||||
|
"werte" => $ueber[$key] ?? kachelVorgabe($room),
|
||||||
|
"vorgabe" => kachelVorgabe($room),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Messwerte, die sich auswählen lassen - nach Gerät gruppiert.
|
||||||
|
*
|
||||||
|
* Quelle ist derselbe Katalog, aus dem der Automatik-Editor seine Bedingungen
|
||||||
|
* baut (deviceCatalog() in restricted/automations.php). Damit steht in beiden
|
||||||
|
* Masken dasselbe zur Auswahl, und ein neu erkanntes Gerät taucht ohne Zutun
|
||||||
|
* auch hier auf.
|
||||||
|
*
|
||||||
|
* Nicht alles daraus taugt für eine Kachel: die Tahoma-Geräte melden ihre
|
||||||
|
* Zustände über die Box, nicht über den Broker (ihre "url" ist dann ein
|
||||||
|
* Zustandsname wie core:OpenClosedState). Eine Kachel liest aber ausschließlich
|
||||||
|
* MQTT - solche Werte blieben ewig auf "–" stehen und sind deshalb hier nicht
|
||||||
|
* dabei. Übrig bleibt, was ein Topic hat.
|
||||||
|
*
|
||||||
|
* Der Katalog darf fehlen: ohne Mesh-Datenbank gibt es eben keine Auswahl,
|
||||||
|
* und die Maske fällt auf die Eingabe von Hand zurück.
|
||||||
|
*/
|
||||||
|
function kachelKatalog()
|
||||||
|
{
|
||||||
|
$datei = __DIR__ . "/automations.php";
|
||||||
|
if (!file_exists($datei)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
require_once($datei);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$katalog = deviceCatalog();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$geraete = [];
|
||||||
|
foreach ($katalog as $g) {
|
||||||
|
$werte = [];
|
||||||
|
foreach ($g["states"] as $s) {
|
||||||
|
$topic = (string)($s["url"] ?? "");
|
||||||
|
// Ein Topic hat Schrägstriche und kein Schema davor; "core:..."
|
||||||
|
// und "io://..." kommen von der Tahoma-Box.
|
||||||
|
if ($topic === "" || strpos($topic, "/") === false
|
||||||
|
|| preg_match('/^[a-z]+:/', $topic)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$werte[] = [
|
||||||
|
"name" => $s["name"],
|
||||||
|
"einheit" => (string)($s["unit"] ?? ""),
|
||||||
|
"topic" => $topic,
|
||||||
|
"pfad" => (string)($s["pfad"] ?? ""),
|
||||||
|
"wert" => $s["value"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!$werte) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* Etage und Raum auch als Kuerzel, nicht nur ausgeschrieben: die
|
||||||
|
* Maske stellt die Geraete des eigenen Raums nach oben und muss dafuer
|
||||||
|
* genau vergleichen koennen. actors.room steht als "EG/Technik" da -
|
||||||
|
* dieselben Kuerzel, die auch rooms.php benutzt.
|
||||||
|
*/
|
||||||
|
$wo = explode("/", (string)($g["room"] ?? ""), 2);
|
||||||
|
$geraete[] = [
|
||||||
|
"label" => $g["label"],
|
||||||
|
"floor" => $g["floorLabel"],
|
||||||
|
"floorKey" => $wo[0] ?? "",
|
||||||
|
"raum" => $g["roomName"],
|
||||||
|
"raumKey" => $wo[1] ?? "",
|
||||||
|
"werte" => $werte,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Nach Etage sortieren, darin nach Raum und Geraet. deviceCatalog()
|
||||||
|
* liefert alphabetisch nach Geraetenamen - in einer nach Etagen
|
||||||
|
* gruppierten Liste stuende dieselbe Etage dann mehrfach da, jedes Mal
|
||||||
|
* mit ein, zwei Geraeten darin.
|
||||||
|
*/
|
||||||
|
$reihenfolge = array_flip($GLOBALS["floors"]);
|
||||||
|
usort($geraete, function ($a, $b) use ($reihenfolge) {
|
||||||
|
$fa = $reihenfolge[$a["floorKey"]] ?? 99;
|
||||||
|
$fb = $reihenfolge[$b["floorKey"]] ?? 99;
|
||||||
|
if ($fa !== $fb) {
|
||||||
|
return $fa <=> $fb;
|
||||||
|
}
|
||||||
|
$raum = strcoll($a["raum"], $b["raum"]);
|
||||||
|
return $raum !== 0 ? $raum : strcoll($a["label"], $b["label"]);
|
||||||
|
});
|
||||||
|
return $geraete;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Topics für die Vorschlagsliste der Eingabe von Hand.
|
||||||
|
*
|
||||||
|
* Alles aus dem Katalog plus das, was die Kacheln heute zeigen - denn nicht
|
||||||
|
* jedes Topic am Broker ist irgendwo registriert. Die beiden Shelly-Zähler in
|
||||||
|
* den Technikräumen etwa stehen in keinem Katalog, sind aber auf zwei Kacheln
|
||||||
|
* in Gebrauch.
|
||||||
|
*/
|
||||||
|
function kachelTopicVorschlaege()
|
||||||
|
{
|
||||||
|
$topics = [];
|
||||||
|
|
||||||
|
foreach (allRooms() as $room) {
|
||||||
|
foreach (kachelVorgabe($room) as $wert) {
|
||||||
|
if (!empty($wert["topic"])) {
|
||||||
|
$topics[$wert["topic"]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (kachelUeberschreibungen() as $werte) {
|
||||||
|
foreach ($werte as $wert) {
|
||||||
|
if (!empty($wert["topic"])) {
|
||||||
|
$topics[$wert["topic"]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (kachelKatalog() as $g) {
|
||||||
|
foreach ($g["werte"] as $w) {
|
||||||
|
$topics[$w["topic"]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$liste = array_keys($topics);
|
||||||
|
sort($liste, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
return $liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Wertdefinition prüfen und auf das Nötige eindampfen.
|
||||||
|
*
|
||||||
|
* Geworfen wird bei allem, was die Anzeige nicht darstellen könnte. Was nur
|
||||||
|
* überflüssig ist - eine Einheit neben format "leistung" etwa - fällt still
|
||||||
|
* weg, damit in der Datenbank keine Angaben stehen, die niemand liest.
|
||||||
|
*/
|
||||||
|
function kachelWertPruefen($wert, $nummer)
|
||||||
|
{
|
||||||
|
if (!is_array($wert)) {
|
||||||
|
throw new InvalidArgumentException("Wert " . $nummer . " ist unlesbar.");
|
||||||
|
}
|
||||||
|
$sauber = [];
|
||||||
|
|
||||||
|
$wechselrichter = array_values(array_filter(array_map(
|
||||||
|
"trim", (array)($wert["wechselrichter"] ?? []))));
|
||||||
|
$topic = trim((string)($wert["topic"] ?? ""));
|
||||||
|
|
||||||
|
if ($wechselrichter) {
|
||||||
|
$sauber["wechselrichter"] = $wechselrichter;
|
||||||
|
$feld = trim((string)($wert["feld"] ?? "p_AC"));
|
||||||
|
$sauber["feld"] = $feld !== "" ? $feld : "p_AC";
|
||||||
|
} elseif ($topic !== "") {
|
||||||
|
if (mb_strlen($topic) > 120) {
|
||||||
|
throw new InvalidArgumentException("Wert " . $nummer . ": das Topic ist zu lang.");
|
||||||
|
}
|
||||||
|
$sauber["topic"] = $topic;
|
||||||
|
$pfad = trim((string)($wert["pfad"] ?? ""));
|
||||||
|
if ($pfad !== "") {
|
||||||
|
$sauber["pfad"] = $pfad;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Wert " . $nummer . " braucht ein Topic oder einen Wechselrichter.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entweder das Format rechnet die Einheit selbst aus, oder sie steht da.
|
||||||
|
if (in_array($wert["format"] ?? "", KACHEL_LEISTUNG, true)) {
|
||||||
|
$sauber["format"] = $wert["format"];
|
||||||
|
} else {
|
||||||
|
$einheit = trim((string)($wert["einheit"] ?? ""));
|
||||||
|
if (mb_strlen($einheit) > 8) {
|
||||||
|
throw new InvalidArgumentException("Wert " . $nummer . ": die Einheit ist zu lang.");
|
||||||
|
}
|
||||||
|
if ($einheit !== "") {
|
||||||
|
$sauber["einheit"] = $einheit;
|
||||||
|
}
|
||||||
|
$stellen = $wert["stellen"] ?? null;
|
||||||
|
if ($stellen !== null && $stellen !== "") {
|
||||||
|
$stellen = intval($stellen);
|
||||||
|
if ($stellen < 0 || $stellen > 3) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Wert " . $nummer . ": Nachkommastellen gehen von 0 bis 3.");
|
||||||
|
}
|
||||||
|
$sauber["stellen"] = $stellen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jedes Bootstrap-Icon darf es sein; geprüft wird gegen dieselbe Liste,
|
||||||
|
// aus der die Anzeige später das Zeichen holt. Ein Tippfehler faellt so
|
||||||
|
// beim Speichern auf und nicht erst als leere Stelle auf der Kachel.
|
||||||
|
$icon = trim((string)($wert["icon"] ?? ""));
|
||||||
|
if ($icon !== "") {
|
||||||
|
if (!isset(bootstrapIcons()[$icon])) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Wert " . $nummer . ": das Symbol \"" . $icon . "\" gibt es nicht. "
|
||||||
|
. "Die Namen stehen auf icons.getbootstrap.com, ohne das \"bi-\" davor.");
|
||||||
|
}
|
||||||
|
$sauber["icon"] = $icon;
|
||||||
|
}
|
||||||
|
if (!empty($wert["negativ"])) {
|
||||||
|
$sauber["negativ"] = true;
|
||||||
|
}
|
||||||
|
if (!empty($wert["gross"])) {
|
||||||
|
$sauber["gross"] = true;
|
||||||
|
}
|
||||||
|
return $sauber;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Werte einer Kachel speichern.
|
||||||
|
*
|
||||||
|
* $werte === null setzt auf die Vorgabe zurück - die Zeile verschwindet, statt
|
||||||
|
* die Vorgabe hineinzukopieren. Sonst stünde in der Datenbank eine Kopie, die
|
||||||
|
* nicht mitzöge, wenn sich rooms.php ändert.
|
||||||
|
*
|
||||||
|
* Eine leere Liste ist etwas anderes: die Kachel zeigt dann bewusst keinen
|
||||||
|
* Messwert, sondern nur ihren Namen.
|
||||||
|
*/
|
||||||
|
function kachelWerteSpeichern($floor, $room, $werte)
|
||||||
|
{
|
||||||
|
if (!kachelTabelleDa()) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Die Tabelle kachelWerte fehlt. Sie steht in solarLog_kacheln.sql.");
|
||||||
|
}
|
||||||
|
$treffer = null;
|
||||||
|
foreach (allRooms() as $r) {
|
||||||
|
if ($r["floor"] === $floor && $r["id"] === $room && isset($r["x"])) {
|
||||||
|
$treffer = $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$treffer) {
|
||||||
|
throw new InvalidArgumentException("Diese Kachel gibt es nicht.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = solarDb();
|
||||||
|
if ($werte === null) {
|
||||||
|
$stmt = $db->prepare("DELETE FROM kachelWerte WHERE floor = ? AND room = ?");
|
||||||
|
$stmt->bind_param("ss", $floor, $room);
|
||||||
|
$stmt->execute();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($werte)) {
|
||||||
|
throw new InvalidArgumentException("Die Werteliste ist unlesbar.");
|
||||||
|
}
|
||||||
|
if (count($werte) > KACHEL_MAX) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Mehr als " . KACHEL_MAX . " Werte passen nicht auf eine Kachel.");
|
||||||
|
}
|
||||||
|
$sauber = [];
|
||||||
|
foreach (array_values($werte) as $i => $wert) {
|
||||||
|
$sauber[] = kachelWertPruefen($wert, $i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zwei grosse Zahlen nebeneinander gibt es nicht: die Kachel hat genau
|
||||||
|
// eine Zeile, die aus der Entfernung lesbar ist.
|
||||||
|
$grosse = count(array_filter($sauber, function ($w) { return !empty($w["gross"]); }));
|
||||||
|
if ($grosse > 1) {
|
||||||
|
throw new InvalidArgumentException("Nur ein Wert darf gross stehen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_encode($sauber, JSON_UNESCAPED_UNICODE);
|
||||||
|
$stmt = $db->prepare("INSERT INTO kachelWerte (floor, room, werte) VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE werte = VALUES(werte)");
|
||||||
|
$stmt->bind_param("sss", $floor, $room, $json);
|
||||||
|
if (!$stmt->execute()) {
|
||||||
|
throw new RuntimeException($stmt->error);
|
||||||
|
}
|
||||||
|
return $sauber;
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Reiter - eine Bauart für die ganze Seite.
|
||||||
|
*
|
||||||
|
* Vorher gab es drei: die Etagenreiter über den Automatiken (Home) mit einem
|
||||||
|
* eigenen switchTab() in JavaScript, die Gerätegruppen im Raum-Modal als
|
||||||
|
* Bootstrap-Reiter mit eigener CSS-Klasse, und die Einstellungsseite noch
|
||||||
|
* einmal anders. Drei Markups, zwei Verhaltensweisen, ein Sonderaussehen -
|
||||||
|
* und jede Änderung am Erscheinungsbild wäre dreimal zu machen gewesen.
|
||||||
|
*
|
||||||
|
* Hier steht nur noch, wie ein Reiter aussieht und heißt. Umgeschaltet wird
|
||||||
|
* von Bootstrap (data-bs-toggle="tab"); eigenes JavaScript dafür gibt es
|
||||||
|
* nicht mehr. Wer sich den offenen Reiter merken will, setzt "merken" - das
|
||||||
|
* übernimmt reiterMerken() in js/solar/common.js für alle Leisten der Seite.
|
||||||
|
*
|
||||||
|
* Ein Reiter ist:
|
||||||
|
*
|
||||||
|
* id Kürzel, taucht in den Element-Ids und in der Adresse auf
|
||||||
|
* titel Beschriftung
|
||||||
|
* symbol optional ein Bootstrap-Icon ("bi-currency-euro")
|
||||||
|
* titel2 optional der lange Name als Sprechblase (Etagenkürzel!)
|
||||||
|
* inhalt nur für reiterBlock(): der fertige Inhalt der Fläche
|
||||||
|
*
|
||||||
|
* Aufrufarten - je nachdem, ob der Inhalt schon als Text vorliegt:
|
||||||
|
*
|
||||||
|
* reiterBlock($reiter, $opt) Leiste und Flächen am Stück
|
||||||
|
* reiterLeiste($reiter, $opt) nur die Leiste ...
|
||||||
|
* reiterFlaecheAuf($id, $opt) ... und die Flächen einzeln, wenn der
|
||||||
|
* reiterFlaecheZu() Inhalt im Template steht
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Kennung eines Reiters. Der Name der Leiste steht davor, damit zwei
|
||||||
|
* Leisten auf derselben Seite sich nicht dieselben Ids teilen.
|
||||||
|
*/
|
||||||
|
function reiterId($opt, $id, $anhang = "")
|
||||||
|
{
|
||||||
|
return ($opt["name"] ?? "reiter") . "-" . $id . $anhang;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welcher Reiter offen ist: der ausdrücklich genannte, sonst der erste.
|
||||||
|
*
|
||||||
|
* Als eigene Funktion, weil Leiste und Flächen dieselbe Antwort brauchen -
|
||||||
|
* und weil "der erste" bei den Flächen sonst noch einmal ausgerechnet würde.
|
||||||
|
*/
|
||||||
|
function reiterAktiv($reiter, $opt)
|
||||||
|
{
|
||||||
|
$ids = array_column($reiter, "id");
|
||||||
|
$aktiv = $opt["aktiv"] ?? null;
|
||||||
|
return ($aktiv !== null && in_array($aktiv, $ids, true)) ? $aktiv : ($ids[0] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Leiste.
|
||||||
|
*
|
||||||
|
* $opt:
|
||||||
|
* name Namensraum der Ids; sollte gesetzt sein, sonst "reiter"
|
||||||
|
* aktiv id des offenen Reiters (Vorgabe: der erste)
|
||||||
|
* merken true: der offene Reiter steht in der Adresse und übersteht ein
|
||||||
|
* Neuladen. Nur eine Leiste je Seite sollte das tun.
|
||||||
|
* schmal true: kleinere Reiter, die waagerecht scrollen statt umzubrechen
|
||||||
|
* - für enge Stellen wie das Raum-Modal
|
||||||
|
* klasse zusätzliche Klassen für die Leiste
|
||||||
|
*/
|
||||||
|
function reiterLeiste($reiter, $opt = [])
|
||||||
|
{
|
||||||
|
if (count($reiter) < 2 && empty($opt["immer"])) {
|
||||||
|
// Ein einzelner Reiter beschriftet nur sich selbst. Das Raum-Modal
|
||||||
|
// hat oft nur eine Gerätegruppe - dort ist die Leiste dann weg.
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
$aktiv = reiterAktiv($reiter, $opt);
|
||||||
|
$klassen = "nav nav-tabs reiter"
|
||||||
|
. (!empty($opt["schmal"]) ? " reiter-schmal" : "")
|
||||||
|
. (isset($opt["klasse"]) ? " " . $opt["klasse"] : "");
|
||||||
|
|
||||||
|
$html = "<ul class='" . $klassen . "' role='tablist'"
|
||||||
|
. (!empty($opt["merken"]) ? " data-reiter-merken='" . htmlspecialchars($opt["name"] ?? "reiter") . "'" : "")
|
||||||
|
. ">";
|
||||||
|
foreach ($reiter as $r) {
|
||||||
|
$offen = $r["id"] === $aktiv;
|
||||||
|
$html .= "<li class='nav-item' role='presentation'>"
|
||||||
|
. "<button class='nav-link" . ($offen ? " active" : "") . "' type='button' role='tab'"
|
||||||
|
. " id='" . reiterId($opt, $r["id"], "-tab") . "'"
|
||||||
|
. " data-bs-toggle='tab' data-bs-target='#" . reiterId($opt, $r["id"]) . "'"
|
||||||
|
. " data-reiter='" . htmlspecialchars($r["id"]) . "'"
|
||||||
|
. " aria-controls='" . reiterId($opt, $r["id"]) . "'"
|
||||||
|
. " aria-selected='" . ($offen ? "true" : "false") . "'"
|
||||||
|
. (isset($r["titel2"]) ? " title='" . htmlspecialchars($r["titel2"]) . "'" : "")
|
||||||
|
. ">"
|
||||||
|
// Der Abstand zum Text kommt aus dem Stylesheet, nicht aus
|
||||||
|
// einer Hilfsklasse: in der schmalen Fassung ist er kleiner.
|
||||||
|
. (isset($r["symbol"]) ? "<i class='bi " . htmlspecialchars($r["symbol"]) . "'></i>" : "")
|
||||||
|
. htmlspecialchars($r["titel"])
|
||||||
|
. "</button></li>";
|
||||||
|
}
|
||||||
|
return $html . "</ul>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anfang einer Fläche - der Inhalt steht danach im Template. */
|
||||||
|
function reiterFlaecheAuf($id, $opt = [])
|
||||||
|
{
|
||||||
|
$offen = ($opt["aktiv"] ?? null) === $id;
|
||||||
|
return "<div class='tab-pane fade" . ($offen ? " show active" : "") . "'"
|
||||||
|
. " id='" . reiterId($opt, $id) . "' role='tabpanel'"
|
||||||
|
. " aria-labelledby='" . reiterId($opt, $id, "-tab") . "'>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ende einer Fläche. Eigene Funktion, damit im Template kein nacktes </div> steht. */
|
||||||
|
function reiterFlaecheZu()
|
||||||
|
{
|
||||||
|
return "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leiste und Flächen am Stück - für Aufrufer, die den Inhalt schon als Text
|
||||||
|
* haben (siehe zeichneGruppen() im Raum-Modal).
|
||||||
|
*/
|
||||||
|
function reiterBlock($reiter, $opt = [])
|
||||||
|
{
|
||||||
|
$opt["aktiv"] = reiterAktiv($reiter, $opt);
|
||||||
|
$html = reiterLeiste($reiter, $opt) . "<div class='tab-content'>";
|
||||||
|
foreach ($reiter as $r) {
|
||||||
|
$html .= reiterFlaecheAuf($r["id"], $opt) . ($r["inhalt"] ?? "") . reiterFlaecheZu();
|
||||||
|
}
|
||||||
|
return $html . "</div>";
|
||||||
|
}
|
||||||
+20
-25
@@ -19,6 +19,10 @@
|
|||||||
* die hier gesetzt werden.
|
* die hier gesetzt werden.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Die Geraetegruppen werden als Reiter gezeichnet - eine Bauart fuer die
|
||||||
|
// ganze Seite, siehe restricted/reiter.php.
|
||||||
|
require_once(__DIR__ . "/reiter.php");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Was das Kommando schaltet: "ein", "aus", "um" - oder null, wenn es kein
|
* Was das Kommando schaltet: "ein", "aus", "um" - oder null, wenn es kein
|
||||||
* Schaltkommando ist.
|
* Schaltkommando ist.
|
||||||
@@ -209,38 +213,29 @@ function zeichneGruppen($gruppen, $fertig = [])
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
$html = "";
|
// Aus den Gruppen werden Reiter. Wie sie aussehen und wie umgeschaltet
|
||||||
if (count($vorhanden) > 1) {
|
// wird, steht in restricted/reiter.php - hier nur, was drinsteht.
|
||||||
$html .= "<ul class='nav nav-tabs flex-nowrap mb-2 raum-tabs' role='tablist'>";
|
$reiter = [];
|
||||||
$erster = true;
|
|
||||||
foreach ($vorhanden as $form => $titel) {
|
|
||||||
$html .= "<li class='nav-item' role='presentation'>"
|
|
||||||
. "<button class='nav-link" . ($erster ? " active" : "") . "' type='button'"
|
|
||||||
. " data-bs-toggle='tab' data-bs-target='#raumtab-" . $form . "' role='tab'>"
|
|
||||||
. "<i class='bi " . $titel[1] . "'></i>"
|
|
||||||
. "<span class='ms-1'>" . htmlspecialchars($titel[0]) . "</span>"
|
|
||||||
. "</button></li>";
|
|
||||||
$erster = false;
|
|
||||||
}
|
|
||||||
$html .= "</ul>";
|
|
||||||
}
|
|
||||||
|
|
||||||
$html .= "<div class='tab-content'>";
|
|
||||||
$erster = true;
|
|
||||||
foreach ($vorhanden as $form => $titel) {
|
foreach ($vorhanden as $form => $titel) {
|
||||||
$html .= "<div class='tab-pane fade" . ($erster ? " show active" : "") . "'"
|
$inhalt = "";
|
||||||
. " id='raumtab-" . $form . "' role='tabpanel'>";
|
|
||||||
if (isset($fertig[$form])) {
|
if (isset($fertig[$form])) {
|
||||||
$html .= $fertig[$form];
|
$inhalt = $fertig[$form];
|
||||||
} else {
|
} else {
|
||||||
foreach ($gruppen[$form] as $d) {
|
foreach ($gruppen[$form] as $d) {
|
||||||
$html .= zeichneGeraet($d, $form);
|
$inhalt .= zeichneGeraet($d, $form);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$html .= "</div>";
|
$reiter[] = [
|
||||||
$erster = false;
|
"id" => $form,
|
||||||
|
"titel" => $titel[0],
|
||||||
|
"symbol" => $titel[1],
|
||||||
|
"inhalt" => $inhalt,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
return $html . "</div>";
|
// "schmal", weil das Modal eng ist: kleinere Reiter, die waagerecht
|
||||||
|
// scrollen statt umzubrechen.
|
||||||
|
return reiterBlock($reiter, ["name" => "raumtab", "schmal" => true,
|
||||||
|
"klasse" => "mb-2"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
+135
-23
@@ -2,6 +2,11 @@
|
|||||||
/*
|
/*
|
||||||
* Einzige Quelle für die Räume der Home-Ansicht.
|
* Einzige Quelle für die Räume der Home-Ansicht.
|
||||||
*
|
*
|
||||||
|
* Die Werte auf den Kacheln sind hier die Vorgabe; überschrieben werden sie
|
||||||
|
* auf der Einstellungsseite (Reiter "Home-Kacheln"). Was dort gesetzt wurde,
|
||||||
|
* steht in der Tabelle kachelWerte und hat Vorrang - alles Übrige zeigt
|
||||||
|
* weiterhin, was hier steht.
|
||||||
|
*
|
||||||
* Aus dieser Tabelle entstehen sowohl die SVG-Kacheln in home.php als auch
|
* Aus dieser Tabelle entstehen sowohl die SVG-Kacheln in home.php als auch
|
||||||
* die Aktualisierungsschleife in js/solar/homeMQTT.js (die Tabelle wird dafür
|
* die Aktualisierungsschleife in js/solar/homeMQTT.js (die Tabelle wird dafür
|
||||||
* als JSON in die Seite geschrieben). Ein neuer oder umbenannter Raum ist
|
* als JSON in die Seite geschrieben). Ein neuer oder umbenannter Raum ist
|
||||||
@@ -12,10 +17,13 @@
|
|||||||
* id Kürzel in den SVG-Element-IDs: <floor>_w0_<id>, <floor>_<id>_heater
|
* id Kürzel in den SVG-Element-IDs: <floor>_w0_<id>, <floor>_<id>_heater
|
||||||
* x, y Position der Kachel im SVG (viewBox 400x300) - fehlt bei Räumen
|
* x, y Position der Kachel im SVG (viewBox 400x300) - fehlt bei Räumen
|
||||||
* ohne Kachel
|
* ohne Kachel
|
||||||
* werte Was auf der Kachel steht: bis zu drei Messwerte, jeder mit
|
* werte Was auf der Kachel steht, sofern auf der Einstellungsseite nichts
|
||||||
|
* anderes gewählt wurde (Tabelle kachelWerte, siehe
|
||||||
|
* restricted/kacheln.php): bis zu drei Messwerte, jeder mit
|
||||||
* topic (oder wechselrichter, siehe Carport), optional pfad
|
* topic (oder wechselrichter, siehe Carport), optional pfad
|
||||||
* (Schlüssel in einer JSON-Nachricht) und entweder format
|
* (Schlüssel in einer JSON-Nachricht) und entweder format
|
||||||
* ("leistung" skaliert selbst zwischen W und kW) oder dem Paar
|
* ("leistung" skaliert selbst zwischen W und kW, "leistung_kw"
|
||||||
|
* ebenso fuer Quellen, die schon Kilowatt melden) oder dem Paar
|
||||||
* einheit und stellen (Nachkommastellen). Fehlt der Eintrag, sind
|
* einheit und stellen (Nachkommastellen). Fehlt der Eintrag, sind
|
||||||
* es die drei Thermostatwerte - Soll, Ist, Feuchte - wie bisher.
|
* es die drei Thermostatwerte - Soll, Ist, Feuchte - wie bisher.
|
||||||
* Eine leere Liste heisst: kein Messwert, die Kachel trägt nur
|
* Eine leere Liste heisst: kein Messwert, die Kachel trägt nur
|
||||||
@@ -33,6 +41,10 @@
|
|||||||
* ist die linke obere Ecke der 24 x 19 grossen Kachel, nicht ihre Mitte.
|
* ist die linke obere Ecke der 24 x 19 grossen Kachel, nicht ihre Mitte.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// solarDb() für die Kachelwerte. costs.php definiert nur Funktionen und
|
||||||
|
// verbindet sich erst, wenn eine davon gerufen wird.
|
||||||
|
require_once(__DIR__ . "/costs.php");
|
||||||
|
|
||||||
// Reihenfolge im Menü und in der Anzeige; OG ist die Standardetage.
|
// Reihenfolge im Menü und in der Anzeige; OG ist die Standardetage.
|
||||||
//
|
//
|
||||||
// AG ist das Außengelände - eine Etage ohne Grundriss. Was draußen hängt,
|
// AG ist das Außengelände - eine Etage ohne Grundriss. Was draußen hängt,
|
||||||
@@ -120,7 +132,7 @@ $rooms = [
|
|||||||
// die man sich vor dem Carport stellt: laedt gerade jemand?
|
// die man sich vor dem Carport stellt: laedt gerade jemand?
|
||||||
["floor" => "AG", "mqtt" => "Carport OG", "id" => "carport_og", "x" => 72, "y" => 154,
|
["floor" => "AG", "mqtt" => "Carport OG", "id" => "carport_og", "x" => 72, "y" => 154,
|
||||||
"werte" => [
|
"werte" => [
|
||||||
["topic" => "solarManager/evPowerOG", "format" => "leistung",
|
["topic" => "solarManager/evPowerOG", "format" => "leistung_kw",
|
||||||
"icon" => "ev-station"],
|
"icon" => "ev-station"],
|
||||||
]],
|
]],
|
||||||
// Veranda und Terrasse gibt es zweimal, je Wohnpartei eine. In der
|
// Veranda und Terrasse gibt es zweimal, je Wohnpartei eine. In der
|
||||||
@@ -148,7 +160,7 @@ $rooms = [
|
|||||||
// restricted/config.php. Ohne Kuerzel im Topic ist das die EG-Wallbox.
|
// restricted/config.php. Ohne Kuerzel im Topic ist das die EG-Wallbox.
|
||||||
["floor" => "AG", "mqtt" => "Garage", "id" => "garage", "x" => 131, "y" => 135,
|
["floor" => "AG", "mqtt" => "Garage", "id" => "garage", "x" => 131, "y" => 135,
|
||||||
"werte" => [
|
"werte" => [
|
||||||
["topic" => "solarManager/evPower", "format" => "leistung",
|
["topic" => "solarManager/evPower", "format" => "leistung_kw",
|
||||||
"icon" => "ev-station"],
|
"icon" => "ev-station"],
|
||||||
]],
|
]],
|
||||||
// Der Garten ist kein Bauteil wie die anderen, aber ein Ort: hier haengen
|
// Der Garten ist kein Bauteil wie die anderen, aber ein Ort: hier haengen
|
||||||
@@ -191,25 +203,55 @@ function floorLabel($floor)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Codepunkt eines Bootstrap-Icons für die Kacheln.
|
* Alle Bootstrap-Icons als Name => Codepunkt.
|
||||||
|
*
|
||||||
|
* Die Zahlen standen früher als Handabschrift hier - drei Stück, und jedes
|
||||||
|
* weitere Symbol war eine Codeänderung. Sie stehen aber ohnehin schon im
|
||||||
|
* Haus: css/bootstrap-icons.min.css führt sie unter .bi-<name>::before, das
|
||||||
|
* ist die Quelle, aus der auch der Browser liest. Also wird sie gelesen,
|
||||||
|
* statt sie abzuschreiben; damit steht jedes der gut zweitausend Symbole zur
|
||||||
|
* Verfügung, und ein Wechsel der Icon-Version zieht von selbst mit.
|
||||||
|
*
|
||||||
|
* Einmal je Anfrage - das Auslesen kostet rund eine Millisekunde, und die
|
||||||
|
* Startseite fragt für jede Kachel nach.
|
||||||
|
*/
|
||||||
|
function bootstrapIcons()
|
||||||
|
{
|
||||||
|
static $icons = null;
|
||||||
|
if ($icons !== null) {
|
||||||
|
return $icons;
|
||||||
|
}
|
||||||
|
$icons = [];
|
||||||
|
$css = @file_get_contents(__DIR__ . "/../css/bootstrap-icons.min.css");
|
||||||
|
if ($css === false) {
|
||||||
|
return $icons; // ohne Datei eben ohne Symbole
|
||||||
|
}
|
||||||
|
// Im CSS steht content:"\f5a2" - der Backslash gehoert zum Inhalt, im
|
||||||
|
// Muster ist er deshalb doppelt.
|
||||||
|
if (preg_match_all('/\.bi-([a-z0-9-]+)::before\{content:"\\\\([0-9a-f]+)"\}/',
|
||||||
|
$css, $treffer, PREG_SET_ORDER)) {
|
||||||
|
foreach ($treffer as $t) {
|
||||||
|
$icons[$t[1]] = hexdec($t[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $icons;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Zeichen eines Bootstrap-Icons für die Kacheln.
|
||||||
*
|
*
|
||||||
* Die Icons kommen als Schrift, nicht als Pfad: css/bootstrap-icons.min.css
|
* Die Icons kommen als Schrift, nicht als Pfad: css/bootstrap-icons.min.css
|
||||||
* ist ohnehin eingebunden (restricted/header.php), und SVG-Text darf dieselbe
|
* ist ohnehin eingebunden (restricted/header.php), und SVG-Text darf dieselbe
|
||||||
* Schriftfamilie benutzen wie HTML. Ein eingebettetes <path> je Symbol wäre
|
* Schriftfamilie benutzen wie HTML. Ein eingebettetes <path> je Symbol wäre
|
||||||
* dieselbe Wirkung mit mehr Zeilen.
|
* dieselbe Wirkung mit mehr Zeilen.
|
||||||
*
|
*
|
||||||
* Die Zahlen stehen in genau dieser CSS unter .bi-<name>::before und sind von
|
* Ein unbekannter Name gibt einen leeren Text - die Kachel zeigt dann die
|
||||||
* dort abgeschrieben - deshalb steht der Name daneben, sonst prüft das nie
|
* Zahl ohne Symbol, statt ein leeres Kästchen anzuzeigen.
|
||||||
* wieder jemand nach.
|
|
||||||
*/
|
*/
|
||||||
function kachelIcon($name)
|
function kachelIcon($name)
|
||||||
{
|
{
|
||||||
$icons = [
|
$icons = bootstrapIcons();
|
||||||
"ev-station" => "F83A", // Wallbox, Ladeleistung
|
return isset($icons[$name]) ? mb_chr($icons[$name], "UTF-8") : "";
|
||||||
"cloud-rain" => "F2B6", // Regenmenge
|
|
||||||
"sun" => "F5A2", // PV-Erzeugung
|
|
||||||
];
|
|
||||||
return isset($icons[$name]) ? mb_chr(hexdec($icons[$name]), "UTF-8") : "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -239,27 +281,97 @@ function roomsWithTile()
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Messwerte einer Kachel, fertig ausgeschrieben.
|
* Was eine Kachel ohne Zutun zeigt.
|
||||||
*
|
*
|
||||||
* Ohne eigene Angabe sind es die drei Thermostatwerte, die dort schon immer
|
* Ohne eigene Angabe sind es die drei Thermostatwerte, die dort schon immer
|
||||||
* standen: Solltemperatur klein darüber, Isttemperatur gross, Feuchte klein
|
* standen: Solltemperatur klein darüber, Isttemperatur gross, Feuchte klein
|
||||||
* darunter. Nur diese Räume bekommen auch die Symbole für "heizt" und
|
* darunter.
|
||||||
* "Pufferbetrieb" - anderswo gibt es nichts zu heizen.
|
|
||||||
*/
|
*/
|
||||||
function mitWerten($room)
|
function kachelVorgabe($room)
|
||||||
{
|
{
|
||||||
if (isset($room["werte"])) {
|
if (isset($room["werte"])) {
|
||||||
$room["heizung"] = false;
|
return array_slice($room["werte"], 0, 3);
|
||||||
$room["werte"] = array_slice($room["werte"], 0, 3);
|
|
||||||
return $room;
|
|
||||||
}
|
}
|
||||||
$zweig = "Raumtemp/" . $room["floor"] . "/" . $room["mqtt"] . "/";
|
$zweig = "Raumtemp/" . $room["floor"] . "/" . $room["mqtt"] . "/";
|
||||||
$room["heizung"] = true;
|
return [
|
||||||
$room["werte"] = [
|
|
||||||
["topic" => $zweig . "Set Temp[degC]", "einheit" => "°C", "stellen" => 0],
|
["topic" => $zweig . "Set Temp[degC]", "einheit" => "°C", "stellen" => 0],
|
||||||
["topic" => $zweig . "Temp[degC]", "einheit" => "°C", "gross" => true],
|
["topic" => $zweig . "Temp[degC]", "einheit" => "°C", "gross" => true],
|
||||||
["topic" => $zweig . "rHum[%]", "einheit" => "%rF"],
|
["topic" => $zweig . "rHum[%]", "einheit" => "%rF"],
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt es die Tabelle mit den selbst gewählten Kachelwerten?
|
||||||
|
*
|
||||||
|
* Sie darf fehlen (solarLog_kacheln.sql legt sie an). Dann gilt überall die
|
||||||
|
* Vorgabe - die Startseite bleibt nicht mit einem SQL-Fehler stehen, nur weil
|
||||||
|
* eine Einstellung noch nie benutzt wurde.
|
||||||
|
*/
|
||||||
|
function kachelTabelleDa()
|
||||||
|
{
|
||||||
|
static $da = null;
|
||||||
|
if ($da === null) {
|
||||||
|
/*
|
||||||
|
* Auch der Ausfall der Datenbank fuehrt hier nur zur Vorgabe. Die
|
||||||
|
* Kacheln holen ihre Zahlen vom Broker, nicht aus MySQL - die
|
||||||
|
* Startseite lief bisher ohne Datenbank, und das soll so bleiben.
|
||||||
|
* Ohne dieses try stuende dort im Ernstfall ein Fatal Error statt des
|
||||||
|
* Hauses.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
$res = solarDb()->query("SHOW TABLES LIKE 'kachelWerte'");
|
||||||
|
$da = $res && $res->num_rows > 0;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$da = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $da;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die von Hand gesetzten Werte, nach "Etage|Raum" abgelegt.
|
||||||
|
*
|
||||||
|
* Eine Abfrage je Seitenaufbau, nicht eine je Kachel - die Startseite zeichnet
|
||||||
|
* zwanzig davon.
|
||||||
|
*/
|
||||||
|
function kachelUeberschreibungen()
|
||||||
|
{
|
||||||
|
static $werte = null;
|
||||||
|
if ($werte !== null) {
|
||||||
|
return $werte;
|
||||||
|
}
|
||||||
|
$werte = [];
|
||||||
|
if (!kachelTabelleDa()) {
|
||||||
|
return $werte;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$res = solarDb()->query("SELECT floor, room, werte FROM kachelWerte");
|
||||||
|
while ($res && $row = $res->fetch_assoc()) {
|
||||||
|
$liste = json_decode($row["werte"], true);
|
||||||
|
if (is_array($liste)) {
|
||||||
|
$werte[$row["floor"] . "|" . $row["room"]] = array_slice($liste, 0, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$werte = []; // siehe kachelTabelleDa(): dann eben die Vorgabe
|
||||||
|
}
|
||||||
|
return $werte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Messwerte einer Kachel, fertig ausgeschrieben.
|
||||||
|
*
|
||||||
|
* Zuerst das, was auf der Einstellungsseite gewählt wurde; sonst die Vorgabe.
|
||||||
|
*
|
||||||
|
* Die Symbole für "heizt" und "Pufferbetrieb" hängen am Raum, nicht an der
|
||||||
|
* Anzeige: ein Thermostatraum bleibt einer, auch wenn auf seiner Kachel
|
||||||
|
* inzwischen etwas anderes steht. Anderswo gibt es nichts zu heizen.
|
||||||
|
*/
|
||||||
|
function mitWerten($room)
|
||||||
|
{
|
||||||
|
$room["heizung"] = !isset($room["werte"]);
|
||||||
|
$eigen = kachelUeberschreibungen()[$room["floor"] . "|" . $room["id"]] ?? null;
|
||||||
|
$room["werte"] = $eigen !== null ? $eigen : kachelVorgabe($room);
|
||||||
return $room;
|
return $room;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+153
-4
@@ -2,10 +2,15 @@
|
|||||||
/**
|
/**
|
||||||
* Einstellungsseite: Parameter, die von Hand gepflegt werden.
|
* Einstellungsseite: Parameter, die von Hand gepflegt werden.
|
||||||
*
|
*
|
||||||
* Bisher sind das die Preistabellen aus restricted/costs.php. Die Seite ist
|
* Gegliedert in Reiter, einer je Thema - Preise und die Anzeigewerte der
|
||||||
* als Sammlung von Karten angelegt - eine Karte je Thema. Kommt spaeter ein
|
* Home-Kacheln. Die Liste $reiter unten ist die einzige Stelle, die davon
|
||||||
* weiterer Parameter dazu, kommt er als eigene Karte hinzu, ohne dass die
|
* weiss: ein neues Thema ist ein Eintrag dort plus der Inhalt, alles Uebrige
|
||||||
* vorhandenen etwas davon merken.
|
* (Navigation, Umschalten, Merken des Reiters in der Adresse) ergibt sich
|
||||||
|
* daraus. Vorher stand jedes Thema als Karte untereinander; bei drei Themen
|
||||||
|
* waere die Seite ein Scrollband geworden.
|
||||||
|
*
|
||||||
|
* Innerhalb eines Reiters bleibt es bei Karten - eine je Tabelle bzw. je
|
||||||
|
* Etage.
|
||||||
*
|
*
|
||||||
* Die Zeilen zeichnet js/solar/settings.js aus dem Modell, das hier
|
* Die Zeilen zeichnet js/solar/settings.js aus dem Modell, das hier
|
||||||
* eingebettet wird - dieselbe Arbeitsteilung wie im Automatik-Editor. Sonst
|
* eingebettet wird - dieselbe Arbeitsteilung wie im Automatik-Editor. Sonst
|
||||||
@@ -15,6 +20,26 @@
|
|||||||
* die Felder dort muessen dieselben sein.
|
* die Felder dort muessen dieselben sein.
|
||||||
*/
|
*/
|
||||||
require_once(__DIR__ . "/costs.php");
|
require_once(__DIR__ . "/costs.php");
|
||||||
|
require_once(__DIR__ . "/kacheln.php");
|
||||||
|
require_once(__DIR__ . "/reiter.php");
|
||||||
|
// Nur wegen SKODA_APP_LINK - der Weg zu den Schlüsseln gehört neben das Feld,
|
||||||
|
// in das sie eingetragen werden, und der Link soll nur an einer Stelle stehen.
|
||||||
|
require_once(__DIR__ . "/skodaKeys.php");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Reiter. Reihenfolge ist Anzeigereihenfolge; der erste ist der offene,
|
||||||
|
* solange die Adresse nichts anderes sagt (#kacheln).
|
||||||
|
*/
|
||||||
|
$reiter = [
|
||||||
|
["id" => "preise", "titel" => "Preise", "symbol" => "bi-currency-euro"],
|
||||||
|
["id" => "kacheln", "titel" => "Home-Kacheln", "symbol" => "bi-grid-1x2"],
|
||||||
|
["id" => "geraete", "titel" => "Geräte", "symbol" => "bi-house-gear"],
|
||||||
|
["id" => "fahrzeug", "titel" => "Fahrzeug", "symbol" => "bi-ev-front"],
|
||||||
|
];
|
||||||
|
// "merken": der offene Reiter steht in der Adresse und uebersteht ein
|
||||||
|
// Neuladen - siehe reiterMerken() in js/solar/common.js.
|
||||||
|
$reiterOpt = ["name" => "einstellung", "merken" => true, "klasse" => "mb-3"];
|
||||||
|
$reiterOpt["aktiv"] = reiterAktiv($reiter, $reiterOpt);
|
||||||
|
|
||||||
$reihen = preisReihen();
|
$reihen = preisReihen();
|
||||||
$daten = [];
|
$daten = [];
|
||||||
@@ -50,6 +75,13 @@ try {
|
|||||||
<?php if ($fehler): ?>
|
<?php if ($fehler): ?>
|
||||||
<div class="alert alert-danger">Die Einstellungen sind nicht erreichbar: <?= htmlspecialchars($fehler) ?></div>
|
<div class="alert alert-danger">Die Einstellungen sind nicht erreichbar: <?= htmlspecialchars($fehler) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Reiter. Welcher offen ist, merkt sich die Adresse (#kacheln) -
|
||||||
|
ein Neuladen nach dem Speichern landet damit wieder dort. -->
|
||||||
|
<?= reiterLeiste($reiter, $reiterOpt) ?>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<?= reiterFlaecheAuf("preise", $reiterOpt) ?>
|
||||||
<?php foreach ($reihen as $key => $reihe): ?>
|
<?php foreach ($reihen as $key => $reihe): ?>
|
||||||
<?php $breiten = preisSpalten($reihe["felder"]); ?>
|
<?php $breiten = preisSpalten($reihe["felder"]); ?>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -94,6 +126,123 @@ try {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
<?= reiterFlaecheZu() ?><!-- /Preise -->
|
||||||
|
|
||||||
|
<!-- ================================================ Home-Kacheln ==== -->
|
||||||
|
<!-- Der Inhalt kommt aus js/solar/settings.js: eine Karte je Etage, darin
|
||||||
|
je Raum eine Zeile mit ihren Werten. Gezeichnet wird aus dem Modell,
|
||||||
|
das ajax/settings.php?action=kacheln liefert - dieselbe Arbeits-
|
||||||
|
teilung wie bei den Preisen. -->
|
||||||
|
<?= reiterFlaecheAuf("kacheln", $reiterOpt) ?>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-xxl-9">
|
||||||
|
<div class="card mb-4" id="kachelKarte">
|
||||||
|
<div class="card-header pb-2 pt-2">
|
||||||
|
<h3 class="card-title"><i class="bi bi-grid-1x2 me-2"></i>Werte auf den Kacheln</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<button type="button" class="btn btn-tool" id="kachelNeuLaden"
|
||||||
|
title="Neu laden">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-secondary small mb-3">
|
||||||
|
Jede Kachel im Grundriss zeigt bis zu drei Messwerte. Ohne eigene
|
||||||
|
Angabe sind das die Werte aus <code>restricted/rooms.php</code> –
|
||||||
|
bei Räumen mit Thermostat Soll, Ist und Feuchte. Was hier gesetzt
|
||||||
|
wird, gilt stattdessen; „Standard“ nimmt es wieder zurück.
|
||||||
|
</p>
|
||||||
|
<div id="kachelListe">
|
||||||
|
<p class="text-secondary small mb-0">Wird geladen …</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?= reiterFlaecheZu() ?><!-- /Home-Kacheln -->
|
||||||
|
|
||||||
|
<!-- ===================================================== Geräte ==== -->
|
||||||
|
<!-- Die Zuordnung Gerät -> Raum lag vorher als Modal auf der Home-Seite.
|
||||||
|
Sie gehört hierher: sie wird selten geändert, sie ist eine
|
||||||
|
Einstellung, und hier ist Platz für das, was daneben gehört - wie
|
||||||
|
lange ein Gerät nichts mehr gemeldet hat, in welchen Automatiken es
|
||||||
|
steckt, und der Weg, eine Karteileiche loszuwerden. -->
|
||||||
|
<?= reiterFlaecheAuf("geraete", $reiterOpt) ?>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-xxl-10">
|
||||||
|
<div class="card mb-4" id="geraeteKarte">
|
||||||
|
<div class="card-header pb-2 pt-2">
|
||||||
|
<h3 class="card-title"><i class="bi bi-house-gear me-2"></i>Geräte und Räume</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<button type="button" class="btn btn-tool" id="geraeteNeuLaden" title="Neu laden">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-secondary small mb-3">
|
||||||
|
Der Raum ordnet die Geräteliste im Automatik-Editor und bestimmt,
|
||||||
|
welche Messwerte bei einer Home-Kachel oben stehen. Er wird von Hand
|
||||||
|
vergeben und bleibt bei einem neuen Suchlauf erhalten – der Suchlauf
|
||||||
|
legt nur an und aktualisiert, er löscht nichts. Abgebaute Geräte
|
||||||
|
bleiben deshalb stehen, bis sie hier entfernt werden.
|
||||||
|
</p>
|
||||||
|
<div id="geraeteListe">
|
||||||
|
<p class="text-secondary small mb-0">Wird geladen …</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?= reiterFlaecheZu() ?><!-- /Geräte -->
|
||||||
|
|
||||||
|
<!-- =================================================== Fahrzeug ==== -->
|
||||||
|
<!-- Die Zugangsschlüssel der MyŠkoda-Anbindung. Sie laufen ab und
|
||||||
|
müssen dann getauscht werden - vorher hieß das: per SSH auf die NAS
|
||||||
|
und skoda.conf im Editor öffnen.
|
||||||
|
|
||||||
|
Die Zeilen zeichnet js/solar/settings.js aus dem, was
|
||||||
|
ajax/settings.php?action=skoda-keys liefert. Was dort NICHT
|
||||||
|
mitkommt, sind die Schlüssel selbst: die Seite steht im Heimnetz
|
||||||
|
ohne Anmeldung offen, und ein solcher Schlüssel steuert das
|
||||||
|
Fahrzeug von überall auf der Welt. Zu sehen ist nur, ob einer
|
||||||
|
hinterlegt ist und auf welche vier Zeichen er endet. -->
|
||||||
|
<?= reiterFlaecheAuf("fahrzeug", $reiterOpt) ?>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-xxl-8">
|
||||||
|
<div class="card mb-4" id="skodaKarte">
|
||||||
|
<div class="card-header pb-2 pt-2">
|
||||||
|
<h3 class="card-title"><i class="bi bi-key me-2"></i>Zugang zum Fahrzeug</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<button type="button" class="btn btn-tool" id="skodaNeuLaden" title="Neu laden">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-secondary small mb-3">
|
||||||
|
Die Schlüssel werden in der MyŠkoda-App erzeugt, gelten nur für die
|
||||||
|
dort ausgewählten Fahrzeuge und laufen nach einiger Zeit ab. Ein neuer
|
||||||
|
wird hier eingetragen und sofort einmal ausprobiert – was die API
|
||||||
|
ablehnt, landet gar nicht erst in der Konfiguration.
|
||||||
|
</p>
|
||||||
|
<p class="mb-3">
|
||||||
|
<a class="btn btn-sm btn-outline-primary"
|
||||||
|
href="<?= SKODA_APP_LINK ?>" target="_blank" rel="noopener">
|
||||||
|
<i class="bi bi-box-arrow-up-right me-1"></i>Schlüssel in der MyŠkoda-App verwalten
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div id="skodaListe">
|
||||||
|
<p class="text-secondary small mb-0">Wird geladen …</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?= reiterFlaecheZu() ?><!-- /Fahrzeug -->
|
||||||
|
</div><!-- /tab-content -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!--end::App Content-->
|
<!--end::App Content-->
|
||||||
|
|||||||
+641
-180
@@ -19,10 +19,16 @@
|
|||||||
* 4648 x 1877 mm, Radstand 2765 mm, daraus das Seitenverhältnis 2,48:1 und
|
* 4648 x 1877 mm, Radstand 2765 mm, daraus das Seitenverhältnis 2,48:1 und
|
||||||
* die Lage der Achsen. Kein Foto, sondern ein Schema - die Teile sollen
|
* die Lage der Achsen. Kein Foto, sondern ein Schema - die Teile sollen
|
||||||
* ihren Zustand zeigen, nicht das Auto porträtieren.
|
* ihren Zustand zeigen, nicht das Auto porträtieren.
|
||||||
|
*
|
||||||
|
* Der Ladestand ist Teil dieser Grafik und nicht mehr eine Anzeige daneben:
|
||||||
|
* die Akkuplatte im Fahrzeugboden füllt sich von hinten nach vorn, trägt die
|
||||||
|
* Zielmarke und nennt Prozent und Reichweite in ihrer Mitte. Alles, was sich
|
||||||
|
* nicht zeichnen lässt - Ladeleistung, Restzeit, Kilometerstand, Klima und
|
||||||
|
* die Herkunft des Ladestroms - steht als Kennzahl daneben.
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
<!--begin::App Main-->
|
<!--begin::App Main-->
|
||||||
<main class="app-main">
|
<main class="app-main skoda-seite">
|
||||||
<div class="app-content-header">
|
<div class="app-content-header">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row align-items-center">
|
<div class="row align-items-center">
|
||||||
@@ -39,208 +45,663 @@
|
|||||||
<div class="app-content">
|
<div class="app-content">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
|
|
||||||
<!-- ================================================ Zustand ===== -->
|
<!-- ================================================ Übersicht ==== -->
|
||||||
|
<!-- Eine Kachel, zwei Hälften: links das Fahrzeug, rechts die Zahlen
|
||||||
|
dazu. Der Ladestand steht nicht mehr als eigener Ring daneben,
|
||||||
|
sondern dort, wo er im Auto auch sitzt - in der Akkuplatte im
|
||||||
|
Fahrzeugboden. Ladestand, Ladeziel und Reichweite gehören zusammen
|
||||||
|
und stehen deshalb beieinander im Bild; alles, was sich nicht
|
||||||
|
zeichnen lässt, steht als Kennzahl daneben. -->
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
<!-- Das Fahrzeug als Anzeigeelement. Jedes Teil trägt seinen Zustand
|
|
||||||
selbst: offene Türen und Klappen leuchten bernstein, Licht gelb,
|
|
||||||
der Ladeanschluss pulst beim Laden. Die Zeile darunter benennt
|
|
||||||
dasselbe in Worten und hebt beim Überfahren das zugehörige Teil
|
|
||||||
hervor - Farbe allein soll die Aussage nicht tragen. -->
|
|
||||||
<div class="col-xxl-4 col-lg-5 d-flex align-items-stretch">
|
|
||||||
<div class="card mb-4 p-0 w-100">
|
<div class="card mb-4 p-0 w-100">
|
||||||
<div class="card-header pb-0 pt-1">
|
<div class="card-header pb-0 pt-1">
|
||||||
<h3 class="card-title">Fahrzeug</h3>
|
<h3 class="card-title">Übersicht</h3>
|
||||||
<div class="card-tools">
|
<div class="card-tools d-flex align-items-center gap-2">
|
||||||
|
<!-- Nur unterwegs sichtbar; zeichneFahrt() blendet es ein. -->
|
||||||
|
<span class="badge text-bg-info" id="skodaUnterwegs" hidden>unterwegs</span>
|
||||||
<span class="badge text-bg-secondary" id="skodaSchloss">–</span>
|
<span class="badge text-bg-secondary" id="skodaSchloss">–</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body d-flex flex-column align-items-center pt-2">
|
|
||||||
|
|
||||||
<svg id="skodaAuto" class="skoda-auto" viewBox="0 0 224 500"
|
|
||||||
role="img" aria-label="Draufsicht des Fahrzeugs mit dem Zustand von Türen, Fenstern, Klappen und Licht">
|
|
||||||
|
|
||||||
<!-- Räder. Vorderachse bei 860 mm, Hinterachse bei 3625 mm
|
|
||||||
ab Bugspitze - der Radstand von 2765 mm maßstäblich. -->
|
|
||||||
<g class="rad">
|
|
||||||
<rect x="5" y="68" width="21" height="58" rx="9"/>
|
|
||||||
<rect x="198" y="68" width="21" height="58" rx="9"/>
|
|
||||||
<rect x="5" y="362" width="21" height="58" rx="9"/>
|
|
||||||
<rect x="198" y="362" width="21" height="58" rx="9"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Karosserie -->
|
|
||||||
<path class="karosserie" d="M112 6
|
|
||||||
C150 6, 198 16, 204 50 C209 90, 212 160, 212 250
|
|
||||||
C212 340, 209 410, 204 450 C198 484, 150 494, 112 494
|
|
||||||
C74 494, 26 484, 20 450 C15 410, 12 340, 12 250
|
|
||||||
C12 160, 15 90, 20 50 C26 16, 74 6, 112 6 Z"/>
|
|
||||||
|
|
||||||
<!-- Tech-Deck Face: die glänzend schwarze Blende über die
|
|
||||||
volle Bugbreite, die den Enyaq seit der Überarbeitung
|
|
||||||
kennzeichnet. Von oben sieht man ihre Oberkante. -->
|
|
||||||
<path class="techdeck" d="M112 7
|
|
||||||
C150 7, 197 17, 203 49 L194 53 C187 27, 148 18, 112 18
|
|
||||||
C76 18, 37 27, 30 53 L21 49 C27 17, 74 7, 112 7 Z"/>
|
|
||||||
|
|
||||||
<!-- Scheinwerfer, vier Elemente je Seite wie beim Original,
|
|
||||||
dazu die Rückleuchten als durchgehendes Band. -->
|
|
||||||
<g id="teil-lights" class="teil teil-licht" data-teil="lights">
|
|
||||||
<rect x="36" y="30" width="9" height="13" rx="4"/>
|
|
||||||
<rect x="48" y="27" width="9" height="13" rx="4"/>
|
|
||||||
<rect x="60" y="25" width="9" height="13" rx="4"/>
|
|
||||||
<rect x="72" y="24" width="9" height="13" rx="4"/>
|
|
||||||
<rect x="143" y="24" width="9" height="13" rx="4"/>
|
|
||||||
<rect x="155" y="25" width="9" height="13" rx="4"/>
|
|
||||||
<rect x="167" y="27" width="9" height="13" rx="4"/>
|
|
||||||
<rect x="179" y="30" width="9" height="13" rx="4"/>
|
|
||||||
<rect class="rueck" x="40" y="462" width="52" height="13" rx="6"/>
|
|
||||||
<rect class="rueck" x="132" y="462" width="52" height="13" rx="6"/>
|
|
||||||
<title>Beleuchtung</title>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Motorhaube mit angedeuteter Mittelsicke -->
|
|
||||||
<g id="teil-bonnet" class="teil" tabindex="0" data-teil="bonnet">
|
|
||||||
<path d="M31 152 L193 152 L189 62 C169 40, 55 40, 35 62 Z"/>
|
|
||||||
<title>Motorhaube</title>
|
|
||||||
</g>
|
|
||||||
<path class="sicke" d="M112 60 L112 150"/>
|
|
||||||
|
|
||||||
<!-- Windschutzscheibe, Dach, Heckscheibe -->
|
|
||||||
<path class="scheibe" d="M34 156 L190 156 L178 200 L46 200 Z"/>
|
|
||||||
<rect class="dach" x="46" y="200" width="132" height="136" rx="10"/>
|
|
||||||
|
|
||||||
<!-- Panoramaglasdach: beim Enyaq nahezu die ganze Dachfläche -->
|
|
||||||
<g id="teil-sunroof" class="teil" tabindex="0" data-teil="sunroof">
|
|
||||||
<rect x="62" y="206" width="100" height="120" rx="8"/>
|
|
||||||
<title>Panoramadach</title>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Dachreling -->
|
|
||||||
<g class="reling">
|
|
||||||
<rect x="48" y="204" width="9" height="134" rx="4"/>
|
|
||||||
<rect x="167" y="204" width="9" height="134" rx="4"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<path class="scheibe" d="M46 336 L178 336 L190 382 L34 382 Z"/>
|
|
||||||
<rect class="spoiler" x="52" y="330" width="120" height="9" rx="4"/>
|
|
||||||
|
|
||||||
<!-- Heckklappe -->
|
|
||||||
<g id="teil-trunk" class="teil" tabindex="0" data-teil="trunk">
|
|
||||||
<path d="M34 384 L190 384 L186 458 C168 478, 56 478, 38 458 Z"/>
|
|
||||||
<title>Heckklappe</title>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Türen, jeweils mit Seitenfenster darin -->
|
|
||||||
<g id="teil-doors" class="teil" data-teil="doors">
|
|
||||||
<g tabindex="0"><rect x="12" y="202" width="23" height="74" rx="6"/><title>Tür vorn links</title></g>
|
|
||||||
<g tabindex="0"><rect x="12" y="278" width="23" height="66" rx="6"/><title>Tür hinten links</title></g>
|
|
||||||
<g tabindex="0"><rect x="189" y="202" width="23" height="74" rx="6"/><title>Tür vorn rechts</title></g>
|
|
||||||
<g tabindex="0"><rect x="189" y="278" width="23" height="66" rx="6"/><title>Tür hinten rechts</title></g>
|
|
||||||
</g>
|
|
||||||
<g id="teil-windows" class="teil teil-fenster" data-teil="windows">
|
|
||||||
<rect x="17" y="210" width="13" height="58" rx="4"/>
|
|
||||||
<rect x="17" y="286" width="13" height="50" rx="4"/>
|
|
||||||
<rect x="194" y="210" width="13" height="58" rx="4"/>
|
|
||||||
<rect x="194" y="286" width="13" height="50" rx="4"/>
|
|
||||||
<title>Seitenfenster</title>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Außenspiegel -->
|
|
||||||
<g class="spiegel">
|
|
||||||
<rect x="1" y="194" width="16" height="10" rx="5"/>
|
|
||||||
<rect x="207" y="194" width="16" height="10" rx="5"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- ============================================================
|
|
||||||
Steuerung: die beiden anklickbaren Stellen.
|
|
||||||
|
|
||||||
Bedient wird das, worum es geht - der Ladeanschluss für
|
|
||||||
das Laden, der Innenraum für Klima und Lüftung. Beide
|
|
||||||
tragen einen Ring als Hinweis darauf, dass sie mehr sind
|
|
||||||
als Anzeige, und öffnen ein Modal mit den Befehlen.
|
|
||||||
|
|
||||||
Sie stehen bewusst NICHT in der Zustandszeile darunter:
|
|
||||||
die zeigt, was ist. Diese beiden ändern etwas.
|
|
||||||
============================================================ -->
|
|
||||||
|
|
||||||
<!-- Ladeanschluss. Beim Enyaq sitzt die Klappe am hinteren
|
|
||||||
rechten Kotflügel; sie pulst, solange geladen wird. -->
|
|
||||||
<g id="teil-plug" class="teil teil-stecker steuer" tabindex="0"
|
|
||||||
role="button" data-steuer="laden" aria-label="Laden steuern">
|
|
||||||
<circle class="ring" cx="203" cy="408" r="17"/>
|
|
||||||
<circle cx="203" cy="408" r="11"/>
|
|
||||||
<path d="M198 402 l6 5 -4 0 4 6"/>
|
|
||||||
<title>Laden steuern</title>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Innenraum, auf der Windschutzscheibe: Klima und Lüftung. -->
|
|
||||||
<g id="steuer-klima" class="steuer" tabindex="0"
|
|
||||||
role="button" data-steuer="klima" aria-label="Klima und Lüftung steuern">
|
|
||||||
<circle class="ring" cx="112" cy="178" r="19"/>
|
|
||||||
<circle class="flaeche" cx="112" cy="178" r="13"/>
|
|
||||||
<path class="glyphe" d="M112 170 v10 M108 174 h8 M105 185 q7 -4 14 0"/>
|
|
||||||
<title>Klima und Lüftung steuern</title>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="skoda-teile" id="skodaTeile"></div>
|
|
||||||
<p class="skoda-hinweis">
|
|
||||||
Ladeanschluss und Innenraum antippen, um zu steuern
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Batterie, Reichweite, Ladung -->
|
|
||||||
<div class="col-xxl-8 col-lg-7 d-flex align-items-stretch">
|
|
||||||
<div class="card mb-4 p-0 w-100">
|
|
||||||
<div class="card-header pb-0 pt-1">
|
|
||||||
<h3 class="card-title">Batterie und Ladung</h3>
|
|
||||||
<div class="card-tools">
|
|
||||||
<span class="badge text-bg-secondary" id="skodaLadeZustand">–</span>
|
<span class="badge text-bg-secondary" id="skodaLadeZustand">–</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row align-items-center g-3">
|
<div class="skoda-fahrzeug">
|
||||||
|
|
||||||
<!-- Ladestandsring. Der Strich auf dem Ring ist das
|
<!-- Das Fahrzeug als Anzeigeelement. Jedes Teil trägt seinen
|
||||||
eingestellte Ladeziel - eine Ladung, die dort endet, ist
|
Zustand selbst: offene Türen und Klappen leuchten
|
||||||
kein Fehler, sondern Absicht. -->
|
bernstein, Licht gelb, der Ladeanschluss pulst beim Laden.
|
||||||
<div class="col-md-5 text-center">
|
Die Zustandszeile rechts benennt dasselbe in Worten und
|
||||||
<svg class="skoda-ring" viewBox="0 0 200 200" role="img" aria-labelledby="skodaSocText">
|
hebt beim Überfahren das zugehörige Teil hervor - Farbe
|
||||||
<circle class="ring-bahn" cx="100" cy="100" r="82"/>
|
allein soll die Aussage nicht tragen. -->
|
||||||
<circle class="ring-wert" id="skodaRing" cx="100" cy="100" r="82"/>
|
<div class="skoda-bild">
|
||||||
<line class="ring-ziel" id="skodaZiel" x1="100" y1="8" x2="100" y2="26"/>
|
<svg id="skodaAuto" class="skoda-auto" viewBox="-35.2 -8 294.4 516"
|
||||||
<text id="skodaSocText" class="ring-zahl" x="100" y="98">–</text>
|
role="img" aria-label="Draufsicht des Fahrzeugs mit Ladestand der Batterie und dem Zustand von Türen, Fenstern, Klappen und Licht">
|
||||||
<text class="ring-einheit" x="100" y="126">Prozent</text>
|
|
||||||
|
<defs>
|
||||||
|
<!-- Der Verlauf der Füllung ist am Fahrzeug verankert,
|
||||||
|
nicht am Füllrechteck: sonst wanderte er mit dem
|
||||||
|
Ladestand mit und die Farbe unten änderte sich bei
|
||||||
|
jedem Prozentpunkt. Die Farbe selbst kommt aus
|
||||||
|
--akku-farbe, gesetzt von der Zustandsklasse am
|
||||||
|
SVG-Element; das Attribut hier ist der Rückfall. -->
|
||||||
|
<linearGradient id="akkuVerlauf" gradientUnits="userSpaceOnUse"
|
||||||
|
x1="0" y1="378" x2="0" y2="108">
|
||||||
|
<stop offset="0" stop-color="#7aa7c7" stop-opacity=".75"/>
|
||||||
|
<stop offset="1" stop-color="#7aa7c7" stop-opacity=".3"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Innenkontur der Platte: schneidet Füllung, Strömung
|
||||||
|
und Pegelkante auf die abgerundete Form zu. -->
|
||||||
|
<clipPath id="akkuInnen">
|
||||||
|
<rect x="50" y="108" width="124" height="270" rx="7"/>
|
||||||
|
</clipPath>
|
||||||
|
|
||||||
|
<!-- Auf Höhe des Ladestands beschnitten, damit die
|
||||||
|
Strömung nur im gefüllten Teil läuft. Das Rechteck
|
||||||
|
darin setzt zeichneAkku() mit. -->
|
||||||
|
<clipPath id="akkuPegel">
|
||||||
|
<rect id="akkuPegelFeld" x="50" y="378" width="124" height="0"/>
|
||||||
|
</clipPath>
|
||||||
|
|
||||||
|
<!-- Ein Flügel des Lüfterrads, um die eigene Nabe im
|
||||||
|
Ursprung gezeichnet. Das Rad setzt ihn dreimal um je
|
||||||
|
120 Grad versetzt ein. -->
|
||||||
|
<path id="klimaFluegel"
|
||||||
|
d="M0 0 C 1.5 -4, 5 -7, 7.5 -5 C 9.6 -3.2, 5.5 -0.8, 0 0 Z"/>
|
||||||
|
|
||||||
|
<!-- Ein Zipfel einer Bananenschale, von oben gesehen und
|
||||||
|
um den eigenen Ansatz im Ursprung gezeichnet. Die
|
||||||
|
Schale setzt ihn dreimal um je 120 Grad versetzt
|
||||||
|
ein - wie das Lüfterrad, nur krummer. -->
|
||||||
|
<path id="bananenZipfel"
|
||||||
|
d="M0 0 C 1 -3.2, 4.5 -6.5, 7.5 -5.2
|
||||||
|
C 9.5 -4.2, 8 -1.2, 3.5 0.6 Z"/>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Die Straße, über die der Wagen fährt. Ganz zuerst
|
||||||
|
gezeichnet, damit alles andere darauf liegt.
|
||||||
|
|
||||||
|
Markiert wie eine deutsche Landstraße: rechts die
|
||||||
|
durchgezogene Fahrbahnbegrenzung, links die Leitlinie
|
||||||
|
mit 6 m Strich auf 12 m Lücke.
|
||||||
|
|
||||||
|
Gezeichnet ist die Straße im Viertel des
|
||||||
|
Fahrzeugmaßstabs. Maßstäblich (105 Einheiten je Meter,
|
||||||
|
aus 4,65 m Länge auf 488 Einheiten) wären die Striche
|
||||||
|
630 lang und 12,6 breit - dann füllen sie die Ränder
|
||||||
|
und ziehen den Blick vom Wagen weg. Vier Mal kleiner
|
||||||
|
bleibt das Verhältnis 1:2 erhalten, die Markierung
|
||||||
|
liest sich weiter als Leitlinie, und sie bleibt
|
||||||
|
Beiwerk statt Hauptsache:
|
||||||
|
|
||||||
|
links Leitlinie 157,5 Strich, 315 Lücke
|
||||||
|
rechts Fahrbahnbegrenzung, durchgezogen
|
||||||
|
beide 3,15 breit
|
||||||
|
|
||||||
|
Die Fahrbahn ist breiter als der Wagen, aber nicht so
|
||||||
|
breit wie eine echte Spur: die Zeichenfläche misst 294
|
||||||
|
Einheiten, gut ein Drittel mehr als das Fahrzeug, und
|
||||||
|
zwischen Rad und Markierung bleibt Platz für den
|
||||||
|
Fahrtwind.
|
||||||
|
|
||||||
|
Die Leitlinie wandert um genau eine Strich-Lücken-
|
||||||
|
Länge (472,5); danach steht das Muster wieder so da wie
|
||||||
|
zuvor, und die Schleife ist unsichtbar. 437 Einheiten
|
||||||
|
je Sekunde entsprechen in diesem Maßstab gut 60 km/h -
|
||||||
|
die Striche ziehen im selben Takt vorbei wie zuvor,
|
||||||
|
nur ruhiger. -->
|
||||||
|
<g class="strasse" aria-hidden="true">
|
||||||
|
<rect class="belag" x="-35.2" y="-8" width="294.4" height="516"/>
|
||||||
|
<line class="leitlinie" x1="-23.7" y1="-520" x2="-23.7" y2="1020"/>
|
||||||
|
<line class="begrenzung" x1="247.7" y1="-8" x2="247.7" y2="508"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Ostergruß: ganz selten liegt eine Bananenschale auf
|
||||||
|
der Fahrbahn, und der Wagen kommt kurz ins Schleudern.
|
||||||
|
Ausgelöst wird das in js/solar/skodaMQTT.js, gezeichnet
|
||||||
|
wird es hier.
|
||||||
|
|
||||||
|
Sie liegt in der linken Radspur (x 29, die Räder stehen
|
||||||
|
zwischen 16,5 und 41,5): unter dem Reifen hat sie ihre
|
||||||
|
Wirkung, und dass der Wagen danach nach rechts
|
||||||
|
ausbricht, passt zur Seite, auf der er den Halt
|
||||||
|
verliert.
|
||||||
|
|
||||||
|
Im Markup steht sie zwischen Straße und Fahrzeug, wird
|
||||||
|
also von der Karosserie verdeckt, sobald der Wagen
|
||||||
|
darüberrollt - so, wie es sich gehört. -->
|
||||||
|
<g class="bananenschale" aria-hidden="true">
|
||||||
|
<g class="schale" transform="translate(29 0) scale(1.4)">
|
||||||
|
<use href="#bananenZipfel"/>
|
||||||
|
<use href="#bananenZipfel" transform="rotate(120)"/>
|
||||||
|
<use href="#bananenZipfel" transform="rotate(240)"/>
|
||||||
|
<circle class="strunk" r="1.6"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Fahrtwind: zeigt an, dass das Fahrzeug gerade rollt.
|
||||||
|
Die Streifen laufen von vorn nach hinten, also im Bild
|
||||||
|
von oben nach unten - das Auto zeigt nach oben.
|
||||||
|
|
||||||
|
Je eine Spur läuft links und rechts am Wagen vorbei,
|
||||||
|
zwei weitere ziehen über ihn hinweg - der Wind macht
|
||||||
|
am Blech ja nicht halt. Die seitlichen liegen zwischen
|
||||||
|
Rad und Fahrbahnmarkierung: auf derselben Linie wie
|
||||||
|
die Begrenzung sähen sie aus wie eine zweite,
|
||||||
|
gestrichelte Markierung statt wie Luft. Die über dem Fahrzeug sind
|
||||||
|
heller und dünner, damit sie wie Luft darüber wirken
|
||||||
|
und nicht wie ein Riss im Lack; die Mitte bleibt frei,
|
||||||
|
dort stehen Ladestand und Reichweite.
|
||||||
|
|
||||||
|
Wenige Streifen mit ungleichem Takt: die Bewegung
|
||||||
|
kommt jetzt vor allem von der Straße darunter, der
|
||||||
|
Wind setzt nur noch einen Akzent.
|
||||||
|
|
||||||
|
Sichtbar nur, solange das SVG die Klasse "faehrt"
|
||||||
|
trägt (siehe zeichneFahrt()); aria-hidden, weil der
|
||||||
|
Zustand als Abzeichen im Kopf schon dasteht. -->
|
||||||
|
<g id="fahrtwind" class="fahrtwind" aria-hidden="true">
|
||||||
|
<line class="boe b1" x1="0" y1="-120" x2="0" y2="-60"/>
|
||||||
|
<line class="boe b6" x1="224" y1="-120" x2="224" y2="-70"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Alles ab hier ist das Fahrzeug selbst, zusammengefasst
|
||||||
|
in einer Gruppe: nur so kann es als Ganzes schleudern,
|
||||||
|
ohne die Straße mitzudrehen. -->
|
||||||
|
<!-- Alles ab hier ist das Fahrzeug selbst, zusammengefasst
|
||||||
|
in einer Gruppe: nur so kann es als Ganzes schleudern,
|
||||||
|
ohne die Straße mitzudrehen.
|
||||||
|
|
||||||
|
Die Malreihenfolge ist die Markup-Reihenfolge, und die
|
||||||
|
ist hier durchgehend als Schichtung gedacht - von unten
|
||||||
|
nach oben:
|
||||||
|
|
||||||
|
1. Fahrwerk Räder, Anhängerkupplung
|
||||||
|
2. Blech Karosserie, Tech-Deck, Haube, Dach,
|
||||||
|
Türen, Reling, Spiegel
|
||||||
|
3. Glas+Licht Scheinwerfer, Rückleuchten, Wind-
|
||||||
|
schutzscheibe, Seitenfenster, Heck-
|
||||||
|
klappe samt Scheibe, darüber der
|
||||||
|
Heckspoiler
|
||||||
|
4. Akku die Antriebsbatterie über allem
|
||||||
|
|
||||||
|
Das ist keine Ansicht von oben, sondern ein Schnitt-
|
||||||
|
bild: unten das, was am Boden läuft, oben das, worum
|
||||||
|
es auf dieser Seite geht. Weil jede Lage nur getönt
|
||||||
|
ist, bleibt darunter alles sichtbar.
|
||||||
|
|
||||||
|
Die beiden Knöpfe (Klima, Ladeanschluss) stehen ganz
|
||||||
|
am Schluss, noch über dem Akku. Sie sind kein Teil des
|
||||||
|
Fahrzeugs, sondern Bedienung, und müssen deckend und
|
||||||
|
anklickbar bleiben. -->
|
||||||
|
<g id="wagen">
|
||||||
|
|
||||||
|
<!-- ===== 1. Fahrwerk =================================== -->
|
||||||
|
<!-- Räder. Vorderachse bei 860 mm, Hinterachse bei 3625 mm
|
||||||
|
ab Bugspitze - der Radstand von 2765 mm maßstäblich.
|
||||||
|
|
||||||
|
Auch der Reifen selbst ist es jetzt: 235/50 R20, das
|
||||||
|
sind 744 mm Durchmesser und 235 mm Breite, also 78 auf
|
||||||
|
25 Einheiten. Die Ecken sind nur leicht gebrochen - von
|
||||||
|
oben ist eine Lauffläche ein Rechteck, keine Kapsel. Gezeichnet waren 58 auf 21 - ein Viertel
|
||||||
|
zu klein. Das Rad ist im Bild der einzige Gegenstand
|
||||||
|
mit bekanntem Mass; ist es zu klein, wirkt alles andere
|
||||||
|
zu gross, allen voran die Schnauze.
|
||||||
|
|
||||||
|
Und sie stehen dort, wo die Spur sie hinstellt: 1585 mm
|
||||||
|
vorn, 1575 mm hinten, macht Radmitten bei x 29 und 195.
|
||||||
|
Der Unterschied zwischen den Achsen ist ein Zentimeter
|
||||||
|
und liegt unter dem, was hier ein Bildpunkt auflöst -
|
||||||
|
beide Achsen stehen deshalb gleich.
|
||||||
|
|
||||||
|
Damit liegen die Reifen INNERHALB des Umrisses, wie am
|
||||||
|
Auto auch: die Karosserie ist 1879 mm breit, Spur plus
|
||||||
|
Reifenbreite sind 1820 mm. Vorher standen sie acht
|
||||||
|
Einheiten über die Flanke hinaus, eine Spur von 1,82 m
|
||||||
|
statt 1,59 m.
|
||||||
|
|
||||||
|
Zu sehen sind sie trotzdem, obwohl sie ganz unten
|
||||||
|
liegen: die Karosserie ist durchscheinend gezeichnet.
|
||||||
|
Von oben saehe man sie in Wirklichkeit gar nicht; hier
|
||||||
|
gilt dieselbe Freiheit wie bei der Akkuplatte, die auch
|
||||||
|
durch den Boden zu sehen ist. -->
|
||||||
|
<g class="rad">
|
||||||
|
<rect x="16.5" y="62" width="25" height="78" rx="5"/>
|
||||||
|
<rect x="182.5" y="62" width="25" height="78" rx="5"/>
|
||||||
|
<rect x="16.5" y="352" width="25" height="78" rx="5"/>
|
||||||
|
<rect x="182.5" y="352" width="25" height="78" rx="5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Anhängerkupplung: Hals und Kugel, mehr sieht man von
|
||||||
|
oben nicht. Sie steht hinten heraus, so wie am Auto -
|
||||||
|
die Kugel sitzt rund neun Zentimeter hinter der
|
||||||
|
Heckkante. Dass das nicht wie ein Loch im Umriss
|
||||||
|
aussieht, liegt an der Farbe: sie ist schwarz und
|
||||||
|
damit erkennbar ein Anbauteil, kein fehlendes Blech. -->
|
||||||
|
<g class="kupplung">
|
||||||
|
<rect x="109" y="480" width="6" height="19" rx="3"/>
|
||||||
|
<circle cx="112" cy="501" r="4.5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ===== 2. Blech ====================================== -->
|
||||||
|
<!-- Karosserie.
|
||||||
|
|
||||||
|
Beide Enden sind stumpf: der Enyaq ist ein SUV, keine
|
||||||
|
Zigarre. Vorn schliesst eine Kante von 70 bis 154 ab,
|
||||||
|
hinten eine von 56 bis 168 - hinten breiter, wie am
|
||||||
|
Auto. Die Flanken bleiben bis
|
||||||
|
kurz vor den Schluss auf voller Breite (212 bei y=250,
|
||||||
|
noch 206 bei y=452), dann runden zwei Ecken auf eine
|
||||||
|
gerade Heckkante von 56 bis 168. Vorher lief das Heck
|
||||||
|
spiegelbildlich zur Front spitz zusammen - von oben
|
||||||
|
genau die Form, die ein Fliessheck hat. -->
|
||||||
|
<path class="karosserie" d="M153.4 6
|
||||||
|
C184.9 9, 200.7 24, 203.6 52 C207.5 90, 210.5 160, 210.5 250
|
||||||
|
C210.5 340, 209.5 415, 204.6 452
|
||||||
|
C201.6 476, 190.8 494, 167.2 494
|
||||||
|
L56.8 494
|
||||||
|
C33.2 494, 22.4 476, 19.4 452
|
||||||
|
C14.5 415, 13.5 340, 13.5 250
|
||||||
|
C13.5 160, 16.5 90, 20.4 52
|
||||||
|
C23.3 24, 39.1 9, 70.6 6
|
||||||
|
L153.4 6 Z"/>
|
||||||
|
|
||||||
|
<!-- Tech-Deck Face: die glänzend schwarze Blende über die
|
||||||
|
volle Bugbreite, die den Enyaq seit der Überarbeitung
|
||||||
|
kennzeichnet. Von oben sieht man ihre Oberkante, also
|
||||||
|
ein schmales Band, das zu den Kotflügeln hin ausläuft.
|
||||||
|
|
||||||
|
Schmal und nur halb deckend: die Blende steht am Auto
|
||||||
|
fast senkrecht, von oben ist sie deshalb ein Streifen
|
||||||
|
und keine Fläche. Als breites, fast schwarzes Band war
|
||||||
|
sie das dunkelste im Bild und las sich wie ein Loch.
|
||||||
|
Die Enden laufen spitz zusammen statt gerade abzu-
|
||||||
|
schneiden - ein gerader Schnitt ergab dort zwei Stufen,
|
||||||
|
die es am Auto nicht gibt. -->
|
||||||
|
<path class="techdeck" d="M19 52
|
||||||
|
C 23 26, 40 13, 70 10 L 154 10 C 184 13, 201 26, 205 52
|
||||||
|
C 199 33, 180 21, 154 20 L 70 20 C 44 21, 25 33, 19 52 Z"/>
|
||||||
|
|
||||||
|
<!-- Motorhaube mit angedeuteter Mittelsicke -->
|
||||||
|
<g id="teil-bonnet" class="teil" tabindex="0" data-teil="bonnet">
|
||||||
|
<path d="M31 146 L193 146 L190 66 C176 48, 48 48, 34 66 Z"/>
|
||||||
|
<title>Motorhaube</title>
|
||||||
|
</g>
|
||||||
|
<path class="sicke" d="M112 62 L112 142"/>
|
||||||
|
|
||||||
|
<!-- Windschutzscheibe, Dach, Heckscheibe.
|
||||||
|
|
||||||
|
Die Stationen stammen aus der Seitenansicht des
|
||||||
|
Fahrzeugs, umgerechnet mit 105 Einheiten je Meter:
|
||||||
|
Scheibenfuss bei 1,37 m (y 150), Dachanfang bei 2,15 m
|
||||||
|
(232), Dachende bei 4,0 m (428). Der Dachanfang liegt
|
||||||
|
damit etwa auf halber Laenge der Vordertuer - genau da,
|
||||||
|
wo die A-Saeule im Foto ans Dach stoesst. Gezeichnet
|
||||||
|
war er vorher am Tuerende.
|
||||||
|
|
||||||
|
Der Scheibenfuss liegt VOR der vorderen Tuerfuge
|
||||||
|
(1,46 m), nicht dahinter: am Auto beginnt die Scheibe
|
||||||
|
schon ueber dem Kotfluegel, die A-Saeule setzt erst
|
||||||
|
weiter hinten an. Damit endet die Haube bei 1,33 m
|
||||||
|
statt bei 1,58 m - die Schnauze wird um einen
|
||||||
|
Vierteilmeter kuerzer, ohne dass sich am Ueberhang
|
||||||
|
etwas aendert.
|
||||||
|
|
||||||
|
Das Dach durfte erst so weit nach hinten, seit
|
||||||
|
Heckscheibe und Heckklappe eine gemeinsame Flaeche
|
||||||
|
sind: als getrennte Schaltflaeche waere die Klappe
|
||||||
|
hier nur noch drei Zentimeter hoch und nicht mehr zu
|
||||||
|
treffen. -->
|
||||||
|
<rect class="dach" x="46" y="232" width="132" height="196" rx="10"/>
|
||||||
|
|
||||||
|
<!-- Kein Panoramadach: dieses Fahrzeug hat ein geschlossenes
|
||||||
|
Blechdach. Die Zustandszeile kennt es deshalb auch nicht
|
||||||
|
(TEILE in js/solar/skodaMQTT.js). -->
|
||||||
|
|
||||||
|
<!-- Dachreling. Sie liegt AUF dem Dach und steht deshalb
|
||||||
|
direkt hinter ihm im Markup.
|
||||||
|
|
||||||
|
Die Holme sitzen ein Stück innerhalb der Dachkanten,
|
||||||
|
wie am Auto. Hinten enden sie vor dem Spoiler, nicht
|
||||||
|
an ihm: am Auto laufen die Holme über dem Radlauf aus,
|
||||||
|
der Spoiler sitzt ein Stück dahinter auf der Dachkante.
|
||||||
|
Der steht weiter unten, bei den Scheiben. -->
|
||||||
|
<g class="reling">
|
||||||
|
<line x1="53" y1="240" x2="53" y2="404"/>
|
||||||
|
<line x1="171" y1="240" x2="171" y2="404"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Türen, jeweils mit Seitenfenster darin.
|
||||||
|
|
||||||
|
Die vordere beginnt dicht hinter dem Vorderrad: ihre
|
||||||
|
Fuge liegt bei 1,52 m, das Rad endet bei 1,24 m - ein
|
||||||
|
knapper Vierteilmeter Blech dazwischen, so wie am Auto.
|
||||||
|
Vorher begann sie erst bei 1,75 m und liess dort eine
|
||||||
|
halbe Handbreit Luft, die es nicht gibt.
|
||||||
|
|
||||||
|
Die Fuge zwischen den Türen liegt bei 2,59 m, gemessen
|
||||||
|
an der B-Säule. Die hintere Tür reicht am Auto noch
|
||||||
|
über den Radlauf hinaus bis etwa 3,5 m; gezeichnet
|
||||||
|
endet sie am Radlauf, denn weiter hinten läge ihr Blech
|
||||||
|
im Bild auf dem Rad. -->
|
||||||
|
<g id="teil-doors" class="teil" data-teil="doors">
|
||||||
|
<g tabindex="0"><rect x="12" y="159" width="23" height="119" rx="6"/><title>Tür vorn links</title></g>
|
||||||
|
<g tabindex="0"><rect x="12" y="280" width="23" height="72" rx="6"/><title>Tür hinten links</title></g>
|
||||||
|
<g tabindex="0"><rect x="189" y="159" width="23" height="119" rx="6"/><title>Tür vorn rechts</title></g>
|
||||||
|
<g tabindex="0"><rect x="189" y="280" width="23" height="72" rx="6"/><title>Tür hinten rechts</title></g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Außenspiegel. Sie sitzen am Fuß der A-Säule, also am
|
||||||
|
vorderen Ende der Tür (1,62 m) und nicht in deren
|
||||||
|
Mitte - von oben ist der Spiegel das erste, was nach
|
||||||
|
der Windschutzscheibe kommt. -->
|
||||||
|
<g class="spiegel">
|
||||||
|
<rect x="-0.7" y="176" width="16" height="10" rx="5"/>
|
||||||
|
<rect x="208.7" y="176" width="16" height="10" rx="5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ===== 3. Glas und Licht ============================= -->
|
||||||
|
<!-- Scheinwerfer, vier Elemente je Seite wie beim Original,
|
||||||
|
dazu die Rückleuchten als durchgehendes Band. -->
|
||||||
|
<g id="teil-lights" class="teil teil-licht" data-teil="lights">
|
||||||
|
<rect x="30" y="29" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="42" y="26" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="54" y="25" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="66" y="24" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="149" y="24" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="161" y="25" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="173" y="26" width="9" height="13" rx="4"/>
|
||||||
|
<rect x="185" y="29" width="9" height="13" rx="4"/>
|
||||||
|
<!-- Die Rueckleuchten sitzen in den Heckecken und greifen
|
||||||
|
von der Klappe auf die Flanke ueber - so wie am Auto,
|
||||||
|
wo das Band um die Ecke laeuft. Weiter innen sahen
|
||||||
|
sie aus wie zwei Griffe in der Klappe. -->
|
||||||
|
<rect class="rueck" x="28" y="468" width="62" height="12" rx="6"/>
|
||||||
|
<rect class="rueck" x="134" y="468" width="62" height="12" rx="6"/>
|
||||||
|
<title>Beleuchtung</title>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Windschutzscheibe. Ihre Stationen stehen oben beim
|
||||||
|
Dach; hier liegt sie, weil Glas ueber dem Blech kommt.
|
||||||
|
Sie beginnt bei 1,37 m, also vor der vorderen Tuerfuge
|
||||||
|
bei 1,46 m, und endet mit dem Dachanfang auf halber
|
||||||
|
Laenge der Vordertuer. -->
|
||||||
|
<path class="scheibe" d="M34 150 L190 150 L178 232 L46 232 Z"/>
|
||||||
|
|
||||||
|
<!-- Seitenfenster. Sie sitzen in den Tueren, liegen im
|
||||||
|
Bild aber ueber ihnen: Glas ist die obere Lage.
|
||||||
|
|
||||||
|
Das vordere reicht bis dicht an die A-Saeule heran
|
||||||
|
(1,66 m) - am Auto beginnt es gleich hinter dem
|
||||||
|
Spiegeldreieck. Gezeichnet war es 16 Einheiten kuerzer
|
||||||
|
und liess vorn ein Stueck Blech stehen, das es nicht
|
||||||
|
gibt. -->
|
||||||
|
<g id="teil-windows" class="teil teil-fenster" data-teil="windows">
|
||||||
|
<rect x="17" y="180" width="13" height="92" rx="4"/>
|
||||||
|
<rect x="17" y="286" width="13" height="60" rx="4"/>
|
||||||
|
<rect x="194" y="180" width="13" height="92" rx="4"/>
|
||||||
|
<rect x="194" y="286" width="13" height="60" rx="4"/>
|
||||||
|
<title>Seitenfenster</title>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Heckklappe samt Heckscheibe: eine Flaeche, ein Zustand.
|
||||||
|
Die Scheibe sitzt in der Klappe und geht mit ihr auf,
|
||||||
|
also ist sie kein eigenes Teil - sie behaelt nur ihr
|
||||||
|
Aussehen (siehe .teil > .scheibe im Stylesheet).
|
||||||
|
|
||||||
|
Die Scheibe steht am Enyaq steil und wird nach unten
|
||||||
|
schmaler; lief sie nach hinten breiter aus, las sich
|
||||||
|
das von oben wie ein Fliessheck. -->
|
||||||
|
<g id="teil-trunk" class="teil" tabindex="0" data-teil="trunk">
|
||||||
|
<path class="scheibe" d="M46 428 L178 428 L172 460 L52 460 Z"/>
|
||||||
|
<path d="M52 462 L172 462 C180 470, 184 478, 183 481
|
||||||
|
C165 490, 59 490, 41 481 C40 478, 44 470, 52 462 Z"/>
|
||||||
|
<title>Heckklappe</title>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Heckspoiler. Er liegt über der Heckscheibe, nicht
|
||||||
|
darunter: am Auto sitzt er auf der Dachkante und
|
||||||
|
damit vor dem Glas. Ein Blatt über dieser Kante, in
|
||||||
|
der Mitte breit, zu den Enden schmaler - dort aber
|
||||||
|
stumpf abgeschnitten, nicht spitz zulaufend. Zwei
|
||||||
|
Spitzen gaben ihm die Form eines Blattes; am Auto
|
||||||
|
hört er schlicht auf. -->
|
||||||
|
<path class="spoiler" d="M42 427 C 74 420, 150 420, 182 427
|
||||||
|
L182 433 C 150 440, 74 440, 42 433 Z"/>
|
||||||
|
|
||||||
|
<!-- ===== 4. Antriebsbatterie =========================== -->
|
||||||
|
<!-- ========================================================
|
||||||
|
Antriebsbatterie - der Ladestand selbst.
|
||||||
|
|
||||||
|
Beim MEB liegt der Akku als flache Platte zwischen den
|
||||||
|
Achsen; genau dort sitzt sie hier auch, von 108 bis 378
|
||||||
|
über den Radstand. Gefüllt wird von hinten nach vorn,
|
||||||
|
also im Bild von unten nach oben - dieselbe Richtung,
|
||||||
|
in der jeder einen Füllstand liest.
|
||||||
|
|
||||||
|
Sie ist die oberste Lage des Fahrzeugs - siehe die
|
||||||
|
Schichtung oben am Anfang von #wagen. Das geht nur
|
||||||
|
auf, weil die Platte über dem Radstand liegt und in
|
||||||
|
x nur von 46 bis 178 reicht: Lichter, Fenster, Türen
|
||||||
|
und Heckklappe liegen alle davor, dahinter oder
|
||||||
|
daneben, keines davon verschwindet unter ihr.
|
||||||
|
|
||||||
|
Alles Veränderliche - Füllhöhe, Zielmarke, Zahlen und
|
||||||
|
Farbe - setzt zeichneAkku().
|
||||||
|
======================================================== -->
|
||||||
|
<g id="akku" class="akku">
|
||||||
|
<rect class="huelle" x="46" y="104" width="132" height="278" rx="10"/>
|
||||||
|
|
||||||
|
<g clip-path="url(#akkuInnen)">
|
||||||
|
<rect class="fuellung" id="akkuFuellung" x="50" y="378" width="124" height="0"/>
|
||||||
|
|
||||||
|
<!-- Ladestrom: Streifen, die im gefüllten Teil nach
|
||||||
|
vorn wandern. Nur beim Laden sichtbar. -->
|
||||||
|
<g class="stroemung" clip-path="url(#akkuPegel)">
|
||||||
|
<g class="bahn">
|
||||||
|
<rect x="50" y="430" width="124" height="3" rx="1.5"/>
|
||||||
|
<rect x="50" y="376" width="124" height="3" rx="1.5"/>
|
||||||
|
<rect x="50" y="322" width="124" height="3" rx="1.5"/>
|
||||||
|
<rect x="50" y="268" width="124" height="3" rx="1.5"/>
|
||||||
|
<rect x="50" y="214" width="124" height="3" rx="1.5"/>
|
||||||
|
<rect x="50" y="160" width="124" height="3" rx="1.5"/>
|
||||||
|
<rect x="50" y="106" width="124" height="3" rx="1.5"/>
|
||||||
|
<rect x="50" y="52" width="124" height="3" rx="1.5"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Pegelkante: die helle Linie oben auf der Füllung.
|
||||||
|
Sie macht den Stand auch dann ablesbar, wenn die
|
||||||
|
Fläche darunter nur schwach getönt ist. -->
|
||||||
|
<rect class="kante" id="akkuKante" x="50" y="377" width="124" height="2.5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Modulfugen. Sie liegen auf 20, 40, 60 und 80 Prozent
|
||||||
|
und dienen damit zugleich als Skala. -->
|
||||||
|
<g class="fugen">
|
||||||
|
<line x1="50" y1="324" x2="174" y2="324"/>
|
||||||
|
<line x1="50" y1="270" x2="174" y2="270"/>
|
||||||
|
<line x1="50" y1="216" x2="174" y2="216"/>
|
||||||
|
<line x1="50" y1="162" x2="174" y2="162"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Ladeziel. Eine Ladung, die dort endet, ist kein
|
||||||
|
Fehler, sondern Absicht - deshalb steht die Marke im
|
||||||
|
Bild und nicht nur als Zahl daneben. -->
|
||||||
|
<g class="zielmarke" id="akkuZielMarke">
|
||||||
|
<line id="akkuZiel" x1="44" y1="108" x2="180" y2="108"/>
|
||||||
|
<text id="akkuZielText" x="174" y="102">Ziel –</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Ladestand und Reichweite in der Platte. Der dunkle
|
||||||
|
Umriss der Zahlen (paint-order) hält sie lesbar,
|
||||||
|
gleich ob Füllung oder leere Zelle dahinter liegt.
|
||||||
|
|
||||||
|
Die Höhe ist gerechnet, nicht geschätzt: die Platte
|
||||||
|
reicht von 108 bis 378, ist also 270 hoch; der
|
||||||
|
Goldene Schnitt von oben liegt bei 108 + 270 × 0,618
|
||||||
|
= 274,9. Dort sitzt die optische Mitte der beiden
|
||||||
|
Zeilen (Oberkante der Ziffern 246, Grundlinie der
|
||||||
|
Reichweite 303). Weiter oben klebten sie am
|
||||||
|
Klimaknopf, dessen Ring bei 205 endet - jetzt liegen
|
||||||
|
41 Einheiten Luft dazwischen. -->
|
||||||
|
<text class="wert" x="112" y="275">
|
||||||
|
<tspan id="akkuText">–</tspan><tspan class="einheit" dx="3">%</tspan>
|
||||||
|
</text>
|
||||||
|
<text class="neben" id="akkuNeben" x="112" y="303">–</text>
|
||||||
|
|
||||||
|
<title id="akkuTitel">Antriebsbatterie</title>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ===== Bedienung ===================================== -->
|
||||||
|
<!-- ========================================================
|
||||||
|
Steuerung: die beiden anklickbaren Stellen.
|
||||||
|
|
||||||
|
Bedient wird das, worum es geht - der Ladeanschluss für
|
||||||
|
das Laden, der Innenraum für Klima und Lüftung. Beide
|
||||||
|
tragen einen Ring als Hinweis darauf, dass sie mehr
|
||||||
|
sind als Anzeige, und öffnen ein Modal mit den
|
||||||
|
Befehlen.
|
||||||
|
|
||||||
|
Sie stehen bewusst NICHT in der Zustandszeile: die
|
||||||
|
zeigt, was ist. Diese beiden ändern etwas.
|
||||||
|
======================================================== -->
|
||||||
|
|
||||||
|
<!-- Der Weg des Ladestroms vom Anschluss in die Platte.
|
||||||
|
Läuft nur, solange geladen wird. -->
|
||||||
|
<path class="ladefluss" d="M199 399 C 192 393, 183 386, 176 376"/>
|
||||||
|
|
||||||
|
<!-- Ladeanschluss. Beim Enyaq sitzt die Klappe am hinteren
|
||||||
|
rechten Kotflügel.
|
||||||
|
|
||||||
|
Gleiche Bauweise wie der Klimaknopf: gestrichelter Ring,
|
||||||
|
deckende Scheibe, Zeichen darauf. Beides sind Knöpfe, also
|
||||||
|
sehen beide gleich aus - was sie unterscheidet, ist das
|
||||||
|
Zeichen und beim Laden die Farbe. Die deckende Scheibe ist
|
||||||
|
auch hier nötig, weil darunter die Akkuplatte durchläuft. -->
|
||||||
|
<g id="teil-plug" class="teil-stecker steuer" tabindex="0"
|
||||||
|
role="button" data-steuer="laden" aria-label="Laden steuern">
|
||||||
|
<circle class="ring" cx="203" cy="408" r="19"/>
|
||||||
|
<circle class="flaeche" cx="203" cy="408" r="13"/>
|
||||||
|
<path class="glyphe" d="M199 401 L207 407 L202 407 L207 415"/>
|
||||||
|
<title>Laden steuern</title>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Innenraum, auf der Windschutzscheibe: Klima und Lüftung.
|
||||||
|
Er steht auf deren Mitte (150 bis 232, also 191).
|
||||||
|
Darunter beginnt bei 246 der Ladestand; die 31
|
||||||
|
Einheiten dazwischen sind der Grund, warum er nicht
|
||||||
|
tiefer sitzt - sonst lesen sich Knopf und Zahl als
|
||||||
|
ein Block. -->
|
||||||
|
<g id="steuer-klima" class="steuer" tabindex="0"
|
||||||
|
role="button" data-steuer="klima" aria-label="Klima und Lüftung steuern">
|
||||||
|
<circle class="ring" cx="112" cy="196" r="19"/>
|
||||||
|
<circle class="flaeche" cx="112" cy="196" r="13"/>
|
||||||
|
<!-- Lüfterrad. Es dreht sich, solange die Klimatisierung
|
||||||
|
läuft; die Drehung sitzt in der inneren Gruppe, damit
|
||||||
|
sie die Verschiebung nach aussen nicht überschreibt. -->
|
||||||
|
<g class="luefter" transform="translate(112 196)">
|
||||||
|
<g class="blaetter">
|
||||||
|
<use href="#klimaFluegel"/>
|
||||||
|
<use href="#klimaFluegel" transform="rotate(120)"/>
|
||||||
|
<use href="#klimaFluegel" transform="rotate(240)"/>
|
||||||
|
</g>
|
||||||
|
<circle class="nabe" r="1.9"/>
|
||||||
|
</g>
|
||||||
|
<title>Klima und Lüftung steuern</title>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g><!-- /#wagen -->
|
||||||
|
|
||||||
|
<!-- Der Teil des Fahrtwinds, der über das Fahrzeug zieht.
|
||||||
|
Eigene Gruppe am Ende des Bildes, weil die Reihenfolge
|
||||||
|
im Markup die Malreihenfolge ist - weiter oben lägen
|
||||||
|
die Streifen unter der Karosserie und wären nicht zu
|
||||||
|
sehen. pointer-events: none steht im Stylesheet: sie
|
||||||
|
liegen über den beiden Knöpfen und dürfen deren
|
||||||
|
Berührungen nicht abfangen. -->
|
||||||
|
<g class="fahrtwind fahrtwind-oben" aria-hidden="true">
|
||||||
|
<line class="boe ueber b7" x1="62" y1="-120" x2="62" y2="-65"/>
|
||||||
|
<line class="boe ueber b10" x1="163" y1="-120" x2="163" y2="-60"/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
<p class="skoda-hinweis">
|
||||||
|
Ladeanschluss und Innenraum antippen, um zu steuern
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-7">
|
<!-- Alles, was sich nicht ins Bild zeichnen lässt. -->
|
||||||
<div class="skoda-werte">
|
<div class="skoda-daten">
|
||||||
<div class="skoda-wert"><span class="k">Reichweite</span><span class="v" id="skodaReichweite">–</span></div>
|
|
||||||
<div class="skoda-wert"><span class="k">Ladeleistung</span><span class="v" id="skodaLeistung">–</span></div>
|
<div class="skoda-kennzahlen">
|
||||||
<div class="skoda-wert"><span class="k">Restzeit</span><span class="v" id="skodaRestzeit">–</span></div>
|
<div class="kennzahl" id="skodaKachelLeistung">
|
||||||
<div class="skoda-wert"><span class="k">Ladeziel</span><span class="v" id="skodaZielText">–</span></div>
|
<span class="zeichen"><i class="bi bi-lightning-charge-fill" aria-hidden="true"></i></span>
|
||||||
<div class="skoda-wert"><span class="k">Kilometerstand</span><span class="v" id="skodaKm">–</span></div>
|
<span class="text">
|
||||||
<div class="skoda-wert"><span class="k">Klima</span><span class="v" id="skodaKlima">–</span></div>
|
<span class="k">Ladeleistung</span>
|
||||||
|
<span class="v" id="skodaLeistung">–</span>
|
||||||
|
<span class="neben" id="skodaLadeart"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="kennzahl" id="skodaKachelRestzeit">
|
||||||
|
<span class="zeichen"><i class="bi bi-hourglass-split" aria-hidden="true"></i></span>
|
||||||
|
<span class="text">
|
||||||
|
<span class="k">Restzeit</span>
|
||||||
|
<span class="v" id="skodaRestzeit">–</span>
|
||||||
|
<span class="neben" id="skodaFertig"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="kennzahl">
|
||||||
|
<span class="zeichen"><i class="bi bi-speedometer2" aria-hidden="true"></i></span>
|
||||||
|
<span class="text">
|
||||||
|
<span class="k">Kilometer­stand</span>
|
||||||
|
<span class="v" id="skodaKm">–</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="kennzahl" id="skodaKachelKlima">
|
||||||
|
<span class="zeichen"><i class="bi bi-fan" id="skodaKlimaZeichen" aria-hidden="true"></i></span>
|
||||||
|
<span class="text">
|
||||||
|
<span class="k">Klima</span>
|
||||||
|
<span class="v" id="skodaKlima">–</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Woher der Strom gerade kommt: der Balken teilt die
|
<!-- Woher der Strom gerade kommt: der Balken teilt die
|
||||||
Ladeleistung in den Anteil, den die PV deckt, und den
|
Ladeleistung in den Anteil, den die PV deckt, und den
|
||||||
Rest aus dem Netz. -->
|
Rest aus dem Netz. Steht nur da, solange geladen wird -
|
||||||
<div class="skoda-quelle mt-3" id="skodaQuelleBox">
|
ohne Ladung hat der Balken nichts zu teilen. -->
|
||||||
<div class="d-flex justify-content-between small text-secondary mb-1">
|
<div class="skoda-quelle" id="skodaQuelleBox" hidden>
|
||||||
<span>Ladestrom</span><span id="skodaQuelleText">–</span>
|
<div class="kopf">
|
||||||
|
<span class="skoda-marke">Ladestrom</span>
|
||||||
|
<span class="summe" id="skodaQuelleText">–</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="skoda-balken">
|
<div class="skoda-balken">
|
||||||
<div class="teil-pv" id="skodaAnteilPV"></div>
|
<div class="teil-pv" id="skodaAnteilPV"></div>
|
||||||
<div class="teil-netz" id="skodaAnteilNetz"></div>
|
<div class="teil-netz" id="skodaAnteilNetz"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="legende">
|
||||||
|
<span><i class="pkt pv"></i>Sonne <b id="skodaQuellePV">–</b></span>
|
||||||
|
<span><i class="pkt netz"></i>Netz <b id="skodaQuelleNetz">–</b></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Was das Fahrzeug in Woche, Monat und Jahr gefahren ist.
|
||||||
|
Gerechnet aus dem Kilometerstand, nicht aus den erkannten
|
||||||
|
Fahrten - siehe ajax/skoda.php, ?was=strecke. -->
|
||||||
|
<div class="skoda-fahrleistung">
|
||||||
|
<span class="skoda-marke">Fahrleistung</span>
|
||||||
|
<div class="reihe" id="skodaFahrleistung"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zustandszeile: benennt in Worten, was die Farben im Bild
|
||||||
|
zeigen, und hebt beim Überfahren das Teil hervor. -->
|
||||||
|
<div class="skoda-zustand">
|
||||||
|
<span class="skoda-marke">Zustand</span>
|
||||||
|
<div class="skoda-teile" id="skodaTeile"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,546 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Die Zugangsschlüssel der MyŠkoda-Anbindung.
|
||||||
|
*
|
||||||
|
* Eine Stelle für alles, was skoda.conf betrifft: wo die Datei liegt, wie sie
|
||||||
|
* gelesen wird, was davon die Oberfläche sehen darf und wie ein neuer
|
||||||
|
* Schlüssel hineinkommt. Vorher wusste ajax/skodaCmd.php das für sich allein;
|
||||||
|
* mit dem Reiter in den Einstellungen wären es zwei Fassungen geworden, die
|
||||||
|
* sich beim ersten Formatwechsel unterscheiden.
|
||||||
|
*
|
||||||
|
* Dieselbe Datei liest gatherSkodaData.py im SolarManager. Dass das Format
|
||||||
|
* dort ein zweites Mal steht, lässt sich nicht vermeiden - zwei Sprachen,
|
||||||
|
* zwei Repositorien. skoda.conf.example daneben ist die gemeinsame
|
||||||
|
* Beschreibung, und sie ist die Stelle, an der ein neues Feld zuerst
|
||||||
|
* auftauchen sollte.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Ein Schlüssel geht nie an den Browser zurück
|
||||||
|
*
|
||||||
|
* Die Seite steht im Heimnetz ohne Anmeldung offen (isLocal() in helper.php),
|
||||||
|
* und so ein Schlüssel steuert das Fahrzeug von überall auf der Welt - Laden,
|
||||||
|
* Klimatisierung, Standort. Wer ihn abliest, braucht das Heimnetz danach nicht
|
||||||
|
* mehr. Die Maske bekommt deshalb nur zu sehen, ob ein Schlüssel hinterlegt
|
||||||
|
* ist und auf welche vier Zeichen er endet; das genügt, um zwei
|
||||||
|
* auseinanderzuhalten, und taugt zu nichts sonst.
|
||||||
|
*
|
||||||
|
* Geschrieben wird nur in eine Richtung: ein neuer Wert kommt herein, der alte
|
||||||
|
* kommt nie heraus.
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Die Datei mit den Zugangsdaten. Liegt außerhalb des Webverzeichnisses; sie
|
||||||
|
* gehört zum SolarManager, der sie ebenfalls liest.
|
||||||
|
*
|
||||||
|
* Mit define() davor umzuhängen - ein Prüflauf arbeitet dann auf einer Kopie
|
||||||
|
* und nicht auf den echten Zugangsdaten. */
|
||||||
|
defined("SKODA_KONFIG") || define("SKODA_KONFIG", "/volume1/homes/wagner/SolarManager/skoda.conf");
|
||||||
|
|
||||||
|
/** Basis der öffentlichen API. Ein GET darauf prüft einen Schlüssel. */
|
||||||
|
const SKODA_API = "https://public.api.connect.skoda-auto.cz/api/v1/vehicles/";
|
||||||
|
|
||||||
|
const SKODA_KENNUNG = "SmartController-Skoda/1.0 (+https://nas.el-wa.org/smart)";
|
||||||
|
|
||||||
|
/** Wo die Schlüssel herkommen - der Link gehört in die Maske. */
|
||||||
|
const SKODA_APP_LINK = "https://go.skoda.eu/api-keys";
|
||||||
|
|
||||||
|
/** Befehle je Stunde.
|
||||||
|
*
|
||||||
|
* Das Kontingent der API - 20 je Stunde - gilt gemessen fürs ganze Konto und
|
||||||
|
* nicht je Schlüssel (die Rechnung steht bei _LIMIT in gatherSkodaData.py).
|
||||||
|
* Abruf und Steuerung laufen in getrennten Prozessen ohne gemeinsamen
|
||||||
|
* Zähler, also ist fest aufgeteilt: 14 für den Abruf, 4 für Befehle, 2
|
||||||
|
* Reserve. Ein eigener CMD_API_KEY ändert daran nichts mehr. */
|
||||||
|
const SKODA_BEFEHLE = 4;
|
||||||
|
|
||||||
|
/** Verzeichnisse für kleine Merkzettel zwischen Weboberfläche und Manager.
|
||||||
|
* Das erste, das der Webserver beschreiben darf, gewinnt. Dieselbe Liste in
|
||||||
|
* derselben Reihenfolge steht als _ANSTOSS_ORTE in gatherSkodaData.py. */
|
||||||
|
const SKODA_ARBEITSORTE = [
|
||||||
|
"/volume1/web/smart/tiles", // falls per synoacltool freigegeben
|
||||||
|
"/var/services/tmp/smart-tiles", // sonst
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Name der Anstoßdatei - siehe skodaAbrufAnstossen(). */
|
||||||
|
const SKODA_ANSTOSS_DATEI = ".skoda_abruf";
|
||||||
|
|
||||||
|
/** Name des Stundenzählers für Befehle - siehe budgetFrei() in skodaCmd.php. */
|
||||||
|
const SKODA_ZAEHLER_DATEI = ".befehle.json";
|
||||||
|
|
||||||
|
/** Abstand zweier Abrufe beim Laden und während der Fahrt. Dieselbe Zahl
|
||||||
|
* steht als _I_LADEN in gatherSkodaData.py; hier nur, um sie anzuzeigen.
|
||||||
|
* Weitere Schlüssel machen sie nicht kleiner - siehe SKODA_BEFEHLE. */
|
||||||
|
const SKODA_TAKT = 260;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Schlüssel, die skoda.conf kennen darf, in Anzeigereihenfolge.
|
||||||
|
*
|
||||||
|
* Als Funktion und nicht als Konstante, weil jeder Eintrag seinen Satz
|
||||||
|
* Erklärung mitbringt - die steht in der Maske und soll nicht dort noch
|
||||||
|
* einmal getippt werden.
|
||||||
|
*/
|
||||||
|
function skodaFelder()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
"feld" => "API_KEY",
|
||||||
|
"titel" => "Abruf",
|
||||||
|
"pflicht" => true,
|
||||||
|
"zweck" => "Holt den Fahrzeugzustand. Ohne ihn bleibt die "
|
||||||
|
. "Fahrzeugseite leer.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"feld" => "API_KEY2",
|
||||||
|
"titel" => "Abruf, zweiter Schlüssel",
|
||||||
|
"pflicht" => false,
|
||||||
|
"zweck" => "Freiwillig, und kein dichterer Abruf: das Kontingent "
|
||||||
|
. "gilt gemessen fürs ganze Konto. Was er bringt, ist "
|
||||||
|
. "Ausfallsicherheit – läuft einer ab oder wird er "
|
||||||
|
. "widerrufen, fragt der Abruf mit dem nächsten weiter.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"feld" => "API_KEY3",
|
||||||
|
"titel" => "Abruf, dritter Schlüssel",
|
||||||
|
"pflicht" => false,
|
||||||
|
"zweck" => "Wie der zweite. Mehr als drei liest die "
|
||||||
|
. "Konfiguration nicht.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"feld" => "CMD_API_KEY",
|
||||||
|
"titel" => "Befehle",
|
||||||
|
"pflicht" => false,
|
||||||
|
"zweck" => "Für Laden, Klimatisierung und Lüftung. Ohne ihn "
|
||||||
|
. "schickt die Weboberfläche ihre Befehle mit dem "
|
||||||
|
. "ersten Schlüssel. Ein eigener lässt sich einzeln "
|
||||||
|
. "widerrufen, ohne den Abruf mitzunehmen.",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- Lesen -- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* skoda.conf als Zuweisungen lesen. Format: NAME=wert, eine je Zeile.
|
||||||
|
*
|
||||||
|
* Liefert null, wenn die Datei fehlt oder nicht lesbar ist - das ist der
|
||||||
|
* normale Zustand einer Installation ohne Fahrzeug und kein Fehler.
|
||||||
|
*/
|
||||||
|
function skodaKonfigLesen()
|
||||||
|
{
|
||||||
|
if (!is_readable(SKODA_KONFIG)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$zeilen = file(SKODA_KONFIG, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
if ($zeilen === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$werte = [];
|
||||||
|
foreach ($zeilen as $zeile) {
|
||||||
|
$zeile = trim($zeile);
|
||||||
|
if ($zeile === "" || $zeile[0] === "#" || strpos($zeile, "=") === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
list($k, $v) = explode("=", $zeile, 2);
|
||||||
|
$werte[strtoupper(trim($k))] = trim(trim($v), "\"'");
|
||||||
|
}
|
||||||
|
return $werte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welcher Schlüssel die Befehle schickt und mit welcher VIN.
|
||||||
|
*
|
||||||
|
* Der eigene, wenn es ihn gibt - sonst der des Abrufs. Liefert null, solange
|
||||||
|
* nichts hinterlegt ist.
|
||||||
|
*/
|
||||||
|
function skodaBefehlsSchluessel()
|
||||||
|
{
|
||||||
|
$werte = skodaKonfigLesen();
|
||||||
|
if (!$werte || empty($werte["API_KEY"]) || empty($werte["VIN"])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$eigener = !empty($werte["CMD_API_KEY"]);
|
||||||
|
return [
|
||||||
|
"vin" => $werte["VIN"],
|
||||||
|
"schluessel" => $eigener ? $werte["CMD_API_KEY"] : $werte["API_KEY"],
|
||||||
|
"eigener" => $eigener,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die letzten vier Zeichen, mehr darf die Maske nicht wissen. */
|
||||||
|
function skodaMaske($wert)
|
||||||
|
{
|
||||||
|
$wert = (string)$wert;
|
||||||
|
return strlen($wert) > 4 ? "…" . substr($wert, -4) : "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wann der erste Schlüssel abläuft, laut der letzten Antwort des Fahrzeugs.
|
||||||
|
*
|
||||||
|
* gatherSkodaData.py schreibt bei jedem Abruf mit, was der Header
|
||||||
|
* X-API-Key-Expires-At sagt - bei mehreren Schlüsseln das früheste Datum.
|
||||||
|
* Genauer geht es von hier aus nicht, ohne die API zu fragen, und dafür ist
|
||||||
|
* die Auskunft zu unwichtig.
|
||||||
|
*/
|
||||||
|
function skodaAblaufAusHistorie()
|
||||||
|
{
|
||||||
|
$datum = null;
|
||||||
|
try {
|
||||||
|
$mysql = new mysqli($GLOBALS["mysql_server"], $GLOBALS["mysql_solarUser"],
|
||||||
|
$GLOBALS["mysql_solarPass"], $GLOBALS["mysql_solarDB"]);
|
||||||
|
if ($mysql->connect_errno) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$res = $mysql->query("SELECT key_expires FROM skoda "
|
||||||
|
. "WHERE key_expires IS NOT NULL ORDER BY id DESC LIMIT 1;");
|
||||||
|
if ($res && ($zeile = $res->fetch_assoc())) {
|
||||||
|
$datum = $zeile["key_expires"];
|
||||||
|
}
|
||||||
|
$mysql->close();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return null; // ohne Historie eben ohne Datum
|
||||||
|
}
|
||||||
|
return $datum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Stand für die Maske: was hinterlegt ist, was daraus folgt.
|
||||||
|
*
|
||||||
|
* Ohne Schlüsselwerte - siehe den Kopfkommentar.
|
||||||
|
*/
|
||||||
|
function skodaStand()
|
||||||
|
{
|
||||||
|
$werte = skodaKonfigLesen();
|
||||||
|
$da = is_array($werte);
|
||||||
|
|
||||||
|
$felder = [];
|
||||||
|
$abruf = 0;
|
||||||
|
foreach (skodaFelder() as $f) {
|
||||||
|
$wert = $da && !empty($werte[$f["feld"]]) ? $werte[$f["feld"]] : "";
|
||||||
|
$f["gesetzt"] = $wert !== "";
|
||||||
|
$f["maske"] = $wert !== "" ? skodaMaske($wert) : "";
|
||||||
|
$felder[] = $f;
|
||||||
|
if ($wert !== "" && $f["feld"] !== "CMD_API_KEY") {
|
||||||
|
$abruf++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"datei" => SKODA_KONFIG,
|
||||||
|
"da" => $da,
|
||||||
|
"schreibbar" => $da && is_writable(SKODA_KONFIG),
|
||||||
|
"vin" => $da && !empty($werte["VIN"]) ? skodaMaske($werte["VIN"]) : "",
|
||||||
|
"felder" => $felder,
|
||||||
|
"abruf" => $abruf,
|
||||||
|
// Was gilt - die Zahlen, nach denen man sonst im Quelltext sucht.
|
||||||
|
"takt" => SKODA_TAKT,
|
||||||
|
"befehle" => SKODA_BEFEHLE,
|
||||||
|
"ablauf" => $da ? skodaAblaufAusHistorie() : null,
|
||||||
|
"link" => SKODA_APP_LINK,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Schreiben -- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft einen Schlüssel, indem einmal das Fahrzeug abgefragt wird.
|
||||||
|
*
|
||||||
|
* Lieber eine Anfrage jetzt als ein Tippfehler, der erst Minuten später als
|
||||||
|
* Zeile im Log auffällt. Sie kostet eine der 20 Anfragen, die der neue
|
||||||
|
* Schlüssel in seiner ersten Stunde hat - die er ohnehin frisch mitbringt.
|
||||||
|
*
|
||||||
|
* Zurück kommt, was die API sagt, und ihr Ablaufdatum: [ok, status, ablauf,
|
||||||
|
* text].
|
||||||
|
*/
|
||||||
|
function skodaSchluesselPruefen($schluessel, $vin)
|
||||||
|
{
|
||||||
|
$ch = curl_init(SKODA_API . rawurlencode($vin));
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HEADER => true,
|
||||||
|
CURLOPT_USERAGENT => SKODA_KENNUNG,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
"X-API-Key: " . $schluessel,
|
||||||
|
"Accept: application/json",
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$inhalt = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
$kopfEnde = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($inhalt === false) {
|
||||||
|
return ["ok" => false, "status" => 0, "ablauf" => null,
|
||||||
|
"text" => "Die Škoda-API ist nicht erreichbar."];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ablauf = null;
|
||||||
|
foreach (explode("\n", substr($inhalt, 0, $kopfEnde)) as $zeile) {
|
||||||
|
if (stripos($zeile, "X-API-Key-Expires-At:") === 0) {
|
||||||
|
$ablauf = trim(substr($zeile, strlen("X-API-Key-Expires-At:")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($status) {
|
||||||
|
case 200:
|
||||||
|
return ["ok" => true, "status" => 200, "ablauf" => $ablauf, "text" => ""];
|
||||||
|
case 401:
|
||||||
|
return ["ok" => false, "status" => 401, "ablauf" => null,
|
||||||
|
"text" => "Die API kennt diesen Schlüssel nicht - abgelaufen "
|
||||||
|
. "oder vertippt."];
|
||||||
|
case 403:
|
||||||
|
return ["ok" => false, "status" => 403, "ablauf" => null,
|
||||||
|
"text" => "Der Schlüssel gilt nicht für dieses Fahrzeug. In "
|
||||||
|
. "der MyŠkoda-App muss es beim Anlegen ausgewählt sein."];
|
||||||
|
case 404:
|
||||||
|
return ["ok" => false, "status" => 404, "ablauf" => null,
|
||||||
|
"text" => "Fahrzeug nicht gefunden - stimmt die VIN in "
|
||||||
|
. SKODA_KONFIG . "?"];
|
||||||
|
case 429:
|
||||||
|
// Kein Urteil über den Schlüssel: er ist in Ordnung, nur gerade
|
||||||
|
// ausgeschöpft. Das darf den Tausch nicht verhindern.
|
||||||
|
return ["ok" => true, "status" => 429, "ablauf" => null,
|
||||||
|
"text" => "Kontingent gerade erschöpft - nicht geprüft, "
|
||||||
|
. "aber übernommen."];
|
||||||
|
default:
|
||||||
|
return ["ok" => true, "status" => $status, "ablauf" => $ablauf,
|
||||||
|
"text" => "Unerwartete Antwort (HTTP " . $status . ") - nicht "
|
||||||
|
. "geprüft, aber übernommen."];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Schlüssel, wie er in einer Zeile NAME=wert stehen darf.
|
||||||
|
*
|
||||||
|
* Entscheidend ist, dass kein Zeilenumbruch hineinkommt: mit einem ließen sich
|
||||||
|
* weitere Zuweisungen unterschieben, etwa eine fremde VIN. Sonst wird bewusst
|
||||||
|
* wenig verlangt - welche Form Škoda seinen Schlüsseln morgen gibt, steht
|
||||||
|
* nicht in unserer Hand.
|
||||||
|
*/
|
||||||
|
function skodaWertPruefen($feld, $wert)
|
||||||
|
{
|
||||||
|
$wert = trim((string)$wert);
|
||||||
|
if ($wert === "") {
|
||||||
|
throw new InvalidArgumentException($feld . ": kein Wert angegeben.");
|
||||||
|
}
|
||||||
|
if (preg_match('/\s/', $wert)) {
|
||||||
|
throw new InvalidArgumentException($feld . ": ein Schlüssel enthält keine "
|
||||||
|
. "Leerzeichen oder Zeilenumbrüche - versehentlich zu viel kopiert?");
|
||||||
|
}
|
||||||
|
if (!preg_match('/^[\x21-\x7e]{8,256}$/', $wert)) {
|
||||||
|
throw new InvalidArgumentException($feld . ": das sieht nicht nach einem "
|
||||||
|
. "Schlüssel aus (8 bis 256 Zeichen, keine Sonderzeichen).");
|
||||||
|
}
|
||||||
|
return $wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* skoda.conf neu schreiben.
|
||||||
|
*
|
||||||
|
* $aenderungen: Feld => neuer Wert, oder Feld => null zum Entfernen.
|
||||||
|
*
|
||||||
|
* Die Datei bleibt, wie sie ist: jede Zeile, die nicht gemeint ist, geht
|
||||||
|
* unverändert durch - Kommentare, VIN, DB_PASSWORD, Leerzeilen. Ein Feld, das
|
||||||
|
* noch nicht vorkommt, wird angehängt.
|
||||||
|
*
|
||||||
|
* Geschrieben wird daneben und dann umbenannt. Das Umbenennen ist der einzige
|
||||||
|
* Augenblick, in dem sich die Datei ändert; wer sie gerade liest - der Manager
|
||||||
|
* alle paar Minuten, skodaCmd.php bei jedem Befehl - sieht entweder den alten
|
||||||
|
* oder den neuen Stand, nie einen halben.
|
||||||
|
*/
|
||||||
|
function skodaKonfigSchreiben(array $aenderungen)
|
||||||
|
{
|
||||||
|
$roh = @file(SKODA_KONFIG);
|
||||||
|
if ($roh === false) {
|
||||||
|
throw new RuntimeException("Die Datei " . SKODA_KONFIG . " ist nicht lesbar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$offen = $aenderungen; // was noch nicht untergebracht ist
|
||||||
|
$neu = [];
|
||||||
|
foreach ($roh as $zeile) {
|
||||||
|
$blank = trim($zeile);
|
||||||
|
$name = "";
|
||||||
|
if ($blank !== "" && $blank[0] !== "#" && strpos($blank, "=") !== false) {
|
||||||
|
$name = strtoupper(trim(explode("=", $blank, 2)[0]));
|
||||||
|
}
|
||||||
|
if ($name === "" || !array_key_exists($name, $offen)) {
|
||||||
|
$neu[] = rtrim($zeile, "\r\n");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($offen[$name] !== null) {
|
||||||
|
$neu[] = $name . "=" . $offen[$name];
|
||||||
|
} // null: Zeile fällt weg
|
||||||
|
unset($offen[$name]);
|
||||||
|
}
|
||||||
|
foreach ($offen as $name => $wert) {
|
||||||
|
if ($wert !== null) {
|
||||||
|
$neu[] = $name . "=" . $wert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rechte der alten Datei übernehmen: der Manager läuft unter einem anderen
|
||||||
|
// Benutzer und muss sie weiter lesen können.
|
||||||
|
$rechte = @fileperms(SKODA_KONFIG);
|
||||||
|
$rechte = $rechte ? ($rechte & 0777) : 0666;
|
||||||
|
|
||||||
|
$vorlaeufig = SKODA_KONFIG . ".neu." . getmypid();
|
||||||
|
if (@file_put_contents($vorlaeufig, implode("\n", $neu) . "\n") === false) {
|
||||||
|
throw new RuntimeException("Konnte neben " . SKODA_KONFIG . " nicht "
|
||||||
|
. "schreiben - fehlen dem Webserver die Rechte auf das Verzeichnis?");
|
||||||
|
}
|
||||||
|
@chmod($vorlaeufig, $rechte);
|
||||||
|
if (!@rename($vorlaeufig, SKODA_KONFIG)) {
|
||||||
|
@unlink($vorlaeufig);
|
||||||
|
throw new RuntimeException("Konnte " . SKODA_KONFIG . " nicht ersetzen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neue Schlüssel prüfen und speichern.
|
||||||
|
*
|
||||||
|
* $neu: Feld => Wert. Leere Werte bedeuten "unverändert" und fallen hier
|
||||||
|
* schon weg - ein leeres Feld in der Maske soll nichts löschen.
|
||||||
|
* $loeschen: Felder, die verschwinden sollen.
|
||||||
|
*
|
||||||
|
* Erst prüfen, dann schreiben: ein Schlüssel, den die API ablehnt, kommt gar
|
||||||
|
* nicht erst in die Datei. Sonst stünde der Abruf still, bis es jemand im Log
|
||||||
|
* bemerkt.
|
||||||
|
*/
|
||||||
|
function skodaSchluesselSpeichern(array $neu, array $loeschen = [])
|
||||||
|
{
|
||||||
|
$stand = skodaStand();
|
||||||
|
if (!$stand["da"]) {
|
||||||
|
throw new RuntimeException("Die Datei " . SKODA_KONFIG . " gibt es nicht. "
|
||||||
|
. "Ohne sie läuft auch der Abruf nicht - sie gehört zum SolarManager.");
|
||||||
|
}
|
||||||
|
$erlaubt = array_column(skodaFelder(), "feld");
|
||||||
|
$werte = skodaKonfigLesen();
|
||||||
|
$vin = $werte["VIN"] ?? "";
|
||||||
|
if ($vin === "") {
|
||||||
|
throw new RuntimeException("In " . SKODA_KONFIG . " steht keine VIN - "
|
||||||
|
. "ohne sie lässt sich kein Schlüssel prüfen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$aenderungen = [];
|
||||||
|
foreach ($neu as $feld => $wert) {
|
||||||
|
$feld = strtoupper((string)$feld);
|
||||||
|
if (!in_array($feld, $erlaubt, true)) {
|
||||||
|
throw new InvalidArgumentException("Unbekanntes Feld: " . $feld);
|
||||||
|
}
|
||||||
|
if (trim((string)$wert) === "") {
|
||||||
|
continue; // leer heißt: so lassen
|
||||||
|
}
|
||||||
|
$aenderungen[$feld] = skodaWertPruefen($feld, $wert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Derselbe Schlüssel zweimal bringt kein zweites Kontingent - die API
|
||||||
|
* zählt den Schlüssel, nicht die Zeile. gatherSkodaData.py wirft ein
|
||||||
|
* Duplikat beim Lesen weg; hier fiele es nur als "zwei Schlüssel" auf,
|
||||||
|
* die den Abruf dann doch nicht beschleunigen.
|
||||||
|
*/
|
||||||
|
foreach ($aenderungen as $feld => $wert) {
|
||||||
|
if ($wert === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($erlaubt as $anderes) {
|
||||||
|
if ($anderes === $feld) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$schon = array_key_exists($anderes, $aenderungen)
|
||||||
|
? $aenderungen[$anderes] : ($werte[$anderes] ?? "");
|
||||||
|
if ($schon !== null && $schon !== "" && hash_equals((string)$schon, $wert)) {
|
||||||
|
throw new InvalidArgumentException($feld . ": dieser Schlüssel steht "
|
||||||
|
. "schon als " . $anderes . " da. Ein zweites Mal derselbe bringt "
|
||||||
|
. "kein zweites Kontingent - in der MyŠkoda-App einen neuen anlegen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($loeschen as $feld) {
|
||||||
|
$feld = strtoupper((string)$feld);
|
||||||
|
if (!in_array($feld, $erlaubt, true)) {
|
||||||
|
throw new InvalidArgumentException("Unbekanntes Feld: " . $feld);
|
||||||
|
}
|
||||||
|
if ($feld === "API_KEY") {
|
||||||
|
throw new InvalidArgumentException("Ohne den ersten Schlüssel gibt es "
|
||||||
|
. "keinen Abruf mehr. Er lässt sich ersetzen, aber nicht entfernen.");
|
||||||
|
}
|
||||||
|
$aenderungen[$feld] = null;
|
||||||
|
}
|
||||||
|
if (!$aenderungen) {
|
||||||
|
throw new InvalidArgumentException("Nichts einzutragen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ab hier wird das Netz befragt, und das kann dauern. helper.php hat die
|
||||||
|
* Sitzung geöffnet und hält sie gesperrt, solange diese Anfrage läuft -
|
||||||
|
* ohne das hier stünde der ganze Browser so lange still, auch die Kacheln
|
||||||
|
* in einem anderen Reiter.
|
||||||
|
*/
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
session_write_close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$meldungen = [];
|
||||||
|
foreach ($aenderungen as $feld => $wert) {
|
||||||
|
if ($wert === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$urteil = skodaSchluesselPruefen($wert, $vin);
|
||||||
|
if (!$urteil["ok"]) {
|
||||||
|
throw new InvalidArgumentException($feld . ": " . $urteil["text"]);
|
||||||
|
}
|
||||||
|
if ($urteil["text"] !== "") {
|
||||||
|
$meldungen[] = $feld . ": " . $urteil["text"];
|
||||||
|
} elseif ($urteil["ablauf"]) {
|
||||||
|
$meldungen[] = $feld . " angenommen, gültig bis "
|
||||||
|
. skodaDatumKurz($urteil["ablauf"]) . ".";
|
||||||
|
} else {
|
||||||
|
$meldungen[] = $feld . " angenommen.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skodaKonfigSchreiben($aenderungen);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Der Manager liest skoda.conf von selbst neu, sobald sie sich geändert
|
||||||
|
* hat - aber erst, wenn der nächste Abruf ohnehin ansteht, und beim Parken
|
||||||
|
* sind das 20 Minuten. Derselbe Anstoß wie nach einem Befehl holt ihn auf
|
||||||
|
* eine halbe Minute heran; dann greift ein neuer Schlüssel sofort.
|
||||||
|
*/
|
||||||
|
skodaAbrufAnstossen();
|
||||||
|
|
||||||
|
return ["meldungen" => $meldungen, "stand" => skodaStand()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- Beiwerk - */
|
||||||
|
|
||||||
|
/** Ein API-Datum kurz und deutsch. Unlesbares bleibt, wie es ist. */
|
||||||
|
function skodaDatumKurz($iso)
|
||||||
|
{
|
||||||
|
$zeit = strtotime((string)$iso);
|
||||||
|
return $zeit ? date("d.m.Y", $zeit) : (string)$iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dem Abruf im solarManager Bescheid geben, dass sich gleich etwas ändert.
|
||||||
|
*
|
||||||
|
* Berührt wird eine leere Datei; gatherSkodaData.py sieht bei jedem seiner
|
||||||
|
* Durchläufe auf ihre Änderungszeit und holt den nächsten Abruf auf
|
||||||
|
* _NACH_BEFEHL Sekunden danach vor. Ein stat alle drei Sekunden ist billiger
|
||||||
|
* als eine Verbindung, die dafür offen bleiben müsste, und es geht auch dann
|
||||||
|
* nichts verloren, wenn einer der beiden Prozesse gerade neu startet.
|
||||||
|
*
|
||||||
|
* Kein Fehler, wenn es nicht klappt: der Befehl ist beim Fahrzeug, nur die
|
||||||
|
* Anzeige zieht dann später nach.
|
||||||
|
*/
|
||||||
|
function skodaAbrufAnstossen()
|
||||||
|
{
|
||||||
|
foreach (SKODA_ARBEITSORTE as $ort) {
|
||||||
|
if (is_dir($ort) && is_writable($ort)) {
|
||||||
|
@touch($ort . "/" . SKODA_ANSTOSS_DATEI);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-17
@@ -64,23 +64,6 @@
|
|||||||
<!-- /.card -->
|
<!-- /.card -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
|
|
||||||
<div class="card p-0 mb-4" style="width:100%">
|
|
||||||
<div class="card-header pb-0 pt-1">
|
|
||||||
<h3 class="card-title">Statistik dieses Jahr</h3>
|
|
||||||
<div class="card-tools">
|
|
||||||
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
|
||||||
<i class="bi bi-x-lg"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row-cols-1 row-cols-md-1 g-4 p-2" id="Stats-Year">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- /.card -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
|
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
|
||||||
<div class="card p-0 mb-4" style="width:100%">
|
<div class="card p-0 mb-4" style="width:100%">
|
||||||
<div class="card-header pb-0 pt-1">
|
<div class="card-header pb-0 pt-1">
|
||||||
@@ -118,6 +101,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch">
|
||||||
|
<div class="card p-0 mb-4" style="width:100%">
|
||||||
|
<div class="card-header pb-0 pt-1">
|
||||||
|
<h3 class="card-title">Statistik dieses Jahr</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<button type="button" class="btn btn-tool" data-lte-toggle="card-remove">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Gefuellt von baueJahresstatistik() aus js/solar/jahresstatistik.js -
|
||||||
|
dieselbe Tabelle wie auf der Historienseite, nur einspaltig. -->
|
||||||
|
<div class="card-body statistik" id="Stats-Year"></div>
|
||||||
|
</div>
|
||||||
|
<!-- /.card -->
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Consumption Graph-->
|
<!-- Consumption Graph-->
|
||||||
<div class="col-lg-12">
|
<div class="col-lg-12">
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Anzeigewerte der Home-Kacheln.
|
||||||
|
--
|
||||||
|
-- Eine Zeile je Kachel, und nur fuer die Kacheln, an denen etwas geaendert
|
||||||
|
-- wurde. Steht keine Zeile da, gilt die Vorgabe aus restricted/rooms.php: bei
|
||||||
|
-- Thermostatraeumen die drei Werte Soll, Ist und Feuchte, sonst die dort
|
||||||
|
-- eingetragene Liste. Die Tabelle ist damit reine Ueberschreibung - sie muss
|
||||||
|
-- weder vollstaendig sein noch mitwachsen, wenn ein Raum hinzukommt.
|
||||||
|
--
|
||||||
|
-- Die Werte stehen als JSON in einer Spalte und nicht in Einzelspalten. Eine
|
||||||
|
-- Wertdefinition ist heterogen: mal ein Topic mit Pfad in einer
|
||||||
|
-- JSON-Nachricht, mal eine Liste von Wechselrichternamen, dazu Einheit,
|
||||||
|
-- Nachkommastellen, Symbol und ob die Zahl gross steht. In Spalten gegossen
|
||||||
|
-- waeren das ein Dutzend Felder, von denen je nach Quelle die Haelfte leer
|
||||||
|
-- bleibt - und jede neue Anzeigeoption waere eine Schemaaenderung. So ist es
|
||||||
|
-- genau die Form, die restricted/rooms.php ohnehin verarbeitet.
|
||||||
|
--
|
||||||
|
-- Geprueft wird beim Speichern in restricted/kacheln.php, nicht hier: die
|
||||||
|
-- Datenbank kann JSON zwar halten, aber nicht wissen, welche Symbole es gibt.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `kachelWerte` (
|
||||||
|
`floor` varchar(4) NOT NULL COMMENT 'Etage, wie in rooms.php',
|
||||||
|
`room` varchar(40) NOT NULL COMMENT 'Kuerzel des Raums (rooms.id)',
|
||||||
|
`werte` text NOT NULL COMMENT 'JSON-Liste der Wertdefinitionen, hoechstens drei',
|
||||||
|
`aktualisiert` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||||
|
PRIMARY KEY (`floor`, `room`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- Verlauf der Wallboxen waehrend einer Ladung.
|
||||||
|
--
|
||||||
|
-- Das Fahrzeug meldet seinen Stand beim Laden an der Wallbox mitunter nur
|
||||||
|
-- einmal in der Stunde (Tabelle skoda). Fuer die Summe einer Ladung genuegt
|
||||||
|
-- das - Energie vom Wallbox-Zaehler, Ladestand von der Zeile davor und danach
|
||||||
|
-- -, fuer den Verlauf nicht: am 13.09.2026 meldete es um 10:29 noch 5,7 kW und
|
||||||
|
-- um 11:22 dann 7,7 kW, waehrend die Wallbox seit 10:54 mit 11 kW lief.
|
||||||
|
--
|
||||||
|
-- Die Wallboxen dagegen stehen alle drei Sekunden zur Verfuegung. In EnergyFlow
|
||||||
|
-- landen sie als Fuenf-Minuten-Werte, aber EnergyFlow wird nach zwoelf Monaten
|
||||||
|
-- ausgeduennt (solarlog-rollup.sh thin) - eine Ladekurve vom letzten Sommer
|
||||||
|
-- liesse sich dann nicht mehr zeichnen. Deshalb haelt gatherSkodaData.py die
|
||||||
|
-- Werte hier fest, solange eine Wallbox Strom liefert, und diese Tabelle wird
|
||||||
|
-- nicht ausgeduennt. Bei 60 Sekunden Abstand sind das rund 180 Zeilen je
|
||||||
|
-- Ladung, im Jahr grob 25.000.
|
||||||
|
--
|
||||||
|
-- Der Gesamtzaehler steht in jeder Zeile mit. Zwischen zwei Meldungen des
|
||||||
|
-- Fahrzeugs laesst sich der Ladestand damit nach der geflossenen Energie
|
||||||
|
-- auffuellen, ohne Akkukapazitaet oder Wirkungsgrad zu kennen:
|
||||||
|
--
|
||||||
|
-- soc(t) = soc_a + (soc_b - soc_a) * (wh(t) - wh_a) / (wh_b - wh_a)
|
||||||
|
--
|
||||||
|
-- Beide Wallboxen werden festgehalten, auch wenn die Auswertung heute nur die
|
||||||
|
-- im Carport kennt: laedt das Fahrzeug einmal im Obergeschoss, soll der
|
||||||
|
-- Verlauf nicht fehlen.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `skoda_ladepunkte` (
|
||||||
|
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`datetime` datetime NOT NULL COMMENT 'Ortszeit',
|
||||||
|
`wallbox` enum('carport','og') NOT NULL COMMENT 'carport = Wattpilot, og = go-e',
|
||||||
|
`kw` decimal(6,3) NOT NULL COMMENT 'Ladeleistung laut Wallbox; 0 = Ende der Ladung',
|
||||||
|
`wh_total` bigint(20) unsigned DEFAULT NULL COMMENT 'Gesamtzaehler der Wallbox; NULL = nicht gelesen',
|
||||||
|
`pv_kw` decimal(7,3) DEFAULT NULL COMMENT 'PV-Leistung im selben Augenblick',
|
||||||
|
`grid_kw` decimal(7,3) DEFAULT NULL COMMENT 'Netz, positiv = Bezug',
|
||||||
|
`quelle` enum('manager','energyflow') NOT NULL DEFAULT 'manager'
|
||||||
|
COMMENT 'energyflow = nachgetragen aus Fuenf-Minuten-Werten, wh_total dann gerechnet',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `datetime` (`datetime`),
|
||||||
|
KEY `wallbox` (`wallbox`, `datetime`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||||
Reference in New Issue
Block a user