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