Compare commits
8
Commits
131d7cc46d
...
79a55aeafd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79a55aeafd | ||
|
|
9f52b095b3 | ||
|
|
08e8e68a61 | ||
|
|
e8612bebb8 | ||
|
|
18557021b0 | ||
|
|
e7fd7715ef | ||
|
|
f6e0414866 | ||
|
|
17f972d081 |
+49
-102
@@ -22,6 +22,7 @@ set_time_limit(180);
|
||||
require_once("../helper.php");
|
||||
require_once("../restricted/automations.php");
|
||||
require_once("../restricted/commands.php");
|
||||
require_once("../restricted/roomControls.php");
|
||||
require_once("./phpMQTT.php");
|
||||
|
||||
if (!checkLogin()) {
|
||||
@@ -75,6 +76,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Inhalt des Modals ----------------------------------------------------
|
||||
$schluessel = preg_replace('/[^A-Za-z0-9_]/', '', strval($_GET["room"] ?? ""));
|
||||
$teile = explode("_", $schluessel, 2);
|
||||
@@ -82,115 +84,60 @@ $etage = $teile[0] ?? "";
|
||||
$name = $teile[1] ?? "";
|
||||
$raumKey = $etage . "/" . $name;
|
||||
|
||||
// Hat der Raum ein Thermostat? Nur dann gibt es etwas zu heizen.
|
||||
$hatThermostat = false;
|
||||
foreach (allRooms() as $raum) {
|
||||
if ($raum["floor"] === $etage && $raum["mqtt"] === $name) {
|
||||
$hatThermostat = isset($raum["x"]); // Kachel = Thermostat vorhanden
|
||||
}
|
||||
}
|
||||
|
||||
// Alles im Raum, sortiert nach Bedienform. Das Thermostat selbst wird nicht
|
||||
// als Karte gezeichnet: es wird zum Schieberegler im Heizungs-Reiter und
|
||||
// braucht daneben keine zweite Bedienung - "Solltemperatur" stand sonst
|
||||
// zweimal im Modal.
|
||||
//
|
||||
// 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();
|
||||
$geraete = array_values(array_filter($katalog, function ($d) use ($raumKey) {
|
||||
return $d["room"] === $raumKey && count($d["commands"]) > 0;
|
||||
}));
|
||||
$messwerte = array_values(array_filter($katalog, function ($d) use ($raumKey) {
|
||||
return $d["room"] === $raumKey && count($d["states"]) > 0 && count($d["commands"]) === 0;
|
||||
}));
|
||||
|
||||
echo "<div class='p-2' id='roomView' data-room='" . htmlspecialchars($schluessel, ENT_QUOTES) . "'>";
|
||||
|
||||
if ($hatThermostat) {
|
||||
echo "<div class='card mb-2'><div class='card-body py-2'>";
|
||||
echo "<label class='form-label mb-0'>Solltemperatur "
|
||||
. "<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>";
|
||||
$hatThermostat = false;
|
||||
$gruppen = [];
|
||||
foreach ($katalog as $d) {
|
||||
if ($d["room"] !== $raumKey) {
|
||||
continue;
|
||||
}
|
||||
$form = bedienform($d);
|
||||
if ($form === "heizung") {
|
||||
$hatThermostat = true;
|
||||
continue;
|
||||
}
|
||||
$gruppen[$form][] = $d;
|
||||
}
|
||||
|
||||
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. "
|
||||
. "Das geht über das Haus-Symbol in der Karte „Automatismen“.</p>";
|
||||
}
|
||||
|
||||
foreach ($geraete as $d) {
|
||||
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 zeichneGruppen($gruppen, $fertig);
|
||||
|
||||
echo "<div id='roomFeedback' class='small mt-2'></div>";
|
||||
echo "</div>";
|
||||
|
||||
+276
-12
@@ -1,15 +1,15 @@
|
||||
.winddir {
|
||||
position:absolute;
|
||||
left:60%;
|
||||
width:25%;
|
||||
height:40%;
|
||||
padding: 0;
|
||||
background-image: url('../assets/img/arrow.svg');
|
||||
background-size:contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.winddir {
|
||||
position:absolute;
|
||||
left:60%;
|
||||
width:25%;
|
||||
height:40%;
|
||||
padding: 0;
|
||||
background-image: url('../assets/img/arrow.svg');
|
||||
background-size:contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
|
||||
/* Schieberegler in den Modals (Autoladung, Heizstab, Thermostat).
|
||||
Der gefuellte Anteil kommt aus --background-size, gesetzt von
|
||||
@@ -111,3 +111,267 @@ input[type='range']::-webkit-slider-runnable-track {
|
||||
.input-group.raum-cmd > .form-select {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -637,6 +637,8 @@ function openAutoActionModal(params) {
|
||||
const alt = document.getElementById("modalSaveBtn");
|
||||
const btn = alt.cloneNode(true);
|
||||
alt.replaceWith(btn);
|
||||
// Das Raum-Modal versteckt ihn; hier wird er gebraucht.
|
||||
btn.hidden = false;
|
||||
btn.addEventListener("click", submitAutoAction);
|
||||
|
||||
// Der Editor braucht mehr Breite als die Ladedialoge, gibt sie aber wieder
|
||||
@@ -742,6 +744,8 @@ function openRoomModal() {
|
||||
const alt = document.getElementById("modalSaveBtn");
|
||||
const btn = alt.cloneNode(true);
|
||||
alt.replaceWith(btn);
|
||||
// Das Raum-Modal versteckt ihn; hier wird er gebraucht.
|
||||
btn.hidden = false;
|
||||
btn.addEventListener("click", speichereRaeume);
|
||||
|
||||
const dialog = document.querySelector("#modalEV .modal-dialog");
|
||||
|
||||
+250
-25
@@ -13,7 +13,10 @@ const homeMQTT = {
|
||||
|
||||
client.on("message", messageReceived);
|
||||
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) {
|
||||
//alert("MQTT Error: " + error);
|
||||
@@ -51,25 +54,67 @@ const homeSVG = {
|
||||
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) {
|
||||
// homeRooms liefert restricted/rooms.php (in home.php eingebettet).
|
||||
// Jeder Raum bekommt die gleiche Behandlung - Raeume kommen dort dazu,
|
||||
// nicht hier.
|
||||
// homeRooms liefert restricted/rooms.php (in home.php eingebettet),
|
||||
// mitsamt den Werten, die auf der Kachel stehen sollen. Jeder Raum
|
||||
// bekommt die gleiche Behandlung - Raeume und ihre Werte kommen dort
|
||||
// dazu, nicht hier.
|
||||
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);
|
||||
|
||||
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")
|
||||
el(room.id + "_heater").setAttribute("display", values["Heating"] == "false" ? "none" : "");
|
||||
if (typeof (values["mode"]) != "undefined")
|
||||
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", "ö");
|
||||
|
||||
// Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne
|
||||
// Zuhoerer stellt sicher, dass nicht auch der Handler eines vorher
|
||||
// geoeffneten Modals mitfeuert.
|
||||
// Zuhoerer stellt sicher, dass nicht der Handler eines vorher
|
||||
// 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 btn = alt.cloneNode(true);
|
||||
alt.replaceWith(btn);
|
||||
btn.addEventListener("click", () => sendeSolltemperatur(raum));
|
||||
btn.hidden = true;
|
||||
|
||||
const koerper = document.getElementById("modal-body");
|
||||
koerper.innerHTML = loadingHTML("Wird geladen...");
|
||||
@@ -202,19 +251,173 @@ function switchFloor(floor){
|
||||
wert => wert + " °C");
|
||||
const stand = mqttData["Raumtemp"]?.[teile[0]]?.[teile[1]]?.["Set Temp[degC]"];
|
||||
slider.value = typeof stand !== "undefined" ? stand : 10;
|
||||
// Nur "input": das zeichnet die Anzeige, schickt aber nichts.
|
||||
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;
|
||||
|
||||
koerper.querySelectorAll("button[data-cmd]").forEach(k => {
|
||||
k.addEventListener("click", () => schalte(k));
|
||||
});
|
||||
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 => {
|
||||
k.addEventListener("click", () => schalte(k));
|
||||
});
|
||||
|
||||
// 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. */
|
||||
function schalte(knopf) {
|
||||
const zeile = knopf.closest("[data-cmdrow]");
|
||||
@@ -222,21 +425,43 @@ function switchFloor(floor){
|
||||
if (zeile) {
|
||||
zeile.querySelectorAll("[data-param]").forEach(f => { params[f.dataset.param] = f.value; });
|
||||
}
|
||||
knopf.disabled = true;
|
||||
raumRequest("command", { command_id: Number(knopf.dataset.cmd), params: params })
|
||||
raumKommando(Number(knopf.dataset.cmd), params, knopf);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 => {
|
||||
knopf.disabled = false;
|
||||
karte.classList.remove("raum-warte");
|
||||
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) {
|
||||
const slider = document.getElementById("modal-slider");
|
||||
if (!slider) return;
|
||||
const karte = slider.closest(".raum-geraet");
|
||||
if (karte) karte.classList.add("raum-warte");
|
||||
raumRequest("temp", { room: raum, temp: Number(slider.value) })
|
||||
.then(antwort => {
|
||||
if (karte) karte.classList.remove("raum-warte");
|
||||
raumMeldung(antwort);
|
||||
if (!antwort.error) setTimeout(() => modalEV.hide(), 700);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
$commands = [];
|
||||
while ($row = $res->fetch_assoc()) {
|
||||
@@ -423,6 +423,12 @@ function deviceCatalog()
|
||||
$devices[$id]["commands"][] = [
|
||||
"id" => intval($row["id"]),
|
||||
"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" => [],
|
||||
];
|
||||
}
|
||||
|
||||
+30
-6
@@ -107,15 +107,36 @@
|
||||
</g>
|
||||
<?php foreach ($floors as $floor): ?>
|
||||
<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">
|
||||
<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"] ?>_buffer" href="#buffer" display="none" />
|
||||
<?php endif; ?>
|
||||
<text fill="#fff" style="font: 4px sans-serif;">
|
||||
<tspan id="<?= $floor ?>_pres_<?= $room["id"] ?>" y="5" x="12" text-anchor="middle">---- hPa</tspan>
|
||||
<tspan id="<?= $floor ?>_tmp_<?= $room["id"] ?>" x="12" dy="1em" font-size="1.6em" text-anchor="middle">--,- °C</tspan>
|
||||
<tspan id="<?= $floor ?>_hum_<?= $room["id"] ?>" x="12" dy="1.2em" text-anchor="middle">-- %rF</tspan>
|
||||
<?php if (!$room["werte"]): ?>
|
||||
<!-- Kein Messwert: die Kachel traegt ihren Namen und
|
||||
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">–</tspan>
|
||||
<?php endforeach; ?>
|
||||
</text>
|
||||
</g>
|
||||
<?php endforeach; ?>
|
||||
@@ -198,8 +219,11 @@
|
||||
|
||||
<script>
|
||||
// Raumtabelle aus restricted/rooms.php - homeMQTT.js aktualisiert darueber
|
||||
// die SVG-Kacheln, ohne die Raeume noch einmal aufzufuehren.
|
||||
const homeRooms = <?= json_encode(roomsWithTile()) ?>;
|
||||
// die SVG-Kacheln, ohne die Raeume noch einmal aufzufuehren. In "werte"
|
||||
// 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)): ?>
|
||||
document.addEventListener('readystatechange', function () {
|
||||
if (event.target.readyState === 'complete') {
|
||||
|
||||
@@ -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
@@ -9,15 +9,26 @@
|
||||
*
|
||||
* floor Etage; bestimmt zugleich den Zweig Raumtemp/<floor>/
|
||||
* 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
|
||||
* 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
|
||||
* (siehe die Spalte actors.room und den Zuordnungsdialog in der Karte
|
||||
* "Automatismen"), er erscheint nur nicht als Kachel im Grundriss. Das
|
||||
* betrifft Räume ohne Thermostat - die Küche im EG, die Technikräume -,
|
||||
* deren Kachel eine Temperatur anzeigen würde, die niemand misst.
|
||||
* "Automatismen"), er erscheint nur nicht als Kachel im Grundriss. Derzeit
|
||||
* hat jeder Raum eine Kachel; die Unterscheidung bleibt, weil ein neuer Raum
|
||||
* 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.
|
||||
@@ -29,17 +40,35 @@ $rooms = [
|
||||
["floor" => "OG", "mqtt" => "Schlafzimmer", "id" => "schlafen", "x" => 235, "y" => 70],
|
||||
["floor" => "OG", "mqtt" => "Bad", "id" => "bad", "x" => 260, "y" => 120],
|
||||
["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" => "Florian", "id" => "kizi", "x" => 130, "y" => 90],
|
||||
["floor" => "EG", "mqtt" => "Schlafen", "id" => "schlafen", "x" => 235, "y" => 80],
|
||||
["floor" => "EG", "mqtt" => "Bad", "id" => "bad", "x" => 253, "y" => 130],
|
||||
["floor" => "EG", "mqtt" => "Magdalena", "id" => "buero", "x" => 258, "y" => 180],
|
||||
["floor" => "EG", "mqtt" => "Kueche", "id" => "kueche"],
|
||||
["floor" => "EG", "mqtt" => "Technik", "id" => "technik"],
|
||||
["floor" => "EG", "mqtt" => "Kueche", "id" => "kueche", "x" => 126, "y" => 146,
|
||||
"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" => "Bad", "id" => "bad", "x" => 210, "y" => 80],
|
||||
["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. */
|
||||
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"]);
|
||||
}));
|
||||
})));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
@@ -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 ?> × <?= PLAN_HOCH ?>. Eine Kachel ist
|
||||
<?= KACHEL_BREIT ?> × <?= KACHEL_HOCH ?> groß, und <code>x</code>/<code>y</code>
|
||||
in <code>restricted/rooms.php</code> ist ihre <b>linke obere Ecke</b> – die
|
||||
Punkte an den vorhandenen Kacheln zeigen, wo dieser Bezugspunkt sitzt.
|
||||
Zeig auf die Stelle, an der die <b>Mitte</b> der neuen Kachel liegen soll; das gelbe
|
||||
Rechteck zeigt sie in Originalgröße, darunter stehen die Werte für die Tabelle.
|
||||
Ein Klick friert die Ablesung ein.
|
||||
</p>
|
||||
|
||||
<?php foreach ($floors as $floor): ?>
|
||||
<?php
|
||||
$mitKachel = roomsOnFloor($floor);
|
||||
$ohneKachel = array_values(array_filter(allRooms(), function ($r) use ($floor) {
|
||||
return $r["floor"] === $floor && !isset($r["x"]);
|
||||
}));
|
||||
?>
|
||||
<section class="etage">
|
||||
<h2><?= htmlspecialchars($floor) ?><?php if ($ohneKachel): ?>
|
||||
<span>· noch ohne Kachel:
|
||||
<?= htmlspecialchars(implode(", ", array_column($ohneKachel, "mqtt"))) ?></span>
|
||||
<?php endif; ?></h2>
|
||||
|
||||
<svg viewBox="0 0 <?= PLAN_BREIT ?> <?= PLAN_HOCH ?>" class="plan">
|
||||
<defs>
|
||||
<!-- Eigenes Raster je Etage: eine Kennung darf im Dokument nur
|
||||
einmal vorkommen, sonst zeigt url(#...) immer auf dasselbe. -->
|
||||
<pattern id="raster<?= $floor ?>" width="10" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M10 0H0V10" fill="none" stroke="#ffffff22" stroke-width=".4"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<image href="../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 + ' → <b>"x" => ' +
|
||||
p.x + ', "y" => ' + p.y + '</b>';
|
||||
}
|
||||
|
||||
plan.addEventListener("pointermove", function (e) {
|
||||
if (fest) return;
|
||||
text.innerHTML = zeile(stelle(e));
|
||||
});
|
||||
plan.addEventListener("pointerleave", function () {
|
||||
if (fest) return;
|
||||
zeiger.style.display = "none";
|
||||
text.textContent = "Zeiger über den Grundriss bewegen …";
|
||||
});
|
||||
plan.addEventListener("click", function (e) {
|
||||
const p = stelle(e);
|
||||
fest = !fest;
|
||||
text.innerHTML = fest
|
||||
? '<span class="fest">festgehalten:</span> "x" => <b>' + p.x +
|
||||
'</b>, "y" => <b>' + p.y + '</b> (Mitte ' + p.mx + ',' + p.my +
|
||||
') – nochmal klicken zum Lösen'
|
||||
: zeile(p);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user