210 lines
6.0 KiB
JavaScript
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}`);
|
|
});
|