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:
2026-09-15 11:11:10 +02:00
co-authored by Claude Opus 5
parent 62a8af3fc6
commit 8d626abc6d
12 changed files with 1365 additions and 255 deletions
+77 -30
View File
@@ -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
View File
@@ -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
View File
@@ -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; }
+1 -1
View File
@@ -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"]],
]; ];
+534
View File
@@ -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();
});
+44
View File
@@ -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();
}
});
}); });
+447
View File
@@ -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).");
}
}
+18
View File
@@ -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 -->
+6 -2
View File
@@ -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
View File
@@ -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>
-214
View File
@@ -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 ?> &times; <?= PLAN_HOCH ?>. Eine Kachel ist
<?= KACHEL_BREIT ?> &times; <?= KACHEL_HOCH ?> groß, und <code>x</code>/<code>y</code>
in <code>restricted/rooms.php</code> ist ihre <b>linke obere Ecke</b> &ndash; 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>&middot; 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 + ' &nbsp;&rarr;&nbsp; <b>"x" =&gt; ' +
p.x + ', "y" =&gt; ' + 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" =&gt; <b>' + p.x +
'</b>, "y" =&gt; <b>' + p.y + '</b> &nbsp;(Mitte ' + p.mx + ',' + p.my +
') &ndash; nochmal klicken zum Lösen'
: zeile(p);
});
});
</script>
</body>
</html>
+6 -2
View File
@@ -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");