MOBKOM_4G/mobkom_dashboard.html

1107 lines
33 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MOBKOM MQTT Dashboard</title>
<script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
<style>
:root {
--bg: #0b0f14;
--card: #171c22;
--card2: #20262e;
--border: #2e3742;
--text: #e7eef6;
--muted: #9aa6b2;
--dim: #697581;
--cyan: #00c8d7;
--green: #2fd17c;
--red: #ff5c5c;
--orange: #ffb020;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
padding: 20px;
background: radial-gradient(circle at top left, rgba(0,200,215,.08), transparent 34%), var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.top {
max-width: 1500px;
margin: 0 auto 16px auto;
display: flex;
gap: 12px;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
}
h1 { margin: 0; font-size: 26px; letter-spacing: -0.03em; }
.subtitle { color: var(--muted); font-size: 13px; margin-top: 4px; }
.connect-box {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
input, button { font: inherit; }
input[type="text"] {
width: 260px;
color: var(--text);
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
outline: none;
}
input[type="text"]:focus {
border-color: var(--cyan);
}
button {
border: 1px solid rgba(255,255,255,.08);
border-radius: 10px;
padding: 9px 12px;
background: var(--card2);
color: var(--text);
cursor: pointer;
}
button:hover:not(:disabled) { filter: brightness(1.12); }
button:disabled { opacity: .45; cursor: not-allowed; }
.primary {
background: linear-gradient(135deg, #00d4e6, #00a8b7);
color: #061014;
font-weight: 700;
border-color: transparent;
}
.danger {
color: #ffb7b7;
border-color: rgba(255,92,92,.3);
background: rgba(255,92,92,.12);
}
.status {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
border-radius: 999px;
background: var(--card);
border: 1px solid var(--border);
color: var(--muted);
font-size: 13px;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--orange);
box-shadow: 0 0 12px rgba(255,176,32,.75);
}
.dot.ok { background: var(--green); box-shadow: 0 0 12px rgba(47,209,124,.75); }
.dot.err { background: var(--red); box-shadow: 0 0 12px rgba(255,92,92,.75); }
.dashboard {
max-width: 1500px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(4, minmax(240px, 1fr));
gap: 16px;
}
.card {
min-height: 420px;
background: linear-gradient(180deg, rgba(255,255,255,.035), rgba(255,255,255,.012)), var(--card);
border: 1px solid rgba(255,255,255,.075);
border-radius: 16px;
padding: 16px;
box-shadow: 0 18px 42px rgba(0,0,0,.32);
}
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
border-bottom: 1px solid var(--border);
padding-bottom: 12px;
margin-bottom: 12px;
}
.card-title h2 {
margin: 0;
color: var(--cyan);
font-size: 18px;
}
.card-title span {
color: var(--dim);
font-size: 12px;
}
.sub-btn {
font-size: 13px;
padding: 8px 10px;
}
.subscribed {
color: #b6f7d1;
background: rgba(47,209,124,.12);
border-color: rgba(47,209,124,.28);
}
.rows { display: grid; gap: 9px; }
.row {
display: grid;
grid-template-columns: 82px 1fr auto;
gap: 10px;
align-items: baseline;
padding: 10px;
border-radius: 12px;
background: rgba(0,0,0,.16);
border: 1px solid rgba(255,255,255,.05);
}
.name { color: var(--muted); font-size: 13px; }
.value {
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 16px;
overflow-wrap: anywhere;
}
.unit { color: var(--dim); font-size: 12px; }
.path {
grid-column: 1 / -1;
color: var(--dim);
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
overflow-wrap: anywhere;
}
.led-tools {
display: grid;
grid-template-columns: 74px 1fr 1fr;
gap: 8px;
margin-bottom: 10px;
align-items: center;
}
input[type="color"] {
width: 74px;
height: 40px;
border-radius: 10px;
background: var(--card2);
border: 1px solid var(--border);
padding: 4px;
cursor: pointer;
}
.patterns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.patterns button {
font-size: 12px;
padding: 8px 8px;
border-radius: 9px;
}
.matrix {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 5px;
padding: 10px;
max-width: 360px;
background: rgba(0,0,0,.22);
border: 1px solid rgba(255,255,255,.06);
border-radius: 14px;
user-select: none;
touch-action: none;
}
.led {
aspect-ratio: 1 / 1;
border-radius: 5px;
background: #2b3036;
border: 1px solid rgba(255,255,255,.06);
cursor: crosshair;
}
.led:hover {
outline: 1px solid var(--cyan);
}
.led-status {
margin-top: 12px;
padding: 10px;
min-height: 40px;
color: var(--muted);
background: rgba(0,0,0,.16);
border: 1px solid rgba(255,255,255,.05);
border-radius: 12px;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
overflow-wrap: anywhere;
}
.log {
max-width: 1500px;
margin: 16px auto 0 auto;
padding: 12px 16px;
min-height: 44px;
max-height: 130px;
overflow-y: auto;
color: var(--muted);
background: rgba(23,28,34,.78);
border: 1px solid rgba(255,255,255,.06);
border-radius: 16px;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
white-space: pre-wrap;
}
.selecta-warning {
padding: 10px;
margin-bottom: 12px;
border-radius: 12px;
background: rgba(255,176,32,.12);
border: 1px solid rgba(255,176,32,.28);
color: #ffd28a;
font-size: 13px;
min-height: 40px;
}
.selecta-warning.inactive {
background: rgba(47,209,124,.10);
border-color: rgba(47,209,124,.25);
color: #b6f7d1;
}
.warning-stack {
display: grid;
gap: 8px;
}
.warning-item {
padding: 9px 10px;
border-radius: 10px;
background: rgba(255,176,32,.13);
border: 1px solid rgba(255,176,32,.32);
color: #ffd28a;
}
.warning-item.empty {
background: rgba(255,92,92,.16);
border-color: rgba(255,92,92,.45);
color: #ffb7b7;
font-weight: 700;
}
.warning-title {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
}
.warning-message {
margin-top: 2px;
font-size: 12px;
opacity: .9;
}
.drink-list {
display: grid;
gap: 9px;
}
.drink-row {
display: grid;
grid-template-columns: 18px 1fr auto;
gap: 10px;
align-items: center;
padding: 10px;
border-radius: 12px;
background: rgba(0,0,0,.16);
border: 1px solid rgba(255,255,255,.05);
}
.drink-row.low {
border-color: rgba(255,176,32,.32);
background: rgba(255,176,32,.08);
}
.drink-row.empty {
border-color: rgba(255,92,92,.45);
background: rgba(255,92,92,.10);
}
.drink-color {
width: 16px;
height: 16px;
border-radius: 5px;
border: 1px solid rgba(255,255,255,.2);
}
.drink-info {
display: grid;
gap: 5px;
}
.drink-title {
display: flex;
justify-content: space-between;
gap: 8px;
color: var(--text);
font-size: 13px;
}
.drink-title .selected {
color: var(--cyan);
font-weight: 700;
}
.stockbar {
height: 8px;
border-radius: 999px;
background: rgba(255,255,255,.08);
overflow: hidden;
}
.stockbar-fill {
height: 100%;
background: var(--cyan);
border-radius: 999px;
width: 0%;
}
.stockbar-fill.low {
background: var(--orange) !important;
}
.stockbar-fill.empty {
background: var(--red) !important;
}
.selecta-tools {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 12px;
}
@media (max-width: 1200px) {
.dashboard { grid-template-columns: repeat(2, minmax(260px, 1fr)); }
}
@media (max-width: 720px) {
body { padding: 12px; }
.dashboard { grid-template-columns: 1fr; }
.card { min-height: auto; }
input[type="text"] { width: 100%; }
.connect-box { width: 100%; justify-content: flex-start; }
}
</style>
</head>
<body>
<div class="top">
<div>
<h1>MOBKOM MQTT Dashboard</h1>
<div class="subtitle">Topics gruppiert nach GPS, SENS, IMU und LED-Matrix</div>
</div>
<div class="connect-box">
<input id="brokerInput" type="text" value="ws://192.168.0.95:9001" />
<button id="connectBtn" class="primary">Verbinden</button>
<button id="disconnectBtn" disabled>Trennen</button>
<span class="status"><span id="statusDot" class="dot"></span><span id="statusText">Nicht verbunden</span></span>
</div>
</div>
<main class="dashboard">
<section class="card">
<div class="card-head">
<div class="card-title"><h2>GPS</h2><span>MOBKOM/GPS/#</span></div>
<button class="sub-btn primary" data-sub="gps">Abonnieren</button>
</div>
<div class="rows" id="gpsRows"></div>
</section>
<section class="card">
<div class="card-head">
<div class="card-title"><h2>SENS</h2><span>MOBKOM/SENS/#</span></div>
<button class="sub-btn primary" data-sub="sens">Abonnieren</button>
</div>
<div class="rows" id="sensRows"></div>
</section>
<section class="card">
<div class="card-head">
<div class="card-title"><h2>IMU</h2><span>MOBKOM/IMU/#</span></div>
<button class="sub-btn primary" data-sub="imu">Abonnieren</button>
</div>
<div class="rows" id="imuRows"></div>
</section>
<section class="card">
<div class="card-head">
<div class="card-title"><h2>LED Matrix</h2><span>MOBKOM/LED/state_matrix</span></div>
<button class="sub-btn primary" data-sub="led">Abonnieren</button>
</div>
<div class="led-tools">
<input id="colorInput" type="color" value="#00e5ee" />
<button id="readAllBtn">Alle lesen</button>
<button id="clearBtn" class="danger">Clear</button>
</div>
<div class="patterns">
<button data-pattern="rainbow">Regenbogen</button>
<button data-pattern="wave">Welle</button>
<button data-pattern="heart">Herz</button>
<button data-pattern="smile">Smiley</button>
<button data-pattern="swiss">Schweiz</button>
<button data-pattern="checker">Schachbrett</button>
<button data-pattern="frame">Rahmen</button>
<button data-pattern="x">X</button>
<button data-pattern="plus">Plus</button>
<button data-pattern="diamond">Diamant</button>
<button data-pattern="spiral">Spirale</button>
<button data-pattern="random">Random</button>
</div>
<div id="matrix" class="matrix"></div>
<div id="ledStatus" class="led-status">Status: -</div>
</section>
<section class="card">
<div class="card-head">
<div class="card-title"><h2>Selecta Automat</h2><span>MOBKOM/SELECTA/#</span></div>
<button class="sub-btn primary" data-sub="selecta">Abonnieren</button>
</div>
<div id="selectaWarning" class="selecta-warning inactive">Keine Warnung.</div>
<div class="selecta-tools">
<button id="selectaGetBtn">Status laden</button>
<button id="selectaResetBtn" class="primary">Alle auffüllen</button>
</div>
<div class="selecta-tools">
<button id="selectaOnBtn">Automat aktivieren</button>
<button id="selectaOffBtn" class="danger">Automat deaktivieren</button>
</div>
<div id="drinkList" class="drink-list"></div>
</section>
</main>
<div id="log" class="log">Bereit.</div>
<script>
const TOPIC_LED_SET = "MOBKOM/LED/set_pixel";
const TOPIC_LED_GET = "MOBKOM/LED/get_pixel";
const TOPIC_LED_GET_MATRIX = "MOBKOM/LED/get_matrix";
const TOPIC_LED_CLEAR = "MOBKOM/LED/clear";
const TOPIC_LED_MATRIX = "MOBKOM/LED/set_matrix";
const TOPIC_LED_STATE = "MOBKOM/LED/state";
const TOPIC_LED_STATE_MATRIX = "MOBKOM/LED/state_matrix";
const TOPIC_SELECTA_STATE = "MOBKOM/SELECTA/state";
const TOPIC_SELECTA_WARNING = "MOBKOM/SELECTA/warning";
const TOPIC_SELECTA_REFILL = "MOBKOM/SELECTA/refill";
const TOPIC_SELECTA_RESET = "MOBKOM/SELECTA/reset";
const TOPIC_SELECTA_GET_STATE = "MOBKOM/SELECTA/get_state";
const TOPIC_SELECTA_ACTIVE = "MOBKOM/SELECTA/active";
const groups = {
gps: {
filter: "MOBKOM/GPS/#",
container: "gpsRows",
rows: [
["N", "MOBKOM/GPS/N", ""],
["E", "MOBKOM/GPS/E", ""],
["Höhe", "MOBKOM/GPS/hoehe", "m"],
["Speed", "MOBKOM/GPS/geschwindigkeit", "km/h"]
]
},
sens: {
filter: "MOBKOM/SENS/#",
container: "sensRows",
rows: [
["Temp", "MOBKOM/SENS/temp", "°C"],
["Hum", "MOBKOM/SENS/humidity", "%"],
["Press", "MOBKOM/SENS/pressure", "hPa"]
]
},
imu: {
filter: "MOBKOM/IMU/#",
container: "imuRows",
rows: [
["Pitch", "MOBKOM/IMU/pitch", "°"],
["Roll", "MOBKOM/IMU/roll", "°"],
["Yaw", "MOBKOM/IMU/yaw", "°"],
["X", "MOBKOM/IMU/x", ""],
["Y", "MOBKOM/IMU/y", ""],
["Z", "MOBKOM/IMU/z", ""]
]
},
led: {
filter: "MOBKOM/LED/state_matrix"
},
selecta: {
filter: "MOBKOM/SELECTA/#"
}
};
const brokerInput = document.getElementById("brokerInput");
const connectBtn = document.getElementById("connectBtn");
const disconnectBtn = document.getElementById("disconnectBtn");
const statusDot = document.getElementById("statusDot");
const statusText = document.getElementById("statusText");
const colorInput = document.getElementById("colorInput");
const matrix = document.getElementById("matrix");
const ledStatus = document.getElementById("ledStatus");
const readAllBtn = document.getElementById("readAllBtn");
const clearBtn = document.getElementById("clearBtn");
const logBox = document.getElementById("log");
const selectaWarning = document.getElementById("selectaWarning");
const drinkList = document.getElementById("drinkList");
const selectaGetBtn = document.getElementById("selectaGetBtn");
const selectaResetBtn = document.getElementById("selectaResetBtn");
const selectaOnBtn = document.getElementById("selectaOnBtn");
const selectaOffBtn = document.getElementById("selectaOffBtn");
let client = null;
let isPainting = false;
let paintMode = "draw";
let patternBusy = false;
const subscribed = { gps: false, sens: false, imu: false, led: false, selecta: false };
const valueEls = {};
const ledEls = [];
function log(msg) {
const time = new Date().toLocaleTimeString();
logBox.textContent = `[${time}] ${msg}\n` + logBox.textContent;
}
function setStatus(text, state) {
statusText.textContent = text;
statusDot.classList.remove("ok", "err");
if (state === "ok") statusDot.classList.add("ok");
if (state === "err") statusDot.classList.add("err");
}
function formatValue(raw) {
const n = Number(raw);
if (!Number.isFinite(n)) return raw;
if (Math.abs(n) >= 10) return n.toFixed(2);
return n.toFixed(4);
}
function buildRows() {
for (const key of ["gps", "sens", "imu"]) {
const group = groups[key];
const container = document.getElementById(group.container);
container.innerHTML = "";
for (const [name, topic, unit] of group.rows) {
const row = document.createElement("div");
row.className = "row";
const nameEl = document.createElement("div");
nameEl.className = "name";
nameEl.textContent = name;
const valueEl = document.createElement("div");
valueEl.className = "value";
valueEl.textContent = "-";
const unitEl = document.createElement("div");
unitEl.className = "unit";
unitEl.textContent = unit;
const pathEl = document.createElement("div");
pathEl.className = "path";
pathEl.textContent = topic;
row.appendChild(nameEl);
row.appendChild(valueEl);
row.appendChild(unitEl);
row.appendChild(pathEl);
container.appendChild(row);
valueEls[topic] = valueEl;
}
}
}
function hexToRgb(hex) {
const h = hex.replace("#", "");
return [parseInt(h.slice(0,2), 16), parseInt(h.slice(2,4), 16), parseInt(h.slice(4,6), 16)];
}
function rgbToCss(rgb) {
return `rgb(${Number(rgb[0]) || 0}, ${Number(rgb[1]) || 0}, ${Number(rgb[2]) || 0})`;
}
function setLedUi(x, y, rgb) {
const idx = y * 8 + x;
if (ledEls[idx]) ledEls[idx].style.background = rgbToCss(rgb);
}
function publish(topic, payload) {
if (!client || !client.connected) {
log("Nicht verbunden. Publish abgebrochen.");
return false;
}
client.publish(topic, payload, { qos: 0, retain: false });
return true;
}
function sendPixel(x, y, rgb) {
setLedUi(x, y, rgb);
publish(TOPIC_LED_SET, JSON.stringify({ pos: [x, y], rgb: rgb }));
}
function readPixel(x, y) {
publish(TOPIC_LED_GET, JSON.stringify({ pos: [x, y] }));
}
function readAllPixels() {
publish(TOPIC_LED_GET_MATRIX, "ok");
ledStatus.textContent = "Status: get_matrix gesendet";
}
function clearMatrix() {
publish(TOPIC_LED_CLEAR, "clear");
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) setLedUi(x, y, [0,0,0]);
ledStatus.textContent = "Status: clear gesendet";
}
function applyMatrix(matrixData) {
if (!Array.isArray(matrixData) || matrixData.length !== 8) return false;
for (let y = 0; y < 8; y++) {
if (!Array.isArray(matrixData[y]) || matrixData[y].length !== 8) return false;
for (let x = 0; x < 8; x++) {
const rgb = matrixData[y][x];
if (!Array.isArray(rgb) || rgb.length < 3) return false;
}
}
for (let y = 0; y < 8; y++) {
for (let x = 0; x < 8; x++) {
setLedUi(x, y, matrixData[y][x]);
}
}
return true;
}
function handleLedState(payload) {
ledStatus.textContent = "Status: " + payload;
try {
const data = JSON.parse(payload);
// Format 1: {"matrix": [[[r,g,b], ...], ...]}
if (data && Array.isArray(data.matrix)) {
if (applyMatrix(data.matrix)) return;
}
// Format 2: [[[r,g,b], ...], ...]
if (applyMatrix(data)) return;
// Format 3: [{"pos":[x,y], "rgb":[r,g,b]}]
const list = Array.isArray(data) ? data : [data];
for (const item of list) {
if (!item || !Array.isArray(item.pos) || !Array.isArray(item.rgb)) continue;
const x = Number(item.pos[0]);
const y = Number(item.pos[1]);
if (x < 0 || x > 7 || y < 0 || y > 7) continue;
setLedUi(x, y, item.rgb);
}
} catch (_) {}
}
function createMatrix() {
matrix.innerHTML = "";
ledEls.length = 0;
for (let y = 0; y < 8; y++) {
for (let x = 0; x < 8; x++) {
const led = document.createElement("button");
led.type = "button";
led.className = "led";
led.title = `(${x}, ${y})`;
led.addEventListener("pointerdown", (ev) => {
ev.preventDefault();
isPainting = true;
paintMode = ev.button === 2 ? "erase" : "draw";
const rgb = paintMode === "erase" ? [0,0,0] : hexToRgb(colorInput.value);
sendPixel(x, y, rgb);
});
led.addEventListener("pointerenter", () => {
if (!isPainting) return;
const rgb = paintMode === "erase" ? [0,0,0] : hexToRgb(colorInput.value);
sendPixel(x, y, rgb);
});
led.addEventListener("contextmenu", (ev) => ev.preventDefault());
matrix.appendChild(led);
ledEls.push(led);
}
}
}
function hsvToRgb(h, s, v) {
let r = 0, g = 0, b = 0;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
function sendPattern(name, fn) {
if (patternBusy) return;
patternBusy = true;
const matrixPayload = [];
for (let y = 0; y < 8; y++) {
const row = [];
for (let x = 0; x < 8; x++) {
const rgb = fn(x, y);
row.push(rgb);
setLedUi(x, y, rgb);
}
matrixPayload.push(row);
}
const ok = publish(TOPIC_LED_MATRIX, JSON.stringify({ matrix: matrixPayload }));
ledStatus.textContent = ok
? "Status: Muster als ganze Matrix gesendet: " + name
: "Status: Muster konnte nicht gesendet werden";
patternBusy = false;
}
function runPattern(name) {
const c = hexToRgb(colorInput.value);
const off = [0,0,0];
const points = (arr) => new Set(arr.map(p => p.join(",")));
if (name === "rainbow") sendPattern("Regenbogen", (x,y) => hsvToRgb((x + y) / 14, 1, 1));
if (name === "wave") sendPattern("Welle", (x,y) => Math.abs(y - Math.round(3.5 + Math.sin(x * 0.9) * 2.2)) <= 1 ? c : off);
if (name === "checker") sendPattern("Schachbrett", (x,y) => (x + y) % 2 === 0 ? c : off);
if (name === "frame") sendPattern("Rahmen", (x,y) => (x === 0 || x === 7 || y === 0 || y === 7) ? c : off);
if (name === "x") sendPattern("X", (x,y) => (x === y || x + y === 7) ? c : off);
if (name === "plus") sendPattern("Plus", (x,y) => (x === 3 || x === 4 || y === 3 || y === 4) ? c : off);
if (name === "diamond") sendPattern("Diamant", (x,y) => (Math.abs(x - 3.5) + Math.abs(y - 3.5) <= 3) ? c : off);
if (name === "random") sendPattern("Random", () => hsvToRgb(Math.random(), 1, 0.7 + Math.random() * 0.3));
if (name === "swiss") sendPattern("Schweiz", (x,y) => (((x === 3 || x === 4) && y >= 1 && y <= 6) || ((y === 3 || y === 4) && x >= 1 && x <= 6)) ? [255,255,255] : [255,0,0]);
if (name === "heart") {
const set = points([[1,1],[2,1],[5,1],[6,1],[0,2],[3,2],[4,2],[7,2],[0,3],[7,3],[1,4],[6,4],[2,5],[5,5],[3,6],[4,6]]);
sendPattern("Herz", (x,y) => set.has(`${x},${y}`) ? c : off);
}
if (name === "smile") {
const set = points([[2,2],[5,2],[2,3],[5,3],[1,5],[2,6],[3,6],[4,6],[5,6],[6,5]]);
sendPattern("Smiley", (x,y) => set.has(`${x},${y}`) ? c : off);
}
if (name === "spiral") {
const set = points([[0,0],[1,0],[2,0],[3,0],[4,0],[5,0],[6,0],[7,0],[7,1],[7,2],[7,3],[7,4],[7,5],[7,6],[7,7],[6,7],[5,7],[4,7],[3,7],[2,7],[1,7],[0,7],[0,6],[0,5],[0,4],[0,3],[0,2],[0,1],[1,1],[2,1],[3,1],[4,1],[5,1],[6,1],[6,2],[6,3],[6,4],[6,5],[6,6],[5,6],[4,6],[3,6],[2,6],[1,6],[1,5],[1,4],[1,3],[1,2],[2,2],[3,2],[4,2],[5,2],[5,3],[5,4],[5,5],[4,5],[3,5],[2,5],[2,4],[2,3],[3,3],[4,3],[4,4],[3,4]]);
sendPattern("Spirale", (x,y) => set.has(`${x},${y}`) ? c : off);
}
}
function rgbToCssSelecta(rgb) {
return `rgb(${Number(rgb[0]) || 0}, ${Number(rgb[1]) || 0}, ${Number(rgb[2]) || 0})`;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function renderSelectaWarnings(warnings) {
if (!warnings || warnings.length === 0) {
selectaWarning.classList.add("inactive");
selectaWarning.innerHTML = "Keine Warnung.";
return;
}
selectaWarning.classList.remove("inactive");
selectaWarning.innerHTML = `
<div class="warning-stack">
${warnings.map(w => {
const stock = Number(w.stock);
const isEmpty = stock <= 0 || w.severity === "empty";
const title = isEmpty ? "LEER" : "Tiefer Bestand";
const name = escapeHtml(w.name || w.id || "Getränk");
const message = escapeHtml(w.message || (isEmpty ? `${name} ist leer.` : `Nur noch ${stock}x ${name} übrig.`));
return `
<div class="warning-item ${isEmpty ? "empty" : ""}">
<div class="warning-title">
<span>${title}: ${name}</span>
<span>${Number.isFinite(stock) ? stock : "?"}</span>
</div>
<div class="warning-message">${message}</div>
</div>
`;
}).join("")}
</div>
`;
}
function handleSelectaWarning(payload) {
try {
const data = JSON.parse(payload);
if (Array.isArray(data.warnings)) {
renderSelectaWarnings(data.active ? data.warnings : []);
return;
}
if (data.active) {
renderSelectaWarnings([data]);
} else {
renderSelectaWarnings([]);
}
} catch (_) {
renderSelectaWarnings([{ name: "Warnung", stock: NaN, message: payload, severity: "low" }]);
}
}
function refillSelecta(id) {
publish(TOPIC_SELECTA_REFILL, JSON.stringify({ id: id, stock: 8 }));
}
function handleSelectaState(payload) {
try {
const data = JSON.parse(payload);
drinkList.innerHTML = "";
for (const d of data.drinks || []) {
const row = document.createElement("div");
row.className = "drink-row";
const stock = Number(d.stock);
const max = Number(d.max) || 8;
const warningLimit = Number(data.warning_limit) || 3;
if (stock <= 0) row.classList.add("empty");
else if (stock <= warningLimit) row.classList.add("low");
const color = document.createElement("div");
color.className = "drink-color";
color.style.background = rgbToCssSelecta(d.color || [0,0,0]);
const info = document.createElement("div");
info.className = "drink-info";
const title = document.createElement("div");
title.className = "drink-title";
title.innerHTML = `<span class="${d.selected ? "selected" : ""}">${d.selected ? "▶ " : ""}${escapeHtml(d.name)}</span><span>${stock}/${max}</span>`;
const bar = document.createElement("div");
bar.className = "stockbar";
const fill = document.createElement("div");
fill.className = "stockbar-fill";
fill.style.width = `${Math.max(0, Math.min(100, (stock / max) * 100))}%`;
fill.style.background = rgbToCssSelecta(d.color || [0,0,0]);
if (stock <= 0) fill.classList.add("empty");
else if (stock <= warningLimit) fill.classList.add("low");
bar.appendChild(fill);
info.appendChild(title);
info.appendChild(bar);
const btn = document.createElement("button");
btn.textContent = "Auffüllen";
btn.onclick = () => refillSelecta(d.id);
row.appendChild(color);
row.appendChild(info);
row.appendChild(btn);
drinkList.appendChild(row);
}
const stateWarnings = (data.drinks || [])
.filter(d => Number(d.stock) <= (Number(data.warning_limit) || 3))
.map(d => ({
id: d.id,
name: d.name,
stock: Number(d.stock),
limit: Number(data.warning_limit) || 3,
severity: Number(d.stock) <= 0 ? "empty" : "low",
message: Number(d.stock) <= 0
? `${d.name} ist leer.`
: `Nur noch ${d.stock}x ${d.name} übrig.`
}));
renderSelectaWarnings(stateWarnings);
} catch (e) {
log("Selecta state konnte nicht gelesen werden: " + payload);
}
}
function updateSubButton(key) {
const btn = document.querySelector(`[data-sub="${key}"]`);
if (!btn) return;
if (subscribed[key]) {
btn.textContent = "Abonniert";
btn.classList.remove("primary");
btn.classList.add("subscribed");
} else {
btn.textContent = "Abonnieren";
btn.classList.add("primary");
btn.classList.remove("subscribed");
}
}
function toggleSubscribe(key) {
if (!client || !client.connected) {
log("Erst verbinden.");
return;
}
const topic = groups[key].filter;
if (!subscribed[key]) {
client.subscribe(topic, { qos: 0 });
subscribed[key] = true;
log("Subscribed: " + topic);
if (key === "selecta") {
setTimeout(() => publish(TOPIC_SELECTA_GET_STATE, "ok"), 200);
}
} else {
client.unsubscribe(topic);
subscribed[key] = false;
log("Unsubscribed: " + topic);
}
updateSubButton(key);
}
function connectMqtt() {
const url = brokerInput.value.trim();
if (!url) return;
if (client) {
try { client.end(true); } catch (_) {}
}
setStatus("Verbinde...", "idle");
client = mqtt.connect(url, {
clientId: "mobkom_web_" + Math.random().toString(16).slice(2),
clean: true,
keepalive: 60,
connectTimeout: 8000,
reconnectPeriod: 2500
});
client.on("connect", () => {
setStatus("Verbunden", "ok");
connectBtn.disabled = true;
disconnectBtn.disabled = false;
log("MQTT verbunden: " + url);
});
client.on("message", (topic, message) => {
const payload = message.toString();
if (topic === TOPIC_LED_STATE || topic === TOPIC_LED_STATE_MATRIX) {
handleLedState(payload);
} else if (topic === TOPIC_SELECTA_STATE) {
handleSelectaState(payload);
} else if (topic === TOPIC_SELECTA_WARNING) {
handleSelectaWarning(payload);
} else if (valueEls[topic]) {
valueEls[topic].textContent = formatValue(payload);
}
});
client.on("reconnect", () => setStatus("Verbinde neu...", "idle"));
client.on("close", () => {
setStatus("Getrennt", "idle");
connectBtn.disabled = false;
disconnectBtn.disabled = true;
});
client.on("error", (err) => {
setStatus("Fehler", "err");
log("MQTT Fehler: " + (err.message || err));
});
}
function disconnectMqtt() {
if (client) client.end();
for (const key of Object.keys(subscribed)) {
subscribed[key] = false;
updateSubButton(key);
}
}
buildRows();
createMatrix();
connectBtn.addEventListener("click", connectMqtt);
disconnectBtn.addEventListener("click", disconnectMqtt);
clearBtn.addEventListener("click", clearMatrix);
readAllBtn.addEventListener("click", readAllPixels);
selectaGetBtn.addEventListener("click", () => publish(TOPIC_SELECTA_GET_STATE, "ok"));
selectaResetBtn.addEventListener("click", () => publish(TOPIC_SELECTA_RESET, "ok"));
selectaOnBtn.addEventListener("click", () => publish(TOPIC_SELECTA_ACTIVE, "on"));
selectaOffBtn.addEventListener("click", () => publish(TOPIC_SELECTA_ACTIVE, "off"));
for (const btn of document.querySelectorAll("[data-sub]")) {
btn.addEventListener("click", () => toggleSubscribe(btn.dataset.sub));
}
for (const btn of document.querySelectorAll("[data-pattern]")) {
btn.addEventListener("click", () => runPattern(btn.dataset.pattern));
}
window.addEventListener("pointerup", () => { isPainting = false; });
window.addEventListener("pointercancel", () => { isPainting = false; });
</script>
</body>
</html>