Die A-Saeule stoesst im Foto etwa auf halber Laenge der Vordertuer ans Dach. Gezeichnet reichte die Scheibe bis fast ans Tuerende - das Dach begann dadurch einen guten Drittelmeter zu weit hinten und war entsprechend kurz. Jetzt: Scheibenfuss 1,6 m, Dachanfang 2,15 m (y 232 statt 268), Dachende unveraendert 4,0 m. Das Dach ist damit 1,87 m lang statt 1,52 m. Reling und Aussenspiegel sind mitgezogen - die Spiegel sitzen am Fuss der A-Saeule, und der wandert mit ihr. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
- Auf einen Blick
- Wie eine Seite entsteht
- Zwei Wege für Daten
- Die Seitenkarte
- Die JavaScript-Schicht
- Die PHP-Schicht
- Die Datenbanken
- Vier Bausteine im Detail
- Wiederkehrende Bauteile und Konventionen
- Wo fange ich an, wenn ich … ändern will
- 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
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:
"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
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:
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.
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, 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(), 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:
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:
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.
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:
{ "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
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.
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.
4. Die Geräte-Erkennung
restricted/deviceDiscovery/device_discovery.py wird von Hand gestartet.
Die Module suchen je eine Geräteart (Tahoma, WLED, Shelly, MQTT/Home-Assistant-
Discovery, Gartenwasser, SolarManager-Werte, Logic); die Datenbanklogik liegt
allein im Hauptskript.
Zwei Eigenschaften, die man kennen muss:
-
Ein Suchlauf löscht nichts.
clear_tablessteht inconfig.iniauffalse, weil die Automatiken auf die Ids vonactor_stateszeigen. 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.urlsteht ein Feldname der JSON-Antwort. Bei Gen2-Geräten mit eingeschaltetemstatus_ntfsteht dagegen das Topic inurl(Power_EG/status/em:0) und der Schlüssel invalue_path.Das gilt aber nur für Komponenten, die auch wirklich senden — die Liste steht als
MQTT_KOMPONENTENinmodules/shelly_module.pyund enthält heuteemundswitch. 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.
Wiederkehrende Bauteile und Konventionen
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:
# 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.