From a4f1a01d24b73019f26ef961b37dda9020db30cc Mon Sep 17 00:00:00 2001 From: balsigernoah Date: Tue, 12 May 2026 20:59:29 +0200 Subject: [PATCH] selecta automat integration --- mobkom_dashboard.html | 317 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 316 insertions(+), 1 deletion(-) diff --git a/mobkom_dashboard.html b/mobkom_dashboard.html index 41d6ed6..85fc2fb 100644 --- a/mobkom_dashboard.html +++ b/mobkom_dashboard.html @@ -280,6 +280,138 @@ 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)); } } @@ -363,6 +495,29 @@
Status: -
+ + +
+
+

Selecta Automat

MOBKOM/SELECTA/#
+ +
+ +
Keine Warnung.
+ +
+ + +
+ +
+ + +
+ +
+
+
Bereit.
@@ -375,6 +530,12 @@ 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: { @@ -410,6 +571,9 @@ }, led: { filter: "MOBKOM/LED/state_matrix" + }, + selecta: { + filter: "MOBKOM/SELECTA/#" } }; @@ -424,13 +588,19 @@ 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 }; + const subscribed = { gps: false, sens: false, imu: false, led: false, selecta: false }; const valueEls = {}; const ledEls = []; @@ -683,6 +853,140 @@ } } + + function rgbToCssSelecta(rgb) { + return `rgb(${Number(rgb[0]) || 0}, ${Number(rgb[1]) || 0}, ${Number(rgb[2]) || 0})`; + } + + function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + function renderSelectaWarnings(warnings) { + if (!warnings || warnings.length === 0) { + selectaWarning.classList.add("inactive"); + selectaWarning.innerHTML = "Keine Warnung."; + return; + } + + selectaWarning.classList.remove("inactive"); + selectaWarning.innerHTML = ` +
+ ${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 ` +
+
+ ${title}: ${name} + ${Number.isFinite(stock) ? stock : "?"} +
+
${message}
+
+ `; + }).join("")} +
+ `; + } + + 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 = `${d.selected ? "▶ " : ""}${escapeHtml(d.name)}${stock}/${max}`; + + 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; @@ -707,6 +1011,9 @@ 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; @@ -743,6 +1050,10 @@ 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); } @@ -775,6 +1086,10 @@ 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));