Files
Carsten Graf 0223cceef8
Some checks failed
/ build (push) Has been cancelled
Button Simmulator, Frontend änderungen
2026-04-11 20:24:39 +02:00

210 lines
6.0 KiB
JavaScript

const express = require("express");
const mqtt = require("mqtt");
const path = require("path");
const PORT = process.env.PORT || 3000;
const BUTTON_KEYS = ["start1", "stop1", "start2", "stop2"];
const state = {
brokerUrl: process.env.BROKER_URL || "mqtt://192.168.1.209:1883",
connected: false,
lastError: null,
heartbeatEnabled: true,
heartbeatIntervalMs: 3000,
buttons: {
start1: { mac: "AA:BB:CC:DD:EE:01", voltage: 3700 },
stop1: { mac: "AA:BB:CC:DD:EE:02", voltage: 3700 },
start2: { mac: "AA:BB:CC:DD:EE:03", voltage: 3700 },
stop2: { mac: "AA:BB:CC:DD:EE:04", voltage: 3700 },
},
log: [],
};
let client = null;
let heartbeatTimer = null;
let bootTime = Date.now();
function logEvent(direction, text) {
const entry = { t: Date.now(), direction, text };
state.log.push(entry);
if (state.log.length > 200) state.log.shift();
const arrow = direction === "out" ? "→" : direction === "in" ? "←" : "·";
console.log(`[${new Date(entry.t).toISOString()}] ${arrow} ${text}`);
}
function publish(topic, payload) {
if (!client || !state.connected) {
logEvent("err", `publish abgelehnt (nicht verbunden): ${topic}`);
return false;
}
const body = typeof payload === "string" ? payload : JSON.stringify(payload);
client.publish(topic, body, { qos: 0 }, (err) => {
if (err) logEvent("err", `publish fehlgeschlagen ${topic}: ${err.message}`);
});
logEvent("out", `${topic} ${body}`);
return true;
}
function startHeartbeats() {
stopHeartbeats();
if (!state.heartbeatEnabled) return;
heartbeatTimer = setInterval(() => {
if (!state.connected) return;
const now = Date.now();
const uptime = now - bootTime;
for (const key of BUTTON_KEYS) {
const mac = state.buttons[key].mac;
publish(`heartbeat/alive/${mac}`, { timestamp: now, uptime });
publish(`aquacross/battery/${mac}`, {
timestamp: now,
voltage: state.buttons[key].voltage,
});
}
}, state.heartbeatIntervalMs);
}
function stopHeartbeats() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
function connectBroker(url) {
disconnectBroker();
state.brokerUrl = url;
state.lastError = null;
logEvent("sys", `Verbinde zu ${url} ...`);
client = mqtt.connect(url, {
clientId: `button-simulator-${Math.random().toString(16).slice(2, 10)}`,
reconnectPeriod: 0,
connectTimeout: 5000,
clean: true,
});
client.on("connect", () => {
state.connected = true;
bootTime = Date.now();
logEvent("sys", `Verbunden mit ${url}`);
startHeartbeats();
});
client.on("error", (err) => {
state.lastError = err.message;
logEvent("err", `MQTT Fehler: ${err.message}`);
});
client.on("close", () => {
if (state.connected) logEvent("sys", "Verbindung geschlossen");
state.connected = false;
stopHeartbeats();
});
client.on("offline", () => {
state.connected = false;
logEvent("sys", "offline");
});
}
function disconnectBroker() {
stopHeartbeats();
if (client) {
try { client.end(true); } catch (_) {}
client = null;
}
state.connected = false;
}
const app = express();
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
app.get("/api/status", (_req, res) => {
res.json({
brokerUrl: state.brokerUrl,
connected: state.connected,
lastError: state.lastError,
heartbeatEnabled: state.heartbeatEnabled,
heartbeatIntervalMs: state.heartbeatIntervalMs,
buttons: state.buttons,
log: state.log.slice(-80),
});
});
app.post("/api/connect", (req, res) => {
const url = (req.body && req.body.brokerUrl) || state.brokerUrl;
connectBroker(url);
res.json({ ok: true });
});
app.post("/api/disconnect", (_req, res) => {
disconnectBroker();
logEvent("sys", "Getrennt (manuell)");
res.json({ ok: true });
});
app.post("/api/config", (req, res) => {
const { button, mac, voltage } = req.body || {};
if (!BUTTON_KEYS.includes(button)) {
return res.status(400).json({ ok: false, error: "unbekannter Button" });
}
if (typeof mac === "string" && mac.length > 0) {
state.buttons[button].mac = mac.toUpperCase();
}
if (typeof voltage === "number" && voltage >= 2500 && voltage <= 4500) {
state.buttons[button].voltage = Math.round(voltage);
}
res.json({ ok: true, button: state.buttons[button] });
});
app.post("/api/heartbeat", (req, res) => {
const { enabled, intervalMs } = req.body || {};
if (typeof enabled === "boolean") state.heartbeatEnabled = enabled;
if (typeof intervalMs === "number" && intervalMs >= 500 && intervalMs <= 60000) {
state.heartbeatIntervalMs = Math.round(intervalMs);
}
if (state.connected) startHeartbeats();
res.json({
ok: true,
heartbeatEnabled: state.heartbeatEnabled,
heartbeatIntervalMs: state.heartbeatIntervalMs,
});
});
app.post("/api/press", (req, res) => {
const { button } = req.body || {};
if (!BUTTON_KEYS.includes(button)) {
return res.status(400).json({ ok: false, error: "unbekannter Button" });
}
// start* sendet type=2, stop* sendet type=1 (siehe communication.h)
const type = button.startsWith("start") ? 2 : 1;
const mac = state.buttons[button].mac;
const payload = { type, timestamp: Date.now() };
const ok = publish(`aquacross/button/${mac}`, payload);
res.json({ ok, button, mac, payload });
});
app.post("/api/battery", (req, res) => {
const { button, voltage } = req.body || {};
if (!BUTTON_KEYS.includes(button)) {
return res.status(400).json({ ok: false, error: "unbekannter Button" });
}
if (typeof voltage !== "number") {
return res.status(400).json({ ok: false, error: "voltage fehlt" });
}
state.buttons[button].voltage = Math.round(voltage);
const mac = state.buttons[button].mac;
const ok = publish(`aquacross/battery/${mac}`, {
timestamp: Date.now(),
voltage: state.buttons[button].voltage,
});
res.json({ ok, button, mac, voltage: state.buttons[button].voltage });
});
app.listen(PORT, () => {
console.log(`Button-Simulator läuft: http://localhost:${PORT}`);
console.log(`Broker (Default): ${state.brokerUrl}`);
});