Einstellungen -> Grundriss: Etagen, Raeume und Kacheln am Plan pflegen
Neuer Reiter mit Plan-Editor: Etagen anlegen, umbenennen, sortieren, loeschen und als Standard markieren; Grundrissbild hochladen; Raeume mit Name, Kuerzel und Thermostat-Zweig anlegen und bearbeiten; Kacheln im Plan ziehen oder einen Raum ohne Kachel mit "Platzieren" und einem Klick setzen. Loest tools/kachelpositionen.php ab - Raster, Geisterkachel und die Umrechnung Mitte -> linke obere Ecke sind von dort uebernommen. - restricted/grundriss.php: alle Pruefungen und Schreibzugriffe. Eine Etage mit Raeumen oder Automatiken laesst sich nicht loeschen, die Ablehnung nennt die Namen. Raeume loeschen laesst ihre Geraete ohne Raum. - Grundrissbilder nach tiles/grundriss/ (beschreibbar, nicht im Git), nur PNG/JPEG/WebP, am Inhalt geprueft; SVG bewusst nicht. Prüfsumme im Dateinamen, alte hochgeladene Bilder werden weggeraeumt. - ajax/settings.php: ?action=grundriss und sieben POST-Aktionen, jede antwortet mit dem ganzen neuen Stand. - "Werte" springt in den Reiter Home-Kacheln und oeffnet die Kachel; Home-Kacheln und Geraete laden nach einer Grundriss-Aenderung nach. - Startseite ohne Etage mit platziertem Raum: Hinweis auf die Einstellungen statt einer leeren Zeichenflaeche. - README: Grundriss als Inhalt statt Code, neue Dateien und Endpunkte, Reihenfolge der Schema-Dateien fuer ein neues Haus. Backend mit einer Probe-Etage durchgespielt (auch Fehlerfaelle, Upload, Aufraeumen); Oberflaeche auf einer Probeseite mit echten Daten und Zeiger-Ereignissen geprueft: Ziehen, Antippen, Platzieren, neuer Raum. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,9 +49,11 @@ smart/
|
|||||||
│ ├── header.php footer.php Rahmen jeder Seite, Menü, Skript-Einbindung
|
│ ├── header.php footer.php Rahmen jeder Seite, Menü, Skript-Einbindung
|
||||||
│ ├── home.php solar.php heat.php history.php
|
│ ├── home.php solar.php heat.php history.php
|
||||||
│ ├── skoda.php weather.php settings.php logs.php ← die acht Seitenvorlagen
|
│ ├── skoda.php weather.php settings.php logs.php ← die acht Seitenvorlagen
|
||||||
│ ├── rooms.php Räume, Etagen, Kachelwerte, Symbole
|
│ ├── rooms.php Etagen und Räume lesen (homeMesh), Kachelvorgabe, Symbole
|
||||||
|
│ ├── grundriss.php Etagen, Räume, Kachelpositionen, Bilder ändern (Reiter Grundriss)
|
||||||
|
│ ├── meshdb.php meshDb(): Verbindung zu homeMesh
|
||||||
│ ├── costs.php Preistabellen + solarDb()
|
│ ├── costs.php Preistabellen + solarDb()
|
||||||
│ ├── automations.php Automatiken, Gerätekatalog + meshDb()
|
│ ├── automations.php Automatiken, Gerätekatalog
|
||||||
│ ├── kacheln.php Anzeigewerte der Home-Kacheln (Modell zur Maske)
|
│ ├── kacheln.php Anzeigewerte der Home-Kacheln (Modell zur Maske)
|
||||||
│ ├── roomControls.php Bedienelemente des Raum-Modals
|
│ ├── roomControls.php Bedienelemente des Raum-Modals
|
||||||
│ ├── commands.php ein Kommando tatsächlich abschicken
|
│ ├── commands.php ein Kommando tatsächlich abschicken
|
||||||
@@ -68,8 +70,8 @@ smart/
|
|||||||
├── js/solar/ der eigene JavaScript-Code
|
├── js/solar/ der eigene JavaScript-Code
|
||||||
├── js/ Fremdbibliotheken, alle lokal (kein CDN)
|
├── js/ Fremdbibliotheken, alle lokal (kein CDN)
|
||||||
├── css/solar.css das eigene Stylesheet (Theme + alle Bauteile)
|
├── css/solar.css das eigene Stylesheet (Theme + alle Bauteile)
|
||||||
├── assets/img/ Grundriss-Renderings OG/EG/UG/AG, Icons
|
├── assets/img/ mitgelieferte Grundriss-Renderings dieses Hauses, Icons
|
||||||
├── tiles/ Kartenkacheln-Zwischenspeicher (nicht im Git)
|
├── tiles/ beschreibbar, nicht im Git: Kartenkacheln, grundriss/ (hochgeladene Grundrisse)
|
||||||
└── *.sql Schema-Dateien zum Nachziehen von Hand
|
└── *.sql Schema-Dateien zum Nachziehen von Hand
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -185,7 +187,7 @@ genau die Topics, die auf den Kacheln stehen (`tileTopics()` in `rooms.php`).
|
|||||||
| `history` | `history.php` | `jahresstatistik.js`, `historyMQTT.js` | `energyHistory` (6 ×), `getStats` | — |
|
| `history` | `history.php` | `jahresstatistik.js`, `historyMQTT.js` | `energyHistory` (6 ×), `getStats` | — |
|
||||||
| `skoda` | `skoda.php` | `skodaMQTT.js` | `skoda.php?was=…`, `skodaCmd.php`, `tile.php` | `solarManager/#` |
|
| `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/#` |
|
| `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=…` | — |
|
| `settings` | `settings.php` | `settings.js`, `grundriss.js` | `settings.php?action=…` | — |
|
||||||
| `logs` | `logs.php` | `logs.js` | `logs.php?action=…` | — |
|
| `logs` | `logs.php` | `logs.js` | `logs.php?action=…` | — |
|
||||||
|
|
||||||
Und dasselbe als Abhängigkeitsbild — von der Seite bis in den Speicher:
|
Und dasselbe als Abhängigkeitsbild — von der Seite bis in den Speicher:
|
||||||
@@ -241,8 +243,9 @@ flowchart TB
|
|||||||
AUTOA --> AUTOM
|
AUTOA --> AUTOM
|
||||||
SETA --> COSTS & KACH & AUTOM
|
SETA --> COSTS & KACH & AUTOM
|
||||||
STATS --> COSTS
|
STATS --> COSTS
|
||||||
|
SETA --> GRM["grundriss.php"]
|
||||||
KACH --> ROOMS
|
KACH --> ROOMS
|
||||||
ROOMS --> COSTS
|
GRM --> ROOMS
|
||||||
LOGA --> LOGD
|
LOGA --> LOGD
|
||||||
|
|
||||||
COSTS --> SOLARLOG
|
COSTS --> SOLARLOG
|
||||||
@@ -291,7 +294,8 @@ Text wird: Topic oder Wechselrichtersumme lesen, ggf. JSON-Pfad
|
|||||||
herausgreifen, Vorzeichen drehen (`negativ`), skalieren (`format:
|
herausgreifen, Vorzeichen drehen (`negativ`), skalieren (`format:
|
||||||
"leistung"` für Watt-Quellen, `"leistung_kw"` für Quellen, die schon
|
"leistung"` für Watt-Quellen, `"leistung_kw"` für Quellen, die schon
|
||||||
Kilowatt melden — die Wallboxen), runden (`stellen`), Einheit anhängen. Klick auf eine Kachel
|
Kilowatt melden — die Wallboxen), runden (`stellen`), Einheit anhängen. Klick auf eine Kachel
|
||||||
öffnet das Raum-Modal über `ajax/room.php?room=EG_Bad`.
|
öffnet das Raum-Modal über `ajax/room.php?room=<Raumnummer>`; Heizsymbol,
|
||||||
|
Regler und Ist-Werte lesen aus dem Thermostat-Zweig des Raums (`mqttZweig()`).
|
||||||
|
|
||||||
**`solarMQTT.js`** — die große Anlagenübersicht. `solarSVG` zeichnet den
|
**`solarMQTT.js`** — die große Anlagenübersicht. `solarSVG` zeichnet den
|
||||||
Energiefluss (Breite der Pfeile aus der Leistung), darunter hängen die drei
|
Energiefluss (Breite der Pfeile aus der Leistung), darunter hängen die drei
|
||||||
@@ -320,6 +324,10 @@ Anfragen dazu, und die Auswahlfelder füllten sich asynchron. Dazu die
|
|||||||
Übersicht: Nachfolger einklappen (`toggleAutomationGroup`) und die Rückfrage
|
Übersicht: Nachfolger einklappen (`toggleAutomationGroup`) und die Rückfrage
|
||||||
vor dem Löschen (`frageNachfolger`).
|
vor dem Löschen (`frageNachfolger`).
|
||||||
|
|
||||||
|
**`grundriss.js`** — der Reiter Grundriss: Etagen, Räume, Grundrissbilder und
|
||||||
|
der Plan-Editor, in dem Kacheln gezogen und gesetzt werden. Eigene Datei, weil
|
||||||
|
`settings.js` schon für vier Reiter reicht; es nutzt `textSchuetzen()` von dort.
|
||||||
|
|
||||||
**`settings.js`** — vier Reiter in einer Datei, deutlich getrennt: Preistabellen,
|
**`settings.js`** — vier Reiter in einer Datei, deutlich getrennt: Preistabellen,
|
||||||
Home-Kacheln, Geräte, Fahrzeug. Jeder Abschnitt hat sein Modell (`kachelModell`,
|
Home-Kacheln, Geräte, Fahrzeug. Jeder Abschnitt hat sein Modell (`kachelModell`,
|
||||||
`geraeteModell`, `skodaModell`), seine `zeichne…()`-Funktion und eine `…Binden()`-Funktion,
|
`geraeteModell`, `skodaModell`), seine `zeichne…()`-Funktion und eine `…Binden()`-Funktion,
|
||||||
@@ -337,9 +345,9 @@ klein: Diagramme füllen bzw. Logzeilen nachladen und einfärben.
|
|||||||
|
|
||||||
| Endpunkt | Aufruf | liefert / tut |
|
| Endpunkt | Aufruf | liefert / tut |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `room.php` | `?room=EG_Bad`, `POST ?action=command\|temp` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden |
|
| `room.php` | `?room=<rooms.id>`, `POST ?action=command\|temp\|jalousie` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden |
|
||||||
| `AutoAction.php` | `?action=editor\|list\|followers\|werte`, `POST save\|delete\|toggle` | Automatik-Editor und -Übersicht |
|
| `AutoAction.php` | `?action=editor\|list\|followers\|werte`, `POST save\|delete\|toggle` | Automatik-Editor und -Übersicht |
|
||||||
| `settings.php` | `?action=list\|kacheln\|geraete\|skoda-keys`, `POST save\|kacheln-save\|geraete-raeume\|geraet-loeschen\|skoda-keys-save` | Einstellungsseite; `skoda-keys` meldet nur, ob ein Schlüssel hinterlegt ist und worauf er endet |
|
| `settings.php` | `?action=list\|kacheln\|geraete\|grundriss\|skoda-keys`, `POST save\|kacheln-save\|geraete-raeume\|geraet-loeschen\|etage-speichern\|etagen-reihenfolge\|etage-loeschen\|etage-bild\|raum-speichern\|raum-position\|raum-loeschen\|skoda-keys-save` | Einstellungsseite; die Grundriss-Aktionen antworten mit dem ganzen neuen Stand, `skoda-keys` meldet nur, ob ein Schlüssel hinterlegt ist und worauf er endet |
|
||||||
| `getStats.php` | `GET` | Jahresstatistik, alle Jahre, mit Metadaten je Kennzahl |
|
| `getStats.php` | `GET` | Jahresstatistik, alle Jahre, mit Metadaten je Kennzahl |
|
||||||
| `energyHistory.php` | `?series=prod\|cons&range=month\|year\|decade` | Verbrauchs-/Erzeugungsverlauf (Rohdaten oder Stundenarchiv) |
|
| `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 |
|
| `getProdData` `getConsData` `getForecastData` `getHeaterData` `getWaterData` | `?FROM=&TO=` | Chart.js-Datensätze für die jeweilige Karte |
|
||||||
@@ -362,9 +370,11 @@ Kennzahlen, `energyHistory.php` wählt die Quelle nach Zeitraum,
|
|||||||
|
|
||||||
| Datei | Zuständig für | Wichtige Funktionen |
|
| Datei | Zuständig für | Wichtige Funktionen |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `rooms.php` | Räume, Etagen, Kachelwerte, Symbole | `allRooms()`, `roomsWithTile()`, `tileTopics()`, `kachelVorgabe()`, `kachelUeberschreibungen()`, `bootstrapIcons()`, `kachelIcon()` |
|
| `rooms.php` | Etagen und Räume lesen (`homeMesh`), Kachelvorgabe, Symbole | `grundriss()`, `allRooms()`, `roomsOnFloor()`, `floorsWithPlan()`, `standardEtage()`, `etageBild()`, `roomsWithTile()`, `tileTopics()`, `kachelVorgabe()`, `bootstrapIcons()`, `kachelIcon()` |
|
||||||
|
| `grundriss.php` | Etagen, Räume, Kachelpositionen und Grundrissbilder ändern | `grundrissStand()`, `etageSpeichern()`, `etagenReihenfolge()`, `etageLoeschen()`, `etageBildSpeichern()`, `raumSpeichern()`, `raumPosition()`, `raumLoeschen()` |
|
||||||
|
| `meshdb.php` | Verbindung zu `homeMesh` | `meshDb()` |
|
||||||
| `costs.php` | Preistabellen, Verbindung zu `solarLog` | `solarDb()`, `preiseLaden()`, `preiseSpeichern()`, `grundpreisImZeitraum()` |
|
| `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()` |
|
| `automations.php` | Automatiken, Gerätekatalog, Raumzuordnung der Geräte | `deviceCatalog()`, `raumListe()`, `loadAutomation()`, `saveAutomation()`, `deleteAutomation()`, `automationFollowers()`, `pruefeKreis()`, `kalendertagChoices()`, `tagVorlagen()`, `stateValues()`, `geraeteListe()`, `geraetLoeschen()`, `saveRooms()` |
|
||||||
| `kacheln.php` | Anzeigewerte der Home-Kacheln (Maskenseite) | `kachelListe()`, `kachelKatalog()`, `kachelWertPruefen()`, `kachelWerteSpeichern()`, `kachelSymbole()` |
|
| `kacheln.php` | Anzeigewerte der Home-Kacheln (Maskenseite) | `kachelListe()`, `kachelKatalog()`, `kachelWertPruefen()`, `kachelWerteSpeichern()`, `kachelSymbole()` |
|
||||||
| `roomControls.php` | Welches Gerät welches Bedienelement bekommt | `bedienform()`, `zeichneBeschattung()`, `zeichneLicht()`, `zeichneMesswerte()` |
|
| `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()` |
|
| `commands.php` | Ein Kommando abschicken (MQTT/WLED/HTTP/Tahoma) | `executeCommand()`, `sendeMqtt()`, `sendeWled()`, `sendeHttp()`, `sendeTahoma()` |
|
||||||
@@ -382,11 +392,13 @@ Datenbank folgen kann, ohne zu springen:
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
HELPER["helper.php"] --> MYSQL["mysql.php<br/><small>Zugangsdaten</small>"]
|
HELPER["helper.php"] --> MYSQL["mysql.php<br/><small>Zugangsdaten</small>"]
|
||||||
ROOMS["rooms.php"] --> COSTS["costs.php"] --> MYSQL
|
COSTS["costs.php"] --> MYSQL
|
||||||
|
MESH["meshdb.php<br/><small>meshDb()</small>"] --> MYSQL
|
||||||
|
ROOMS["rooms.php"] --> MESH
|
||||||
|
GRM["grundriss.php"] --> ROOMS
|
||||||
KACH["kacheln.php"] --> ROOMS
|
KACH["kacheln.php"] --> ROOMS
|
||||||
KACH --> COSTS
|
|
||||||
KACH -. "nur wenn vorhanden" .-> AUTOM["automations.php"]
|
KACH -. "nur wenn vorhanden" .-> AUTOM["automations.php"]
|
||||||
AUTOM --> MYSQL
|
AUTOM --> MESH
|
||||||
AUTOM --> ROOMS
|
AUTOM --> ROOMS
|
||||||
CMD["commands.php"] --> MYSQL
|
CMD["commands.php"] --> MYSQL
|
||||||
CMD --> TAH["tahoma_EG.php"]
|
CMD --> TAH["tahoma_EG.php"]
|
||||||
@@ -441,7 +453,6 @@ erDiagram
|
|||||||
| `skoda_ladepunkte` | Wallbox-Verlauf während einer Ladung, im Minutentakt, wird nicht ausgedünnt (`solarLog_skoda_ladepunkte.sql`) | `gatherSkodaData.py`; ältere Ladungen `skoda_ladepunkte_nachtragen.py` |
|
| `skoda_ladepunkte` | Wallbox-Verlauf während einer Ladung, im Minutentakt, wird nicht ausgedünnt (`solarLog_skoda_ladepunkte.sql`) | `gatherSkodaData.py`; ältere Ladungen `skoda_ladepunkte_nachtragen.py` |
|
||||||
| `weatherStation`, `weatherHours`, `weatherDays`, `daylight`, `simPower` | Wetter, Sonnenzeiten, Ertragsprognose | Wetterbrücke, Open-Meteo, Prognose |
|
| `weatherStation`, `weatherHours`, `weatherDays`, `daylight`, `simPower` | Wetter, Sonnenzeiten, Ertragsprognose | Wetterbrücke, Open-Meteo, Prognose |
|
||||||
| `gridCosts`, `gasCosts`, `fuelCosts` | Preiszeitreihen (Stichtag, kein Enddatum) | Einstellungsseite (`costs.php`) |
|
| `gridCosts`, `gasCosts`, `fuelCosts` | Preiszeitreihen (Stichtag, kein Enddatum) | Einstellungsseite (`costs.php`) |
|
||||||
| `kachelWerte` | Überschreibungen der Home-Kacheln, JSON je Raum | Einstellungsseite (`kacheln.php`) |
|
|
||||||
|
|
||||||
**`Logins`** — `users` (Passkeys) und `addUser` (Einmal-Links).
|
**`Logins`** — `users` (Passkeys) und `addUser` (Einmal-Links).
|
||||||
|
|
||||||
@@ -449,15 +460,50 @@ erDiagram
|
|||||||
|
|
||||||
## Vier Bausteine im Detail
|
## Vier Bausteine im Detail
|
||||||
|
|
||||||
### 1. Die Home-Kacheln
|
### 1. Grundriss und Home-Kacheln
|
||||||
|
|
||||||
Was auf einer Kachel steht, hat drei Stufen: die Vorgabe in `rooms.php`, die
|
Etagen, Räume, Kachelpositionen und Kachelwerte sind **Inhalt, kein Code**.
|
||||||
Überschreibung in `kachelWerte`, und die Anzeige im Browser.
|
Sie liegen in `homeMesh.floors` und `homeMesh.rooms` (`homeMesh_grundriss.sql`)
|
||||||
|
und werden unter **Einstellungen → Grundriss** und **→ Home-Kacheln** gepflegt.
|
||||||
|
Bis September 2026 standen sie als Tabelle in `rooms.php`; für ein anderes
|
||||||
|
Haus hätte man den Quelltext umschreiben müssen.
|
||||||
|
|
||||||
|
| Tabelle | trägt |
|
||||||
|
|---|---|
|
||||||
|
| `floors` | `code` (fest: URL, SVG-Ids, `automations.floor`), `label`, Reihenfolge, Grundrissbild, Standard-Etage |
|
||||||
|
| `rooms` | feste Nummer `id`, Etage, Name, `kuerzel` (SVG-Ids), Kachel `x`/`y` (leer = keine Kachel), `thermostat` (MQTT-Zweig), `werte` (JSON, leer = Vorgabe) |
|
||||||
|
|
||||||
|
Auf die feste Raumnummer zeigen `actors.room_id` und das Raum-Modal
|
||||||
|
(`room.php?room=12`). Ein umbenannter Raum behält damit Geräte, Regler und
|
||||||
|
Kachel. `automations.floor` ist ein Fremdschlüssel auf `floors`: eine Etage mit
|
||||||
|
Automatiken lässt sich nicht löschen.
|
||||||
|
|
||||||
|
**Die Vorgabe einer Kachel** ist allein das Thermostat: mit `thermostat` Soll,
|
||||||
|
Ist und Feuchte aus diesem Zweig, sonst nur der Name. Derselbe Zweig trägt
|
||||||
|
Heizsymbol und Temperaturregler (`mqttZweig()` in `homeMQTT.js`,
|
||||||
|
`<zweig>/changeSetTemp` in `room.php`) — nichts setzt mehr voraus, dass ein
|
||||||
|
Thermostat unter `Raumtemp/<Etage>/<Raum>` meldet.
|
||||||
|
|
||||||
|
**Grundrissbilder** kommen per Upload nach `tiles/grundriss/` — das einzige
|
||||||
|
Verzeichnis, in das der Webserver schreiben darf, und nicht im Git. PNG, JPEG
|
||||||
|
oder WebP, geprüft am Inhalt; SVG bewusst nicht, es könnte Skript enthalten.
|
||||||
|
Der Name trägt einen Teil der Prüfsumme, ein neues Bild hat also eine neue
|
||||||
|
Adresse. Die mitgelieferten unter `assets/img` bleiben daneben gültig.
|
||||||
|
|
||||||
|
**Der Plan-Editor** (`js/solar/grundriss.js`, Modell `restricted/grundriss.php`)
|
||||||
|
löst `tools/kachelpositionen.php` ab: Kacheln werden gezogen, ein Raum ohne
|
||||||
|
Kachel mit „Platzieren“ und einem Klick gesetzt. Übernommen sind Raster,
|
||||||
|
Geisterkachel und die Umrechnung von der gezeigten Mitte zur gespeicherten
|
||||||
|
linken oberen Ecke. Jede Aktion antwortet mit dem ganzen neuen Stand. Nach
|
||||||
|
einer Änderung laden „Home-Kacheln“ und „Geräte“ beim nächsten Öffnen nach.
|
||||||
|
|
||||||
|
Ohne Etage mit platziertem Raum zeigt die Startseite statt der Zeichenfläche
|
||||||
|
den Weg zu den Einstellungen — der Anfang jeder neuen Installation.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
ROOMS["rooms.php<br/><small>$rooms: Vorgabe je Raum</small>"] --> MIT["mitWerten()"]
|
DB[("homeMesh.floors<br/>homeMesh.rooms")] --> GR["grundriss()<br/><small>rooms.php</small>"]
|
||||||
KW[("kachelWerte<br/><small>je Raum, JSON</small>")] --> MIT
|
GR --> MIT["mitWerten()"]
|
||||||
MIT --> RWT["roomsWithTile()"]
|
MIT --> RWT["roomsWithTile()"]
|
||||||
RWT -->|"als JSON in die Seite"| HOMEJS["homeMQTT.js<br/><small>homeRooms</small>"]
|
RWT -->|"als JSON in die Seite"| HOMEJS["homeMQTT.js<br/><small>homeRooms</small>"]
|
||||||
RWT --> SVG["home.php<br/><small>SVG-Kacheln</small>"]
|
RWT --> SVG["home.php<br/><small>SVG-Kacheln</small>"]
|
||||||
@@ -465,8 +511,10 @@ flowchart LR
|
|||||||
BROKER --> HOMEJS
|
BROKER --> HOMEJS
|
||||||
HOMEJS -->|"kachelWert()"| SVG
|
HOMEJS -->|"kachelWert()"| SVG
|
||||||
|
|
||||||
SETJS["settings.js<br/><small>Reiter Kacheln</small>"] --> SETA["ajax/settings.php"]
|
GRJS["grundriss.js<br/><small>Reiter Grundriss</small>"] --> SETA["ajax/settings.php"]
|
||||||
SETA --> KACHM["kacheln.php<br/><small>prüfen, speichern</small>"] --> KW
|
SETJS["settings.js<br/><small>Reiter Kacheln</small>"] --> SETA
|
||||||
|
SETA --> GRM["grundriss.php<br/><small>Etagen, Räume, Bilder</small>"] --> DB
|
||||||
|
SETA --> KACHM["kacheln.php<br/><small>Werte prüfen, speichern</small>"] --> DB
|
||||||
KACHM -->|"Auswahlliste"| KAT["deviceCatalog()<br/><small>automations.php</small>"]
|
KACHM -->|"Auswahlliste"| KAT["deviceCatalog()<br/><small>automations.php</small>"]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -486,8 +534,6 @@ der Name **irgendeines** Bootstrap-Icons — die Codepunkte liest
|
|||||||
`bootstrapIcons()` aus `css/bootstrap-icons.min.css`, gezeichnet wird als
|
`bootstrapIcons()` aus `css/bootstrap-icons.min.css`, gezeichnet wird als
|
||||||
SVG-Text in der Icon-Schrift.
|
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
|
### 2. Das Raum-Modal
|
||||||
|
|
||||||
@@ -499,8 +545,8 @@ sequenceDiagram
|
|||||||
participant C as commands.php
|
participant C as commands.php
|
||||||
participant G as Gerät
|
participant G as Gerät
|
||||||
|
|
||||||
B->>R: GET ?room=EG_Bad
|
B->>R: GET ?room=12 (rooms.id)
|
||||||
R->>RC: Geräte des Raums (actors.room) → Bedienformen
|
R->>RC: Geräte des Raums (actors.room_id) → Bedienformen
|
||||||
RC-->>B: fertiges Markup (Rollladen, Licht, Messwerte …)
|
RC-->>B: fertiges Markup (Rollladen, Licht, Messwerte …)
|
||||||
B->>R: POST ?action=command {command_id, params}
|
B->>R: POST ?action=command {command_id, params}
|
||||||
R->>C: executeCommand()
|
R->>C: executeCommand()
|
||||||
@@ -733,8 +779,8 @@ englisch bleibt, was von außen vorgegeben ist (`floor`, `topic`, Chart.js-
|
|||||||
Optionen, Spaltennamen). Kommentare erklären **warum**, nicht was.
|
Optionen, Spaltennamen). Kommentare erklären **warum**, nicht was.
|
||||||
|
|
||||||
**Fehlertoleranz vor Vollständigkeit.** Was eine Seite nicht zwingend braucht,
|
**Fehlertoleranz vor Vollständigkeit.** Was eine Seite nicht zwingend braucht,
|
||||||
wird in `try/catch` geholt: fehlt die Mesh-Datenbank, bleibt die Startseite
|
wird in `try/catch` geholt: fehlt die Mesh-Datenbank oder der Grundriss, zeigt
|
||||||
sichtbar; fehlt `kachelWerte`, gilt die Vorgabe.
|
die Startseite einen Hinweis statt eines Fatal Errors.
|
||||||
|
|
||||||
**Zugriffsschutz.** `.htaccess` sperrt Punkt-Dateien (allen voran `.git`) und
|
**Zugriffsschutz.** `.htaccess` sperrt Punkt-Dateien (allen voran `.git`) und
|
||||||
alle Dateitypen, die der Browser nie anfordert (`.py`, `.sql`, `.ini`, `.md`
|
alle Dateitypen, die der Browser nie anfordert (`.py`, `.sql`, `.ini`, `.md`
|
||||||
@@ -750,8 +796,9 @@ ausgeliefert.
|
|||||||
|
|
||||||
| Vorhaben | Anlaufstelle |
|
| Vorhaben | Anlaufstelle |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Ein Raum kommt dazu oder heißt anders | `restricted/rooms.php`, Tabelle `$rooms` — Kachel, Menü und MQTT-Abo ergeben sich daraus |
|
| Ein Raum oder eine Etage kommt dazu, heißt anders, bekommt ein neues Grundrissbild | Einstellungen → Grundriss (keine Codeänderung) — Kachel, Menü und MQTT-Abo ergeben sich daraus |
|
||||||
| Andere Werte auf einer Kachel | Einstellungen → Kacheln (keine Codeänderung). Vorgabe für neue Räume: `kachelVorgabe()` in `rooms.php` |
|
| Andere Werte auf einer Kachel | Einstellungen → Home-Kacheln (keine Codeänderung). Vorgabe ohne eigene Werte: `kachelVorgabe()` in `rooms.php` |
|
||||||
|
| Ein anderes Haus einrichten | `homeMesh_DB-layout.sql`, `homeMesh_automations.sql`, `homeMesh_grundriss.sql` in dieser Reihenfolge; dann Einstellungen → Grundriss |
|
||||||
| Neues Symbol auf einer Kachel | Name eines Bootstrap-Icons ins Feld tippen. Vorschlagsliste: `kachelSymbole()` in `kacheln.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 |
|
| 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()` |
|
| Neues Diagramm auf einer Seite | Karte in der Vorlage, `<canvas>`, dazu ein Endpunkt nach dem Muster von `getProdData.php`, geladen mit `getData()` |
|
||||||
|
|||||||
+61
-3
@@ -8,15 +8,25 @@
|
|||||||
* POST ?action=kacheln-save {"floor": "OG", "room": "bad",
|
* POST ?action=kacheln-save {"floor": "OG", "room": "bad",
|
||||||
* "werte": [ ... ] oder null}
|
* "werte": [ ... ] oder null}
|
||||||
* GET ?action=geraete Geraete mit Raum, Verwendung und Alter
|
* GET ?action=geraete Geraete mit Raum, Verwendung und Alter
|
||||||
* POST ?action=geraete-raeume {"rooms": {"12": "OG/Bad", ...}}
|
* POST ?action=geraete-raeume {"rooms": {"123": "11", ...}} Geraet -> Raumnummer
|
||||||
* POST ?action=geraet-loeschen {"id": 12}
|
* POST ?action=geraet-loeschen {"id": 12}
|
||||||
* GET ?action=skoda-keys Stand der Fahrzeug-Schluessel (ohne Werte!)
|
* GET ?action=skoda-keys Stand der Fahrzeug-Schluessel (ohne Werte!)
|
||||||
* POST ?action=skoda-keys-save {"felder": {"API_KEY2": "..."},
|
* POST ?action=skoda-keys-save {"felder": {"API_KEY2": "..."},
|
||||||
* "loeschen": ["API_KEY3"]}
|
* "loeschen": ["API_KEY3"]}
|
||||||
*
|
*
|
||||||
|
* GET ?action=grundriss Etagen, Raeume, Kachelpositionen
|
||||||
|
* POST ?action=etage-speichern {"code": "EG", "label": "...", "standard": true, "neu": false}
|
||||||
|
* POST ?action=etagen-reihenfolge {"codes": ["OG", "EG", ...]}
|
||||||
|
* POST ?action=etage-loeschen {"code": "EG"}
|
||||||
|
* POST ?action=etage-bild multipart: code, bild (Datei)
|
||||||
|
* POST ?action=raum-speichern {"nr": 0|12, "floor": "EG", "name": "...", "kuerzel": "", "thermostat": ""}
|
||||||
|
* POST ?action=raum-position {"nr": 12, "x": 120, "y": 80} (x/y null = vom Plan)
|
||||||
|
* POST ?action=raum-loeschen {"nr": 12}
|
||||||
|
* Die Grundriss-Aktionen antworten mit dem ganzen neuen Stand.
|
||||||
|
*
|
||||||
* Ein reiner Verteiler: geprueft und geschrieben wird in
|
* Ein reiner Verteiler: geprueft und geschrieben wird in
|
||||||
* restricted/costs.php bzw. restricted/kacheln.php, damit Maske und Endpunkt
|
* restricted/costs.php, kacheln.php bzw. grundriss.php, damit Maske und
|
||||||
* dieselbe Wahrheit ueber einen Zeitraum oder eine Kachel haben.
|
* Endpunkt dieselbe Wahrheit ueber einen Zeitraum oder eine Kachel haben.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once("../helper.php");
|
require_once("../helper.php");
|
||||||
@@ -24,6 +34,7 @@ require_once("../restricted/costs.php");
|
|||||||
require_once("../restricted/kacheln.php");
|
require_once("../restricted/kacheln.php");
|
||||||
require_once("../restricted/automations.php");
|
require_once("../restricted/automations.php");
|
||||||
require_once("../restricted/skodaKeys.php");
|
require_once("../restricted/skodaKeys.php");
|
||||||
|
require_once("../restricted/grundriss.php");
|
||||||
|
|
||||||
if (!checkLogin()) {
|
if (!checkLogin()) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
@@ -40,6 +51,20 @@ function jsonAntwort($data, $code = 200)
|
|||||||
|
|
||||||
$action = $_GET["action"] ?? "list";
|
$action = $_GET["action"] ?? "list";
|
||||||
|
|
||||||
|
// Das Grundrissbild kommt als Formular mit Datei, nicht als JSON - deshalb
|
||||||
|
// vor dem JSON-Teil.
|
||||||
|
if ($_SERVER["REQUEST_METHOD"] === "POST" && $action === "etage-bild") {
|
||||||
|
try {
|
||||||
|
$bild = etageBildSpeichern($_POST["code"] ?? "", $_FILES["bild"] ?? null);
|
||||||
|
grundriss(true);
|
||||||
|
jsonAntwort(["bild" => $bild, "stand" => grundrissStand()]);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
jsonAntwort(["error" => $e->getMessage()], 400);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
jsonAntwort(["error" => $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||||
$body = json_decode(file_get_contents("php://input"), true);
|
$body = json_decode(file_get_contents("php://input"), true);
|
||||||
if (!is_array($body)) {
|
if (!is_array($body)) {
|
||||||
@@ -90,6 +115,31 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
|||||||
$name = geraetLoeschen($body["id"] ?? 0);
|
$name = geraetLoeschen($body["id"] ?? 0);
|
||||||
jsonAntwort(["geloescht" => $name, "geraete" => geraeteListe()]);
|
jsonAntwort(["geloescht" => $name, "geraete" => geraeteListe()]);
|
||||||
|
|
||||||
|
case "etage-speichern":
|
||||||
|
etageSpeichern($body);
|
||||||
|
grundriss(true);
|
||||||
|
jsonAntwort(["stand" => grundrissStand()]);
|
||||||
|
case "etagen-reihenfolge":
|
||||||
|
etagenReihenfolge($body["codes"] ?? null);
|
||||||
|
grundriss(true);
|
||||||
|
jsonAntwort(["stand" => grundrissStand()]);
|
||||||
|
case "etage-loeschen":
|
||||||
|
etageLoeschen($body["code"] ?? "");
|
||||||
|
grundriss(true);
|
||||||
|
jsonAntwort(["stand" => grundrissStand()]);
|
||||||
|
case "raum-speichern":
|
||||||
|
$nr = raumSpeichern($body);
|
||||||
|
grundriss(true);
|
||||||
|
jsonAntwort(["nr" => $nr, "stand" => grundrissStand()]);
|
||||||
|
case "raum-position":
|
||||||
|
$pos = raumPosition($body["nr"] ?? 0, $body["x"] ?? null, $body["y"] ?? null);
|
||||||
|
grundriss(true);
|
||||||
|
jsonAntwort(["position" => $pos, "stand" => grundrissStand()]);
|
||||||
|
case "raum-loeschen":
|
||||||
|
raumLoeschen($body["nr"] ?? 0);
|
||||||
|
grundriss(true);
|
||||||
|
jsonAntwort(["stand" => grundrissStand()]);
|
||||||
|
|
||||||
case "skoda-keys-save":
|
case "skoda-keys-save":
|
||||||
/*
|
/*
|
||||||
* Neue Schluessel eintragen. Zurueck kommt der neue Stand -
|
* Neue Schluessel eintragen. Zurueck kommt der neue Stand -
|
||||||
@@ -130,6 +180,14 @@ if ($action === "kacheln") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($action === "grundriss") {
|
||||||
|
try {
|
||||||
|
jsonAntwort(grundrissStand());
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
jsonAntwort(["error" => "Grundriss nicht abrufbar: " . $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($action === "skoda-keys") {
|
if ($action === "skoda-keys") {
|
||||||
try {
|
try {
|
||||||
jsonAntwort(skodaStand());
|
jsonAntwort(skodaStand());
|
||||||
|
|||||||
+133
@@ -2606,3 +2606,136 @@ input[type='range']::-webkit-slider-runnable-track {
|
|||||||
|
|
||||||
.floorBtn.active circle { stroke: #00788F; opacity: 1; }
|
.floorBtn.active circle { stroke: #00788F; opacity: 1; }
|
||||||
.floorBtn.active text { fill: #e8ede9; opacity: 1; }
|
.floorBtn.active text { fill: #e8ede9; opacity: 1; }
|
||||||
|
|
||||||
|
/* ===========================================================================
|
||||||
|
Einstellungen -> Grundriss (js/solar/grundriss.js)
|
||||||
|
|
||||||
|
Links der Plan, rechts die Formulare. Die Kachel im Plan sieht aus wie auf
|
||||||
|
der Startseite (restricted/home.php): dunkles Rechteck, weisse Schrift -
|
||||||
|
nur ohne Messwerte, dafuer mit Rand, wenn sie gewaehlt ist.
|
||||||
|
=========================================================================== */
|
||||||
|
.gr-etagen {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .4rem;
|
||||||
|
margin-bottom: .9rem;
|
||||||
|
}
|
||||||
|
/* Eine Klasse mehr als Bootstrap: solar.css laedt vor AdminLTE. */
|
||||||
|
.gr-etagen .btn.gr-etage {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: .4rem;
|
||||||
|
border: 1px solid var(--ton-linie-satt, rgba(138, 148, 158, .38));
|
||||||
|
color: var(--ton-schrift-2, #9aa6ad);
|
||||||
|
}
|
||||||
|
.gr-etagen .btn.gr-etage.aktiv {
|
||||||
|
border-color: #00788F;
|
||||||
|
color: var(--ton-schrift, #e8ede9);
|
||||||
|
background: rgba(0, 120, 143, .18);
|
||||||
|
}
|
||||||
|
.gr-etage-code { font: 600 .9rem var(--font-anzeige); }
|
||||||
|
.gr-etage-name { font-size: .75rem; }
|
||||||
|
.gr-etage-std { font-size: .65rem; color: var(--ton-sonne, #f0b429); }
|
||||||
|
|
||||||
|
.gr-plan-huelle {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--ton-radius-klein, .5rem);
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0b0d10;
|
||||||
|
}
|
||||||
|
.gr-plan-huelle.platzieren .gr-plan { cursor: crosshair; }
|
||||||
|
.gr-plan-leer {
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ton-schrift-3, #6f7a81);
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
.gr-plan {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.gr-ohne-bild {
|
||||||
|
fill: var(--ton-schrift-3, #6f7a81);
|
||||||
|
font: 12px sans-serif;
|
||||||
|
text-anchor: middle;
|
||||||
|
}
|
||||||
|
.gr-kachel { cursor: grab; }
|
||||||
|
.gr-kachel.zieht { cursor: grabbing; }
|
||||||
|
.gr-kachel rect {
|
||||||
|
fill: #0006;
|
||||||
|
stroke: #ffffff55;
|
||||||
|
stroke-width: .5;
|
||||||
|
}
|
||||||
|
.gr-kachel:hover rect { stroke: #ffffffaa; }
|
||||||
|
.gr-kachel.gewaehlt rect { stroke: var(--ton-sonne, #f0b429); stroke-width: 1; }
|
||||||
|
.gr-kachel text {
|
||||||
|
fill: #fff;
|
||||||
|
font: 4.8px sans-serif;
|
||||||
|
text-anchor: middle;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.gr-geist rect {
|
||||||
|
fill: rgba(240, 180, 41, .28);
|
||||||
|
stroke: var(--ton-sonne, #f0b429);
|
||||||
|
stroke-width: .8;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gr-block {
|
||||||
|
border: 1px solid var(--ton-linie, rgba(138, 148, 158, .2));
|
||||||
|
border-radius: var(--ton-radius-klein, .5rem);
|
||||||
|
padding: .6rem .75rem;
|
||||||
|
margin-bottom: .6rem;
|
||||||
|
}
|
||||||
|
.gr-block-titel {
|
||||||
|
font-size: .7rem;
|
||||||
|
letter-spacing: .13em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ton-schrift-3, #6f7a81);
|
||||||
|
margin-bottom: .35rem;
|
||||||
|
}
|
||||||
|
.gr-knoepfe {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: .35rem;
|
||||||
|
margin-top: .6rem;
|
||||||
|
}
|
||||||
|
/* Papierkorb wie in der Geraetetafel: erst rot, wenn man ihn anfasst. */
|
||||||
|
.gr-knoepfe .btn.gr-etage-weg,
|
||||||
|
.gr-knoepfe .btn.gr-raum-weg {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--ton-schrift-3, #6f7a81);
|
||||||
|
}
|
||||||
|
.gr-knoepfe .btn.gr-etage-weg:hover,
|
||||||
|
.gr-knoepfe .btn.gr-raum-weg:hover { color: var(--ton-schlecht, #e06060); }
|
||||||
|
|
||||||
|
.gr-raeume { display: flex; flex-direction: column; gap: .15rem; }
|
||||||
|
.gr-raum {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
padding: .1rem .3rem;
|
||||||
|
border-radius: .35rem;
|
||||||
|
}
|
||||||
|
.gr-raum.gewaehlt { background: rgba(240, 180, 41, .12); }
|
||||||
|
.gr-raum-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: .2rem 0;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-size: .88rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.gr-raum-stand { color: var(--ton-schrift-3, #6f7a81); font-size: .8rem; }
|
||||||
|
.gr-raum .btn.gr-platzieren { padding: 0 .45rem; font-size: .75rem; }
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ if (isset($_GET["addUser"])) {
|
|||||||
"weather" => ["template" => "weather.php", "mqtt" => true, "charts" => false, "meteogram" => true, "leaflet" => false,
|
"weather" => ["template" => "weather.php", "mqtt" => true, "charts" => false, "meteogram" => true, "leaflet" => false,
|
||||||
"scripts" => ["js/solar/weatherMQTT.js"]],
|
"scripts" => ["js/solar/weatherMQTT.js"]],
|
||||||
"settings" => ["template" => "settings.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false,
|
"settings" => ["template" => "settings.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false,
|
||||||
"scripts" => ["js/solar/settings.js"]],
|
"scripts" => ["js/solar/settings.js", "js/solar/grundriss.js"]],
|
||||||
"logs" => ["template" => "logs.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false,
|
"logs" => ["template" => "logs.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false,
|
||||||
"scripts" => ["js/solar/logs.js"]],
|
"scripts" => ["js/solar/logs.js"]],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,534 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
Reiter "Grundriss": Etagen, Räume und wo ihre Kacheln sitzen.
|
||||||
|
|
||||||
|
Vorher stand das alles in restricted/rooms.php, und eine Kachel setzte man,
|
||||||
|
indem man in tools/kachelpositionen.php die Stelle ablas und die Zahlen in
|
||||||
|
die Datei schrieb. Hier geschieht beides am Plan selbst: Kacheln werden
|
||||||
|
gezogen, ein Raum ohne Kachel mit "Platzieren" und einem Klick gesetzt.
|
||||||
|
Von dort übernommen sind Raster, Geisterkachel und die Umrechnung: gezeigt
|
||||||
|
wird auf die Mitte, gespeichert die linke obere Ecke.
|
||||||
|
|
||||||
|
Geprüft wird nichts, was der Server nicht auch prüft
|
||||||
|
(restricted/grundriss.php). Jede Aktion antwortet mit dem ganzen neuen
|
||||||
|
Stand; die Maske zeichnet ihn einfach neu.
|
||||||
|
|
||||||
|
Nutzt textSchuetzen() aus settings.js - beide Dateien stehen auf derselben
|
||||||
|
Seite, settings.js zuerst.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
let grundrissModell = null; // Antwort von ?action=grundriss
|
||||||
|
const grundrissZustand = {
|
||||||
|
etage: null, // gewählte Etage (code) oder "" für "neue Etage"
|
||||||
|
raum: null, // gewählter Raum (nr), 0 = neuer Raum, null = keiner
|
||||||
|
platzieren: null, // nr des Raums, der gerade auf den Plan gesetzt wird
|
||||||
|
ziehen: null, // { nr, dx, dy, x, y, bewegt } während eines Zugs
|
||||||
|
};
|
||||||
|
|
||||||
|
/* --- Ableitungen --------------------------------------------------------- */
|
||||||
|
|
||||||
|
function grEtage(code) {
|
||||||
|
return (grundrissModell.etagen || []).find(e => e.code === code) || null;
|
||||||
|
}
|
||||||
|
function grRaum(nr) {
|
||||||
|
return (grundrissModell.raeume || []).find(r => r.nr === nr) || null;
|
||||||
|
}
|
||||||
|
function grRaeumeDerEtage(code) {
|
||||||
|
return (grundrissModell.raeume || []).filter(r => r.floor === code);
|
||||||
|
}
|
||||||
|
function grMass() {
|
||||||
|
return grundrissModell.mass;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lange Namen brechen am letzten Leerzeichen um - wie auf der Startseite
|
||||||
|
* (restricted/home.php), damit die Kachel hier genauso breit wirkt wie dort.
|
||||||
|
*/
|
||||||
|
function grNamenszeilen(name) {
|
||||||
|
const luecke = name.lastIndexOf(" ");
|
||||||
|
return name.length > 8 && luecke > 0 ? [name.slice(0, luecke), name.slice(luecke + 1)] : [name];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigerposition in Koordinaten der Zeichenfläche. */
|
||||||
|
function grPunkt(svg, ereignis) {
|
||||||
|
const p = svg.createSVGPoint();
|
||||||
|
p.x = ereignis.clientX;
|
||||||
|
p.y = ereignis.clientY;
|
||||||
|
return p.matrixTransform(svg.getScreenCTM().inverse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linke obere Ecke auf die Zeichenfläche begrenzen - wie der Server. */
|
||||||
|
function grBegrenzt(x, y) {
|
||||||
|
const m = grMass();
|
||||||
|
return {
|
||||||
|
x: Math.round(Math.max(0, Math.min(m.planBreit - m.kachelBreit, x))),
|
||||||
|
y: Math.round(Math.max(0, Math.min(m.planHoch - m.kachelHoch, y))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Zeichnen ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function zeichneGrundriss() {
|
||||||
|
const ziel = document.getElementById("grundrissFlaeche");
|
||||||
|
if (!ziel || !grundrissModell) return;
|
||||||
|
|
||||||
|
const etagen = grundrissModell.etagen || [];
|
||||||
|
if (grundrissZustand.etage === null || (grundrissZustand.etage !== "" && !grEtage(grundrissZustand.etage))) {
|
||||||
|
grundrissZustand.etage = etagen.length ? (grundrissModell.standard || etagen[0].code) : "";
|
||||||
|
}
|
||||||
|
const code = grundrissZustand.etage;
|
||||||
|
const etage = grEtage(code);
|
||||||
|
|
||||||
|
let html = '<div class="gr-etagen">';
|
||||||
|
etagen.forEach(e => {
|
||||||
|
html += '<button type="button" class="btn btn-sm gr-etage' + (e.code === code ? " aktiv" : "") + '"'
|
||||||
|
+ ' data-code="' + textSchuetzen(e.code) + '" title="' + textSchuetzen(e.label) + '">'
|
||||||
|
+ '<span class="gr-etage-code">' + textSchuetzen(e.code) + "</span>"
|
||||||
|
+ '<span class="gr-etage-name">' + textSchuetzen(e.label) + "</span>"
|
||||||
|
+ (e.standard ? '<i class="bi bi-star-fill gr-etage-std" title="Standard-Etage"></i>' : "")
|
||||||
|
+ "</button>";
|
||||||
|
});
|
||||||
|
html += '<button type="button" class="btn btn-sm btn-outline-secondary gr-etage-neu' + (code === "" ? " active" : "") + '">'
|
||||||
|
+ '<i class="bi bi-plus-lg me-1"></i>Etage</button></div>';
|
||||||
|
|
||||||
|
if (!etagen.length && code === "") {
|
||||||
|
html += '<p class="small text-body-secondary mb-3">Noch keine Etage angelegt. Eine Etage '
|
||||||
|
+ "braucht ein Kürzel (so steht sie im Menü und in den Etagenschaltern), einen Namen "
|
||||||
|
+ "und später ein Grundrissbild.</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div class="row g-3">'
|
||||||
|
+ '<div class="col-lg-8">' + (etage ? grPlan(etage) : grPlanLeer()) + "</div>"
|
||||||
|
+ '<div class="col-lg-4">' + grEtageFormular(etage)
|
||||||
|
+ (etage ? grRaumListe(etage) + grRaumFormular(etage) : "")
|
||||||
|
+ "</div></div>"
|
||||||
|
+ '<div class="gr-stand small mt-2"></div>';
|
||||||
|
|
||||||
|
ziel.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function grPlanLeer() {
|
||||||
|
return '<div class="gr-plan-huelle gr-plan-leer"><span>Erst die Etage speichern, dann '
|
||||||
|
+ "lässt sich ihr Grundriss hochladen und bestücken.</span></div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Plan einer Etage: Bild, Raster, Kacheln, Geisterkachel. */
|
||||||
|
function grPlan(etage) {
|
||||||
|
const m = grMass();
|
||||||
|
const raeume = grRaeumeDerEtage(etage.code).filter(r => r.x !== null);
|
||||||
|
let svg = '<div class="gr-plan-huelle' + (grundrissZustand.platzieren ? " platzieren" : "") + '">'
|
||||||
|
+ '<svg class="gr-plan" viewBox="0 0 ' + m.planBreit + " " + m.planHoch + '">'
|
||||||
|
+ '<defs><pattern id="grRaster" width="10" height="10" patternUnits="userSpaceOnUse">'
|
||||||
|
+ '<path d="M10 0H0V10" fill="none" stroke="#ffffff1c" stroke-width=".4"/></pattern></defs>';
|
||||||
|
if (etage.bild) {
|
||||||
|
// Dieselbe Einpassung wie auf der Startseite: das Bild füllt die
|
||||||
|
// Zeichenfläche, ohne verzerrt zu werden (preserveAspectRatio "meet").
|
||||||
|
svg += '<image href="' + textSchuetzen(etage.bild) + '" x="0" y="0" width="' + m.planBreit
|
||||||
|
+ '" height="' + m.planHoch + '"/>';
|
||||||
|
} else {
|
||||||
|
svg += '<text x="' + m.planBreit / 2 + '" y="' + m.planHoch / 2 + '" class="gr-ohne-bild">'
|
||||||
|
+ "Noch kein Grundrissbild</text>";
|
||||||
|
}
|
||||||
|
svg += '<rect width="' + m.planBreit + '" height="' + m.planHoch + '" fill="url(#grRaster)" pointer-events="none"/>';
|
||||||
|
|
||||||
|
raeume.forEach(r => {
|
||||||
|
const zeilen = grNamenszeilen(r.name);
|
||||||
|
const ys = zeilen.length === 2 ? [9, 15.5] : [11.5];
|
||||||
|
svg += '<g class="gr-kachel' + (r.nr === grundrissZustand.raum ? " gewaehlt" : "") + '"'
|
||||||
|
+ ' data-nr="' + r.nr + '" transform="translate(' + r.x + "," + r.y + ')">'
|
||||||
|
+ '<rect width="' + m.kachelBreit + '" height="' + m.kachelHoch + '" rx="5"/>'
|
||||||
|
+ zeilen.map((z, i) => '<text x="' + m.kachelBreit / 2 + '" y="' + ys[i] + '">'
|
||||||
|
+ textSchuetzen(z) + "</text>").join("")
|
||||||
|
+ "</g>";
|
||||||
|
});
|
||||||
|
|
||||||
|
svg += '<g class="gr-geist" style="display:none"><rect width="' + m.kachelBreit + '" height="'
|
||||||
|
+ m.kachelHoch + '" rx="5"/></g></svg></div>';
|
||||||
|
|
||||||
|
const hinweis = grundrissZustand.platzieren
|
||||||
|
? "Auf die Stelle klicken, an der die Mitte der Kachel „"
|
||||||
|
+ textSchuetzen((grRaum(grundrissZustand.platzieren) || {}).name || "") + "“ liegen soll. Esc bricht ab."
|
||||||
|
: "Kacheln lassen sich ziehen; ein Klick wählt den Raum. Die Zeichenfläche ist "
|
||||||
|
+ m.planBreit + " × " + m.planHoch + ", eine Kachel " + m.kachelBreit + " × " + m.kachelHoch + ".";
|
||||||
|
return svg + '<p class="small text-body-secondary mt-1 mb-0 gr-plan-hinweis">' + hinweis + "</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formular der gewählten Etage - oder einer neuen. */
|
||||||
|
function grEtageFormular(etage) {
|
||||||
|
const neu = !etage;
|
||||||
|
const etagen = grundrissModell.etagen || [];
|
||||||
|
const i = neu ? -1 : etagen.findIndex(e => e.code === etage.code);
|
||||||
|
let html = '<div class="gr-block" data-block="etage">'
|
||||||
|
+ '<div class="gr-block-titel">' + (neu ? "Neue Etage" : "Etage") + "</div>"
|
||||||
|
+ '<div class="row g-2">'
|
||||||
|
+ '<div class="col-4"><label class="form-label small mb-0">Kürzel</label>'
|
||||||
|
+ '<input type="text" class="form-control form-control-sm" data-feld="code" maxlength="4"'
|
||||||
|
+ ' value="' + (neu ? "" : textSchuetzen(etage.code)) + '"' + (neu ? ' placeholder="EG"' : " readonly") + ' autocomplete="off"></div>'
|
||||||
|
+ '<div class="col-8"><label class="form-label small mb-0">Name</label>'
|
||||||
|
+ '<input type="text" class="form-control form-control-sm" data-feld="label" maxlength="40"'
|
||||||
|
+ ' value="' + (neu ? "" : textSchuetzen(etage.label)) + '" placeholder="Erdgeschoss"></div>'
|
||||||
|
+ "</div>"
|
||||||
|
+ '<div class="form-check mt-2"><input class="form-check-input" type="checkbox" id="grStandard" data-feld="standard"'
|
||||||
|
+ (!neu && etage.standard ? " checked" : "") + '>'
|
||||||
|
+ '<label class="form-check-label small" for="grStandard">Öffnet, wenn keine Etage gewählt ist</label></div>'
|
||||||
|
+ '<div class="gr-knoepfe">'
|
||||||
|
+ '<button type="button" class="btn btn-primary btn-sm gr-etage-speichern">Speichern</button>';
|
||||||
|
if (!neu) {
|
||||||
|
html += '<button type="button" class="btn btn-outline-secondary btn-sm gr-etage-links" title="Nach vorn"'
|
||||||
|
+ (i <= 0 ? " disabled" : "") + '><i class="bi bi-arrow-left"></i></button>'
|
||||||
|
+ '<button type="button" class="btn btn-outline-secondary btn-sm gr-etage-rechts" title="Nach hinten"'
|
||||||
|
+ (i >= etagen.length - 1 ? " disabled" : "") + '><i class="bi bi-arrow-right"></i></button>'
|
||||||
|
+ '<label class="btn btn-outline-secondary btn-sm mb-0" title="PNG, JPEG oder WebP">'
|
||||||
|
+ '<i class="bi bi-image me-1"></i>' + (etage.bild ? "Bild tauschen" : "Bild hochladen")
|
||||||
|
+ '<input type="file" class="d-none gr-bild" accept="image/png,image/jpeg,image/webp"></label>'
|
||||||
|
+ '<button type="button" class="btn btn-sm gr-etage-weg" title="Etage löschen"><i class="bi bi-trash3"></i></button>';
|
||||||
|
}
|
||||||
|
html += "</div>";
|
||||||
|
if (!neu && etage.automatiken.length) {
|
||||||
|
html += '<p class="small text-body-secondary mb-0 mt-1">' + etage.automatiken.length
|
||||||
|
+ " Automatik(en) stehen unter dieser Etage.</p>";
|
||||||
|
}
|
||||||
|
return html + "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Räume der Etage, mit und ohne Kachel. */
|
||||||
|
function grRaumListe(etage) {
|
||||||
|
const raeume = grRaeumeDerEtage(etage.code);
|
||||||
|
let html = '<div class="gr-block" data-block="raeume"><div class="gr-block-titel">Räume</div>';
|
||||||
|
if (!raeume.length) {
|
||||||
|
html += '<p class="small text-body-secondary mb-2">Noch keine Räume auf dieser Etage.</p>';
|
||||||
|
}
|
||||||
|
html += '<div class="gr-raeume">';
|
||||||
|
raeume.forEach(r => {
|
||||||
|
const aktiv = r.nr === grundrissZustand.raum;
|
||||||
|
html += '<div class="gr-raum' + (aktiv ? " gewaehlt" : "") + '" data-nr="' + r.nr + '">'
|
||||||
|
+ '<button type="button" class="gr-raum-name">' + textSchuetzen(r.name)
|
||||||
|
+ (r.thermostat ? ' <i class="bi bi-thermometer-half text-body-secondary" title="' + textSchuetzen(r.thermostat) + '"></i>' : "")
|
||||||
|
+ "</button>"
|
||||||
|
+ (r.x !== null
|
||||||
|
? '<span class="gr-raum-stand" title="Kachel bei ' + r.x + " / " + r.y + '"><i class="bi bi-geo-alt-fill"></i></span>'
|
||||||
|
: '<button type="button" class="btn btn-outline-secondary btn-sm gr-platzieren'
|
||||||
|
+ (grundrissZustand.platzieren === r.nr ? " active" : "") + '">Platzieren</button>')
|
||||||
|
+ "</div>";
|
||||||
|
});
|
||||||
|
html += '</div><button type="button" class="btn btn-sm btn-outline-secondary mt-2 gr-raum-neu'
|
||||||
|
+ (grundrissZustand.raum === 0 ? " active" : "") + '"><i class="bi bi-plus-lg me-1"></i>Raum</button></div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formular des gewählten Raums, sofern einer gewählt ist. */
|
||||||
|
function grRaumFormular(etage) {
|
||||||
|
const nr = grundrissZustand.raum;
|
||||||
|
if (nr === null) return "";
|
||||||
|
const r = nr === 0 ? { nr: 0, floor: etage.code, name: "", kuerzel: "", thermostat: "", x: null, geraete: 0 } : grRaum(nr);
|
||||||
|
if (!r) return "";
|
||||||
|
|
||||||
|
const etagenAuswahl = (grundrissModell.etagen || []).map(e =>
|
||||||
|
'<option value="' + textSchuetzen(e.code) + '"' + (e.code === r.floor ? " selected" : "") + ">"
|
||||||
|
+ textSchuetzen(e.code + " · " + e.label) + "</option>").join("");
|
||||||
|
const zweige = '<datalist id="grZweige">' + (grundrissModell.zweige || []).map(z =>
|
||||||
|
'<option value="' + textSchuetzen(z) + '">').join("") + "</datalist>";
|
||||||
|
|
||||||
|
let html = '<div class="gr-block" data-block="raum">'
|
||||||
|
+ '<div class="gr-block-titel">' + (nr === 0 ? "Neuer Raum" : "Raum") + "</div>"
|
||||||
|
+ '<label class="form-label small mb-0">Name</label>'
|
||||||
|
+ '<input type="text" class="form-control form-control-sm" data-feld="name" maxlength="40" value="'
|
||||||
|
+ textSchuetzen(r.name) + '" placeholder="Wohnzimmer">'
|
||||||
|
+ '<div class="row g-2 mt-0">'
|
||||||
|
+ '<div class="col-6"><label class="form-label small mb-0">Etage</label>'
|
||||||
|
+ '<select class="form-select form-select-sm" data-feld="floor">' + etagenAuswahl + "</select></div>"
|
||||||
|
+ '<div class="col-6"><label class="form-label small mb-0" title="Teil der Element-Ids auf der Startseite. Leer lassen: entsteht aus dem Namen.">Kürzel</label>'
|
||||||
|
+ '<input type="text" class="form-control form-control-sm" data-feld="kuerzel" maxlength="40" value="'
|
||||||
|
+ textSchuetzen(r.kuerzel) + '" placeholder="aus dem Namen" autocomplete="off"></div></div>'
|
||||||
|
+ '<label class="form-label small mb-0 mt-2">Thermostat-Zweig</label>'
|
||||||
|
+ '<input type="text" class="form-control form-control-sm" data-feld="thermostat" list="grZweige" value="'
|
||||||
|
+ textSchuetzen(r.thermostat || "") + '" placeholder="leer = kein Thermostat" autocomplete="off">' + zweige
|
||||||
|
+ '<div class="form-text small">Unter diesem MQTT-Zweig meldet das Thermostat, z. B. Raumtemp/EG/Bad. '
|
||||||
|
+ "Er trägt Heizsymbol, Temperaturregler und die Vorgabewerte der Kachel.</div>"
|
||||||
|
+ '<div class="gr-knoepfe">'
|
||||||
|
+ '<button type="button" class="btn btn-primary btn-sm gr-raum-speichern">Speichern</button>';
|
||||||
|
if (nr !== 0) {
|
||||||
|
if (r.x !== null) {
|
||||||
|
html += '<button type="button" class="btn btn-outline-secondary btn-sm gr-kachelwerte" title="Werte dieser Kachel bearbeiten">'
|
||||||
|
+ '<i class="bi bi-grid-1x2 me-1"></i>Werte</button>'
|
||||||
|
+ '<button type="button" class="btn btn-outline-secondary btn-sm gr-vom-plan">Vom Plan</button>';
|
||||||
|
}
|
||||||
|
html += '<button type="button" class="btn btn-sm gr-raum-weg" title="Raum löschen"><i class="bi bi-trash3"></i></button>';
|
||||||
|
}
|
||||||
|
html += "</div>";
|
||||||
|
if (nr !== 0 && r.geraete) {
|
||||||
|
html += '<p class="small text-body-secondary mb-0 mt-1">' + r.geraete + " Gerät(e) zugeordnet.</p>";
|
||||||
|
}
|
||||||
|
return html + "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Sprechen mit dem Endpunkt ------------------------------------------- */
|
||||||
|
|
||||||
|
function grStand(text, klasse) {
|
||||||
|
const ziel = document.querySelector("#grundrissFlaeche .gr-stand");
|
||||||
|
if (!ziel) return;
|
||||||
|
ziel.className = "gr-stand small mt-2 " + (klasse || "text-body-secondary");
|
||||||
|
ziel.textContent = text;
|
||||||
|
clearTimeout(grStand._uhr);
|
||||||
|
if (klasse !== "text-danger") {
|
||||||
|
grStand._uhr = setTimeout(() => { if (ziel.textContent === text) ziel.textContent = ""; }, 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Aktion schicken, den neuen Stand übernehmen, neu zeichnen.
|
||||||
|
*
|
||||||
|
* Danach wissen die anderen Reiter nicht mehr, was gilt: neue Räume fehlen in
|
||||||
|
* der Kachelliste und in der Raumauswahl der Geräte. Sie laden beim nächsten
|
||||||
|
* Öffnen nach (grundrissGeaendert, siehe settings.js).
|
||||||
|
*/
|
||||||
|
async function grSenden(action, koerper, meldung) {
|
||||||
|
try {
|
||||||
|
const optionen = koerper instanceof FormData
|
||||||
|
? { method: "POST", body: koerper }
|
||||||
|
: { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(koerper) };
|
||||||
|
const antwort = await fetch("ajax/settings.php?action=" + action, optionen);
|
||||||
|
const d = await antwort.json();
|
||||||
|
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||||||
|
grundrissModell = d.stand;
|
||||||
|
window.grundrissGeaendert = true;
|
||||||
|
zeichneGrundriss();
|
||||||
|
if (meldung) grStand(meldung, "text-success");
|
||||||
|
return d;
|
||||||
|
} catch (e) {
|
||||||
|
grStand(e.message, "text-danger");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function grundrissHolen() {
|
||||||
|
const ziel = document.getElementById("grundrissFlaeche");
|
||||||
|
if (!ziel) return;
|
||||||
|
try {
|
||||||
|
const antwort = await fetch("ajax/settings.php?action=grundriss", { cache: "no-store" });
|
||||||
|
const d = await antwort.json();
|
||||||
|
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||||||
|
grundrissModell = d;
|
||||||
|
zeichneGrundriss();
|
||||||
|
} catch (e) {
|
||||||
|
ziel.innerHTML = '<div class="alert alert-danger mb-0">Grundriss nicht abrufbar: '
|
||||||
|
+ textSchuetzen(e.message) + "</div>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Felder eines Blocks als Objekt. */
|
||||||
|
function grFelder(block) {
|
||||||
|
const werte = {};
|
||||||
|
block.querySelectorAll("[data-feld]").forEach(f => {
|
||||||
|
werte[f.dataset.feld] = f.type === "checkbox" ? f.checked : f.value;
|
||||||
|
});
|
||||||
|
return werte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Bedienung ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
function grundrissBinden() {
|
||||||
|
const flaeche = document.getElementById("grundrissFlaeche");
|
||||||
|
if (!flaeche) return;
|
||||||
|
|
||||||
|
flaeche.addEventListener("click", async function (ereignis) {
|
||||||
|
if (!grundrissModell) return;
|
||||||
|
const t = ereignis.target;
|
||||||
|
|
||||||
|
const etagenKnopf = t.closest(".gr-etage");
|
||||||
|
if (etagenKnopf) {
|
||||||
|
grundrissZustand.etage = etagenKnopf.dataset.code;
|
||||||
|
grundrissZustand.raum = null;
|
||||||
|
grundrissZustand.platzieren = null;
|
||||||
|
zeichneGrundriss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t.closest(".gr-etage-neu")) {
|
||||||
|
grundrissZustand.etage = "";
|
||||||
|
grundrissZustand.raum = null;
|
||||||
|
grundrissZustand.platzieren = null;
|
||||||
|
zeichneGrundriss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t.closest(".gr-etage-speichern")) {
|
||||||
|
const werte = grFelder(t.closest('[data-block="etage"]'));
|
||||||
|
const neu = grundrissZustand.etage === "";
|
||||||
|
const d = await grSenden("etage-speichern",
|
||||||
|
{ code: werte.code, label: werte.label, standard: werte.standard, neu: neu }, "Etage gespeichert.");
|
||||||
|
if (d && neu) {
|
||||||
|
grundrissZustand.etage = String(werte.code).trim().toUpperCase();
|
||||||
|
zeichneGrundriss();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t.closest(".gr-etage-links") || t.closest(".gr-etage-rechts")) {
|
||||||
|
const codes = grundrissModell.etagen.map(e => e.code);
|
||||||
|
const i = codes.indexOf(grundrissZustand.etage);
|
||||||
|
const j = i + (t.closest(".gr-etage-links") ? -1 : 1);
|
||||||
|
if (i < 0 || j < 0 || j >= codes.length) return;
|
||||||
|
[codes[i], codes[j]] = [codes[j], codes[i]];
|
||||||
|
grSenden("etagen-reihenfolge", { codes: codes }, "Reihenfolge gespeichert.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t.closest(".gr-etage-weg")) {
|
||||||
|
const e = grEtage(grundrissZustand.etage);
|
||||||
|
if (!e || !confirm("Etage „" + e.label + "“ löschen?")) return;
|
||||||
|
const d = await grSenden("etage-loeschen", { code: e.code }, "Etage gelöscht.");
|
||||||
|
if (d) {
|
||||||
|
grundrissZustand.etage = null;
|
||||||
|
zeichneGrundriss();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raumZeile = t.closest(".gr-raum");
|
||||||
|
if (t.closest(".gr-platzieren") && raumZeile) {
|
||||||
|
const nr = Number(raumZeile.dataset.nr);
|
||||||
|
grundrissZustand.platzieren = grundrissZustand.platzieren === nr ? null : nr;
|
||||||
|
grundrissZustand.raum = nr;
|
||||||
|
zeichneGrundriss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t.closest(".gr-raum-name") && raumZeile) {
|
||||||
|
const nr = Number(raumZeile.dataset.nr);
|
||||||
|
grundrissZustand.raum = grundrissZustand.raum === nr ? null : nr;
|
||||||
|
grundrissZustand.platzieren = null;
|
||||||
|
zeichneGrundriss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t.closest(".gr-raum-neu")) {
|
||||||
|
grundrissZustand.raum = 0;
|
||||||
|
grundrissZustand.platzieren = null;
|
||||||
|
zeichneGrundriss();
|
||||||
|
const feld = flaeche.querySelector('[data-block="raum"] [data-feld="name"]');
|
||||||
|
if (feld) feld.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t.closest(".gr-raum-speichern")) {
|
||||||
|
const werte = grFelder(t.closest('[data-block="raum"]'));
|
||||||
|
werte.nr = grundrissZustand.raum || 0;
|
||||||
|
const d = await grSenden("raum-speichern", werte, "Raum gespeichert.");
|
||||||
|
if (d) {
|
||||||
|
grundrissZustand.raum = d.nr;
|
||||||
|
// Hat der Raum die Etage gewechselt, geht der Blick mit.
|
||||||
|
const r = grRaum(d.nr);
|
||||||
|
if (r && r.floor !== grundrissZustand.etage) grundrissZustand.etage = r.floor;
|
||||||
|
zeichneGrundriss();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t.closest(".gr-vom-plan")) {
|
||||||
|
grSenden("raum-position", { nr: grundrissZustand.raum, x: null, y: null }, "Kachel vom Plan genommen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t.closest(".gr-raum-weg")) {
|
||||||
|
const r = grRaum(grundrissZustand.raum);
|
||||||
|
if (!r) return;
|
||||||
|
const frage = "Raum „" + r.name + "“ löschen?"
|
||||||
|
+ (r.geraete ? "\n\n" + r.geraete + " Gerät(e) stehen danach ohne Raum da." : "");
|
||||||
|
if (!confirm(frage)) return;
|
||||||
|
const d = await grSenden("raum-loeschen", { nr: r.nr }, "Raum gelöscht.");
|
||||||
|
if (d) {
|
||||||
|
grundrissZustand.raum = null;
|
||||||
|
zeichneGrundriss();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t.closest(".gr-kachelwerte")) {
|
||||||
|
const r = grRaum(grundrissZustand.raum);
|
||||||
|
if (r && typeof kachelOeffnen === "function") kachelOeffnen(r.floor, r.kuerzel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Das Bild geht los, sobald eine Datei gewählt ist - ein zweiter Klick auf
|
||||||
|
// "Speichern" wäre hier nur eine Gelegenheit, es zu vergessen.
|
||||||
|
flaeche.addEventListener("change", function (ereignis) {
|
||||||
|
const feld = ereignis.target.closest(".gr-bild");
|
||||||
|
if (!feld || !feld.files.length) return;
|
||||||
|
const formular = new FormData();
|
||||||
|
formular.append("code", grundrissZustand.etage);
|
||||||
|
formular.append("bild", feld.files[0]);
|
||||||
|
grStand("Bild wird hochgeladen …");
|
||||||
|
grSenden("etage-bild", formular, "Grundriss übernommen.");
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Ziehen und Platzieren. Pointer-Ereignisse, damit es mit Maus, Stift und
|
||||||
|
Finger gleich geht; die Kachel folgt dem Zeiger, gespeichert wird beim
|
||||||
|
Loslassen. */
|
||||||
|
flaeche.addEventListener("pointerdown", function (ereignis) {
|
||||||
|
const kachel = ereignis.target.closest(".gr-kachel");
|
||||||
|
const svg = ereignis.target.closest(".gr-plan");
|
||||||
|
if (!kachel || !svg || grundrissZustand.platzieren) return;
|
||||||
|
const r = grRaum(Number(kachel.dataset.nr));
|
||||||
|
if (!r) return;
|
||||||
|
const p = grPunkt(svg, ereignis);
|
||||||
|
grundrissZustand.ziehen = { nr: r.nr, dx: p.x - r.x, dy: p.y - r.y, x: r.x, y: r.y, bewegt: false, kachel: kachel, svg: svg };
|
||||||
|
kachel.setPointerCapture(ereignis.pointerId);
|
||||||
|
kachel.classList.add("zieht");
|
||||||
|
ereignis.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
flaeche.addEventListener("pointermove", function (ereignis) {
|
||||||
|
const z = grundrissZustand.ziehen;
|
||||||
|
if (z) {
|
||||||
|
const p = grPunkt(z.svg, ereignis);
|
||||||
|
const pos = grBegrenzt(p.x - z.dx, p.y - z.dy);
|
||||||
|
if (pos.x !== z.x || pos.y !== z.y) z.bewegt = true;
|
||||||
|
z.x = pos.x;
|
||||||
|
z.y = pos.y;
|
||||||
|
z.kachel.setAttribute("transform", "translate(" + pos.x + "," + pos.y + ")");
|
||||||
|
grStand("x " + pos.x + " · y " + pos.y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (grundrissZustand.platzieren) {
|
||||||
|
const svg = ereignis.target.closest(".gr-plan");
|
||||||
|
const geist = flaeche.querySelector(".gr-geist");
|
||||||
|
if (!svg || !geist) return;
|
||||||
|
const m = grMass();
|
||||||
|
const p = grPunkt(svg, ereignis);
|
||||||
|
const pos = grBegrenzt(p.x - m.kachelBreit / 2, p.y - m.kachelHoch / 2);
|
||||||
|
geist.style.display = "";
|
||||||
|
geist.setAttribute("transform", "translate(" + pos.x + "," + pos.y + ")");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
flaeche.addEventListener("pointerup", async function (ereignis) {
|
||||||
|
const z = grundrissZustand.ziehen;
|
||||||
|
if (z) {
|
||||||
|
grundrissZustand.ziehen = null;
|
||||||
|
z.kachel.classList.remove("zieht");
|
||||||
|
if (!z.bewegt) {
|
||||||
|
// Nur angetippt: den Raum wählen statt ihn zu verschieben.
|
||||||
|
grundrissZustand.raum = grundrissZustand.raum === z.nr ? null : z.nr;
|
||||||
|
zeichneGrundriss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grSenden("raum-position", { nr: z.nr, x: z.x, y: z.y }, "Kachel gesetzt.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (grundrissZustand.platzieren) {
|
||||||
|
const svg = ereignis.target.closest(".gr-plan");
|
||||||
|
if (!svg) return;
|
||||||
|
const m = grMass();
|
||||||
|
const p = grPunkt(svg, ereignis);
|
||||||
|
const pos = grBegrenzt(p.x - m.kachelBreit / 2, p.y - m.kachelHoch / 2);
|
||||||
|
const nr = grundrissZustand.platzieren;
|
||||||
|
grundrissZustand.platzieren = null;
|
||||||
|
grSenden("raum-position", { nr: nr, x: pos.x, y: pos.y }, "Kachel gesetzt.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (ereignis) {
|
||||||
|
if (ereignis.key === "Escape" && grundrissZustand.platzieren) {
|
||||||
|
grundrissZustand.platzieren = null;
|
||||||
|
zeichneGrundriss();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const neuLaden = document.getElementById("grundrissNeuLaden");
|
||||||
|
if (neuLaden) neuLaden.addEventListener("click", grundrissHolen);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
grundrissBinden();
|
||||||
|
grundrissHolen();
|
||||||
|
});
|
||||||
@@ -812,11 +812,55 @@ function kachelnBinden() {
|
|||||||
if (neuLaden) neuLaden.addEventListener("click", kachelnHolen);
|
if (neuLaden) neuLaden.addEventListener("click", kachelnHolen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Kachel von aussen aufschlagen - der Knopf "Werte" im Reiter Grundriss.
|
||||||
|
*
|
||||||
|
* Wechselt in diesen Reiter, laedt nach, falls sich der Grundriss seit dem
|
||||||
|
* letzten Laden geaendert hat (eine eben gesetzte Kachel stuende sonst nicht
|
||||||
|
* in der Liste), und oeffnet die Zeile.
|
||||||
|
*/
|
||||||
|
async function kachelOeffnen(floor, room) {
|
||||||
|
const knopf = document.getElementById("einstellung-kacheln-tab");
|
||||||
|
if (knopf && typeof bootstrap !== "undefined") new bootstrap.Tab(knopf).show();
|
||||||
|
if (window.grundrissGeaendert || !kachelModell) {
|
||||||
|
window.grundrissGeaendert = false;
|
||||||
|
await kachelnHolen();
|
||||||
|
}
|
||||||
|
const k = kachelModell && kachelSuchen(floor, room);
|
||||||
|
if (!k) return;
|
||||||
|
kachelModell.kacheln.forEach(a => { a._offen = false; });
|
||||||
|
k._offen = true;
|
||||||
|
if (!k._entwurf) k._entwurf = JSON.parse(JSON.stringify(k.werte));
|
||||||
|
zeichneKacheln();
|
||||||
|
const zeile = document.querySelector('.kachel-zeile[data-floor="' + k.floor + '"][data-room="' + k.room + '"]');
|
||||||
|
if (zeile) zeile.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
// Das Merken des offenen Reiters macht reiterMerken() in common.js - fuer
|
// Das Merken des offenen Reiters macht reiterMerken() in common.js - fuer
|
||||||
// jede Leiste der Seite, nicht nur fuer diese.
|
// jede Leiste der Seite, nicht nur fuer diese.
|
||||||
kachelnBinden();
|
kachelnBinden();
|
||||||
kachelnHolen();
|
kachelnHolen();
|
||||||
|
|
||||||
|
// Nach einer Aenderung im Reiter Grundriss wissen Kacheln und Geraete nicht
|
||||||
|
// mehr, was gilt: neue Raeume fehlen, umbenannte heissen noch wie vorher.
|
||||||
|
// Nachgeladen wird beim naechsten Oeffnen - nicht sofort, sonst liefe bei
|
||||||
|
// jedem gezogenen Kachelchen eine Anfrage fuer zwei Reiter mit.
|
||||||
|
const kachelReiter = document.getElementById("einstellung-kacheln-tab");
|
||||||
|
if (kachelReiter) kachelReiter.addEventListener("shown.bs.tab", function () {
|
||||||
|
if (window.grundrissGeaendert) {
|
||||||
|
window.grundrissGeaendert = false;
|
||||||
|
window.geraeteVeraltet = true;
|
||||||
|
kachelnHolen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const geraeteReiter = document.getElementById("einstellung-geraete-tab");
|
||||||
|
if (geraeteReiter) geraeteReiter.addEventListener("shown.bs.tab", function () {
|
||||||
|
if (window.grundrissGeaendert || window.geraeteVeraltet) {
|
||||||
|
window.geraeteVeraltet = false;
|
||||||
|
if (typeof geraeteHolen === "function") geraeteHolen();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Modell-Schicht für den Reiter "Grundriss": Etagen, Räume, Kachelpositionen,
|
||||||
|
* Grundrissbilder.
|
||||||
|
*
|
||||||
|
* Gelesen wird der Grundriss in rooms.php - dort, wo ihn Startseite, Menü und
|
||||||
|
* Editor ohnehin holen. Hier steht nur, was ihn ändert, und jede Prüfung
|
||||||
|
* dazu. Die Maske (js/solar/grundriss.js) prüft vorab nichts, was hier nicht
|
||||||
|
* auch geprüft würde; sie zeigt nur die Meldung, die von hier kommt.
|
||||||
|
*
|
||||||
|
* Tabellen: homeMesh.floors und homeMesh.rooms, siehe homeMesh_grundriss.sql.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once(__DIR__ . "/rooms.php"); // grundriss(), meshDb()
|
||||||
|
|
||||||
|
/** Zeichenfläche und Kachel - dieselben Maße wie in restricted/home.php. */
|
||||||
|
const PLAN_BREIT = 400;
|
||||||
|
const PLAN_HOCH = 300;
|
||||||
|
const KACHEL_BREIT = 24;
|
||||||
|
const KACHEL_HOCH = 19;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wohin hochgeladene Grundrisse kommen, relativ zum Web-Root.
|
||||||
|
*
|
||||||
|
* tiles/ ist das eine Verzeichnis, in das der Webserver schreiben darf - dort
|
||||||
|
* liegt schon der Zwischenspeicher der Kartenkacheln. Es steht nicht im Git;
|
||||||
|
* ein Grundriss ist Inhalt dieses Hauses, nicht Teil der Anwendung.
|
||||||
|
*/
|
||||||
|
const GRUNDRISS_ORDNER = "tiles/grundriss";
|
||||||
|
|
||||||
|
/** Größte Bilddatei. Ein Rendering in 2048 x 1536 hat gut drei Megabyte. */
|
||||||
|
const GRUNDRISS_MAX_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Web-Root als Dateipfad. */
|
||||||
|
function webRoot()
|
||||||
|
{
|
||||||
|
return dirname(__DIR__);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alles, was der Reiter zum Zeichnen braucht, in einer Antwort.
|
||||||
|
*
|
||||||
|
* Je Etage steht dabei, wie viele Räume und Automatiken an ihr hängen, je
|
||||||
|
* Raum, wie viele Geräte - danach richtet sich, ob und mit welcher Rückfrage
|
||||||
|
* gelöscht werden darf. Die Maske soll die Namen nicht erst nachfragen müssen.
|
||||||
|
*/
|
||||||
|
function grundrissStand()
|
||||||
|
{
|
||||||
|
$db = meshDb();
|
||||||
|
[$etagen, $raeume] = grundriss();
|
||||||
|
|
||||||
|
$automatiken = [];
|
||||||
|
$res = $db->query("SELECT floor, name FROM automations ORDER BY name");
|
||||||
|
while ($res && $z = $res->fetch_assoc()) {
|
||||||
|
$automatiken[$z["floor"]][] = $z["name"];
|
||||||
|
}
|
||||||
|
$geraete = [];
|
||||||
|
$res = $db->query("SELECT room_id, COUNT(*) AS n FROM actors
|
||||||
|
WHERE room_id IS NOT NULL GROUP BY room_id");
|
||||||
|
while ($res && $z = $res->fetch_assoc()) {
|
||||||
|
$geraete[intval($z["room_id"])] = intval($z["n"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$liste = [];
|
||||||
|
foreach ($etagen as $code => $e) {
|
||||||
|
$liste[] = [
|
||||||
|
"code" => $code,
|
||||||
|
"label" => $e["label"],
|
||||||
|
"bild" => $e["bild"],
|
||||||
|
"standard" => $e["standard"],
|
||||||
|
"raeume" => count(array_filter($raeume, fn($r) => $r["floor"] === $code)),
|
||||||
|
"automatiken" => $automatiken[$code] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$raumListe = [];
|
||||||
|
foreach ($raeume as $r) {
|
||||||
|
$raumListe[] = [
|
||||||
|
"nr" => $r["nr"],
|
||||||
|
"floor" => $r["floor"],
|
||||||
|
"name" => $r["mqtt"],
|
||||||
|
"kuerzel" => $r["id"],
|
||||||
|
"x" => $r["x"] ?? null,
|
||||||
|
"y" => $r["y"] ?? null,
|
||||||
|
"thermostat" => $r["thermostat"],
|
||||||
|
"geraete" => $geraete[$r["nr"]] ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
"etagen" => $liste,
|
||||||
|
"raeume" => $raumListe,
|
||||||
|
"zweige" => thermostatZweige(),
|
||||||
|
"standard" => standardEtage(),
|
||||||
|
"mass" => ["planBreit" => PLAN_BREIT, "planHoch" => PLAN_HOCH,
|
||||||
|
"kachelBreit" => KACHEL_BREIT, "kachelHoch" => KACHEL_HOCH],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorschläge für das Thermostat-Feld: die Zweige, unter denen gerade ein
|
||||||
|
* Thermostat meldet.
|
||||||
|
*
|
||||||
|
* Erkannt an einem Messwert, dessen Topic auf "/Temp[degC]" endet - so
|
||||||
|
* schreiben die ESP32-Thermostate. Ein anderes Haus mit anderen Thermostaten
|
||||||
|
* tippt den Zweig von Hand; die Liste ist eine Abkürzung, keine Grenze.
|
||||||
|
*/
|
||||||
|
function thermostatZweige()
|
||||||
|
{
|
||||||
|
$zweige = [];
|
||||||
|
$res = meshDb()->query("SELECT DISTINCT url FROM actor_states WHERE url LIKE '%/Temp[degC]'");
|
||||||
|
while ($res && $z = $res->fetch_row()) {
|
||||||
|
$zweige[] = substr($z[0], 0, -strlen("/Temp[degC]"));
|
||||||
|
}
|
||||||
|
sort($zweige, SORT_NATURAL | SORT_FLAG_CASE);
|
||||||
|
return $zweige;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Etagen -------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Etage anlegen oder ändern.
|
||||||
|
*
|
||||||
|
* Das Kürzel ist nach dem Anlegen fest: es steht in URLs, SVG-Ids und
|
||||||
|
* automations.floor. Ändern lassen sich Name und "Standard".
|
||||||
|
*
|
||||||
|
* Kürzel aus bis zu vier Großbuchstaben oder Ziffern - so passt es in den
|
||||||
|
* Etagenschalter im Grundriss und braucht in keiner URL eine Maskierung.
|
||||||
|
*/
|
||||||
|
function etageSpeichern($daten)
|
||||||
|
{
|
||||||
|
$db = meshDb();
|
||||||
|
$code = strtoupper(trim(strval($daten["code"] ?? "")));
|
||||||
|
$label = trim(strval($daten["label"] ?? ""));
|
||||||
|
$neu = !empty($daten["neu"]);
|
||||||
|
$standard = !empty($daten["standard"]);
|
||||||
|
|
||||||
|
if (!preg_match('/^[A-Z0-9]{1,4}$/', $code)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Das Kürzel besteht aus 1 bis 4 Großbuchstaben oder Ziffern, z. B. EG.");
|
||||||
|
}
|
||||||
|
if ($label === "" || mb_strlen($label) > 40) {
|
||||||
|
throw new InvalidArgumentException("Der Name der Etage braucht 1 bis 40 Zeichen.");
|
||||||
|
}
|
||||||
|
$da = isset(grundriss()[0][$code]);
|
||||||
|
if ($neu && $da) {
|
||||||
|
throw new InvalidArgumentException("Eine Etage " . $code . " gibt es schon.");
|
||||||
|
}
|
||||||
|
if (!$neu && !$da) {
|
||||||
|
throw new InvalidArgumentException("Die Etage " . $code . " gibt es nicht (mehr).");
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->begin_transaction();
|
||||||
|
try {
|
||||||
|
if ($standard) {
|
||||||
|
// Höchstens eine - der Schalter an der einen nimmt ihn allen anderen.
|
||||||
|
$db->query("UPDATE floors SET standard = 0");
|
||||||
|
}
|
||||||
|
$std = $standard ? 1 : 0;
|
||||||
|
if ($neu) {
|
||||||
|
$pos = intval($db->query("SELECT COALESCE(MAX(position), -1) + 1 FROM floors")->fetch_row()[0]);
|
||||||
|
$stmt = $db->prepare("INSERT INTO floors (code, label, position, standard) VALUES (?,?,?,?)");
|
||||||
|
$stmt->bind_param("ssii", $code, $label, $pos, $std);
|
||||||
|
} else {
|
||||||
|
$stmt = $db->prepare("UPDATE floors SET label = ?, standard = ? WHERE code = ?");
|
||||||
|
$stmt->bind_param("sis", $label, $std, $code);
|
||||||
|
}
|
||||||
|
$stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
$db->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$db->rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neue Reihenfolge der Etagen: die Kürzel in der gewünschten Folge. */
|
||||||
|
function etagenReihenfolge($codes)
|
||||||
|
{
|
||||||
|
if (!is_array($codes)) {
|
||||||
|
throw new InvalidArgumentException("Die Reihenfolge ist unlesbar.");
|
||||||
|
}
|
||||||
|
$vorhanden = array_keys(grundriss()[0]);
|
||||||
|
$codes = array_values(array_map("strval", $codes));
|
||||||
|
if (count($codes) !== count($vorhanden) || array_diff($vorhanden, $codes)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Die Reihenfolge passt nicht zu den Etagen - bitte neu laden.");
|
||||||
|
}
|
||||||
|
$db = meshDb();
|
||||||
|
$stmt = $db->prepare("UPDATE floors SET position = ? WHERE code = ?");
|
||||||
|
foreach ($codes as $i => $code) {
|
||||||
|
$stmt->bind_param("is", $i, $code);
|
||||||
|
$stmt->execute();
|
||||||
|
}
|
||||||
|
$stmt->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Etage löschen - nur, wenn nichts mehr an ihr hängt.
|
||||||
|
*
|
||||||
|
* Räume und Automatiken mitzulöschen wäre ein stiller Datenverlust. Die
|
||||||
|
* Ablehnung nennt, was im Weg steht; aufräumen muss man bewusst.
|
||||||
|
*/
|
||||||
|
function etageLoeschen($code)
|
||||||
|
{
|
||||||
|
$code = strval($code);
|
||||||
|
$etage = grundriss()[0][$code] ?? null;
|
||||||
|
if (!$etage) {
|
||||||
|
throw new InvalidArgumentException("Die Etage " . $code . " gibt es nicht (mehr).");
|
||||||
|
}
|
||||||
|
$raeume = array_column(array_filter(grundriss()[1], fn($r) => $r["floor"] === $code), "mqtt");
|
||||||
|
if ($raeume) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Auf " . $etage["label"] . " liegen noch " . count($raeume) . " Räume: "
|
||||||
|
. implode(", ", $raeume) . ". Erst die Räume löschen oder verschieben.");
|
||||||
|
}
|
||||||
|
$db = meshDb();
|
||||||
|
$stmt = $db->prepare("SELECT name FROM automations WHERE floor = ? ORDER BY name");
|
||||||
|
$stmt->bind_param("s", $code);
|
||||||
|
$stmt->execute();
|
||||||
|
$namen = array_column($stmt->get_result()->fetch_all(MYSQLI_ASSOC), "name");
|
||||||
|
$stmt->close();
|
||||||
|
if ($namen) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Unter " . $etage["label"] . " stehen noch " . count($namen) . " Automatiken: "
|
||||||
|
. implode(", ", $namen) . ". Erst dort löschen oder einer anderen Etage zuordnen.");
|
||||||
|
}
|
||||||
|
$stmt = $db->prepare("DELETE FROM floors WHERE code = ?");
|
||||||
|
$stmt->bind_param("s", $code);
|
||||||
|
$stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
grundrissBildWegraeumen($etage["bild"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein hochgeladenes Grundrissbild übernehmen.
|
||||||
|
*
|
||||||
|
* Erlaubt sind PNG, JPEG und WebP - geprüft am Inhalt (getimagesize), nicht an
|
||||||
|
* der Endung. SVG bewusst nicht: es kann Skript enthalten, und hier läge es
|
||||||
|
* direkt abrufbar im Web-Root derselben Seite.
|
||||||
|
*
|
||||||
|
* Der Dateiname trägt einen Teil der Prüfsumme. Ein neues Bild hat damit eine
|
||||||
|
* neue Adresse, und kein Browser zeigt nach dem Hochladen noch das alte aus
|
||||||
|
* seinem Zwischenspeicher.
|
||||||
|
*/
|
||||||
|
function etageBildSpeichern($code, $datei)
|
||||||
|
{
|
||||||
|
$code = strval($code);
|
||||||
|
$etage = grundriss()[0][$code] ?? null;
|
||||||
|
if (!$etage) {
|
||||||
|
throw new InvalidArgumentException("Die Etage " . $code . " gibt es nicht (mehr).");
|
||||||
|
}
|
||||||
|
if (!is_array($datei) || ($datei["error"] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
|
$fehler = $datei["error"] ?? UPLOAD_ERR_NO_FILE;
|
||||||
|
throw new InvalidArgumentException($fehler === UPLOAD_ERR_INI_SIZE || $fehler === UPLOAD_ERR_FORM_SIZE
|
||||||
|
? "Das Bild ist zu groß." : "Es kam kein Bild an.");
|
||||||
|
}
|
||||||
|
if ($datei["size"] > GRUNDRISS_MAX_BYTES) {
|
||||||
|
throw new InvalidArgumentException("Das Bild ist größer als 16 MB.");
|
||||||
|
}
|
||||||
|
$info = @getimagesize($datei["tmp_name"]);
|
||||||
|
$endungen = [IMAGETYPE_PNG => "png", IMAGETYPE_JPEG => "jpg", IMAGETYPE_WEBP => "webp"];
|
||||||
|
if (!$info || !isset($endungen[$info[2]])) {
|
||||||
|
throw new InvalidArgumentException("Nur PNG, JPEG oder WebP.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$ordner = webRoot() . "/" . GRUNDRISS_ORDNER;
|
||||||
|
if (!is_dir($ordner) && !@mkdir($ordner, 0775, true)) {
|
||||||
|
throw new RuntimeException("Der Ordner " . GRUNDRISS_ORDNER . " lässt sich nicht anlegen.");
|
||||||
|
}
|
||||||
|
$name = $code . "-" . substr(sha1_file($datei["tmp_name"]), 0, 10) . "." . $endungen[$info[2]];
|
||||||
|
$ziel = $ordner . "/" . $name;
|
||||||
|
if (!move_uploaded_file($datei["tmp_name"], $ziel)) {
|
||||||
|
throw new RuntimeException("Das Bild ließ sich nicht nach " . GRUNDRISS_ORDNER . " schreiben.");
|
||||||
|
}
|
||||||
|
@chmod($ziel, 0664);
|
||||||
|
|
||||||
|
$pfad = GRUNDRISS_ORDNER . "/" . $name;
|
||||||
|
$db = meshDb();
|
||||||
|
$stmt = $db->prepare("UPDATE floors SET bild = ? WHERE code = ?");
|
||||||
|
$stmt->bind_param("ss", $pfad, $code);
|
||||||
|
$stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
if ($etage["bild"] !== $pfad) {
|
||||||
|
grundrissBildWegraeumen($etage["bild"]);
|
||||||
|
}
|
||||||
|
return ["bild" => $pfad, "breit" => $info[0], "hoch" => $info[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein nicht mehr benutztes Bild löschen - aber nur ein hochgeladenes.
|
||||||
|
*
|
||||||
|
* Die mitgelieferten unter assets/img gehören zum Repository und bleiben.
|
||||||
|
* Benutzt eine andere Etage dasselbe Bild, bleibt es ebenfalls.
|
||||||
|
*/
|
||||||
|
function grundrissBildWegraeumen($pfad)
|
||||||
|
{
|
||||||
|
if (!$pfad || strpos($pfad, GRUNDRISS_ORDNER . "/") !== 0 || strpos($pfad, "..") !== false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$stmt = meshDb()->prepare("SELECT COUNT(*) FROM floors WHERE bild = ?");
|
||||||
|
$stmt->bind_param("s", $pfad);
|
||||||
|
$stmt->execute();
|
||||||
|
$benutzt = intval($stmt->get_result()->fetch_row()[0]);
|
||||||
|
$stmt->close();
|
||||||
|
if ($benutzt === 0) {
|
||||||
|
@unlink(webRoot() . "/" . $pfad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Räume --------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Kürzel aus einem Namen: klein, ohne Umlaute, nur a-z, 0-9 und _.
|
||||||
|
*
|
||||||
|
* Es steht in SVG-Element-Ids; Leerzeichen und Umlaute gehören dort nicht
|
||||||
|
* hinein.
|
||||||
|
*/
|
||||||
|
function raumKuerzelAus($name)
|
||||||
|
{
|
||||||
|
$k = mb_strtolower($name);
|
||||||
|
$k = strtr($k, ["ä" => "ae", "ö" => "oe", "ü" => "ue", "ß" => "ss"]);
|
||||||
|
$k = preg_replace('/[^a-z0-9]+/', "_", $k);
|
||||||
|
$k = trim($k, "_");
|
||||||
|
return substr($k !== "" ? $k : "raum", 0, 36);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einen Raum anlegen oder ändern.
|
||||||
|
*
|
||||||
|
* Fehlt das Kürzel, entsteht es aus dem Namen - und wird durchnummeriert,
|
||||||
|
* falls es auf der Etage schon vergeben ist. Ein von Hand eingetragenes muss
|
||||||
|
* dagegen passen: wer es tippt, meint genau dieses.
|
||||||
|
*
|
||||||
|
* Wechselt ein Raum die Etage, behält er seine Kachel an derselben Stelle der
|
||||||
|
* Zeichenfläche - verschieben ist dann ein Zug im Plan.
|
||||||
|
*/
|
||||||
|
function raumSpeichern($daten)
|
||||||
|
{
|
||||||
|
$db = meshDb();
|
||||||
|
$nr = intval($daten["nr"] ?? 0);
|
||||||
|
$floor = strval($daten["floor"] ?? "");
|
||||||
|
$name = trim(strval($daten["name"] ?? ""));
|
||||||
|
$kuerzel = trim(strval($daten["kuerzel"] ?? ""));
|
||||||
|
$thermostat = trim(strval($daten["thermostat"] ?? ""), " /");
|
||||||
|
|
||||||
|
if (!isset(grundriss()[0][$floor])) {
|
||||||
|
throw new InvalidArgumentException("Bitte eine Etage wählen.");
|
||||||
|
}
|
||||||
|
if ($name === "" || mb_strlen($name) > 40) {
|
||||||
|
throw new InvalidArgumentException("Der Name des Raums braucht 1 bis 40 Zeichen.");
|
||||||
|
}
|
||||||
|
if ($thermostat !== "" && (mb_strlen($thermostat) > 120 || preg_match('/[#+\s]/', $thermostat))) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Der Thermostat-Zweig ist ein MQTT-Topic ohne Leerzeichen, # oder +, z. B. Raumtemp/EG/Bad.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$andere = array_filter(grundriss()[1], fn($r) => $r["floor"] === $floor && $r["nr"] !== $nr);
|
||||||
|
foreach ($andere as $r) {
|
||||||
|
if (mb_strtolower($r["mqtt"]) === mb_strtolower($name)) {
|
||||||
|
throw new InvalidArgumentException("Auf dieser Etage gibt es schon einen Raum „" . $name . "“.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$vergeben = array_column($andere, "id");
|
||||||
|
if ($kuerzel === "") {
|
||||||
|
$basis = raumKuerzelAus($name);
|
||||||
|
$kuerzel = $basis;
|
||||||
|
for ($i = 2; in_array($kuerzel, $vergeben, true); $i++) {
|
||||||
|
$kuerzel = $basis . "_" . $i;
|
||||||
|
}
|
||||||
|
} elseif (!preg_match('/^[a-z0-9_]{1,40}$/', $kuerzel)) {
|
||||||
|
throw new InvalidArgumentException("Das Kürzel besteht nur aus a-z, 0-9 und _.");
|
||||||
|
} elseif (in_array($kuerzel, $vergeben, true)) {
|
||||||
|
throw new InvalidArgumentException("Das Kürzel " . $kuerzel . " ist auf dieser Etage schon vergeben.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$th = $thermostat === "" ? null : $thermostat;
|
||||||
|
if ($nr > 0) {
|
||||||
|
$alt = null;
|
||||||
|
foreach (grundriss()[1] as $r) {
|
||||||
|
if ($r["nr"] === $nr) {
|
||||||
|
$alt = $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$alt) {
|
||||||
|
throw new InvalidArgumentException("Diesen Raum gibt es nicht (mehr).");
|
||||||
|
}
|
||||||
|
$stmt = $db->prepare("UPDATE rooms SET floor = ?, name = ?, kuerzel = ?, thermostat = ? WHERE id = ?");
|
||||||
|
$stmt->bind_param("ssssi", $floor, $name, $kuerzel, $th, $nr);
|
||||||
|
$stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
return $nr;
|
||||||
|
}
|
||||||
|
$pos = intval($db->query("SELECT COALESCE(MAX(position), -1) + 1 FROM rooms")->fetch_row()[0]);
|
||||||
|
$stmt = $db->prepare("INSERT INTO rooms (floor, name, kuerzel, position, thermostat) VALUES (?,?,?,?,?)");
|
||||||
|
$stmt->bind_param("sssis", $floor, $name, $kuerzel, $pos, $th);
|
||||||
|
$stmt->execute();
|
||||||
|
$nr = $db->insert_id;
|
||||||
|
$stmt->close();
|
||||||
|
return $nr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Kachel eines Raums setzen oder vom Plan nehmen ($x === null).
|
||||||
|
*
|
||||||
|
* x/y ist die linke obere Ecke, begrenzt auf die Zeichenfläche - eine Kachel,
|
||||||
|
* die halb über den Rand hängt, ließe sich im Plan nicht mehr greifen.
|
||||||
|
*/
|
||||||
|
function raumPosition($nr, $x, $y)
|
||||||
|
{
|
||||||
|
$nr = intval($nr);
|
||||||
|
$db = meshDb();
|
||||||
|
if ($x === null || $y === null) {
|
||||||
|
$stmt = $db->prepare("UPDATE rooms SET x = NULL, y = NULL WHERE id = ?");
|
||||||
|
$stmt->bind_param("i", $nr);
|
||||||
|
} else {
|
||||||
|
$x = max(0, min(PLAN_BREIT - KACHEL_BREIT, intval(round(floatval($x)))));
|
||||||
|
$y = max(0, min(PLAN_HOCH - KACHEL_HOCH, intval(round(floatval($y)))));
|
||||||
|
$stmt = $db->prepare("UPDATE rooms SET x = ?, y = ? WHERE id = ?");
|
||||||
|
$stmt->bind_param("iii", $x, $y, $nr);
|
||||||
|
}
|
||||||
|
$stmt->execute();
|
||||||
|
$geaendert = $stmt->affected_rows;
|
||||||
|
$stmt->close();
|
||||||
|
if ($geaendert === 0 && !array_filter(grundriss()[1], fn($r) => $r["nr"] === $nr)) {
|
||||||
|
throw new InvalidArgumentException("Diesen Raum gibt es nicht (mehr).");
|
||||||
|
}
|
||||||
|
return $x === null ? null : ["x" => $x, "y" => $y];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einen Raum löschen. Seine Geräte bleiben und stehen danach ohne Raum da
|
||||||
|
* (fk_actors_room: ON DELETE SET NULL) - die Rückfrage in der Maske nennt,
|
||||||
|
* wie viele es sind.
|
||||||
|
*/
|
||||||
|
function raumLoeschen($nr)
|
||||||
|
{
|
||||||
|
$nr = intval($nr);
|
||||||
|
$stmt = meshDb()->prepare("DELETE FROM rooms WHERE id = ?");
|
||||||
|
$stmt->bind_param("i", $nr);
|
||||||
|
$stmt->execute();
|
||||||
|
$weg = $stmt->affected_rows;
|
||||||
|
$stmt->close();
|
||||||
|
if ($weg === 0) {
|
||||||
|
throw new InvalidArgumentException("Diesen Raum gibt es nicht (mehr).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,23 @@ require_once(__DIR__ . "/reiter.php");
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body pb-1">
|
<div class="card-body pb-1">
|
||||||
|
<?php if (!floorsWithPlan()): ?>
|
||||||
|
<!-- Eine neue Installation: noch keine Etage mit platzierten
|
||||||
|
Raeumen. Statt einer leeren Zeichenflaeche der Weg dorthin,
|
||||||
|
wo sich das aendert. -->
|
||||||
|
<div class="alert alert-secondary mb-3">
|
||||||
|
<?php if (!grundrissDa()): ?>
|
||||||
|
Etagen und Räume lassen sich nicht lesen – die Datenbank
|
||||||
|
<code>homeMesh</code> ist nicht erreichbar, oder
|
||||||
|
<code>homeMesh_grundriss.sql</code> ist noch nicht eingespielt.
|
||||||
|
<?php else: ?>
|
||||||
|
Noch kein Grundriss. Unter
|
||||||
|
<a href="?action=settings#grundriss">Einstellungen → Grundriss</a>
|
||||||
|
werden Etagen angelegt, ihre Grundrissbilder hochgeladen und die
|
||||||
|
Kacheln der Räume gesetzt.
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
<div lass="img-overlay-wrap">
|
<div lass="img-overlay-wrap">
|
||||||
<svg viewBox="0 0 400 300" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 400 300" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
@@ -179,6 +196,7 @@ require_once(__DIR__ . "/reiter.php");
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- /.card -->
|
<!-- /.card -->
|
||||||
|
|||||||
@@ -37,13 +37,17 @@ require_once(__DIR__ . "/meshdb.php");
|
|||||||
*
|
*
|
||||||
* Gibt [etagen, raeume] zurück: etagen ist code => Zeile aus floors in der
|
* Gibt [etagen, raeume] zurück: etagen ist code => Zeile aus floors in der
|
||||||
* eingestellten Reihenfolge, raeume die Liste im Format oben.
|
* eingestellten Reihenfolge, raeume die Liste im Format oben.
|
||||||
|
*
|
||||||
|
* $neuLaden nach einer Änderung in derselben Anfrage (restricted/grundriss.php)
|
||||||
|
* - sonst antwortete der Endpunkt mit dem Stand von davor.
|
||||||
*/
|
*/
|
||||||
function grundriss()
|
function grundriss($neuLaden = false)
|
||||||
{
|
{
|
||||||
static $stand = null;
|
static $stand = null;
|
||||||
if ($stand !== null) {
|
if ($stand !== null && !$neuLaden) {
|
||||||
return $stand;
|
return $stand;
|
||||||
}
|
}
|
||||||
|
unset($GLOBALS["grundrissFehler"]);
|
||||||
$etagen = [];
|
$etagen = [];
|
||||||
$raeume = [];
|
$raeume = [];
|
||||||
try {
|
try {
|
||||||
|
|||||||
+38
-3
@@ -32,6 +32,7 @@ require_once(__DIR__ . "/skodaKeys.php");
|
|||||||
*/
|
*/
|
||||||
$reiter = [
|
$reiter = [
|
||||||
["id" => "preise", "titel" => "Preise", "symbol" => "bi-currency-euro"],
|
["id" => "preise", "titel" => "Preise", "symbol" => "bi-currency-euro"],
|
||||||
|
["id" => "grundriss", "titel" => "Grundriss", "symbol" => "bi-house"],
|
||||||
["id" => "kacheln", "titel" => "Home-Kacheln", "symbol" => "bi-grid-1x2"],
|
["id" => "kacheln", "titel" => "Home-Kacheln", "symbol" => "bi-grid-1x2"],
|
||||||
["id" => "geraete", "titel" => "Geräte", "symbol" => "bi-house-gear"],
|
["id" => "geraete", "titel" => "Geräte", "symbol" => "bi-house-gear"],
|
||||||
["id" => "fahrzeug", "titel" => "Fahrzeug", "symbol" => "bi-ev-front"],
|
["id" => "fahrzeug", "titel" => "Fahrzeug", "symbol" => "bi-ev-front"],
|
||||||
@@ -128,6 +129,39 @@ try {
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?= reiterFlaecheZu() ?><!-- /Preise -->
|
<?= reiterFlaecheZu() ?><!-- /Preise -->
|
||||||
|
|
||||||
|
<!-- ================================================== Grundriss ==== -->
|
||||||
|
<!-- Etagen, Räume und wo ihre Kacheln sitzen. Stand bis September 2026
|
||||||
|
als Tabelle in restricted/rooms.php; jetzt in homeMesh.floors und
|
||||||
|
homeMesh.rooms, gepflegt hier. Den Inhalt zeichnet
|
||||||
|
js/solar/grundriss.js aus ajax/settings.php?action=grundriss. -->
|
||||||
|
<?= reiterFlaecheAuf("grundriss", $reiterOpt) ?>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-xxl-11">
|
||||||
|
<div class="card mb-4" id="grundrissKarte">
|
||||||
|
<div class="card-header pb-2 pt-2">
|
||||||
|
<h3 class="card-title"><i class="bi bi-house me-2"></i>Etagen und Räume</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<button type="button" class="btn btn-tool" id="grundrissNeuLaden" 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 Etage hat ein Grundrissbild, darauf sitzen die Kacheln ihrer Räume.
|
||||||
|
Ein Raum ohne Kachel ist trotzdem ein Raum: Geräte lassen sich ihm
|
||||||
|
zuordnen, er erscheint nur nicht im Plan. Was auf einer Kachel steht,
|
||||||
|
wird unter „Home-Kacheln“ eingestellt.
|
||||||
|
</p>
|
||||||
|
<div id="grundrissFlaeche">
|
||||||
|
<p class="text-secondary small mb-0">Wird geladen …</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?= reiterFlaecheZu() ?><!-- /Grundriss -->
|
||||||
|
|
||||||
<!-- ================================================ Home-Kacheln ==== -->
|
<!-- ================================================ Home-Kacheln ==== -->
|
||||||
<!-- Der Inhalt kommt aus js/solar/settings.js: eine Karte je Etage, darin
|
<!-- 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,
|
je Raum eine Zeile mit ihren Werten. Gezeichnet wird aus dem Modell,
|
||||||
@@ -149,9 +183,10 @@ try {
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="text-secondary small mb-3">
|
<p class="text-secondary small mb-3">
|
||||||
Jede Kachel im Grundriss zeigt bis zu drei Messwerte. Ohne eigene
|
Jede Kachel im Grundriss zeigt bis zu drei Messwerte. Ohne eigene
|
||||||
Angabe sind das die Werte aus <code>restricted/rooms.php</code> –
|
Angabe sind das bei Räumen mit Thermostat Soll, Ist und Feuchte,
|
||||||
bei Räumen mit Thermostat Soll, Ist und Feuchte. Was hier gesetzt
|
sonst nur der Name. Was hier gesetzt wird, gilt stattdessen;
|
||||||
wird, gilt stattdessen; „Standard“ nimmt es wieder zurück.
|
„Standard“ nimmt es wieder zurück. Wo Kacheln sitzen und welcher
|
||||||
|
Raum ein Thermostat hat, steht unter „Grundriss“.
|
||||||
</p>
|
</p>
|
||||||
<div id="kachelListe">
|
<div id="kachelListe">
|
||||||
<p class="text-secondary small mb-0">Wird geladen …</p>
|
<p class="text-secondary small mb-0">Wird geladen …</p>
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Messblatt für die Kachelpositionen im Grundriss.
|
|
||||||
*
|
|
||||||
* Die Kacheln der Home-Ansicht stehen in restricted/rooms.php als x/y in
|
|
||||||
* einer 400 × 300 großen Zeichenfläche. Wo eine neue hingehört, lässt sich
|
|
||||||
* an einem Bild nur schätzen - und der Bezugspunkt ist auch noch die linke
|
|
||||||
* obere Ecke, nicht die Mitte, die man eigentlich im Auge hat.
|
|
||||||
*
|
|
||||||
* Diese Seite nimmt beides ab: sie zeigt die Grundrisse mit Raster und den
|
|
||||||
* vorhandenen Kacheln in Originalgröße, hängt eine Geisterkachel an den
|
|
||||||
* Zeiger und schreibt darunter die Zeile, die in die Tabelle gehört.
|
|
||||||
*
|
|
||||||
* Aufruf: http://<host>/smart/tools/kachelpositionen.php
|
|
||||||
*
|
|
||||||
* Bewusst eine eigene Seite und kein Eintrag im Menü: sie wird gebraucht,
|
|
||||||
* wenn ein Raum dazukommt, und das ist selten.
|
|
||||||
*/
|
|
||||||
|
|
||||||
require_once(__DIR__ . "/../helper.php");
|
|
||||||
require_once(__DIR__ . "/../restricted/rooms.php");
|
|
||||||
|
|
||||||
if (!checkLogin()) {
|
|
||||||
http_response_code(403);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** So groß ist eine Kachel im SVG - siehe restricted/home.php. */
|
|
||||||
const KACHEL_BREIT = 24;
|
|
||||||
const KACHEL_HOCH = 19;
|
|
||||||
|
|
||||||
/** So groß ist die Zeichenfläche. */
|
|
||||||
const PLAN_BREIT = 400;
|
|
||||||
const PLAN_HOCH = 300;
|
|
||||||
|
|
||||||
?><!doctype html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Kachelpositionen</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--grund: #fbfbfa; --schrift: #1f2328; --leise: #6b7280;
|
|
||||||
--linie: #d8d8d4; --karte: #ffffff; --akzent: #00788f;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--grund: #16181c; --schrift: #e8eaed; --leise: #9aa0a6;
|
|
||||||
--linie: #2f333a; --karte: #1e2126; --akzent: #35b6cf;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 0; padding: 1.5rem 1rem 3rem;
|
|
||||||
background: var(--grund); color: var(--schrift);
|
|
||||||
font: 15px/1.55 -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
||||||
}
|
|
||||||
.huelle { max-width: 900px; margin: 0 auto; }
|
|
||||||
h1 { font-size: 1.35rem; margin: 0 0 .3rem; }
|
|
||||||
.vorwort { color: var(--leise); margin: 0 0 1.5rem; max-width: 62ch; }
|
|
||||||
.vorwort code {
|
|
||||||
background: var(--karte); border: 1px solid var(--linie);
|
|
||||||
border-radius: .25rem; padding: .05rem .3rem; font-size: .88em;
|
|
||||||
}
|
|
||||||
.etage {
|
|
||||||
background: var(--karte); border: 1px solid var(--linie);
|
|
||||||
border-radius: .6rem; padding: .8rem; margin-bottom: 1.2rem;
|
|
||||||
}
|
|
||||||
h2 { font-size: .95rem; margin: 0 0 .5rem; font-weight: 600; }
|
|
||||||
h2 span { font-weight: 400; color: var(--leise); }
|
|
||||||
.plan {
|
|
||||||
display: block; width: 100%; height: auto; border-radius: .4rem;
|
|
||||||
cursor: crosshair; touch-action: none; background: #0b0d10;
|
|
||||||
}
|
|
||||||
.kachel { fill: rgba(0, 0, 0, .4); stroke: var(--akzent); stroke-width: .7; }
|
|
||||||
.anker { fill: var(--akzent); }
|
|
||||||
.kachelname { fill: #fff; font: 4px sans-serif; text-anchor: middle; opacity: .85; }
|
|
||||||
.achsen text { fill: #ffffffaa; font: 7px sans-serif; }
|
|
||||||
.geist { fill: rgba(255, 190, 60, .28); stroke: #ffbe3c; stroke-width: .8; }
|
|
||||||
.kreuz { stroke: #ffbe3c; stroke-width: .4; stroke-dasharray: 3 3; }
|
|
||||||
.ablesung {
|
|
||||||
margin: .6rem 0 0; font-family: ui-monospace, Consolas, monospace;
|
|
||||||
font-size: .88rem; color: var(--leise); min-height: 1.5em;
|
|
||||||
}
|
|
||||||
.ablesung b { color: var(--schrift); }
|
|
||||||
.ablesung .fest { color: var(--akzent); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="huelle">
|
|
||||||
|
|
||||||
<h1>Kachelpositionen im Grundriss</h1>
|
|
||||||
<p class="vorwort">
|
|
||||||
Die Zeichenfläche ist <?= PLAN_BREIT ?> × <?= PLAN_HOCH ?>. Eine Kachel ist
|
|
||||||
<?= KACHEL_BREIT ?> × <?= KACHEL_HOCH ?> groß, und <code>x</code>/<code>y</code>
|
|
||||||
in <code>restricted/rooms.php</code> ist ihre <b>linke obere Ecke</b> – die
|
|
||||||
Punkte an den vorhandenen Kacheln zeigen, wo dieser Bezugspunkt sitzt.
|
|
||||||
Zeig auf die Stelle, an der die <b>Mitte</b> der neuen Kachel liegen soll; das gelbe
|
|
||||||
Rechteck zeigt sie in Originalgröße, darunter stehen die Werte für die Tabelle.
|
|
||||||
Ein Klick friert die Ablesung ein.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<?php foreach ($floors as $floor): ?>
|
|
||||||
<?php
|
|
||||||
$mitKachel = roomsOnFloor($floor);
|
|
||||||
$ohneKachel = array_values(array_filter(allRooms(), function ($r) use ($floor) {
|
|
||||||
return $r["floor"] === $floor && !isset($r["x"]);
|
|
||||||
}));
|
|
||||||
?>
|
|
||||||
<section class="etage">
|
|
||||||
<h2><?= htmlspecialchars($floor) ?><?php if ($ohneKachel): ?>
|
|
||||||
<span>· noch ohne Kachel:
|
|
||||||
<?= htmlspecialchars(implode(", ", array_column($ohneKachel, "mqtt"))) ?></span>
|
|
||||||
<?php endif; ?></h2>
|
|
||||||
|
|
||||||
<svg viewBox="0 0 <?= PLAN_BREIT ?> <?= PLAN_HOCH ?>" class="plan">
|
|
||||||
<defs>
|
|
||||||
<!-- Eigenes Raster je Etage: eine Kennung darf im Dokument nur
|
|
||||||
einmal vorkommen, sonst zeigt url(#...) immer auf dasselbe. -->
|
|
||||||
<pattern id="raster<?= $floor ?>" width="10" height="10" patternUnits="userSpaceOnUse">
|
|
||||||
<path d="M10 0H0V10" fill="none" stroke="#ffffff22" stroke-width=".4"/>
|
|
||||||
</pattern>
|
|
||||||
</defs>
|
|
||||||
<image href="../<?= htmlspecialchars((string)etageBild($floor)) ?>" x="0" y="0"
|
|
||||||
width="<?= PLAN_BREIT ?>" height="<?= PLAN_HOCH ?>"/>
|
|
||||||
<rect x="0" y="0" width="<?= PLAN_BREIT ?>" height="<?= PLAN_HOCH ?>"
|
|
||||||
fill="url(#raster<?= $floor ?>)"/>
|
|
||||||
|
|
||||||
<g class="achsen">
|
|
||||||
<?php for ($v = 50; $v < PLAN_BREIT; $v += 50): ?>
|
|
||||||
<text x="<?= $v ?>" y="9" text-anchor="middle"><?= $v ?></text>
|
|
||||||
<?php endfor; ?>
|
|
||||||
<?php for ($v = 50; $v < PLAN_HOCH; $v += 50): ?>
|
|
||||||
<text x="4" y="<?= $v + 3 ?>"><?= $v ?></text>
|
|
||||||
<?php endfor; ?>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<?php foreach ($mitKachel as $room): ?>
|
|
||||||
<g transform="translate(<?= $room["x"] ?>,<?= $room["y"] ?>)">
|
|
||||||
<rect class="kachel" width="<?= KACHEL_BREIT ?>" height="<?= KACHEL_HOCH ?>" rx="5"/>
|
|
||||||
<circle class="anker" r="1.6"/>
|
|
||||||
<text class="kachelname" x="<?= KACHEL_BREIT / 2 ?>" y="11">
|
|
||||||
<?= htmlspecialchars(mb_substr($room["mqtt"], 0, 8)) ?></text>
|
|
||||||
</g>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
|
|
||||||
<g class="zeiger" style="display:none">
|
|
||||||
<rect class="geist" width="<?= KACHEL_BREIT ?>" height="<?= KACHEL_HOCH ?>" rx="5"/>
|
|
||||||
<line class="kreuz" x1="<?= -PLAN_BREIT ?>" x2="<?= PLAN_BREIT ?>"
|
|
||||||
y1="<?= KACHEL_HOCH / 2 ?>" y2="<?= KACHEL_HOCH / 2 ?>"/>
|
|
||||||
<line class="kreuz" y1="<?= -PLAN_HOCH ?>" y2="<?= PLAN_HOCH ?>"
|
|
||||||
x1="<?= KACHEL_BREIT / 2 ?>" x2="<?= KACHEL_BREIT / 2 ?>"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
<p class="ablesung">Zeiger über den Grundriss bewegen …</p>
|
|
||||||
</section>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Muss zu den Konstanten oben passen - der Server schreibt sie hier hinein.
|
|
||||||
const BREIT = <?= KACHEL_BREIT ?>, HOCH = <?= KACHEL_HOCH ?>;
|
|
||||||
const PLAN_B = <?= PLAN_BREIT ?>, PLAN_H = <?= PLAN_HOCH ?>;
|
|
||||||
|
|
||||||
document.querySelectorAll(".etage").forEach(function (etage) {
|
|
||||||
const plan = etage.querySelector(".plan");
|
|
||||||
const zeiger = etage.querySelector(".zeiger");
|
|
||||||
const text = etage.querySelector(".ablesung");
|
|
||||||
let fest = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vom Zeiger zur Kachel: gezeigt wird auf die Mitte, gebraucht wird die
|
|
||||||
* linke obere Ecke. Die Umrechnung gehoert hierher und nicht in den Kopf
|
|
||||||
* dessen, der misst.
|
|
||||||
*/
|
|
||||||
function stelle(ereignis) {
|
|
||||||
const r = plan.getBoundingClientRect();
|
|
||||||
const mx = (ereignis.clientX - r.left) / r.width * PLAN_B;
|
|
||||||
const my = (ereignis.clientY - r.top) / r.height * PLAN_H;
|
|
||||||
const x = Math.round(mx - BREIT / 2);
|
|
||||||
const y = Math.round(my - HOCH / 2);
|
|
||||||
zeiger.style.display = "";
|
|
||||||
zeiger.setAttribute("transform", "translate(" + x + "," + y + ")");
|
|
||||||
return { x: x, y: y, mx: Math.round(mx), my: Math.round(my) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function zeile(p) {
|
|
||||||
return 'Mitte ' + p.mx + ',' + p.my + ' → <b>"x" => ' +
|
|
||||||
p.x + ', "y" => ' + p.y + '</b>';
|
|
||||||
}
|
|
||||||
|
|
||||||
plan.addEventListener("pointermove", function (e) {
|
|
||||||
if (fest) return;
|
|
||||||
text.innerHTML = zeile(stelle(e));
|
|
||||||
});
|
|
||||||
plan.addEventListener("pointerleave", function () {
|
|
||||||
if (fest) return;
|
|
||||||
zeiger.style.display = "none";
|
|
||||||
text.textContent = "Zeiger über den Grundriss bewegen …";
|
|
||||||
});
|
|
||||||
plan.addEventListener("click", function (e) {
|
|
||||||
const p = stelle(e);
|
|
||||||
fest = !fest;
|
|
||||||
text.innerHTML = fest
|
|
||||||
? '<span class="fest">festgehalten:</span> "x" => <b>' + p.x +
|
|
||||||
'</b>, "y" => <b>' + p.y + '</b> (Mitte ' + p.mx + ',' + p.my +
|
|
||||||
') – nochmal klicken zum Lösen'
|
|
||||||
: zeile(p);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+6
-2
@@ -34,8 +34,12 @@
|
|||||||
* Aufruf: http://<host>/smart/tools/lageplan.php
|
* Aufruf: http://<host>/smart/tools/lageplan.php
|
||||||
*
|
*
|
||||||
* Bewusst eine eigene Seite und kein Eintrag im Menü: sie wird gebraucht,
|
* Bewusst eine eigene Seite und kein Eintrag im Menü: sie wird gebraucht,
|
||||||
* wenn sich draußen etwas ändert, und das ist selten - genau wie
|
* wenn sich draußen etwas ändert, und das ist selten.
|
||||||
* kachelpositionen.php, mit der sie sich die Bauart teilt.
|
*
|
||||||
|
* Seit September 2026 ist sie nur noch ein Weg unter mehreren: das
|
||||||
|
* Außengelände ist eine Etage wie jede andere, und ihr Grundriss darf ein
|
||||||
|
* beliebiges hochgeladenes Bild sein (Einstellungen -> Grundriss). Diese Seite
|
||||||
|
* hilft nur, eines in Schrägsicht zu erzeugen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once(__DIR__ . "/../helper.php");
|
require_once(__DIR__ . "/../helper.php");
|
||||||
|
|||||||
Reference in New Issue
Block a user