This commit is contained in:
209
tools/button-simulator/server.js
Normal file
209
tools/button-simulator/server.js
Normal file
@@ -0,0 +1,209 @@
|
||||
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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user