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));