selecta automat integration

This commit is contained in:
Noah Balsiger 2026-05-12 20:59:29 +02:00
parent 0403ac21ef
commit a4f1a01d24

View File

@ -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 @@
<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>
@ -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("&", "&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;
@ -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));