1107 lines
33 KiB
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("&", "&")
|
|
.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 = `
|
|
<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>
|