This commit is contained in:
2
tools/button-simulator/.gitignore
vendored
Normal file
2
tools/button-simulator/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
44
tools/button-simulator/README.md
Normal file
44
tools/button-simulator/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# AquaMaster Button-Simulator
|
||||
|
||||
Kleine Node.js-/Express-App zum Simulieren der vier Funktaster
|
||||
(`start1`, `stop1`, `start2`, `stop2`) gegen den MQTT-Broker des AquaMasters.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd tools/button-simulator
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
UI öffnen: <http://localhost:3000>
|
||||
|
||||
## Bedienung
|
||||
|
||||
1. Mit dem WLAN des AquaMasters verbinden (Default-AP-IP: `192.168.10.1`).
|
||||
2. In der UI die Broker-URL prüfen (`mqtt://192.168.10.1:1883`) und auf **Verbinden** klicken.
|
||||
3. MAC-Adressen der vier virtuellen Buttons ggf. anpassen — die Default-MACs
|
||||
`AA:BB:CC:DD:EE:01..04` funktionieren für einen frischen Anlernlauf:
|
||||
- Im Web-UI des Masters **Anlernmodus starten**
|
||||
- Im Simulator nacheinander **Start 1 → Stop 1 → Start 2 → Stop 2** drücken
|
||||
- Der Master speichert die MACs und ordnet sie den Rollen zu
|
||||
4. Danach lassen sich mit denselben Buttons Timerläufe auslösen.
|
||||
|
||||
## Was die App sendet
|
||||
|
||||
| Topic | Auslöser | Payload |
|
||||
|---------------------------|---------------------------|--------------------------------|
|
||||
| `aquacross/button/<MAC>` | PRESS-Button | `{"type":1|2,"timestamp":ms}` |
|
||||
| `aquacross/battery/<MAC>` | Slider / Auto-Heartbeat | `{"voltage":mV}` |
|
||||
| `heartbeat/alive/<MAC>` | Auto-Heartbeat | `{"timestamp":ms}` |
|
||||
|
||||
`type=2` wird für Start-Rollen (start1/start2) gesendet, `type=1` für
|
||||
Stop-Rollen (stop1/stop2) — genau wie es `src/communication.h` erwartet.
|
||||
|
||||
Heartbeat und Battery werden standardmäßig alle 3 s automatisch publiziert,
|
||||
sobald eine Verbindung besteht. Intervall/Abschaltung per Formular unten.
|
||||
|
||||
## Environment-Variablen
|
||||
|
||||
- `PORT` — Webserver-Port (Default: `3000`)
|
||||
- `BROKER_URL` — vorbelegte Broker-URL
|
||||
14
tools/button-simulator/package.json
Normal file
14
tools/button-simulator/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "aquamaster-button-simulator",
|
||||
"version": "1.0.0",
|
||||
"description": "Simuliert die vier Funktaster (start1/stop1/start2/stop2) für den AquaMaster MQTT über einen Web-UI-Frontend.",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"mqtt": "^5.10.1"
|
||||
}
|
||||
}
|
||||
96
tools/button-simulator/public/app.js
Normal file
96
tools/button-simulator/public/app.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const $ = (sel, root = document) => root.querySelector(sel);
|
||||
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
||||
|
||||
async function api(path, body) {
|
||||
const opts = { method: body ? "POST" : "GET" };
|
||||
if (body) {
|
||||
opts.headers = { "Content-Type": "application/json" };
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, opts);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function formatLog(entries) {
|
||||
return entries
|
||||
.map((e) => {
|
||||
const ts = new Date(e.t).toLocaleTimeString();
|
||||
const arrow = e.direction === "out" ? "→" : e.direction === "in" ? "←" : e.direction === "err" ? "✖" : "·";
|
||||
return `${ts} ${arrow} ${e.text}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
let lastKnownButtons = null;
|
||||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const s = await api("/api/status");
|
||||
$("#status").textContent = s.connected ? "online" : "offline";
|
||||
$("#status").className = "status " + (s.connected ? "online" : "offline");
|
||||
|
||||
if (!lastKnownButtons) {
|
||||
$("#brokerUrl").value = s.brokerUrl;
|
||||
$("#hbEnabled").checked = s.heartbeatEnabled;
|
||||
$("#hbInterval").value = s.heartbeatIntervalMs;
|
||||
for (const key of ["start1", "stop1", "start2", "stop2"]) {
|
||||
const card = $(`.button-card[data-button="${key}"]`);
|
||||
card.querySelector(".mac").value = s.buttons[key].mac;
|
||||
card.querySelector(".mv").value = s.buttons[key].voltage;
|
||||
card.querySelector(".mv-value").textContent = s.buttons[key].voltage;
|
||||
}
|
||||
lastKnownButtons = s.buttons;
|
||||
}
|
||||
|
||||
$("#log").textContent = formatLog(s.log);
|
||||
$("#log").scrollTop = $("#log").scrollHeight;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
$("#btnConnect").addEventListener("click", async () => {
|
||||
await api("/api/connect", { brokerUrl: $("#brokerUrl").value.trim() });
|
||||
refreshStatus();
|
||||
});
|
||||
|
||||
$("#btnDisconnect").addEventListener("click", async () => {
|
||||
await api("/api/disconnect", {});
|
||||
refreshStatus();
|
||||
});
|
||||
|
||||
$("#btnHbApply").addEventListener("click", async () => {
|
||||
await api("/api/heartbeat", {
|
||||
enabled: $("#hbEnabled").checked,
|
||||
intervalMs: parseInt($("#hbInterval").value, 10),
|
||||
});
|
||||
refreshStatus();
|
||||
});
|
||||
|
||||
$$(".button-card").forEach((card) => {
|
||||
const button = card.dataset.button;
|
||||
const macInput = card.querySelector(".mac");
|
||||
const mvInput = card.querySelector(".mv");
|
||||
const mvValue = card.querySelector(".mv-value");
|
||||
const pressBtn = card.querySelector(".press");
|
||||
|
||||
macInput.addEventListener("change", async () => {
|
||||
await api("/api/config", { button, mac: macInput.value.trim() });
|
||||
});
|
||||
|
||||
mvInput.addEventListener("input", () => {
|
||||
mvValue.textContent = mvInput.value;
|
||||
});
|
||||
|
||||
mvInput.addEventListener("change", async () => {
|
||||
await api("/api/battery", { button, voltage: parseInt(mvInput.value, 10) });
|
||||
});
|
||||
|
||||
pressBtn.addEventListener("click", async () => {
|
||||
await api("/api/press", { button });
|
||||
refreshStatus();
|
||||
});
|
||||
});
|
||||
|
||||
refreshStatus();
|
||||
setInterval(refreshStatus, 1500);
|
||||
95
tools/button-simulator/public/index.html
Normal file
95
tools/button-simulator/public/index.html
Normal file
@@ -0,0 +1,95 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>AquaMaster Button-Simulator</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>AquaMaster Button-Simulator</h1>
|
||||
<div class="connection">
|
||||
<input id="brokerUrl" type="text" placeholder="mqtt://192.168.10.1:1883" />
|
||||
<button id="btnConnect" class="primary">Verbinden</button>
|
||||
<button id="btnDisconnect">Trennen</button>
|
||||
<span id="status" class="status offline">offline</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="grid">
|
||||
<div class="lane lane1">
|
||||
<h2>Bahn 1</h2>
|
||||
<div class="button-card" data-button="start1">
|
||||
<div class="button-header">
|
||||
<span class="role">Start 1</span>
|
||||
<input class="mac" type="text" spellcheck="false" />
|
||||
</div>
|
||||
<button class="press press-start">PRESS</button>
|
||||
<div class="battery">
|
||||
<label>Akku (mV): <span class="mv-value">3700</span></label>
|
||||
<input class="mv" type="range" min="3000" max="4200" step="10" value="3700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-card" data-button="stop1">
|
||||
<div class="button-header">
|
||||
<span class="role">Stop 1</span>
|
||||
<input class="mac" type="text" spellcheck="false" />
|
||||
</div>
|
||||
<button class="press press-stop">PRESS</button>
|
||||
<div class="battery">
|
||||
<label>Akku (mV): <span class="mv-value">3700</span></label>
|
||||
<input class="mv" type="range" min="3000" max="4200" step="10" value="3700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lane lane2">
|
||||
<h2>Bahn 2</h2>
|
||||
<div class="button-card" data-button="start2">
|
||||
<div class="button-header">
|
||||
<span class="role">Start 2</span>
|
||||
<input class="mac" type="text" spellcheck="false" />
|
||||
</div>
|
||||
<button class="press press-start">PRESS</button>
|
||||
<div class="battery">
|
||||
<label>Akku (mV): <span class="mv-value">3700</span></label>
|
||||
<input class="mv" type="range" min="3000" max="4200" step="10" value="3700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-card" data-button="stop2">
|
||||
<div class="button-header">
|
||||
<span class="role">Stop 2</span>
|
||||
<input class="mac" type="text" spellcheck="false" />
|
||||
</div>
|
||||
<button class="press press-stop">PRESS</button>
|
||||
<div class="battery">
|
||||
<label>Akku (mV): <span class="mv-value">3700</span></label>
|
||||
<input class="mv" type="range" min="3000" max="4200" step="10" value="3700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="controls">
|
||||
<label>
|
||||
<input id="hbEnabled" type="checkbox" checked />
|
||||
Heartbeat + Battery automatisch senden
|
||||
</label>
|
||||
<label>
|
||||
Intervall (ms):
|
||||
<input id="hbInterval" type="number" min="500" max="60000" step="500" value="3000" />
|
||||
</label>
|
||||
<button id="btnHbApply">Übernehmen</button>
|
||||
</section>
|
||||
|
||||
<section class="log-wrap">
|
||||
<h3>Log</h3>
|
||||
<pre id="log"></pre>
|
||||
</section>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
189
tools/button-simulator/public/style.css
Normal file
189
tools/button-simulator/public/style.css
Normal file
@@ -0,0 +1,189 @@
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.connection {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.connection input {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
padding: 8px 10px;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
color: #e2e8f0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid #334155;
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button:hover { background: #334155; }
|
||||
button.primary { background: #2563eb; border-color: #2563eb; }
|
||||
button.primary:hover { background: #1d4ed8; }
|
||||
|
||||
.status {
|
||||
padding: 4px 10px;
|
||||
border-radius: 99px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.status.online { background: #14532d; color: #86efac; }
|
||||
.status.offline { background: #7f1d1d; color: #fca5a5; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.lane {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.lane h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.button-card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.button-card:last-child { margin-bottom: 0; }
|
||||
|
||||
.button-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.role {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.mac {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 4px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.press {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.press-start { background: #166534; border-color: #166534; }
|
||||
.press-start:hover { background: #15803d; }
|
||||
.press-stop { background: #991b1b; border-color: #991b1b; }
|
||||
.press-stop:hover { background: #b91c1c; }
|
||||
.press:active { transform: scale(0.98); }
|
||||
|
||||
.battery label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.battery input[type="range"] { width: 100%; }
|
||||
|
||||
.controls {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.controls label { font-size: 13px; display: flex; align-items: center; gap: 6px; }
|
||||
.controls input[type="number"] {
|
||||
width: 80px;
|
||||
padding: 6px 8px;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 4px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.log-wrap {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.log-wrap h3 { margin: 0 0 8px; font-size: 13px; color: #94a3b8; }
|
||||
|
||||
#log {
|
||||
margin: 0;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
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}`);
|
||||
});
|
||||
142
tools/update-anleitung.html
Normal file
142
tools/update-anleitung.html
Normal file
@@ -0,0 +1,142 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AquaMaster – OTA Update Anleitung</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 18mm 20mm; }
|
||||
html, body { font-family: "Segoe UI", Arial, sans-serif; color: #1a1a1a; font-size: 11pt; line-height: 1.45; }
|
||||
h1 { font-size: 22pt; margin: 0 0 4pt 0; color: #0b4a7a; }
|
||||
h2 { font-size: 14pt; margin: 18pt 0 6pt 0; color: #0b4a7a; border-bottom: 1px solid #c8d8e4; padding-bottom: 3pt; }
|
||||
h3 { font-size: 12pt; margin: 12pt 0 4pt 0; color: #12466b; }
|
||||
p { margin: 4pt 0; }
|
||||
ul, ol { margin: 4pt 0 6pt 20pt; padding: 0; }
|
||||
li { margin: 2pt 0; }
|
||||
code, .mono { font-family: "Consolas", "Courier New", monospace; background: #f1f5f9; padding: 1pt 4pt; border-radius: 3pt; font-size: 10pt; }
|
||||
.lead { color: #475569; font-size: 11pt; margin-bottom: 10pt; }
|
||||
.box { border-left: 4pt solid #0b4a7a; background: #f1f6fb; padding: 8pt 12pt; margin: 10pt 0; border-radius: 0 4pt 4pt 0; }
|
||||
.warn { border-left-color: #b45309; background: #fff7ed; }
|
||||
.ok { border-left-color: #15803d; background: #f0fdf4; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 6pt 0; font-size: 10pt; }
|
||||
th, td { border: 1px solid #c8d8e4; padding: 5pt 7pt; text-align: left; vertical-align: top; }
|
||||
th { background: #eaf2f9; }
|
||||
.meta { color: #64748b; font-size: 9pt; margin-top: 4pt; }
|
||||
.step { margin: 8pt 0; }
|
||||
.step-num { display: inline-block; width: 18pt; height: 18pt; line-height: 18pt; text-align: center; background: #0b4a7a; color: white; border-radius: 50%; font-weight: bold; margin-right: 6pt; font-size: 9pt; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>AquaMaster – OTA Update Anleitung</h1>
|
||||
<p class="lead">Schritt-für-Schritt-Anleitung zum Einspielen eines neuen Firmware- und Filesystem-Updates auf die AquaMaster-Einheit über die Weboberfläche (PrettyOTA).</p>
|
||||
<p class="meta">Gilt für: AquaMaster MQTT (ESP32) · OTA-Oberfläche: PrettyOTA · Build: esp32thing_CI</p>
|
||||
|
||||
<h2>1. Warum zwei Dateien?</h2>
|
||||
<p>Der ESP32 speichert die Software des AquaMasters in <b>zwei getrennten Flash-Partitionen</b>. Jede enthält etwas anderes, und beide können (und müssen teilweise) einzeln aktualisiert werden:</p>
|
||||
|
||||
<table>
|
||||
<tr><th style="width:28%">Datei</th><th style="width:22%">Partition</th><th>Inhalt</th></tr>
|
||||
<tr>
|
||||
<td><code>firmware.bin</code></td>
|
||||
<td>App-Partition (OTA)</td>
|
||||
<td>Die eigentliche ESP32-Software: Timer-Logik, MQTT-Broker, WiFi, Webserver, Spielmodi, RFID-Auswertung, Lizenzprüfung usw. Also alles, was der Master <i>tut</i>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>spiffs.bin</code></td>
|
||||
<td>SPIFFS-Partition</td>
|
||||
<td>Das Dateisystem mit der <b>Weboberfläche</b>: <code>index.html</code>, <code>settings.html</code>, <code>leaderboard.html</code>, <code>rfid.html</code>, CSS, Bilder sowie die Button-<code>firmware.bin</code>, über die sich die Funktaster selbst updaten.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="box">
|
||||
<b>Kurz gesagt:</b> <code>firmware.bin</code> = was der Master <i>macht</i>, <code>spiffs.bin</code> = wie der Master im Browser <i>aussieht</i>. Beide leben in verschiedenen Flash-Bereichen und werden deshalb getrennt hochgeladen.
|
||||
</div>
|
||||
|
||||
<h3>Muss ich immer beide flashen?</h3>
|
||||
<ul>
|
||||
<li><b>Nur Code geändert</b> (z. B. neue Timerlogik, Bugfix): es reicht <code>firmware.bin</code>.</li>
|
||||
<li><b>Nur Weboberfläche geändert</b> (HTML/CSS, neue Button-Firmware): es reicht <code>spiffs.bin</code>.</li>
|
||||
<li><b>Im Zweifel beide</b> einspielen – in der Reihenfolge unten – dann kann garantiert nichts „alt gegen neu" kollidieren.</li>
|
||||
</ul>
|
||||
|
||||
<h2>2. Vorbereitung</h2>
|
||||
<ol>
|
||||
<li>Die aktuelle Release-ZIP entpacken. Darin liegen <code>firmware.bin</code> und <code>spiffs.bin</code>.</li>
|
||||
<li>AquaMaster einschalten und mit seinem WLAN verbinden:
|
||||
<ul>
|
||||
<li><b>AP-Modus:</b> SSID <code>AquaMaster-xxxx</code>, IP <code>192.168.10.1</code></li>
|
||||
<li><b>STA-Modus:</b> die im Heimnetz vergebene IP (im Router oder per mDNS-Namen <code>aquamaster.local</code>)</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Im Browser die OTA-Seite öffnen: <code>http://<IP-des-Masters>/update</code><br>
|
||||
Beispiel AP: <code>http://192.168.10.1/update</code></li>
|
||||
</ol>
|
||||
|
||||
<div class="box warn">
|
||||
<b>Wichtig:</b> Während des Updates den AquaMaster <b>nicht vom Strom trennen</b> und die Verbindung nicht abbrechen. Ein unterbrochener Firmware-Upload führt im schlimmsten Fall zu einem Rollback auf die vorherige Version – ein unterbrochenes Filesystem-Update zu einer leeren Weboberfläche.
|
||||
</div>
|
||||
|
||||
<h2>3. Update-Modus wählen – <i>das Wichtigste</i></h2>
|
||||
<p>Auf der PrettyOTA-Seite gibt es oben ein Dropdown <b>„OTA-Mode"</b> (oder <b>„Update Mode"</b>) mit zwei Einträgen:</p>
|
||||
|
||||
<table>
|
||||
<tr><th style="width:30%">Modus</th><th>Wann auswählen?</th></tr>
|
||||
<tr><td><b>Firmware</b></td><td>Wenn du <code>firmware.bin</code> hochladen willst (Standard, die Option ist beim Öffnen der Seite bereits aktiv).</td></tr>
|
||||
<tr><td><b>Filesystem</b></td><td>Wenn du <code>spiffs.bin</code> hochladen willst. Muss <b>vor dem Upload</b> manuell umgestellt werden!</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="box warn">
|
||||
<b>Häufigster Fehler:</b> <code>spiffs.bin</code> wird im Modus „Firmware" hochgeladen. PrettyOTA akzeptiert die Datei dann zwar, schreibt sie aber in die falsche Partition – der Master startet nicht mehr sauber bzw. zeigt nach dem Neustart eine kaputte Weboberfläche. <b>Immer zuerst den richtigen Modus wählen, dann die Datei hinzufügen.</b>
|
||||
</div>
|
||||
|
||||
<h2>4. Update durchführen</h2>
|
||||
|
||||
<h3>4.1 Firmware aktualisieren</h3>
|
||||
<p class="step"><span class="step-num">1</span>Im Dropdown <b>„Firmware"</b> auswählen.</p>
|
||||
<p class="step"><span class="step-num">2</span><code>firmware.bin</code> per Drag-&-Drop auf den Upload-Bereich ziehen (oder über <i>„Datei auswählen"</i> öffnen).</p>
|
||||
<p class="step"><span class="step-num">3</span>Fortschrittsbalken abwarten, bis „Update successful" erscheint.</p>
|
||||
<p class="step"><span class="step-num">4</span>Der AquaMaster startet automatisch neu. Warten, bis die Status-LED wieder Normalbetrieb signalisiert (ca. 5–10 s).</p>
|
||||
|
||||
<h3>4.2 Filesystem (Weboberfläche) aktualisieren</h3>
|
||||
<p class="step"><span class="step-num">1</span>Erneut <code>http://<IP>/update</code> öffnen (nach dem Neustart ist die Verbindung evtl. kurz weg).</p>
|
||||
<p class="step"><span class="step-num">2</span>Im Dropdown auf <b>„Filesystem"</b> umstellen. <b>Nicht vergessen!</b></p>
|
||||
<p class="step"><span class="step-num">3</span><code>spiffs.bin</code> per Drag-&-Drop hochladen.</p>
|
||||
<p class="step"><span class="step-num">4</span>Nach „Update successful" startet der Master erneut.</p>
|
||||
|
||||
<div class="box ok">
|
||||
<b>Tipp – Reihenfolge:</b> Erst <code>firmware.bin</code>, dann <code>spiffs.bin</code>. So läuft nach dem ersten Reboot bereits die neue Timer-Software, die dann passend zur neuen Weboberfläche ist.
|
||||
</div>
|
||||
|
||||
<h2>5. Prüfen, ob das Update sitzt</h2>
|
||||
<ul>
|
||||
<li>Die Hauptseite <code>http://<IP>/</code> öffnen – die Oberfläche sollte fehlerfrei laden.</li>
|
||||
<li>Unter <b>Einstellungen</b> / <b>Status</b> die Firmware-Version bzw. das Build-Datum kontrollieren.</li>
|
||||
<li>Einen kurzen Testlauf starten (Start-/Stopp-Taster drücken), um zu prüfen, dass die Timerlogik reagiert.</li>
|
||||
<li>Falls die Seite nach einem Filesystem-Update weiß/leer bleibt: <code>spiffs.bin</code> nochmal im Modus „Filesystem" hochladen.</li>
|
||||
</ul>
|
||||
|
||||
<h2>6. Fehlerbehebung</h2>
|
||||
<table>
|
||||
<tr><th style="width:42%">Symptom</th><th>Ursache / Fix</th></tr>
|
||||
<tr>
|
||||
<td>„Wrong partition" oder Upload bricht sofort ab</td>
|
||||
<td>Falscher Modus gewählt – Dropdown umstellen und erneut hochladen.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Nach dem Update leere / unformatierte Weboberfläche</td>
|
||||
<td>Es wurde nur <code>firmware.bin</code> geflasht, aber die HTML/CSS-Dateien haben sich geändert. <code>spiffs.bin</code> nachliefern (Modus „Filesystem").</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Master kommt nach Firmware-Update nicht mehr ins WLAN</td>
|
||||
<td>Kurz stromlos machen; PrettyOTA führt intern bei einem fehlerhaften Boot ein Rollback auf die vorherige App-Partition aus.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Browser zeigt „Verbindung verloren"</td>
|
||||
<td>Normal während des Reboots. Seite nach ~10 s neu laden.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p class="meta">Bei fortbestehenden Problemen: seriellen Monitor mit 115200 Baud anschließen – PrettyOTA und der Master schreiben detaillierte Update- und Boot-Logs auf UART.</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user