Compare commits

...
8 Commits
Author SHA1 Message Date
adminandClaude Opus 5 79a55aeafd Kacheln zeigen frei gewaehlte Messwerte, Heizung nur wo eine haengt
Drei Sachen an den Kacheln und am Raum-Modal.

Die Platzhalter sind nur noch Striche. Vorher stand dort "---- hPa",
gefuellt wurde die Zeile aber mit der Solltemperatur - eine Einheit, die
nie kam, an einer Stelle, die etwas anderes zeigt.

Der Heizungs-Reiter erscheint nur noch, wo auch ein Thermostat haengt.
Frueher entschied das "hat der Raum eine Kachel" - das stimmte, solange
nur Raeume mit Thermostat eine bekamen. Seit auch Kueche und Technik eine
haben, bekamen sie einen Regler fuer eine Heizung, die es dort nicht gibt.
Jetzt zaehlt, ob im Raum ein Geraet der Bedienform "heizung" steht.

Und eine Kachel zeigt jetzt ein bis drei frei gewaehlte Messwerte. In
restricted/rooms.php steht je Raum unter "werte", welche das sind: topic,
optional pfad (Schluessel in einer JSON-Nachricht), einheit, stellen. Ohne
Angabe bleibt es bei den drei Thermostatwerten samt Heizsymbol, also genau
wie bisher - nur diese Raeume zeichnen die Symbole ueberhaupt noch.

Alles kommt live ueber MQTT, auch die beiden Shelly-Zaehler: sie schicken
ihre sechzehn Werte als eine JSON-Nachricht auf Power_EG/status/em:0, aus
der "pfad" den gesuchten Schluessel greift. Der Browser meldet nicht mehr
fest "Raumtemp/#" an, sondern die Zweige, die die Raumtabelle nennt -
derzeit vier statt vierzig einzelner Topics.

Damit steht auf EG Technik die Leistung (gemessen 293 W), auf UG Technik
Leistung und Wasserzaehler (78 W, 400,4 m3). Raeume ohne Messwert tragen
ihren Namen und oeffnen den Raum; ein leeres Rechteck saehe aus wie ein
Fehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:55:58 +02:00
adminandClaude Opus 5 9f52b095b3 Kachelpositionen: Messblatt unter tools/, fuenf neue Kacheln
Wo eine Kachel im Grundriss hingehoert, liess sich bisher nur schaetzen -
und der Bezugspunkt ist auch noch die linke obere Ecke, nicht die Mitte,
die man im Auge hat. tools/kachelpositionen.php nimmt beides ab: Grundriss
mit Raster, die vorhandenen Kacheln in Originalgroesse samt einem Punkt an
ihrem Bezugspunkt, eine Geisterkachel am Zeiger und darunter die Zeile,
die in rooms.php gehoert. Ein Klick friert sie ein.

Als PHP-Seite im Webverzeichnis, nicht als erzeugte HTML-Datei: so liest
sie ihre Raeume live aus restricted/rooms.php und veraltet nicht, wenn ein
Raum dazukommt. Geprueft an bekannten Werten - auf die Mitte einer
vorhandenen Kachel gezeigt kommt genau ihre gespeicherte Koordinate
heraus.

Damit haben jetzt alle Raeume eine Kachel: EG Kueche und Technik, UG
Technik, dazu OG Kueche und Technik als neue Raeume. Sie zeigen vorerst
die Platzhalter der Temperaturvorlage - was auf ihnen stehen soll
(Leistung, Wasserverbrauch), ist der naechste Schritt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:38:10 +02:00
adminandClaude Opus 5 08e8e68a61 Raum-Modal: Solltemperatur wirkt sofort, kein Speichern-Knopf mehr
Im Raum-Modal wirkt jedes Bedienelement sofort - der Rollladen faehrt, der
Schalter schaltet, die Lampe leuchtet. Nur die Solltemperatur wartete auf
"Save changes". Sie schickt jetzt beim Loslassen des Reglers, wie die
Rollladenregler daneben, und der Knopf ist im Raum-Modal weg. Einer, der
nur fuer einen der Reiter gilt, waere in allen anderen eine Falle.

Das Modal bleibt nach dem Schicken offen; frueher schloss es sich, weil
der Knopf das Ende der Bedienung war. Wer dreimal nachjustiert, will nicht
dreimal neu aufmachen. Die Heizungskarte traegt dafuer dieselbe Sperre wie
die Geraetekarten - waehrend das Kommando unterwegs ist, ist sie gesperrt.

Dabei einen Fehler mitgenommen: der Speichern-Knopf gehoert allen Modals
gemeinsam, und wer ihn versteckt, versteckt ihn fuer alle. Ein Raum ohne
Thermostat liess ihn schon bisher verschwinden - danach hatten der
AutoAction-Editor und die Raumzuordnung bis zum Neuladen keinen
Speichern-Knopf mehr. Beide holen ihn jetzt wieder hervor. Nachgemessen:
im Raum-Modal versteckt, im Editor und in der Raumzuordnung sichtbar.

Die Fahrknoepfe sind von 34 auf 50 Pixel gewachsen - sie sind das, was man
im Vorbeigehen antippt, die Regler daneben braucht man seltener.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:20:15 +02:00
adminandClaude Opus 5 e8612bebb8 Raum-Modal: Heizung in einen eigenen Reiter, Spalten buendig
Die Heizung stand ueber den Tabs und war in jedem Raum sichtbar, auch wenn
man wegen der Rollladen gekommen war. Sie ist jetzt eine Gruppe wie die
anderen - nur baut sie sich nicht aus einer Geraeteliste, sondern ist ein
Regler fuer den Raum. zeichneGruppen() nimmt dafuer fertige Inhalte
entgegen. Bei einem Raum mit nur einem Thermostat bleibt die Leiste weg,
dort sieht es aus wie vorher.

Aus den Pillen sind Reiter geworden - sie sehen jetzt aus wie das, was sie
sind, und nicht wie eine Knopfreihe.

Position und Neigung standen um 0,35 rem versetzt: die Regel fuer
gestapelte Regler (Helligkeit ueber Farbe) griff auch auf die zweite
Spalte, die aber danebensteht. Sie gilt jetzt nur noch fuer Regler, die
keine Spalte sind - nachgemessen stehen beide Bahnen auf 181/104.

Das Fensterbild ist von "so breit wie moeglich" auf 5 rem zurueckgenommen;
der uebrige Platz verteilt sich auf die Zwischenraeume, statt das Bild
breitzuziehen. Zwischen ihm und den Bedienelementen liegt jetzt Luft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:11:42 +02:00
adminandClaude Opus 5 18557021b0 Raum-Modal: Fensterbild zeigt Hoehe und Neigung, Regler nach rechts
Die Regler standen neben den Fahrknoepfen - beim Antippen von "Auf" fand
der Daumen leicht den Schieber daneben. Jetzt stehen sie rechts aussen,
zwischen ihnen und den Knoepfen liegt das Fensterbild.

Das Bild zeigt jetzt beides. Die Hoehe schneidet ab: gezeichnet wird nur
der Teil des Fensters, den die Jalousie bedeckt, mit der Endschiene an
seiner Unterkante; darunter sieht man hinaus. Die Neigung bestimmt wie
bisher, wie dick eine Lamelle von vorn erscheint. Ein Rollladen ohne
Neigung bekommt volle Lamellenhoehe - er ist ja ein geschlossener Panzer.

Die Prozentzahl unter den Reglern ist weg; die Bahn ist dafuer von 5 auf
6,5 rem gewachsen. Was der Regler bewirkt, zeigt das Bild ohnehin besser
als eine Zahl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:59:52 +02:00
adminandClaude Opus 5 e7fd7715ef Raum-Modal: Regler gedreht statt writing-mode, eigenes Aussehen
Der senkrechte Positionsregler liess sich am Handy kaum anfassen. Gebaut
war er mit writing-mode: vertical-rl - dem neueren Weg, aber einem, bei
dem der Browser das Ziehen als Wischen liest und die Seite blaettert,
statt den Griff mitzunehmen.

Jetzt liegt ein gewoehnlicher waagerechter Regler um 90 Grad gedreht in
seinem Kasten. Er bewegt sich in seinen eigenen Koordinaten waagerecht,
wird von jedem Browser unterstuetzt, traegt dieselbe Gestaltung wie die
liegenden Regler, und touch-action: none haelt die Seite still, solange
der Finger auf ihm liegt. Die Trefferflaeche ist 1,6 rem breit statt der
sichtbaren 0,375 rem Bahn. Nachgemessen: oben angeklickt 8 %, unten 93 %.

Die Neigung steht jetzt ebenfalls senkrecht, daneben das Lamellenbild -
Regler und Bild bilden ein Paar, das Bild sagt, was der Regler bedeutet.
Beide Spalten sind mit Namen und den Worten an ihren Enden beschriftet
("oben"/"unten", "auf"/"zu"); die Zahl darunter ist das, was sich beim
Ziehen aendert.

Die Regler im Raum-Modal haben ein eigenes Aussehen bekommen: Bootstraps
weisse Bahn mit blauem Griff stach auf den dunklen Karten heraus, und in
einem Raum mit drei Jalousien waren das sechs helle Balken. Bahn und Griff
tragen jetzt dasselbe Blaugruen wie der Temperaturregler, der ungefuellte
Teil ein Grau, das in beiden Themes traegt. Die Regler in den Modals fuer
Autoladung und Heizstab bleiben unberuehrt.

Die Regel fuer die Bahn steht zweimal da, einmal fuer -webkit- und einmal
fuer -moz-: als Liste notiert wirft der Browser beim unbekannten Selektor
die ganze Regel weg, und dann bliebe die Bahn ungestaltet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:53:49 +02:00
adminandClaude Opus 5 f6e0414866 Raum-Modal: Gruppen als Tabs, Jalousien senkrecht bedienen
Die Gruppenueberschriften sind Tabs geworden, Beschattung zuerst - das ist
es, was man in einem Raum am haeufigsten anfasst. Vorher scrollte man an
drei Rollladen vorbei, um zum Licht zu kommen. Bei nur einer Gruppe bleibt
die Leiste weg; auf schmalen Schirmen scrollt sie seitwaerts, statt die
Namen umzubrechen.

Bei den Jalousien stehen Auf, Stop und Zu jetzt untereinander - so zeigen
die Knoepfe in die Richtung, in die sie fahren. Die Hoehe daneben ist ein
senkrechter Regler mit "oben" und "unten" an den Enden: der Griff steht
dort, wo der Rollladen steht, und niemand muss "100 %" uebersetzen.

Die Neigung hat ein Bild dazubekommen. "30 %" oder "70 %" sagt nicht, ob
man noch hinaussieht; vier Streifen sagen es. Gezeichnet wird die Hoehe,
die eine gekippte Lamelle von vorn zeigt (w * sin(Winkel)) - bei "auf"
sieht man nur ihre Kante und viel Luft dazwischen, bei "zu" deckt sie den
Abstand zur naechsten vollstaendig ab. Genau das, was das Licht abhaelt.

Die Regler faerben ihre Bahn jetzt mit, wie es der Temperaturregler seit
jeher tut - dieselbe Eigenschaft --background-size, nur beim senkrechten
von oben nach unten.

css/solar.css hatte gemischte Zeilenenden und ist dabei durchgehend auf LF
gebracht worden; das erklaert den grossen Diff bei kleiner Aenderung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:38:09 +02:00
adminandClaude Opus 5 17f972d081 Raum-Modal: Bedienelemente statt Kommandoliste
Das Modal zeigte jedes Kommando so, wie es in der Datenbank steht: eine
Jalousie kam mit acht Rohbefehlen daher, darunter "Winken", die Farbe einer
Lampe als drei Zahlenfelder 0-255, ein Stromzaehler als abgeschnittene
Textzeile. Die Solltemperatur stand doppelt da - einmal als Schieberegler,
einmal als Zahlenfeld der Thermostat-Karte.

Jetzt entscheidet restricted/roomControls.php, welches Geraet welches
Bedienelement bekommt, und zwar an seinen Faehigkeiten statt am Typnamen:
hat es ein "setClosure"? ein An/Aus-Paar? drei Parameter rot/gruen/blau?
Damit bekommt auch ein neu gefundenes Geraet sein passendes Element, ohne
dass jemand einen Typnamen nachtraegt.

  Beschattung   Auf/Stop/Zu, darunter Position und Neigung als Schieber
                auf dem zuletzt gemeldeten Stand; "Winken" und
                "my-Position" im Mehr-Menue
  Licht         Kippschalter, Helligkeit, Farbwaehler des Browsers,
                Effekt und Preset als Auswahl
  Schalten      ein Kippschalter statt dreier Knoepfe
  Messwerte     Wertegitter, die sechs wichtigsten sichtbar - beim
                Wasserzaehler standen vorher MAC-Adresse und freier
                Speicher vorn und der Verbrauch hinter "12 weitere"
  Heizung       nur noch der Schieberegler, daneben Ist-Temperatur und
                Feuchte live aus dem MQTT-Strom

Ab zwei Geraeten einer Art ordnet eine Ueberschrift; darunter waere sie
nur Aufwand. Schieberegler schicken beim Loslassen, nicht beim Ziehen -
sonst bekaeme eine Jalousie bei jedem Pixel ein neues Ziel. Waehrend ein
Kommando unterwegs ist, sperrt sich die Karte sichtbar: ein "Zu" dauert
ueber eine Minute, und ohne Sperre tippt man in der Zeit nach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:18:46 +02:00
9 changed files with 1611 additions and 155 deletions
+49 -102
View File
@@ -22,6 +22,7 @@ set_time_limit(180);
require_once("../helper.php"); require_once("../helper.php");
require_once("../restricted/automations.php"); require_once("../restricted/automations.php");
require_once("../restricted/commands.php"); require_once("../restricted/commands.php");
require_once("../restricted/roomControls.php");
require_once("./phpMQTT.php"); require_once("./phpMQTT.php");
if (!checkLogin()) { if (!checkLogin()) {
@@ -75,6 +76,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
} }
} }
// --- Inhalt des Modals ---------------------------------------------------- // --- Inhalt des Modals ----------------------------------------------------
$schluessel = preg_replace('/[^A-Za-z0-9_]/', '', strval($_GET["room"] ?? "")); $schluessel = preg_replace('/[^A-Za-z0-9_]/', '', strval($_GET["room"] ?? ""));
$teile = explode("_", $schluessel, 2); $teile = explode("_", $schluessel, 2);
@@ -82,115 +84,60 @@ $etage = $teile[0] ?? "";
$name = $teile[1] ?? ""; $name = $teile[1] ?? "";
$raumKey = $etage . "/" . $name; $raumKey = $etage . "/" . $name;
// Hat der Raum ein Thermostat? Nur dann gibt es etwas zu heizen. // Alles im Raum, sortiert nach Bedienform. Das Thermostat selbst wird nicht
$hatThermostat = false; // als Karte gezeichnet: es wird zum Schieberegler im Heizungs-Reiter und
foreach (allRooms() as $raum) { // braucht daneben keine zweite Bedienung - "Solltemperatur" stand sonst
if ($raum["floor"] === $etage && $raum["mqtt"] === $name) { // zweimal im Modal.
$hatThermostat = isset($raum["x"]); // Kachel = Thermostat vorhanden //
} // Ob es einen Heizungs-Reiter gibt, entscheidet genau das: haengt in diesem
} // Raum ein Thermostat? Frueher stand hier "hat der Raum eine Kachel" - das
// stimmte, solange nur Raeume mit Thermostat eine bekamen. Seit auch Kueche
// und Technik eine haben, bekamen sie einen Heizungsregler fuer eine
// Heizung, die es dort nicht gibt.
$katalog = deviceCatalog(); $katalog = deviceCatalog();
$geraete = array_values(array_filter($katalog, function ($d) use ($raumKey) { $hatThermostat = false;
return $d["room"] === $raumKey && count($d["commands"]) > 0; $gruppen = [];
})); foreach ($katalog as $d) {
$messwerte = array_values(array_filter($katalog, function ($d) use ($raumKey) { if ($d["room"] !== $raumKey) {
return $d["room"] === $raumKey && count($d["states"]) > 0 && count($d["commands"]) === 0; continue;
})); }
$form = bedienform($d);
echo "<div class='p-2' id='roomView' data-room='" . htmlspecialchars($schluessel, ENT_QUOTES) . "'>"; if ($form === "heizung") {
$hatThermostat = true;
if ($hatThermostat) { continue;
echo "<div class='card mb-2'><div class='card-body py-2'>"; }
echo "<label class='form-label mb-0'>Solltemperatur " $gruppen[$form][] = $d;
. "<span id='modal-slider-label' style='font-size:15px;color:#297195;display:inline-block'>–</span></label>";
echo "<input type='range' id='modal-slider' class='form-range range-color-track' min='10' max='30'>";
echo "<div class='d-flex justify-content-between small text-body-secondary'>"
. "<span>10 °C</span><span>30 °C</span></div>";
echo "</div></div>";
} }
if (!$geraete && !$messwerte && !$hatThermostat) { // Die Heizung ist eine Gruppe wie die anderen, nur baut sie sich nicht aus
// einer Geraeteliste: ein Regler fuer den Raum statt einer Karte je Geraet.
$fertig = [];
if ($hatThermostat) {
$karte = "<div class='card mb-2 raum-geraet'><div class='card-body py-2'>"
. "<div class='d-flex justify-content-between align-items-baseline'>"
. "<label class='form-label mb-0 small' for='modal-slider'>Solltemperatur</label>"
. "<span id='modal-slider-label' class='fw-semibold text-nowrap ms-2' style='color:#297195'>–</span>"
. "</div>"
. "<input type='range' id='modal-slider' class='form-range range-color-track' min='10' max='30'>"
// Der Ist-Zustand kommt live ueber MQTT, nicht aus der Datenbank -
// der Browser hoert ohnehin schon auf Raumtemp/#, und ein Wert von
// vorhin waere neben dem Regler nur irrefuehrend.
. "<div class='d-flex flex-wrap gap-3 small text-body-secondary'>"
. "<span><i class='bi bi-thermometer-half me-1'></i><span data-mqtt='Temp[degC]'>–</span> °C</span>"
. "<span><i class='bi bi-droplet me-1'></i><span data-mqtt='rHum[%]'>–</span> %rF</span>"
. "<span data-mqtt-heizung class='d-none'><i class='bi bi-fire text-danger me-1'></i>heizt</span>"
. "</div></div></div>";
$fertig["heizung"] = $karte;
}
echo "<div class='p-1' id='roomView' data-room='" . htmlspecialchars($schluessel, ENT_QUOTES) . "'>";
if (!$gruppen && !$fertig) {
echo "<p class='text-body-secondary mb-0'>Diesem Raum ist noch nichts zugeordnet. " echo "<p class='text-body-secondary mb-0'>Diesem Raum ist noch nichts zugeordnet. "
. "Das geht über das Haus-Symbol in der Karte „Automatismen“.</p>"; . "Das geht über das Haus-Symbol in der Karte „Automatismen“.</p>";
} }
foreach ($geraete as $d) { echo zeichneGruppen($gruppen, $fertig);
echo "<div class='card mb-2'><div class='card-body py-2'>";
echo "<div class='fw-bold small mb-2'>" . htmlspecialchars($d["label"])
. " <span class='fw-normal text-body-secondary'>" . htmlspecialchars($d["type"]) . "</span></div>";
// Kommandos ohne Parameter werden Knoepfe nebeneinander - "Auf", "Zu",
// "Stop" will man antippen und nicht erst einstellen. Die Reihenfolge in
// der Datenbank ist die des Geraete-Suchlaufs und damit Zufall; die
// haeufig gebrauchten kommen deshalb nach vorne.
$knoepfe = array_values(array_filter($d["commands"], function ($c) { return !count($c["params"]); }));
$vorne = ["Auf", "Zu", "Stop", "An", "Aus", "turn_on", "turn_off", "toggle"];
usort($knoepfe, function ($a, $b) use ($vorne) {
$ia = array_search($a["name"], $vorne, true);
$ib = array_search($b["name"], $vorne, true);
$ia = $ia === false ? 99 : $ia;
$ib = $ib === false ? 99 : $ib;
return $ia === $ib ? strcoll($a["name"], $b["name"]) : $ia - $ib;
});
if ($knoepfe) {
echo "<div class='d-flex flex-wrap gap-1 mb-2'>";
foreach ($knoepfe as $c) {
echo "<button type='button' class='btn btn-outline-primary btn-sm' data-cmd='" . $c["id"] . "'>"
. htmlspecialchars($c["name"]) . "</button>";
}
echo "</div>";
}
foreach ($d["commands"] as $c) {
if (!count($c["params"])) {
continue;
}
echo "<div class='input-group input-group-sm mb-1 raum-cmd' data-cmdrow='" . $c["id"] . "'>";
echo "<span class='input-group-text'>" . htmlspecialchars($c["name"]) . "</span>";
foreach ($c["params"] as $p) {
if (count($c["params"]) > 1) {
echo "<span class='input-group-text'>" . htmlspecialchars($p["name"]) . "</span>";
}
if ($p["options"]) {
echo "<select class='form-select' data-param='" . $p["id"] . "'>";
foreach ($p["options"] as $o) {
echo "<option value='" . htmlspecialchars($o["value"], ENT_QUOTES) . "'>"
. htmlspecialchars($o["label"]) . "</option>";
}
echo "</select>";
} else {
echo "<input type='" . ($p["input"] === "number" ? "number" : "text")
. "' class='form-control' data-param='" . $p["id"] . "'"
. ($p["min"] !== null ? " min='" . $p["min"] . "'" : "")
. ($p["max"] !== null ? " max='" . $p["max"] . "'" : "")
. " value='" . ($p["min"] !== null ? intval($p["min"]) : 0) . "'>";
}
}
echo "<button type='button' class='btn btn-outline-primary' data-cmd='" . $c["id"] . "'>"
. "<i class='bi bi-play-fill'></i></button>";
echo "</div>";
}
echo "</div></div>";
}
// Geraete ohne Kommandos: nur anzeigen, was sie melden.
foreach ($messwerte as $d) {
echo "<div class='card mb-2'><div class='card-body py-2'>";
echo "<div class='fw-bold small mb-1'>" . htmlspecialchars($d["label"])
. " <span class='fw-normal text-body-secondary'>" . htmlspecialchars($d["type"]) . "</span></div>";
echo "<div class='small text-body-secondary'>";
$teile = [];
foreach (array_slice($d["states"], 0, 8) as $s) {
$teile[] = htmlspecialchars($s["name"]) . ": " . htmlspecialchars(strval($s["value"]))
. ($s["unit"] ? " " . htmlspecialchars($s["unit"]) : "");
}
echo implode(" · ", $teile);
if (count($d["states"]) > 8) {
echo " · … " . (count($d["states"]) - 8) . " weitere";
}
echo "</div></div></div>";
}
echo "<div id='roomFeedback' class='small mt-2'></div>"; echo "<div id='roomFeedback' class='small mt-2'></div>";
echo "</div>"; echo "</div>";
+264
View File
@@ -111,3 +111,267 @@ input[type='range']::-webkit-slider-runnable-track {
.input-group.raum-cmd > .form-select { .input-group.raum-cmd > .form-select {
min-width: 5rem; min-width: 5rem;
} }
/* --------------------------------------------------------------------------
Raum-Modal: Bedienelemente
Erzeugt von restricted/roomControls.php, verdrahtet in js/solar/homeMQTT.js.
-------------------------------------------------------------------------- */
/* Die Geraetegruppen als Reiter. Auf einem Handy passen fuenf
Beschriftungen nicht nebeneinander - dann scrollt die Leiste seitwaerts,
statt die Namen umzubrechen. Die durchgehende Linie unter den Reitern
darf dabei nicht mitscrollen, sie gehoert an den unteren Rand. */
.raum-tabs {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
flex-wrap: nowrap;
}
.raum-tabs::-webkit-scrollbar {
display: none;
}
.raum-tabs .nav-link {
padding: 0.3rem 0.7rem;
font-size: 0.8rem;
white-space: nowrap;
border-bottom-color: transparent;
}
/* Waehrend ein Kommando unterwegs ist. Ein "Zu" an einer Jalousie dauert
ueber eine Minute; ohne sichtbare Sperre tippt man in der Zeit nach. */
.raum-geraet.raum-warte {
opacity: 0.55;
pointer-events: none;
}
.raum-geraet.raum-warte::after {
content: "";
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 0.9rem;
height: 0.9rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: raum-dreht 0.8s linear infinite;
}
.raum-geraet {
position: relative;
}
@keyframes raum-dreht {
to { transform: rotate(360deg); }
}
/* --------------------------------------------------------------------------
Regler im Raum-Modal
Eigenes Aussehen statt des Bootstrap-Standards: dessen weisse Bahn mit dem
blauen Griff sticht auf den dunklen Karten heraus, und in einem Raum mit
drei Jalousien sind das sechs helle Balken. Bahn und Griff tragen deshalb
dasselbe Blaugruen wie der Temperaturregler, und der ungefuellte Teil ist
ein Grau, das in beiden Themes traegt.
Bewusst nur innerhalb von #roomView: die Regler in den Modals fuer
Autoladung und Heizstab bleiben, wie sie sind.
-------------------------------------------------------------------------- */
#roomView input[type='range'] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 1.5rem;
padding: 0;
margin: 0;
background: transparent;
}
#roomView input[type='range']:focus {
outline: 0;
}
/* Getrennt notiert, nicht als Liste: einen Selektor, den er nicht kennt,
wirft der Browser samt der ganzen Regel weg - und dann bliebe die Bahn
ungestaltet. */
#roomView input[type='range']::-webkit-slider-runnable-track {
height: 0.375rem;
border: 0;
border-radius: 0.375rem;
background: linear-gradient(to right, #00788F, #00788F), rgba(128, 128, 128, 0.32);
background-size: var(--background-size, 0%) 100%;
background-repeat: no-repeat;
}
#roomView input[type='range']::-moz-range-track {
height: 0.375rem;
border: 0;
border-radius: 0.375rem;
background: linear-gradient(to right, #00788F, #00788F), rgba(128, 128, 128, 0.32);
background-size: var(--background-size, 0%) 100%;
background-repeat: no-repeat;
}
#roomView input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 1.05rem;
height: 1.05rem;
/* (Bahnhoehe - Griffhoehe) / 2, damit der Griff auf der Bahn sitzt. */
margin-top: -0.34rem;
border: 2px solid #fff;
border-radius: 50%;
background: #00788F;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
}
#roomView input[type='range']::-moz-range-thumb {
width: 1.05rem;
height: 1.05rem;
border: 2px solid #fff;
border-radius: 50%;
background: #00788F;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
}
/* Schieberegler stehen dicht untereinander (Helligkeit, Farbe). Ohne den
Abstand kleben Beschriftung und Bahn aneinander.
Die senkrechten Spalten stehen dagegen nebeneinander - dort wuerde der
Abstand die zweite Spalte um genau diese 0,35 rem tiefer setzen. */
.raum-regler + .raum-regler:not(.raum-spalte) {
margin-top: 0.35rem;
}
/* Messwerte als Gitter statt als Textwurst: ein Stromzaehler hat sechzehn
Werte, die in einer Zeile niemand findet. auto-fill statt fester Spalten,
damit auf dem Handy zwei und auf dem Schirm vier nebeneinander passen. */
.raum-werte {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr));
gap: 0.4rem;
}
.raum-wert {
background: rgba(128, 128, 128, 0.08);
border-radius: 0.35rem;
padding: 0.3rem 0.45rem;
overflow: hidden;
}
.raum-wert-zahl {
font-size: 0.95rem;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.raum-wert-einheit {
font-size: 0.7rem;
font-weight: 400;
opacity: 0.6;
}
.raum-wert-name {
font-size: 0.68rem;
opacity: 0.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* --------------------------------------------------------------------------
Raum-Modal: Jalousien
-------------------------------------------------------------------------- */
/* Fahrknoepfe, Hoehe und Neigung nebeneinander. Die Neigung nimmt den Rest -
sie ist die einzige der drei Spalten, die von Breite profitiert. */
.raum-jalousie {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 0.4rem;
}
/* Das Fensterbild steht zwischen Knoepfen und Reglern - schmal, mit Luft
zu beiden Seiten. Der uebrige Platz verteilt sich auf die Zwischenraeume
(space-between an .raum-jalousie), statt das Bild breitzuziehen. */
.raum-jalousie .raum-lamellen {
flex: 0 1 5rem;
min-width: 2.2rem;
margin: 0 0.4rem;
}
.raum-jalousie .dropdown {
align-self: flex-start;
}
/* Auf, Stop, Zu untereinander - die Knoepfe zeigen in die Richtung, in die
sie fahren. Breit genug fuer einen Daumen: sie sind das, was man im
Vorbeigehen antippt, die Regler daneben braucht man seltener. */
.raum-fahrt {
flex: 0 0 3.1rem;
}
.raum-fahrt .btn {
padding: 0.3rem 0;
line-height: 1.5;
}
/* Eine Spalte mit senkrechtem Regler: Name, oberes Wort, Bahn, unteres
Wort, Zahl. Breit genug fuer "Position" in der kleinen Schrift. */
.raum-spalte {
flex: 0 0 3.4rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.raum-name {
font-size: 0.62rem;
opacity: 0.8;
white-space: nowrap;
line-height: 1.2;
}
/* Der Kasten, in dem ein gewoehnlicher Regler auf der Seite liegt.
Gedreht statt writing-mode: so gebaute Regler lassen sich am Handy kaum
anfassen - das Ziehen wird als Wischen gelesen und blaettert die Seite.
Gedreht bewegt sich der Regler in seinen eigenen Koordinaten waagerecht,
und touch-action haelt die Seite still, solange der Finger auf ihm liegt.
rotate(90deg) legt den kleinen Wert nach oben: "oben" ist 0 %. */
.raum-senkrecht {
position: relative;
width: 1.6rem;
height: 6.5rem;
margin: 0.1rem 0;
}
#roomView .raum-senkrecht input[type='range'] {
position: absolute;
top: 50%;
left: 50%;
width: 6.5rem; /* = Hoehe des Kastens, die Laenge der Bahn */
height: 1.6rem; /* = Breite des Kastens, die Flaeche zum Treffen */
transform: translate(-50%, -50%) rotate(90deg);
touch-action: none;
}
/* Die Endstellungen in Worten statt in Prozent. */
.raum-ende {
font-size: 0.68rem;
opacity: 0.65;
line-height: 1.1;
}
/* Die Lamellen zur Neigung. Ihre Hoehe setzt lamellenZeichnen() in
js/solar/homeMQTT.js - schmal mit Luft dazwischen heisst offen,
aneinanderliegend heisst geschlossen. */
.raum-lamellen {
display: block;
align-self: center;
height: 6.9rem;
/* Der Rahmen macht aus dem Streifen ein Fenster - ohne ihn liest sich das
geschlossene Bild wie eine zweite Reglerbahn. */
background: rgba(0, 120, 143, 0.14);
border: 1px solid rgba(128, 128, 128, 0.35);
border-radius: 0.2rem;
}
.raum-lamelle {
fill: currentColor;
opacity: 0.6;
stroke: rgba(0, 0, 0, 0.35);
stroke-width: 0.6;
vector-effect: non-scaling-stroke;
}
.raum-endschiene {
fill: currentColor;
opacity: 0.85;
}
+4
View File
@@ -637,6 +637,8 @@ function openAutoActionModal(params) {
const alt = document.getElementById("modalSaveBtn"); const alt = document.getElementById("modalSaveBtn");
const btn = alt.cloneNode(true); const btn = alt.cloneNode(true);
alt.replaceWith(btn); alt.replaceWith(btn);
// Das Raum-Modal versteckt ihn; hier wird er gebraucht.
btn.hidden = false;
btn.addEventListener("click", submitAutoAction); btn.addEventListener("click", submitAutoAction);
// Der Editor braucht mehr Breite als die Ladedialoge, gibt sie aber wieder // Der Editor braucht mehr Breite als die Ladedialoge, gibt sie aber wieder
@@ -742,6 +744,8 @@ function openRoomModal() {
const alt = document.getElementById("modalSaveBtn"); const alt = document.getElementById("modalSaveBtn");
const btn = alt.cloneNode(true); const btn = alt.cloneNode(true);
alt.replaceWith(btn); alt.replaceWith(btn);
// Das Raum-Modal versteckt ihn; hier wird er gebraucht.
btn.hidden = false;
btn.addEventListener("click", speichereRaeume); btn.addEventListener("click", speichereRaeume);
const dialog = document.querySelector("#modalEV .modal-dialog"); const dialog = document.querySelector("#modalEV .modal-dialog");
+249 -24
View File
@@ -13,7 +13,10 @@ const homeMQTT = {
client.on("message", messageReceived); client.on("message", messageReceived);
client.on("connect", function () { client.on("connect", function () {
client.subscribe("Raumtemp/#"); // Welche Zweige gebraucht werden, sagt die Raumtabelle: seit eine
// Kachel jeden Messwert zeigen kann, reicht Raumtemp allein nicht
// mehr. homeTopics kommt aus restricted/rooms.php.
homeTopics.forEach(function (topic) { client.subscribe(topic); });
}); });
client.on("error", function (error) { client.on("error", function (error) {
//alert("MQTT Error: " + error); //alert("MQTT Error: " + error);
@@ -51,25 +54,67 @@ const homeSVG = {
fillElementArray: function () { fillElementArray: function () {
}, },
/**
* Eine Nachricht aus dem Baum holen. mqttData ist nach den Teilen des
* Topics verschachtelt, siehe setNestedProp() weiter oben.
*/
ausTopic: function (mqttData, topic) {
return topic.split("/").reduce(function (zweig, teil) {
return zweig && zweig[teil];
}, mqttData);
},
/**
* Einen Kachelwert lesen und hinschreiben.
*
* "pfad" ist gesetzt, wenn das Geraet mehrere Werte in einer einzigen
* JSON-Nachricht schickt - die beiden Shelly-Zaehler tun das. "stellen"
* schneidet Nachkommastellen ab; ohne Angabe bleibt der Text so, wie das
* Geraet ihn meldet.
*/
kachelWert: function (mqttData, wert) {
const roh = homeSVG.ausTopic(mqttData, wert.topic);
if (typeof roh === "undefined") return null;
let text = String(roh);
if (wert.pfad) {
try {
const daten = JSON.parse(text);
if (!(wert.pfad in daten)) return null;
text = String(daten[wert.pfad]);
} catch (fehler) {
return null; // noch keine vollstaendige Nachricht
}
}
if (typeof wert.stellen === "number" && text !== "" && !isNaN(Number(text))) {
text = Number(text).toFixed(wert.stellen);
}
// Schmales Leerzeichen zwischen Zahl und Einheit, wie es sich gehoert.
return wert.einheit ? text + "\u2009" + wert.einheit : text;
},
updateValuesMQTT: function (mqttData) { updateValuesMQTT: function (mqttData) {
// homeRooms liefert restricted/rooms.php (in home.php eingebettet). // homeRooms liefert restricted/rooms.php (in home.php eingebettet),
// Jeder Raum bekommt die gleiche Behandlung - Raeume kommen dort dazu, // mitsamt den Werten, die auf der Kachel stehen sollen. Jeder Raum
// nicht hier. // bekommt die gleiche Behandlung - Raeume und ihre Werte kommen dort
// dazu, nicht hier.
homeRooms.forEach(function (room) { homeRooms.forEach(function (room) {
const values = mqttData["Raumtemp"]?.[room.floor]?.[room.mqtt];
if (!values) return; // Topic noch nicht empfangen
const el = (part) => document.getElementById(room.floor + "_" + part); const el = (part) => document.getElementById(room.floor + "_" + part);
room.werte.forEach(function (wert, i) {
const text = homeSVG.kachelWert(mqttData, wert);
const feld = el("w" + i + "_" + room.id);
if (text !== null && feld) feld.innerHTML = text;
});
// Heizsymbol und Pufferbetrieb gibt es nur, wo ein Thermostat
// haengt - anderswo sind die Elemente gar nicht gezeichnet.
if (!room.heizung) return;
const values = mqttData["Raumtemp"]?.[room.floor]?.[room.mqtt];
if (!values) return;
if (typeof (values["Heating"]) != "undefined") if (typeof (values["Heating"]) != "undefined")
el(room.id + "_heater").setAttribute("display", values["Heating"] == "false" ? "none" : ""); el(room.id + "_heater").setAttribute("display", values["Heating"] == "false" ? "none" : "");
if (typeof (values["mode"]) != "undefined") if (typeof (values["mode"]) != "undefined")
el(room.id + "_buffer").setAttribute("display", values["mode"] == "Overheating" ? "" : "none"); el(room.id + "_buffer").setAttribute("display", values["mode"] == "Overheating" ? "" : "none");
if (typeof (values["Set Temp[degC]"]) != "undefined")
el("pres_" + room.id).innerHTML = Math.floor(values["Set Temp[degC]"]) + "\u2009\u00b0C";
if (typeof (values["Temp[degC]"]) != "undefined")
el("tmp_" + room.id).innerHTML = values["Temp[degC]"] + "\u2009\u00b0C";
if (typeof (values["rHum[%]"]) != "undefined")
el("hum_" + room.id).innerHTML = values["rHum[%]"] + "\u2009%rF";
}); });
} }
@@ -177,12 +222,16 @@ function switchFloor(floor){
.replace("ue", "ü").replace("ae", "ä").replace("oe", "ö"); .replace("ue", "ü").replace("ae", "ä").replace("oe", "ö");
// Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne // Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne
// Zuhoerer stellt sicher, dass nicht auch der Handler eines vorher // Zuhoerer stellt sicher, dass nicht der Handler eines vorher
// geoeffneten Modals mitfeuert. // geoeffneten Modals mitfeuert; im Raum-Modal bleibt er weg. Hier wird
// nichts gesammelt und dann abgeschickt - jeder Rollladen, jeder
// Schalter und jede Lampe wirkt sofort, und die Solltemperatur tut es
// jetzt auch. Ein Knopf, der nur fuer einen der Reiter gilt, waere in
// allen anderen eine Falle.
const alt = document.getElementById("modalSaveBtn"); const alt = document.getElementById("modalSaveBtn");
const btn = alt.cloneNode(true); const btn = alt.cloneNode(true);
alt.replaceWith(btn); alt.replaceWith(btn);
btn.addEventListener("click", () => sendeSolltemperatur(raum)); btn.hidden = true;
const koerper = document.getElementById("modal-body"); const koerper = document.getElementById("modal-body");
koerper.innerHTML = loadingHTML("Wird geladen..."); koerper.innerHTML = loadingHTML("Wird geladen...");
@@ -202,17 +251,171 @@ function switchFloor(floor){
wert => wert + " °C"); wert => wert + " °C");
const stand = mqttData["Raumtemp"]?.[teile[0]]?.[teile[1]]?.["Set Temp[degC]"]; const stand = mqttData["Raumtemp"]?.[teile[0]]?.[teile[1]]?.["Set Temp[degC]"];
slider.value = typeof stand !== "undefined" ? stand : 10; slider.value = typeof stand !== "undefined" ? stand : 10;
// Nur "input": das zeichnet die Anzeige, schickt aber nichts.
slider.dispatchEvent(new Event("input")); slider.dispatchEvent(new Event("input"));
// "change" kommt beim Loslassen - wie bei den Rollladenreglern.
slider.addEventListener("change", () => sendeSolltemperatur(raum));
} }
// Ohne Thermostat gibt es nichts zu speichern - dann waere der Knopf
// eine Falle.
btn.hidden = !slider;
raumBedienungAktivieren(koerper);
raumMesswerteAktualisieren(teile[0], teile[1]);
})
.catch(fehler => { koerper.innerHTML = "Konnte den Raum nicht laden: " + fehler.message; });
}
/**
* Die Bedienelemente des Raum-Modals mit ihren Kommandos verbinden.
*
* Welches Geraet welches Element bekommt, entscheidet der Server
* (restricted/roomControls.php); hier haengen nur die Zuhoerer an den
* data-Attributen, die er dabei setzt.
*/
function raumBedienungAktivieren(koerper) {
koerper.querySelectorAll("button[data-cmd]").forEach(k => { koerper.querySelectorAll("button[data-cmd]").forEach(k => {
k.addEventListener("click", () => schalte(k)); k.addEventListener("click", () => schalte(k));
}); });
})
.catch(fehler => { koerper.innerHTML = "Konnte den Raum nicht laden: " + fehler.message; }); // Schieberegler: waehrend des Ziehens nur die Anzeige, geschickt wird
// erst beim Loslassen. Sonst bekaeme eine Jalousie bei jedem Pixel ein
// neues Ziel und kaeme aus dem Anfahren nicht mehr heraus.
koerper.querySelectorAll(".raum-jalousie").forEach(lamellenZeichnen);
koerper.querySelectorAll(".raum-regler").forEach(regler => {
const schieber = regler.querySelector("input[type=range]");
reglerAnzeigen(regler);
schieber.addEventListener("input", () => reglerAnzeigen(regler));
schieber.addEventListener("change", () => {
const params = {};
params[regler.dataset.param] = schieber.value;
raumKommando(Number(regler.dataset.cmd), params, regler);
});
});
// Kippschalter: An und Aus sind zwei getrennte Kommandos. Kennt das
// Geraet nur "umschalten", tut es auch das - dann stimmt die Stellung
// hinterher, weil wir sie selbst gesetzt haben.
koerper.querySelectorAll(".raum-schalter").forEach(schalter => {
const kasten = schalter.querySelector("input[type=checkbox]");
const beschriftung = schalter.querySelector("label");
kasten.addEventListener("change", () => {
const ziel = kasten.checked ? schalter.dataset.ein : schalter.dataset.aus;
const cmd = ziel || schalter.dataset.um;
beschriftung.textContent = kasten.checked ? "An" : "Aus";
if (!cmd) return;
raumKommando(Number(cmd), {}, schalter);
});
});
// Farbe: der Browser bringt seinen Farbwaehler mit, das Geraet will drei
// Zahlen 0-255. Zerlegt wird deshalb erst hier.
koerper.querySelectorAll(".raum-farbe").forEach(feld => {
const eingabe = feld.querySelector("input[type=color]");
eingabe.addEventListener("change", () => {
const hex = eingabe.value;
const params = {};
params[feld.dataset.red] = parseInt(hex.substr(1, 2), 16);
params[feld.dataset.green] = parseInt(hex.substr(3, 2), 16);
params[feld.dataset.blue] = parseInt(hex.substr(5, 2), 16);
raumKommando(Number(feld.dataset.cmd), params, feld);
});
});
// Auswahllisten, die ohne zusaetzlichen Knopf schalten - Effekt, Preset.
koerper.querySelectorAll("select.raum-sofort").forEach(auswahl => {
auswahl.addEventListener("change", () => {
const params = {};
params[auswahl.dataset.param] = auswahl.value;
raumKommando(Number(auswahl.dataset.cmd), params, auswahl);
});
});
}
/**
* Anzeige, gefuellte Bahn und Lamellenbild eines Reglers nachziehen.
*
* Was 0 % und 100 % bedeuten, steht bei den senkrechten Reglern als Wort
* an ihren Enden ("oben"/"unten", "auf"/"zu"); eine Zahl steht dort nicht
* mehr - das Bild daneben zeigt beim Ziehen mehr als sie.
*/
function reglerAnzeigen(regler) {
const schieber = regler.querySelector("input[type=range]");
const anzeige = regler.querySelector(".raum-regler-wert");
const wert = Number(schieber.value);
if (anzeige) {
anzeige.textContent = wert + (schieber.dataset.einheit || "");
}
// Der gefuellte Anteil der Bahn - dieselbe Eigenschaft, aus der auch
// bindModalSlider() in common.js den Temperaturregler faerbt.
const min = Number(schieber.min);
const max = Number(schieber.max);
schieber.style.setProperty("--background-size",
((wert - min) * 100 / (max - min)) + "%");
lamellenZeichnen(regler.closest(".raum-jalousie"));
}
/** Der Stand eines der beiden Regler einer Jalousie, als Anteil 0..1. */
function reglerAnteil(jalousie, rolle, ersatz) {
const schieber = jalousie.querySelector("[data-rolle=" + rolle + "] input[type=range]");
if (!schieber) return ersatz;
const min = Number(schieber.min);
return (Number(schieber.value) - min) / (Number(schieber.max) - min);
}
/**
* Das Fenster zeichnen, wie die Jalousie davor gerade steht.
*
* Zwei Zahlen in einem Bild. Die Hoehe schneidet ab - gezeichnet wird nur
* der Teil des Fensters, den die Jalousie bedeckt, mit der Endschiene an
* seiner Unterkante. Die Neigung bestimmt, wie dick eine Lamelle von vorn
* erscheint: w * sin(Winkel). Bei "auf" sieht man nur ihre Kante und viel
* Luft dazwischen, bei "zu" deckt sie den Abstand zur naechsten
* vollstaendig ab - genau das, was das Licht abhaelt. Von Mitte zu Mitte
* sind es 10 Einheiten, die Lamelle ist 11 tief; ab etwa 65 % schliesst
* das Bild, so wie es die Jalousie auch tut.
*
* Ein Rollladen ohne Neigung bekommt volle Lamellenhoehe - er ist ja ein
* geschlossener Panzer.
*/
function lamellenZeichnen(jalousie) {
if (!jalousie) return;
const bild = jalousie.querySelector(".raum-lamellen");
if (!bild) return;
const hoch = bild.viewBox.baseVal.height;
const abdeckung = hoch * reglerAnteil(jalousie, "position", 1);
const kippung = reglerAnteil(jalousie, "neigung", 1);
const dicke = Math.max(1.6, 11 * Math.sin(kippung * Math.PI / 2));
bild.querySelector(".raum-abdeckung").setAttribute("height", abdeckung);
bild.querySelectorAll(".raum-lamelle").forEach(lamelle => {
const mitte = Number(lamelle.dataset.mitte);
lamelle.setAttribute("height", dicke);
lamelle.setAttribute("y", mitte - dicke / 2);
});
// Die Endschiene sitzt auf der Unterkante und bleibt im Bild, auch wenn
// die Jalousie ganz oben steht.
const schiene = bild.querySelector(".raum-endschiene");
const dick = schiene.height.baseVal.value;
schiene.setAttribute("y", Math.max(0, Math.min(hoch - dick, abdeckung - dick)));
}
/**
* Die Ist-Werte im Heizungsteil aus dem laufenden MQTT-Strom fuellen.
*
* Der Browser hoert ohnehin schon auf Raumtemp/#, und ein Wert aus der
* Datenbank waere neben dem Regler nur ein Stand von vorhin.
*/
function raumMesswerteAktualisieren(etage, raum) {
const werte = mqttData["Raumtemp"]?.[etage]?.[raum];
if (!werte) return;
document.querySelectorAll("#roomView [data-mqtt]").forEach(feld => {
const wert = werte[feld.dataset.mqtt];
if (typeof wert !== "undefined") feld.textContent = wert;
});
const heizt = document.querySelector("#roomView [data-mqtt-heizung]");
if (heizt) heizt.classList.toggle("d-none", String(werte["Heating"]) !== "true");
} }
/** Ein Kommando des angeklickten Knopfes ausfuehren. */ /** Ein Kommando des angeklickten Knopfes ausfuehren. */
@@ -222,21 +425,43 @@ function switchFloor(floor){
if (zeile) { if (zeile) {
zeile.querySelectorAll("[data-param]").forEach(f => { params[f.dataset.param] = f.value; }); zeile.querySelectorAll("[data-param]").forEach(f => { params[f.dataset.param] = f.value; });
} }
knopf.disabled = true; raumKommando(Number(knopf.dataset.cmd), params, knopf);
raumRequest("command", { command_id: Number(knopf.dataset.cmd), params: params }) }
/**
* Ein Kommando schicken und die Karte so lange sperren.
*
* Ein "Zu" an einer Jalousie dauert ueber eine Minute (die Mechanik will
* zweimal geschwenkt werden, siehe restricted/commands.php). Ohne sichtbare
* Sperre tippt man in der Zeit dreimal nach.
*/
function raumKommando(cmdId, params, element) {
const karte = element.closest(".raum-geraet") || element;
karte.classList.add("raum-warte");
raumRequest("command", { command_id: cmdId, params: params })
.then(antwort => { .then(antwort => {
knopf.disabled = false; karte.classList.remove("raum-warte");
raumMeldung(antwort); raumMeldung(antwort);
}); });
} }
/**
* Die neue Solltemperatur schicken.
*
* Das Modal bleibt danach offen: frueher schloss es sich, weil der
* Speichern-Knopf das Ende der Bedienung war. Jetzt ist das Verschieben
* des Reglers die Bedienung, und wer dreimal nachjustiert, will nicht
* dreimal neu aufmachen.
*/
function sendeSolltemperatur(raum) { function sendeSolltemperatur(raum) {
const slider = document.getElementById("modal-slider"); const slider = document.getElementById("modal-slider");
if (!slider) return; if (!slider) return;
const karte = slider.closest(".raum-geraet");
if (karte) karte.classList.add("raum-warte");
raumRequest("temp", { room: raum, temp: Number(slider.value) }) raumRequest("temp", { room: raum, temp: Number(slider.value) })
.then(antwort => { .then(antwort => {
if (karte) karte.classList.remove("raum-warte");
raumMeldung(antwort); raumMeldung(antwort);
if (!antwort.error) setTimeout(() => modalEV.hide(), 700);
}); });
} }
+7 -1
View File
@@ -411,7 +411,7 @@ function deviceCatalog()
]; ];
} }
$res = $db->query("SELECT id, actor_id, command_name FROM actor_commands $res = $db->query("SELECT id, actor_id, command_name, command_url FROM actor_commands
ORDER BY actor_id, id"); ORDER BY actor_id, id");
$commands = []; $commands = [];
while ($row = $res->fetch_assoc()) { while ($row = $res->fetch_assoc()) {
@@ -423,6 +423,12 @@ function deviceCatalog()
$devices[$id]["commands"][] = [ $devices[$id]["commands"][] = [
"id" => intval($row["id"]), "id" => intval($row["id"]),
"name" => $row["command_name"], "name" => $row["command_name"],
// Das Raum-Modal erkennt an der URL, was ein Kommando tut:
// "setClosure" ist eine Position, "up"/"down" faehrt, ein
// WLED-"{"on":true}" schaltet ein. Am Namen ginge das nicht -
// der kommt aus dem Geraetesuchlauf und heisst mal "An", mal
// "turn_on".
"url" => $row["command_url"],
"params" => [], "params" => [],
]; ];
} }
+30 -6
View File
@@ -107,15 +107,36 @@
</g> </g>
<?php foreach ($floors as $floor): ?> <?php foreach ($floors as $floor): ?>
<g id="<?= $floor ?>_Info" <?php if ($floor != "OG") echo 'opacity="0" '; ?><?php if($_GET["floor"] != $floor){echo "display='none'";} ?> > <g id="<?= $floor ?>_Info" <?php if ($floor != "OG") echo 'opacity="0" '; ?><?php if($_GET["floor"] != $floor){echo "display='none'";} ?> >
<?php foreach (roomsOnFloor($floor) as $room): ?> <?php foreach (array_map("mitWerten", roomsOnFloor($floor)) as $room): ?>
<?php
/*
* Grundlinien je nach Anzahl der Werte. Die Kachel ist 19 hoch; mit
* drei Werten sind es dieselben Zeilen wie eh und je (klein, gross,
* klein), mit weniger ruecken sie in die Mitte.
*/
$zeilen = [1 => [12], 2 => [9, 16], 3 => [5, 11.5, 16.5]];
$y = $zeilen[count($room["werte"])] ?? [];
?>
<g transform="translate(<?= $room["x"] ?>,<?= $room["y"] ?>)" onclick="openHeaterSettings('<?= $floor ?>_<?= $room["mqtt"] ?>');" cursor="pointer"> <g transform="translate(<?= $room["x"] ?>,<?= $room["y"] ?>)" onclick="openHeaterSettings('<?= $floor ?>_<?= $room["mqtt"] ?>');" cursor="pointer">
<rect width="24" height="19" rx="5" fill="#0006" /> <rect width="24" height="19" rx="5" fill="#0006" />
<?php if ($room["heizung"]): ?>
<use id="<?= $floor ?>_<?= $room["id"] ?>_heater" href="#heater" display="none" /> <use id="<?= $floor ?>_<?= $room["id"] ?>_heater" href="#heater" display="none" />
<use id="<?= $floor ?>_<?= $room["id"] ?>_buffer" href="#buffer" display="none" /> <use id="<?= $floor ?>_<?= $room["id"] ?>_buffer" href="#buffer" display="none" />
<?php endif; ?>
<text fill="#fff" style="font: 4px sans-serif;"> <text fill="#fff" style="font: 4px sans-serif;">
<tspan id="<?= $floor ?>_pres_<?= $room["id"] ?>" y="5" x="12" text-anchor="middle">---- hPa</tspan> <?php if (!$room["werte"]): ?>
<tspan id="<?= $floor ?>_tmp_<?= $room["id"] ?>" x="12" dy="1em" font-size="1.6em" text-anchor="middle">--,- °C</tspan> <!-- Kein Messwert: die Kachel traegt ihren Namen und
<tspan id="<?= $floor ?>_hum_<?= $room["id"] ?>" x="12" dy="1.2em" text-anchor="middle">-- %rF</tspan> oeffnet den Raum. Ein leeres Rechteck saehe aus
wie ein Fehler. -->
<tspan x="12" y="11.5" font-size="1.2em" text-anchor="middle"><?= htmlspecialchars($room["mqtt"]) ?></tspan>
<?php endif; ?>
<?php foreach ($room["werte"] as $i => $wert): ?>
<!-- Der Platzhalter ist nur ein Strich: bevor der
erste Wert da ist, soll hier keine Einheit
stehen, die vielleicht gar nicht passt. -->
<tspan id="<?= $floor ?>_w<?= $i ?>_<?= $room["id"] ?>" x="12" y="<?= $y[$i] ?>"
<?= empty($wert["gross"]) ? "" : 'font-size="1.6em"' ?> text-anchor="middle">&ndash;</tspan>
<?php endforeach; ?>
</text> </text>
</g> </g>
<?php endforeach; ?> <?php endforeach; ?>
@@ -198,8 +219,11 @@
<script> <script>
// Raumtabelle aus restricted/rooms.php - homeMQTT.js aktualisiert darueber // Raumtabelle aus restricted/rooms.php - homeMQTT.js aktualisiert darueber
// die SVG-Kacheln, ohne die Raeume noch einmal aufzufuehren. // die SVG-Kacheln, ohne die Raeume noch einmal aufzufuehren. In "werte"
const homeRooms = <?= json_encode(roomsWithTile()) ?>; // steht, welche Messwerte eine Kachel zeigt; homeTopics sind die Zweige,
// die der Browser dafuer beim Broker anmelden muss.
const homeRooms = <?= json_encode(roomsWithTile(), JSON_UNESCAPED_UNICODE) ?>;
const homeTopics = <?= json_encode(tileTopics(), JSON_UNESCAPED_UNICODE) ?>;
<?php if ($_GET["floor"] != "OG" && in_array($_GET["floor"], $floors)): ?> <?php if ($_GET["floor"] != "OG" && in_array($_GET["floor"], $floors)): ?>
document.addEventListener('readystatechange', function () { document.addEventListener('readystatechange', function () {
if (event.target.readyState === 'complete') { if (event.target.readyState === 'complete') {
+699
View File
@@ -0,0 +1,699 @@
<?php
/**
* Bedienformen fuer das Raum-Modal.
*
* Die Geraetedatenbank kennt nur Kommandos mit Parametern - eine Jalousie hat
* acht davon, darunter "Winken". Stellt man die eins zu eins dar, steht im
* Raum-Modal eine Bedienungsanleitung statt einer Bedienung. Hier entscheidet
* sich deshalb, welches Geraet welches Bedienelement bekommt: ein Rollladen
* Auf/Stop/Zu mit zwei Schiebereglern, eine Lampe einen Kippschalter mit
* Helligkeit und Farbe, ein Zaehler ein Wertegitter.
*
* Erkannt wird an den Faehigkeiten, nicht am Typnamen: hat das Geraet ein
* "setClosure"? ein An/Aus-Paar? drei Parameter rot/gruen/blau? Damit bekommt
* auch ein neu gefundenes Geraet sein passendes Bedienelement, ohne dass hier
* jemand einen Typnamen nachtraegt.
*
* Das Gegenstueck im Browser ist raumBedienungAktivieren() in
* js/solar/homeMQTT.js - dort haengen die Zuhoerer an den data-Attributen,
* die hier gesetzt werden.
*/
/**
* Was das Kommando schaltet: "ein", "aus", "um" - oder null, wenn es kein
* Schaltkommando ist.
*
* Der Geraetesuchlauf vergibt die Namen so, wie das Geraet sie meldet: mal
* "An", mal "turn_on", mal "Anschalten". Deshalb zaehlt zuerst die URL.
*/
function schaltRichtung($cmd)
{
if (count($cmd["params"])) {
return null; // Ein Schalter hat nichts einzustellen.
}
$url = strtolower(trim($cmd["url"]));
$name = strtolower(trim($cmd["name"]));
if (strpos($url, "toggle") !== false || $name === "toggle" || $name === "umschalten") {
return "um";
}
if (in_array($url, ["on", "turn=on", '{"on":true}'], true)
|| in_array($name, ["an", "ein", "anschalten", "einschalten", "turn_on"], true)) {
return "ein";
}
if (in_array($url, ["off", "turn=off", '{"on":false}'], true)
|| in_array($name, ["aus", "ausschalten", "turn_off"], true)) {
return "aus";
}
return null;
}
/** Erstes Kommando, dessen URL in $urls steht. */
function kommandoMitUrl($d, $urls)
{
foreach ($d["commands"] as $c) {
if (in_array($c["url"], $urls, true)) {
return $c;
}
}
return null;
}
/** Erstes Kommando, das genau einen Parameter dieses Namens hat. */
function kommandoMitParameter($d, $name)
{
foreach ($d["commands"] as $c) {
if (count($c["params"]) === 1 && $c["params"][0]["name"] === $name) {
return $c;
}
}
return null;
}
/** Kommando mit Parametern rot/gruen/blau - eine Farbe zum Aussuchen. */
function farbKommando($d)
{
foreach ($d["commands"] as $c) {
$namen = array_map(function ($p) { return strtolower($p["name"]); }, $c["params"]);
sort($namen);
if ($namen === ["blue", "green", "red"]) {
return $c;
}
}
return null;
}
/** An/Aus/Um eines Geraets, sofern vorhanden. */
function schaltKommandos($d)
{
$gefunden = [];
foreach ($d["commands"] as $c) {
$richtung = schaltRichtung($c);
if ($richtung !== null && !isset($gefunden[$richtung])) {
$gefunden[$richtung] = $c;
}
}
return $gefunden;
}
/** Aktueller Wert eines Zustands - der erste Name, den es gibt. */
function zustandsWert($d, $namen)
{
foreach ($namen as $name) {
foreach ($d["states"] as $s) {
if (strcasecmp($s["name"], $name) === 0) {
return $s["value"];
}
}
}
return null;
}
/**
* Welche Bedienform passt zu diesem Geraet?
*
* Die Reihenfolge ist die Rangfolge: eine Jalousie hat auch ein "Auf" und
* "Zu", ist aber keine Lampe, und ein Thermostat mit Solltemperatur ist kein
* Schalter.
*/
function bedienform($d)
{
if (!count($d["commands"])) {
return "messwerte";
}
if (stripos($d["type"], "thermostat") !== false) {
return "heizung";
}
if (kommandoMitUrl($d, ["setClosure", "setClosureAndOrientation"])
|| kommandoMitUrl($d, ["up", "down"])) {
return "beschattung";
}
$schalter = schaltKommandos($d);
if ($schalter && (kommandoMitParameter($d, "brightness") || farbKommando($d))) {
return "licht";
}
if ($schalter) {
return "schalter";
}
return "sonstiges";
}
/**
* Die Gruppen in der Reihenfolge, in der ihre Tabs erscheinen.
*
* Beschattung zuerst: das ist es, was man in einem Raum am haeufigsten
* anfasst. Messwerte zuletzt - die schaut man an, man bedient sie nicht.
*/
function raumGruppenTitel()
{
return [
"beschattung" => ["Beschattung", "bi-blinds"],
"heizung" => ["Heizung", "bi-thermometer-half"],
"licht" => ["Licht", "bi-lightbulb"],
"schalter" => ["Schalten", "bi-toggle-on"],
"sonstiges" => ["Geräte", "bi-sliders"],
"messwerte" => ["Messwerte", "bi-speedometer2"],
];
}
/**
* Die Geraetegruppen als Tabs.
*
* Vorher standen sie untereinander, und man scrollte an drei Rollladen
* vorbei, um zum Licht zu kommen. Bei nur einer Gruppe bleibt die Leiste
* weg - ein einzelner Tab beschriftet nur sich selbst.
*
* $fertig sind Gruppen, deren Inhalt der Aufrufer schon gebaut hat und die
* nicht aus einer Geraeteliste entstehen - die Heizung ist so eine: ein
* Regler fuer den Raum, nicht eine Karte je Geraet.
*/
function zeichneGruppen($gruppen, $fertig = [])
{
$vorhanden = [];
foreach (raumGruppenTitel() as $form => $titel) {
if (!empty($gruppen[$form]) || isset($fertig[$form])) {
$vorhanden[$form] = $titel;
}
}
if (!$vorhanden) {
return "";
}
$html = "";
if (count($vorhanden) > 1) {
$html .= "<ul class='nav nav-tabs flex-nowrap mb-2 raum-tabs' role='tablist'>";
$erster = true;
foreach ($vorhanden as $form => $titel) {
$html .= "<li class='nav-item' role='presentation'>"
. "<button class='nav-link" . ($erster ? " active" : "") . "' type='button'"
. " data-bs-toggle='tab' data-bs-target='#raumtab-" . $form . "' role='tab'>"
. "<i class='bi " . $titel[1] . "'></i>"
. "<span class='ms-1'>" . htmlspecialchars($titel[0]) . "</span>"
. "</button></li>";
$erster = false;
}
$html .= "</ul>";
}
$html .= "<div class='tab-content'>";
$erster = true;
foreach ($vorhanden as $form => $titel) {
$html .= "<div class='tab-pane fade" . ($erster ? " show active" : "") . "'"
. " id='raumtab-" . $form . "' role='tabpanel'>";
if (isset($fertig[$form])) {
$html .= $fertig[$form];
} else {
foreach ($gruppen[$form] as $d) {
$html .= zeichneGeraet($d, $form);
}
}
$html .= "</div>";
$erster = false;
}
return $html . "</div>";
}
// ===========================================================================
// Bausteine
// ===========================================================================
function karteAuf($d, $status = "")
{
$html = "<div class='card mb-2 raum-geraet' data-actor='" . intval($d["id"]) . "'>"
. "<div class='card-body py-2'>"
. "<div class='d-flex justify-content-between align-items-center gap-2 mb-2'>"
. "<span class='fw-semibold small text-truncate'>" . htmlspecialchars($d["label"]) . "</span>";
if ($status !== "") {
$html .= "<span class='small text-body-secondary text-nowrap raum-status'>" . $status . "</span>";
}
return $html . "</div>";
}
function karteZu()
{
return "</div></div>";
}
/**
* Ein Schieberegler, der beim Loslassen schaltet.
*
* Waehrend des Ziehens zu schicken hiesse, bei jedem Pixel ein Kommando an
* die Box zu geben - eine Jalousie kaeme aus dem Anfahren nicht mehr heraus.
*/
function reglerZeile($titel, $cmd, $param, $wert, $einheit = "%")
{
$min = reglerMin($param);
$max = reglerMax($param);
$wert = reglerWert($wert, $min, $max);
return "<div class='raum-regler' data-cmd='" . intval($cmd["id"]) . "'"
. " data-param='" . intval($param["id"]) . "'>"
. "<div class='d-flex justify-content-between small'>"
. "<span>" . htmlspecialchars($titel) . "</span>"
. "<span class='raum-regler-wert fw-semibold'>" . $wert . $einheit . "</span></div>"
. reglerFeld($min, $max, $wert, $einheit)
. "</div>";
}
function reglerMin($param)
{
return $param["min"] === null ? 0 : intval($param["min"]);
}
function reglerMax($param)
{
return $param["max"] === null ? 100 : intval($param["max"]);
}
function reglerWert($wert, $min, $max)
{
$wert = $wert === null || $wert === "" ? $min : intval($wert);
return max($min, min($max, $wert));
}
function reglerFeld($min, $max, $wert, $einheit, $beschriftung = "")
{
return "<input type='range' class='form-range range-color-track' min='" . $min . "' max='" . $max . "'"
. " step='1' value='" . $wert . "' data-einheit='" . htmlspecialchars($einheit, ENT_QUOTES) . "'"
. ($beschriftung === "" ? ""
: " aria-label='" . htmlspecialchars($beschriftung, ENT_QUOTES) . "'")
. ">";
}
/**
* Ein senkrechter Regler mit Wort an jedem Ende.
*
* Beide Werte einer Jalousie bewegen etwas, das oben und unten hat; ein
* waagerechter Schieber dafuer muss erst uebersetzt werden. Senkrecht steht
* der Griff dort, wo der Rollladen steht.
*
* Gedreht wird mit transform, nicht mit writing-mode. writing-mode ist zwar
* der neuere Weg, aber am Handy laesst sich ein so gebauter Regler nur
* schlecht anfassen - das Ziehen wird als Wischen gelesen und blaettert die
* Seite, statt den Griff mitzunehmen. Ein gedrehter gewoehnlicher Regler
* bewegt sich in seinen eigenen Koordinaten waagerecht, wird von jedem
* Browser unterstuetzt und traegt dieselbe Gestaltung wie die liegenden.
*/
function senkrechterRegler($cmd, $param, $wert, $titel, $oben, $unten, $rolle)
{
$min = reglerMin($param);
$max = reglerMax($param);
$wert = reglerWert($wert, $min, $max);
// Keine Prozentzahl darunter: was der Regler bewirkt, zeigt das
// Lamellenbild daneben, und die gesparte Zeile macht die Bahn laenger.
return "<div class='raum-regler raum-spalte' data-cmd='" . intval($cmd["id"]) . "'"
. " data-param='" . intval($param["id"]) . "' data-rolle='" . $rolle . "'>"
. "<span class='raum-name'>" . htmlspecialchars($titel) . "</span>"
. "<span class='raum-ende'>" . htmlspecialchars($oben) . "</span>"
. "<div class='raum-senkrecht'>" . reglerFeld($min, $max, $wert, "%", $titel) . "</div>"
. "<span class='raum-ende'>" . htmlspecialchars($unten) . "</span>"
. "</div>";
}
/**
* Das Fenster, wie die Jalousie davor gerade steht.
*
* Zwei Zahlen in einem Bild: wie weit sie heruntergefahren ist und wie die
* Lamellen stehen. Weder "60 %" noch "30 %" sagt, ob man noch hinaussieht -
* das Bild sagt es.
*
* Die Hoehe schneidet ab: gezeichnet wird nur der Teil des Fensters, den die
* Jalousie bedeckt, mit der Endschiene an seiner Unterkante. Die Neigung
* bestimmt, wie dick eine Lamelle von vorn erscheint - w * sin(Winkel),
* genau das, was das Licht abhaelt. Beides setzt lamellenZeichnen() in
* js/solar/homeMQTT.js.
*
* Die Kennung des Beschnitts traegt die Geraetenummer: in einem Raum haengen
* mehrere dieser Bilder, und url(#...) findet sonst immer dasselbe.
*/
function lamellenBild($actorId)
{
$schnitt = "lamellen" . intval($actorId);
$html = "<svg class='raum-lamellen' viewBox='0 0 100 60' preserveAspectRatio='none' aria-hidden='true'>"
. "<defs><clipPath id='" . $schnitt . "'>"
. "<rect class='raum-abdeckung' x='0' y='0' width='100' height='60'></rect>"
. "</clipPath></defs>"
. "<g clip-path='url(#" . $schnitt . ")'>";
foreach ([5, 15, 25, 35, 45, 55] as $mitte) {
$html .= "<rect class='raum-lamelle' x='0' width='100' data-mitte='" . $mitte . "'"
. " y='" . ($mitte - 1) . "' height='2'></rect>";
}
return $html . "</g>"
. "<rect class='raum-endschiene' x='0' y='57' width='100' height='3'></rect>"
. "</svg>";
}
/** Kippschalter mit dem Stand, den das Geraet zuletzt gemeldet hat. */
function kippSchalter($d, $schalter, $an)
{
$id = "sw" . intval($d["id"]);
$html = "<div class='form-check form-switch raum-schalter'"
. " data-ein='" . (isset($schalter["ein"]) ? intval($schalter["ein"]["id"]) : "") . "'"
. " data-aus='" . (isset($schalter["aus"]) ? intval($schalter["aus"]["id"]) : "") . "'"
. " data-um='" . (isset($schalter["um"]) ? intval($schalter["um"]["id"]) : "") . "'>"
. "<input class='form-check-input' type='checkbox' role='switch' id='" . $id . "'"
. ($an ? " checked" : "") . ">"
. "<label class='form-check-label small' for='" . $id . "'>"
. ($an ? "An" : "Aus") . "</label></div>";
return $html;
}
/** Ein "wahr"-artiger Wert - die Geraete melden mal True, mal 1, mal "on". */
function istAn($wert)
{
if ($wert === null) {
return false;
}
return in_array(strtolower(trim(strval($wert))), ["1", "true", "on", "an", "ein", "yes"], true);
}
/**
* Nebensaechliche Kommandos in ein Mehr-Menue, damit sie nicht stoeren.
*
* Nur Kommandos ohne Parameter: ein Menuepunkt bietet nichts zum Einstellen,
* ein "Position+Neigung" von hier aus wuerde ohne Werte losgeschickt. Was
* Parameter braucht, hat oben seinen Schieberegler.
*/
function mehrMenue($cmds)
{
$cmds = array_values(array_filter($cmds, function ($c) { return !count($c["params"]); }));
if (!$cmds) {
return "";
}
$html = "<div class='dropdown'>"
. "<button class='btn btn-sm btn-link text-body-secondary p-0' type='button'"
. " data-bs-toggle='dropdown' aria-label='Weitere Befehle'>"
. "<i class='bi bi-three-dots'></i></button><ul class='dropdown-menu dropdown-menu-end'>";
foreach ($cmds as $c) {
$html .= "<li><button class='dropdown-item small' type='button' data-cmd='" . intval($c["id"]) . "'>"
. htmlspecialchars($c["name"]) . "</button></li>";
}
return $html . "</ul></div>";
}
// ===========================================================================
// Die Bedienformen
// ===========================================================================
/**
* Der zuletzt gemeldete Stand in Worten.
*
* "0 % zu" muss man erst uebersetzen, "offen" nicht. Die Prozentzahl steht
* ohnehin am Schieberegler darunter.
*/
function beschattungsStand($position, $neigung)
{
if ($position === null || $position === "") {
return "";
}
$p = intval($position);
$text = $p === 0 ? "offen" : ($p === 100 ? "geschlossen" : $p . " % zu");
if ($neigung !== null && $neigung !== "" && $p > 0) {
$text .= ", Neigung " . intval($neigung) . " %";
}
return htmlspecialchars($text);
}
/**
* Rollladen, Jalousie, Markise, Garagentor.
*
* Auf/Stop/Zu gross zum Antippen, darunter die Feinheiten. Die Schieber
* starten auf dem zuletzt gemeldeten Stand - so sieht man, wo das Geraet
* gerade steht, bevor man es bewegt.
*/
function zeichneBeschattung($d)
{
$auf = kommandoMitUrl($d, ["up"]);
$stop = kommandoMitUrl($d, ["stop"]);
$zu = kommandoMitUrl($d, ["down"]);
$position = kommandoMitParameter($d, "Position");
$neigung = kommandoMitParameter($d, "Neigung");
// Was das Geraet zuletzt gemeldet hat. Bei Somfy heisst die Lamelle
// "Lamellenausrichtung", die Hoehe schlicht "Position".
$standPos = zustandsWert($d, ["Position"]);
$standNeigung = zustandsWert($d, ["Lamellenausrichtung"]);
$status = beschattungsStand($standPos, $standNeigung);
$benutzt = array_filter([$auf, $stop, $zu, $position, $neigung]);
$benutzteIds = array_map(function ($c) { return $c["id"]; }, $benutzt);
$rest = array_values(array_filter($d["commands"], function ($c) use ($benutzteIds) {
return !in_array($c["id"], $benutzteIds, true);
}));
$html = karteAuf($d, $status);
$html .= "<div class='raum-jalousie'>";
// Auf, Stop, Zu untereinander: so zeigen die Knoepfe in die Richtung, in
// die sie fahren.
if ($auf || $stop || $zu) {
$html .= "<div class='btn-group-vertical btn-group-sm raum-fahrt' role='group'>";
foreach ([[$auf, "bi-chevron-up", "Auf"], [$stop, "bi-stop-fill", "Stop"],
[$zu, "bi-chevron-down", "Zu"]] as $k) {
if (!$k[0]) {
continue;
}
$html .= "<button type='button' class='btn btn-outline-primary' data-cmd='" . intval($k[0]["id"]) . "'"
. " title='" . htmlspecialchars($k[2], ENT_QUOTES) . "'"
. " aria-label='" . htmlspecialchars($k[2], ENT_QUOTES) . "'>"
. "<i class='bi " . $k[1] . "'></i></button>";
}
$html .= "</div>";
}
// Erst das Bild, dann die Regler: neben den Fahrknoepfen faende der
// Daumen sonst beim Antippen gleich einen Schieber.
if ($position || $neigung) {
$html .= lamellenBild($d["id"]);
}
if ($position) {
$html .= senkrechterRegler($position, $position["params"][0], $standPos,
"Position", "oben", "unten", "position");
}
if ($neigung) {
$html .= senkrechterRegler($neigung, $neigung["params"][0], $standNeigung,
"Neigung", "auf", "zu", "neigung");
}
$html .= mehrMenue($rest);
return $html . "</div>" . karteZu();
}
/**
* Lampe: Kippschalter, Helligkeit, Farbe, Effekt.
*
* Die Farbe kommt in der Datenbank als drei Parameter 0-255 an. Drei
* Zahlenfelder sind keine Farbwahl - deshalb ein Farbfeld, das der Browser
* aufmacht, und die Zerlegung passiert erst beim Absenden.
*/
function zeichneLicht($d)
{
$schalter = schaltKommandos($d);
$an = istAn(zustandsWert($d, ["An", "ison", "on", "state"]));
$helligkeit = kommandoMitParameter($d, "brightness");
$farbe = farbKommando($d);
$benutzteIds = [];
foreach ($schalter as $c) {
$benutzteIds[] = $c["id"];
}
foreach ([$helligkeit, $farbe] as $c) {
if ($c) {
$benutzteIds[] = $c["id"];
}
}
$html = karteAuf($d, "");
$html .= "<div class='d-flex justify-content-between align-items-center mb-2'>";
$html .= $schalter ? kippSchalter($d, $schalter, $an) : "<span></span>";
if ($farbe) {
// Der zuletzt gemeldete Farbwert steht als [r, g, b, w] in der
// Datenbank; alles andere faellt auf Weiss zurueck.
$roh = json_decode(strval(zustandsWert($d, ["Farbe", "color"])), true);
// Steht dort schwarz, leuchtet die Lampe ueber ihren Weisskanal - ein
// schwarzes Feld waere dann eine falsche Auskunft.
$hex = is_array($roh) && count($roh) >= 3
&& (intval($roh[0]) || intval($roh[1]) || intval($roh[2]))
? sprintf("#%02x%02x%02x", intval($roh[0]), intval($roh[1]), intval($roh[2]))
: "#ffffff";
$html .= "<span class='raum-farbe d-inline-flex align-items-center gap-1'"
. " data-cmd='" . intval($farbe["id"]) . "'";
foreach ($farbe["params"] as $p) {
$html .= " data-" . strtolower($p["name"]) . "='" . intval($p["id"]) . "'";
}
$html .= "><label class='small text-body-secondary' for='col" . intval($d["id"]) . "'>Farbe</label>"
. "<input type='color' class='form-control form-control-color form-control-sm'"
. " id='col" . intval($d["id"]) . "' value='" . $hex . "'></span>";
}
$html .= "</div>";
if ($helligkeit) {
$html .= reglerZeile("Helligkeit", $helligkeit, $helligkeit["params"][0],
zustandsWert($d, ["Helligkeit", "brightness", "bri"]), "");
}
// Effekt und Preset sind Listen - die gehoeren in eine Auswahl, nicht in
// ein Zahlenfeld. Sie schalten sofort, ohne zusaetzlichen Knopf.
foreach ($d["commands"] as $c) {
if (in_array($c["id"], $benutzteIds, true) || count($c["params"]) !== 1
|| !count($c["params"][0]["options"])) {
continue;
}
$benutzteIds[] = $c["id"];
$p = $c["params"][0];
$html .= "<div class='input-group input-group-sm mb-1 raum-cmd' data-cmdrow='" . intval($c["id"]) . "'>"
. "<span class='input-group-text'>" . htmlspecialchars($c["name"]) . "</span>"
. "<select class='form-select raum-sofort' data-param='" . intval($p["id"]) . "'"
. " data-cmd='" . intval($c["id"]) . "'>";
foreach ($p["options"] as $o) {
$html .= "<option value='" . htmlspecialchars($o["value"], ENT_QUOTES) . "'>"
. htmlspecialchars($o["label"]) . "</option>";
}
$html .= "</select></div>";
}
$rest = array_values(array_filter($d["commands"], function ($c) use ($benutzteIds) {
return !in_array($c["id"], $benutzteIds, true);
}));
if ($rest) {
$html .= "<div class='d-flex justify-content-end'>" . mehrMenue($rest) . "</div>";
}
return $html . karteZu();
}
/** Alles, was nur an und aus kennt. */
function zeichneSchalter($d)
{
$schalter = schaltKommandos($d);
$an = istAn(zustandsWert($d, ["An", "ison", "on", "state", "Status"]));
$benutzteIds = array_map(function ($c) { return $c["id"]; }, array_values($schalter));
$rest = array_values(array_filter($d["commands"], function ($c) use ($benutzteIds) {
return !in_array($c["id"], $benutzteIds, true);
}));
$html = karteAuf($d, "");
$html .= "<div class='d-flex justify-content-between align-items-center'>"
. kippSchalter($d, $schalter, $an) . mehrMenue($rest) . "</div>";
return $html . karteZu();
}
/**
* Geraete, die nur melden: ein Wertegitter statt einer Textwurst.
*
* Ein Stromzaehler hat sechzehn Werte. Alle sechzehn hinzuschreiben ist
* genauso unbrauchbar wie acht davon abzuschneiden - die ersten sechs
* stehen da, der Rest ist einen Klick entfernt.
*/
function zeichneMesswerte($d)
{
$html = karteAuf($d, htmlspecialchars($d["type"]));
$states = nachWichtigkeit($d["states"]);
$sichtbar = array_slice($states, 0, 6);
$weitere = array_slice($states, 6);
$html .= "<div class='raum-werte'>";
foreach ($sichtbar as $s) {
$html .= wertKachel($s);
}
$html .= "</div>";
if ($weitere) {
$html .= "<details class='mt-2'><summary class='small text-body-secondary'>"
. count($weitere) . " weitere Werte</summary>"
. "<div class='raum-werte mt-2'>";
foreach ($weitere as $s) {
$html .= wertKachel($s);
}
$html .= "</div></details>";
}
return $html . karteZu();
}
/**
* Messwerte so ordnen, dass die sechs sichtbaren die richtigen sind.
*
* Die Reihenfolge in der Datenbank ist die des Geraetesuchlaufs: beim
* Wasserzaehler standen MAC-Adresse, Firmware und freier Speicher vorn, der
* Verbrauch hinter "12 weitere Werte". Vorne gehoert hin, was gemessen wird -
* ein Wert mit Einheit -, hinten, was ueber das Geraet selbst Auskunft gibt.
*/
function nachWichtigkeit($states)
{
$diagnose = ["mac", "hostname", "fwversion", "uptime", "freemem", "rssi",
"error", "status", "ip", "ssid", "version"];
$rang = function ($s) use ($diagnose) {
$leer = $s["value"] === null || $s["value"] === "";
$technisch = in_array(strtolower($s["name"]), $diagnose, true);
return ($technisch ? 4 : 0) + ($leer ? 2 : 0) + ($s["unit"] ? 0 : 1);
};
usort($states, function ($a, $b) use ($rang) { return $rang($a) - $rang($b); });
return $states;
}
function wertKachel($s)
{
$wert = strval($s["value"]);
if ($wert === "") {
$wert = "–";
}
// Messwerte kommen mit voller Rechengenauigkeit aus dem Geraet; auf einer
// Kachel liest niemand 231.10000000000002.
if (is_numeric($wert) && strpos($wert, ".") !== false) {
$wert = rtrim(rtrim(number_format(floatval($wert), 2, ",", ""), "0"), ",");
}
return "<div class='raum-wert'>"
. "<div class='raum-wert-zahl'>" . htmlspecialchars($wert)
. ($s["unit"] ? " <span class='raum-wert-einheit'>" . htmlspecialchars($s["unit"]) . "</span>" : "")
. "</div>"
. "<div class='raum-wert-name'>" . htmlspecialchars($s["name"]) . "</div></div>";
}
/** Was in keine Form passt: Kommandos so, wie sie in der Datenbank stehen. */
function zeichneSonstiges($d)
{
$html = karteAuf($d, htmlspecialchars($d["type"]));
$knoepfe = array_values(array_filter($d["commands"], function ($c) { return !count($c["params"]); }));
if ($knoepfe) {
$html .= "<div class='d-flex flex-wrap gap-1 mb-2'>";
foreach ($knoepfe as $c) {
$html .= "<button type='button' class='btn btn-outline-primary btn-sm' data-cmd='" . intval($c["id"]) . "'>"
. htmlspecialchars($c["name"]) . "</button>";
}
$html .= "</div>";
}
foreach ($d["commands"] as $c) {
if (!count($c["params"])) {
continue;
}
$html .= "<div class='input-group input-group-sm mb-1 raum-cmd' data-cmdrow='" . intval($c["id"]) . "'>"
. "<span class='input-group-text'>" . htmlspecialchars($c["name"]) . "</span>";
foreach ($c["params"] as $p) {
if (count($c["params"]) > 1) {
$html .= "<span class='input-group-text'>" . htmlspecialchars($p["name"]) . "</span>";
}
if ($p["options"]) {
$html .= "<select class='form-select' data-param='" . intval($p["id"]) . "'>";
foreach ($p["options"] as $o) {
$html .= "<option value='" . htmlspecialchars($o["value"], ENT_QUOTES) . "'>"
. htmlspecialchars($o["label"]) . "</option>";
}
$html .= "</select>";
} else {
$html .= "<input type='" . ($p["input"] === "number" ? "number" : "text")
. "' class='form-control' data-param='" . intval($p["id"]) . "'"
. ($p["min"] !== null ? " min='" . intval($p["min"]) . "'" : "")
. ($p["max"] !== null ? " max='" . intval($p["max"]) . "'" : "")
. " value='" . ($p["min"] !== null ? intval($p["min"]) : 0) . "'>";
}
}
$html .= "<button type='button' class='btn btn-outline-primary' data-cmd='" . intval($c["id"]) . "'>"
. "<i class='bi bi-play-fill'></i></button></div>";
}
return $html . karteZu();
}
/** Ein Geraet in seiner Bedienform zeichnen. */
function zeichneGeraet($d, $form)
{
switch ($form) {
case "beschattung": return zeichneBeschattung($d);
case "licht": return zeichneLicht($d);
case "schalter": return zeichneSchalter($d);
case "messwerte": return zeichneMesswerte($d);
default: return zeichneSonstiges($d);
}
}
+82 -9
View File
@@ -9,15 +9,26 @@
* *
* floor Etage; bestimmt zugleich den Zweig Raumtemp/<floor>/ * floor Etage; bestimmt zugleich den Zweig Raumtemp/<floor>/
* mqtt Name des Raums im MQTT-Baum: Raumtemp/<floor>/<mqtt>/... * mqtt Name des Raums im MQTT-Baum: Raumtemp/<floor>/<mqtt>/...
* id Kürzel in den SVG-Element-IDs: <floor>_tmp_<id>, <floor>_<id>_heater * id Kürzel in den SVG-Element-IDs: <floor>_w0_<id>, <floor>_<id>_heater
* x, y Position der Kachel im SVG (viewBox 400x300) - fehlt bei Räumen * x, y Position der Kachel im SVG (viewBox 400x300) - fehlt bei Räumen
* ohne Kachel * ohne Kachel
* werte Was auf der Kachel steht: bis zu drei Messwerte, jeder mit
* topic, optional pfad (Schlüssel in einer JSON-Nachricht),
* einheit und stellen (Nachkommastellen). Fehlt der Eintrag, sind
* es die drei Thermostatwerte - Soll, Ist, Feuchte - wie bisher.
* Eine leere Liste heisst: kein Messwert, die Kachel trägt nur
* ihren Namen und öffnet den Raum.
* *
* Ein Raum ohne x/y ist trotzdem ein Raum: Geräte lassen sich ihm zuordnen * Ein Raum ohne x/y ist trotzdem ein Raum: Geräte lassen sich ihm zuordnen
* (siehe die Spalte actors.room und den Zuordnungsdialog in der Karte * (siehe die Spalte actors.room und den Zuordnungsdialog in der Karte
* "Automatismen"), er erscheint nur nicht als Kachel im Grundriss. Das * "Automatismen"), er erscheint nur nicht als Kachel im Grundriss. Derzeit
* betrifft Räume ohne Thermostat - die Küche im EG, die Technikräume -, * hat jeder Raum eine Kachel; die Unterscheidung bleibt, weil ein neuer Raum
* deren Kachel eine Temperatur anzeigen würde, die niemand misst. * zuerst als Zeile ohne Position entsteht und erst dann seinen Platz im
* Grundriss bekommt.
*
* Wo eine Kachel hingehört, misst tools/kachelpositionen.php aus: Grundriss
* mit Raster, Geisterkachel am Zeiger, fertige Zeile zum Abschreiben. x/y
* ist die linke obere Ecke der 24 x 19 grossen Kachel, nicht ihre Mitte.
*/ */
// Reihenfolge im Menü und in der Anzeige; OG ist die Standardetage. // Reihenfolge im Menü und in der Anzeige; OG ist die Standardetage.
@@ -29,17 +40,35 @@ $rooms = [
["floor" => "OG", "mqtt" => "Schlafzimmer", "id" => "schlafen", "x" => 235, "y" => 70], ["floor" => "OG", "mqtt" => "Schlafzimmer", "id" => "schlafen", "x" => 235, "y" => 70],
["floor" => "OG", "mqtt" => "Bad", "id" => "bad", "x" => 260, "y" => 120], ["floor" => "OG", "mqtt" => "Bad", "id" => "bad", "x" => 260, "y" => 120],
["floor" => "OG", "mqtt" => "Buero", "id" => "buero", "x" => 265, "y" => 175], ["floor" => "OG", "mqtt" => "Buero", "id" => "buero", "x" => 265, "y" => 175],
// Kein Thermostat, und noch kein Gerät zugeordnet - die Kachel trägt
// vorerst nur ihren Namen.
["floor" => "OG", "mqtt" => "Kueche", "id" => "kueche", "x" => 122, "y" => 138,
"werte" => []],
["floor" => "OG", "mqtt" => "Technik", "id" => "technik", "x" => 173, "y" => 67,
"werte" => []],
["floor" => "EG", "mqtt" => "Wohnzimmer", "id" => "wozi", "x" => 185, "y" => 150], ["floor" => "EG", "mqtt" => "Wohnzimmer", "id" => "wozi", "x" => 185, "y" => 150],
["floor" => "EG", "mqtt" => "Florian", "id" => "kizi", "x" => 130, "y" => 90], ["floor" => "EG", "mqtt" => "Florian", "id" => "kizi", "x" => 130, "y" => 90],
["floor" => "EG", "mqtt" => "Schlafen", "id" => "schlafen", "x" => 235, "y" => 80], ["floor" => "EG", "mqtt" => "Schlafen", "id" => "schlafen", "x" => 235, "y" => 80],
["floor" => "EG", "mqtt" => "Bad", "id" => "bad", "x" => 253, "y" => 130], ["floor" => "EG", "mqtt" => "Bad", "id" => "bad", "x" => 253, "y" => 130],
["floor" => "EG", "mqtt" => "Magdalena", "id" => "buero", "x" => 258, "y" => 180], ["floor" => "EG", "mqtt" => "Magdalena", "id" => "buero", "x" => 258, "y" => 180],
["floor" => "EG", "mqtt" => "Kueche", "id" => "kueche"], ["floor" => "EG", "mqtt" => "Kueche", "id" => "kueche", "x" => 126, "y" => 146,
["floor" => "EG", "mqtt" => "Technik", "id" => "technik"], "werte" => []],
// Der Shelly-Zähler des EG meldet seine Werte als JSON in einer einzigen
// Nachricht; "pfad" greift den Schlüssel daraus heraus.
["floor" => "EG", "mqtt" => "Technik", "id" => "technik", "x" => 172, "y" => 78,
"werte" => [
["topic" => "Power_EG/status/em:0", "pfad" => "total_act_power",
"einheit" => "W", "stellen" => 0, "gross" => true],
]],
["floor" => "UG", "mqtt" => "Buero", "id" => "buero", "x" => 140, "y" => 140], ["floor" => "UG", "mqtt" => "Buero", "id" => "buero", "x" => 140, "y" => 140],
["floor" => "UG", "mqtt" => "Bad", "id" => "bad", "x" => 210, "y" => 80], ["floor" => "UG", "mqtt" => "Bad", "id" => "bad", "x" => 210, "y" => 80],
["floor" => "UG", "mqtt" => "Kueche", "id" => "kueche", "x" => 190, "y" => 195], ["floor" => "UG", "mqtt" => "Kueche", "id" => "kueche", "x" => 190, "y" => 195],
["floor" => "UG", "mqtt" => "Technik", "id" => "technik"], ["floor" => "UG", "mqtt" => "Technik", "id" => "technik", "x" => 238, "y" => 93,
"werte" => [
["topic" => "Power_UG/status/em:0", "pfad" => "total_act_power",
"einheit" => "W", "stellen" => 0, "gross" => true],
["topic" => "wasser/main/value", "einheit" => "m³", "stellen" => 1],
]],
]; ];
/** /**
@@ -65,7 +94,51 @@ function allRooms()
/** Nur die Räume mit Kachel, für die Aktualisierungsschleife im Browser. */ /** Nur die Räume mit Kachel, für die Aktualisierungsschleife im Browser. */
function roomsWithTile() function roomsWithTile()
{ {
return array_values(array_filter($GLOBALS["rooms"], function ($room) { return array_values(array_map("mitWerten", array_filter($GLOBALS["rooms"], function ($room) {
return isset($room["x"]); return isset($room["x"]);
})); })));
}
/**
* Die Messwerte einer Kachel, fertig ausgeschrieben.
*
* Ohne eigene Angabe sind es die drei Thermostatwerte, die dort schon immer
* standen: Solltemperatur klein darüber, Isttemperatur gross, Feuchte klein
* darunter. Nur diese Räume bekommen auch die Symbole für "heizt" und
* "Pufferbetrieb" - anderswo gibt es nichts zu heizen.
*/
function mitWerten($room)
{
if (isset($room["werte"])) {
$room["heizung"] = false;
$room["werte"] = array_slice($room["werte"], 0, 3);
return $room;
}
$zweig = "Raumtemp/" . $room["floor"] . "/" . $room["mqtt"] . "/";
$room["heizung"] = true;
$room["werte"] = [
["topic" => $zweig . "Set Temp[degC]", "einheit" => "°C", "stellen" => 0],
["topic" => $zweig . "Temp[degC]", "einheit" => "°C", "gross" => true],
["topic" => $zweig . "rHum[%]", "einheit" => "%rF"],
];
return $room;
}
/**
* Was der Browser beim Broker anmelden muss, damit die Kacheln laufen.
*
* Nicht die einzelnen Topics, sondern ihre Zweige: aus vierzig Anmeldungen
* für Raumtemp werden so vier insgesamt. Der Zweig bringt ausserdem mit,
* was die Kachel nebenbei braucht - "Heating" und "mode" stehen in keiner
* Werteliste, das Heizsymbol lebt aber davon.
*/
function tileTopics()
{
$zweige = [];
foreach (roomsWithTile() as $room) {
foreach ($room["werte"] as $wert) {
$zweige[explode("/", $wert["topic"])[0] . "/#"] = true;
}
}
return array_keys($zweige);
} }
+214
View File
@@ -0,0 +1,214 @@
<?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="../assets/img/<?= $floor ?>.png" 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>