Add dynamic UART/BLE host connect for serve, dashboard, and WS.

Serve starts without a required port; REST/WS/UI can scan and switch transports, with BLE live ticks and UART-only OTA.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-23 22:40:28 +02:00
co-authored by Cursor
parent 92a13d7fd0
commit aa67edfe94
18 changed files with 1573 additions and 404 deletions
+251 -54
View File
@@ -179,8 +179,8 @@
<span class="navbar-brand mb-0 h1">Powerpod</span>
<span class="d-flex align-items-center gap-2">
<span class="badge rounded-pill me-1"
:class="state.uart_connected ? 'badge-online' : 'badge-offline'"
x-text="state.uart_connected ? 'UART' : 'UART off'"></span>
:class="hostUp() ? 'badge-online' : 'badge-offline'"
x-text="hostBadge()"></span>
<span class="badge rounded-pill" :class="wsConnected ? 'badge-online' : 'badge-offline'"
x-text="wsConnected ? 'WS' : 'WS off'"></span>
<small class="text-muted ms-2" x-text="state.updated_at || '—'"></small>
@@ -195,19 +195,94 @@
x-text="configMsg"></div>
</template>
<section class="card mb-4">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<span>Host-Verbindung</span>
<span class="text-muted small" x-text="hostEndpointLabel()"></span>
</div>
<div class="card-body">
<ul class="nav nav-pills mb-3 gap-1">
<li class="nav-item">
<button type="button" class="nav-link"
:class="connTab === 'uart' ? 'active' : ''"
@click="connTab = 'uart'">UART</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link"
:class="connTab === 'ble' ? 'active' : ''"
@click="connTab = 'ble'">BLE</button>
</li>
</ul>
<div x-show="connTab === 'uart'" class="d-flex flex-wrap gap-2 align-items-end">
<div>
<label class="form-label text-muted small mb-1" for="uart-port">Port</label>
<select id="uart-port" class="form-control form-control-sm" style="min-width:12rem"
x-model="uartPort" :disabled="hostBusy">
<option value="">— Port wählen —</option>
<template x-for="p in serialPorts" :key="p">
<option :value="p" x-text="p"></option>
</template>
</select>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="refreshSerialPorts()" :disabled="hostBusy">Aktualisieren</button>
<button type="button" class="btn btn-primary btn-sm"
@click="connectUART()" :disabled="hostBusy || !uartPort">Connect</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="disconnectHost()" :disabled="hostBusy || !hostUp()">Disconnect</button>
</div>
<div x-show="connTab === 'ble'">
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<button type="button" class="btn btn-primary btn-sm"
@click="scanBLE()" :disabled="hostBusy || bleScanning"
x-text="bleScanning ? 'Scan…' : 'Scan'"></button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="disconnectHost()" :disabled="hostBusy || !hostUp()">Disconnect</button>
<span class="text-muted small" x-show="bleScanErr" x-text="bleScanErr"></span>
</div>
<div class="table-responsive" x-show="bleDevices.length">
<table class="table table-sm pp-table mb-0">
<thead>
<tr><th>Name</th><th>Adresse</th><th>RSSI</th><th></th></tr>
</thead>
<tbody>
<template x-for="d in bleDevices" :key="d.address">
<tr>
<td x-text="d.name || '—'"></td>
<td class="mac" x-text="d.address"></td>
<td x-text="d.rssi"></td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-primary"
@click="connectBLE(d)" :disabled="hostBusy">Connect</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<p class="text-muted small mb-0" x-show="!bleDevices.length && !bleScanning">
Scan startet eine kurze Suche (trennt aktive BLE-Verbindung).
</p>
</div>
</div>
</section>
<div class="row g-4">
<section class="col-lg-4">
<div class="card h-100">
<div class="card-header">Master (BMA456 lokal)</div>
<div class="card-body">
<p class="text-muted small mb-2" x-text="'UART ' + (state.serial_port || '—')"></p>
<p class="text-muted small mb-2"
x-text="(state.host_transport || 'none').toUpperCase() + ' ' + (state.host_endpoint || state.serial_port || '—')"></p>
<p class="text-muted small mb-3">Deadzone nur am Master — Slaves bleiben unverändert.</p>
<template x-if="!state.uart_connected">
<template x-if="!hostUp()">
<div class="alert alert-warning py-2 mb-2"
x-text="state.serial_error || 'UART disconnected — reconnecting…'"></div>
x-text="state.serial_error || 'Host nicht verbunden — oben UART oder BLE wählen.'"></div>
</template>
<template x-if="state.uart_connected && !state.serial_ok">
<div class="alert alert-danger py-2" x-text="state.serial_error || 'Serial error'"></div>
<template x-if="hostUp() && !state.serial_ok">
<div class="alert alert-danger py-2" x-text="state.serial_error || 'Host error'"></div>
</template>
<template x-if="state.master?.ok">
<dl class="row mb-0">
@@ -242,26 +317,26 @@
min="0" max="4095" step="1"
placeholder="z. B. 100"
x-model.number="masterDz"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="readMasterDeadzone()"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
Lesen
</button>
<button type="button" class="btn btn-primary btn-sm"
@click="setMasterDeadzone()"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
Setzen
</button>
<button type="button" class="btn btn-outline-warning btn-sm"
@click="findMe()"
:disabled="busy || !state.uart_connected"
:disabled="busy || !hostUp()"
title="LED-Ring: Rot/Grün/Blau je 3×">
Find me
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(0)"
:disabled="busy || !state.uart_connected"
:disabled="busy || !hostUp()"
title="Master neu starten">
Restart
</button>
@@ -272,7 +347,7 @@
<div class="d-flex flex-wrap gap-2 align-items-end">
<select id="master-log-level" class="form-select form-select-sm config-input"
x-model.number="masterLogLevel"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
<option value="0">None (aus)</option>
<option value="1">Error</option>
<option value="2">Warn</option>
@@ -282,12 +357,12 @@
</select>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="readMasterLogLevel()"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
Lesen
</button>
<button type="button" class="btn btn-primary btn-sm"
@click="setMasterLogLevel()"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
Setzen
</button>
</div>
@@ -297,65 +372,65 @@
<label class="form-label text-muted mb-0" for="tap-sens">sens (0–15)</label>
<input id="tap-sens" type="number" class="form-control form-control-sm"
min="0" max="15" x-model.number="masterTapCfg.tap_sens_thres"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
</div>
<div class="col-6 col-md-4">
<label class="form-label text-muted mb-0" for="tap-max-gest">max_gest (×5ms)</label>
<input id="tap-max-gest" type="number" class="form-control form-control-sm"
min="0" max="65535" x-model.number="masterTapCfg.max_gest_dur"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
</div>
<div class="col-6 col-md-4">
<label class="form-label text-muted mb-0" for="tap-shock">shock (×5ms)</label>
<input id="tap-shock" type="number" class="form-control form-control-sm"
min="0" max="65535" x-model.number="masterTapCfg.tap_shock_dur"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
</div>
<div class="col-6 col-md-4">
<label class="form-label text-muted mb-0" for="tap-quiet">quiet (×5ms)</label>
<input id="tap-quiet" type="number" class="form-control form-control-sm"
min="0" max="65535" x-model.number="masterTapCfg.quite_time_after_gest"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
</div>
<div class="col-6 col-md-4">
<label class="form-label text-muted mb-0" for="tap-wait">wait (0|1)</label>
<input id="tap-wait" type="number" class="form-control form-control-sm"
min="0" max="1" x-model.number="masterTapCfg.wait_for_timeout"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
</div>
<div class="col-6 col-md-4">
<label class="form-label text-muted mb-0" for="tap-axis">axis (0=X…2=Z)</label>
<input id="tap-axis" type="number" class="form-control form-control-sm"
min="0" max="2" x-model.number="masterTapCfg.axis_sel"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
</div>
</div>
<div class="d-flex flex-wrap gap-3 align-items-center mt-2 small">
<label class="tap-toggle mb-0">
<input type="checkbox" x-model="masterTapCfg.enable_single"
:disabled="busy || !state.uart_connected"> Single
:disabled="busy || !hostUp()"> Single
</label>
<label class="tap-toggle mb-0">
<input type="checkbox" x-model="masterTapCfg.enable_double"
:disabled="busy || !state.uart_connected"> Double
:disabled="busy || !hostUp()"> Double
</label>
<label class="tap-toggle mb-0">
<input type="checkbox" x-model="masterTapCfg.enable_triple"
:disabled="busy || !state.uart_connected"> Triple
:disabled="busy || !hostUp()"> Triple
</label>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="readMasterTapConfig()"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
Lesen
</button>
<button type="button" class="btn btn-primary btn-sm"
@click="setMasterTapConfig()"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
Setzen
</button>
</div>
<p class="text-muted small mt-2 mb-0" x-show="!state.uart_connected">
UART nicht verbunden — Eingabe gesperrt.
<p class="text-muted small mt-2 mb-0" x-show="!hostUp()">
Host nicht verbunden — Eingabe gesperrt.
</p>
</div>
</div>
@@ -373,12 +448,12 @@
:disabled="busy">
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="setDeadzoneAll(allDz)"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
Alle Slaves
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="setTapConfigAll()"
:disabled="busy || !state.uart_connected"
:disabled="busy || !hostUp()"
title="Master-Tap-Config an alle Slaves (ESP-NOW)">
Tap-Cfg alle
</button>
@@ -395,7 +470,7 @@
class="btn btn-sm"
:class="state.live_stream ? 'btn-warning' : 'btn-success'"
@click="setLiveStream(!state.live_stream)"
:disabled="busy || !state.uart_connected || !(state.clients || []).length"
:disabled="busy || !hostUp() || !(state.clients || []).length"
x-text="state.live_stream ? 'Live-Stream aus' : 'Live-Stream an'"></button>
<span class="text-muted small">Tap alle Slaves:</span>
<label class="tap-toggle"><input type="checkbox" x-model="allTapSingle" :disabled="busy"> S</label>
@@ -403,7 +478,7 @@
<label class="tap-toggle"><input type="checkbox" x-model="allTapTriple" :disabled="busy"> T</label>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="setTapNotifyAll(allTapSingle, allTapDouble, allTapTriple)"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
Tap setzen
</button>
</div>
@@ -445,7 +520,7 @@
<span x-text="formatTapCfg(c.tap_config)" :title="tapCfgTitle(c.tap_config)"></span>
<button type="button" class="btn btn-outline-primary btn-sm ms-1 py-0 px-1"
@click="setTapConfig(c.id, masterTapCfg)"
:disabled="busy || !state.uart_connected || !c.available"
:disabled="busy || !hostUp() || !c.available"
title="Master-Tap-Config an diesen Slave">
Set
</button>
@@ -462,7 +537,7 @@
class="btn btn-sm"
:class="c.accel_stream ? 'btn-warning' : 'btn-outline-success'"
@click="setAccelStream(c.id, !c.accel_stream)"
:disabled="busy || !state.uart_connected || !c.available"
:disabled="busy || !hostUp() || !c.available"
x-text="c.accel_stream ? 'Aus' : 'An'"
title="ESP-NOW Accel-Stream auf Slave"></button>
</td>
@@ -472,19 +547,19 @@
<input type="checkbox"
:checked="c.tap_notify_single"
@change="setTapNotify(c.id, $event.target.checked, c.tap_notify_double, c.tap_notify_triple)"
:disabled="busy || !state.uart_connected || !c.available"> S
:disabled="busy || !hostUp() || !c.available"> S
</label>
<label class="tap-toggle" title="Double tap">
<input type="checkbox"
:checked="c.tap_notify_double"
@change="setTapNotify(c.id, c.tap_notify_single, $event.target.checked, c.tap_notify_triple)"
:disabled="busy || !state.uart_connected || !c.available"> D
:disabled="busy || !hostUp() || !c.available"> D
</label>
<label class="tap-toggle" title="Triple tap">
<input type="checkbox"
:checked="c.tap_notify_triple"
@change="setTapNotify(c.id, c.tap_notify_single, c.tap_notify_double, $event.target.checked)"
:disabled="busy || !state.uart_connected || !c.available"> T
:disabled="busy || !hostUp() || !c.available"> T
</label>
</div>
</td>
@@ -500,36 +575,36 @@
:disabled="busy">
<button type="button" class="btn btn-primary btn-sm"
@click="setDeadzone(c.id, slaveDz[c.id] ?? c.deadzone ?? 100)"
:disabled="busy || !state.uart_connected || !c.available">
:disabled="busy || !hostUp() || !c.available">
Setzen
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="unicastTest(c.id)"
:disabled="busy || !state.uart_connected || !c.available"
:disabled="busy || !hostUp() || !c.available"
title="ESP-NOW Unicast-Test">
Test
</button>
<button type="button" class="btn btn-outline-info btn-sm"
@click="echoPing(c.id)"
:disabled="busy || !state.uart_connected || !c.available"
:disabled="busy || !hostUp() || !c.available"
title="ESP-NOW Timestamp-Echo (Round-Trip-Latenz)">
Ping
</button>
<button type="button" class="btn btn-outline-info btn-sm"
@click="ledRing({ clientId: c.id })"
:disabled="busy || !state.uart_connected || !c.available"
:disabled="busy || !hostUp() || !c.available"
title="LED-Ring (aktueller Modus)">
LED
</button>
<button type="button" class="btn btn-outline-warning btn-sm"
@click="ledRing({ clientId: c.id, mode: 'find-me' })"
:disabled="busy || !state.uart_connected || !c.available"
:disabled="busy || !hostUp() || !c.available"
title="Find me">
Find
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(c.id)"
:disabled="busy || !state.uart_connected || !c.available"
:disabled="busy || !hostUp() || !c.available"
title="Neustart per ESP-NOW">
Restart
</button>
@@ -602,17 +677,17 @@
<div class="col-md-4 d-flex flex-wrap gap-2">
<button type="button" class="btn btn-primary btn-sm"
@click="ledRing({ clientId: 0 })"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
Master
</button>
<button type="button" class="btn btn-outline-primary btn-sm"
@click="ledRing({ allClients: true, slavesOnly: true })"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
Alle Slaves
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="ledRing({ allClients: true })"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
Alle + Master
</button>
</div>
@@ -621,23 +696,23 @@
</div>
</section>
<section class="col-12">
<section class="col-12" x-show="isUARTHost() || !hostUp()">
<div class="card">
<div class="card-header">Firmware OTA (A/B)</div>
<div class="card-body">
<p class="text-muted small mb-3">
Lädt eine <code>.bin</code> auf den Master (UART), danach verteilt die Firmware automatisch per ESP-NOW an alle verfügbaren Slaves.
Während des Uploads pausiert das Live-Polling.
Lädt eine <code>.bin</code> auf den Master (nur UART-Host, DIP8=0), danach verteilt die Firmware automatisch per ESP-NOW an alle verfügbaren Slaves.
Während des Uploads pausiert das Live-Polling. Bei BLE-Host ist OTA nicht verfügbar.
</p>
<div class="mb-3">
<input type="file" class="form-control form-control-sm" accept=".bin,application/octet-stream"
@change="otaFile = $event.target.files[0]"
:disabled="ota.active || busy || !state.uart_connected">
:disabled="ota.active || busy || !isUARTHost()">
</div>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<button type="button" class="btn btn-primary btn-sm"
@click="uploadOTA()"
:disabled="ota.active || busy || !state.uart_connected || !otaFile">
:disabled="ota.active || busy || !isUARTHost() || !otaFile">
Firmware hochladen
</button>
<span class="text-muted small" x-show="otaFile"
@@ -666,7 +741,7 @@
<td class="ota-restart-col text-end">
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(0)"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
Restart
</button>
</td>
@@ -703,7 +778,7 @@
<td class="ota-restart-col text-end">
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="restart(row.id)"
:disabled="busy || !state.uart_connected">
:disabled="busy || !hostUp()">
Restart
</button>
</td>
@@ -733,6 +808,13 @@
state: { master: {}, clients: [] },
ws: null,
wsConnected: false,
connTab: 'uart',
uartPort: '',
serialPorts: [],
bleDevices: [],
bleScanning: false,
bleScanErr: '',
hostBusy: false,
masterDz: 100,
masterDzSeeded: false,
masterLogLevel: 0,
@@ -779,9 +861,121 @@
blinkMs: 350,
blinkCount: 1
},
hostUp() {
return !!(this.state?.host_connected || this.state?.uart_connected);
},
isUARTHost() {
return this.hostUp() && (this.state?.host_transport || 'uart') === 'uart';
},
hostBadge() {
const t = this.state?.host_transport || 'none';
if (!this.hostUp()) return 'off';
if (t === 'ble') return 'BLE';
if (t === 'uart') return 'UART';
return 'on';
},
hostEndpointLabel() {
const ep = this.state?.host_endpoint || this.state?.serial_port || '';
const t = this.state?.host_transport || 'none';
return ep ? `${t}: ${ep}` : t;
},
async refreshSerialPorts() {
try {
const r = await fetch('/api/serial/ports');
const data = await r.json();
this.serialPorts = data.ports || [];
if (!this.uartPort && this.serialPorts.length) {
this.uartPort = this.serialPorts[0];
}
} catch (_) {
this.serialPorts = [];
}
},
async connectUART() {
if (!this.uartPort) return;
this.hostBusy = true;
this.bleScanErr = '';
try {
const r = await fetch('/api/host/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transport: 'uart', port: this.uartPort }),
});
const data = await r.json();
if (!r.ok || data.error) {
this.flash(data.error || 'UART connect failed', false);
} else {
this.flash('UART verbunden: ' + (data.endpoint || this.uartPort), true);
}
} catch (e) {
this.flash(String(e), false);
} finally {
this.hostBusy = false;
}
},
async scanBLE() {
this.bleScanning = true;
this.bleScanErr = '';
this.bleDevices = [];
this.hostBusy = true;
try {
const r = await fetch('/api/ble/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ timeout_s: 5 }),
});
const data = await r.json();
if (data.error) this.bleScanErr = data.error;
this.bleDevices = (data.devices || []).slice().sort((a, b) => (b.rssi || 0) - (a.rssi || 0));
} catch (e) {
this.bleScanErr = String(e);
} finally {
this.bleScanning = false;
this.hostBusy = false;
}
},
async connectBLE(d) {
if (!d?.address) return;
this.hostBusy = true;
this.bleScanErr = '';
try {
const r = await fetch('/api/host/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transport: 'ble',
address: d.address,
name: d.name || '',
}),
});
const data = await r.json();
if (!r.ok || data.error) {
this.flash(data.error || 'BLE connect failed', false);
} else {
this.flash('BLE verbunden: ' + (data.endpoint || d.address), true);
this.connTab = 'ble';
}
} catch (e) {
this.flash(String(e), false);
} finally {
this.hostBusy = false;
}
},
async disconnectHost() {
this.hostBusy = true;
try {
await fetch('/api/host/disconnect', { method: 'POST' });
this.flash('Host getrennt', true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.hostBusy = false;
}
},
connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = proto + '//' + location.host + '/ws';
this.refreshSerialPorts();
if (this._batteryTimer) clearInterval(this._batteryTimer);
this._batteryTimer = setInterval(() => this.refreshBattery(), 5000);
if (this._tapTimer) clearInterval(this._tapTimer);
@@ -791,6 +985,7 @@
this.ws.onopen = () => {
this.wsConnected = true;
this.refreshBattery();
this.refreshSerialPorts();
};
this.ws.onclose = () => {
this.wsConnected = false;
@@ -809,6 +1004,8 @@
}
const prev = this.state;
this.state = msg;
if (msg.host_transport === 'ble') this.connTab = 'ble';
else if (msg.host_transport === 'uart') this.connTab = 'uart';
this.preserveBatteryInState(prev, this.state);
this.syncTapDisplay(msg.clients || []);
if (!this.masterDzSeeded && msg.master?.deadzone != null) {
@@ -898,7 +1095,7 @@
};
},
async refreshBattery() {
if (!this.state?.uart_connected) return;
if (!this.hostUp()) return;
try {
const r = await fetch('/api/battery?all_clients=1');
if (!r.ok) return;