Add runtime UV protection toggle and treat near-zero sense as external power.

Per-pod NVS enable (default off) via UART/ESP-NOW/dashboard, and skip UV latch when LiPo ADC reads ~0 mV so USB/external supply still boots.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-01 22:51:17 +01:00
co-authored by Cursor
parent c3b20887bf
commit 7f28a48a10
33 changed files with 1411 additions and 240 deletions
+134 -1
View File
@@ -383,6 +383,11 @@
<dd class="col-7" x-text="formatLipo(state.master?.lipo1)"></dd>
<dt class="col-5 text-muted">LiPo 2</dt>
<dd class="col-7" x-text="formatLipo(state.master?.lipo2)"></dd>
<dt class="col-5 text-muted">UV-Schutz</dt>
<dd class="col-7">
<span x-text="state.master?.uv_protection == null ? '—' : (state.master.uv_protection ? 'an' : 'aus')"></span>
<span class="text-warning small ms-1" x-show="state.master?.uv_latched">· gelatcht</span>
</dd>
</dl>
</template>
<template x-if="state.master && !state.master.ok">
@@ -512,6 +517,31 @@
Setzen
</button>
</div>
<h6 class="text-secondary mb-2 mt-3">UV-Schutz (LiPo)</h6>
<div class="d-flex flex-wrap gap-2 align-items-center">
<label class="tap-toggle mb-0">
<input type="checkbox"
:checked="!!state.master?.uv_protection"
@change="setBatteryUv(0, $event.target.checked)"
:disabled="busy || !hostUp()">
Master aktiv
</label>
<button type="button" class="btn btn-outline-secondary btn-sm"
@click="setBatteryUvAll(true)"
:disabled="busy || !hostUp()"
title="UV-Schutz auf Master + alle Slaves einschalten">
Alle an
</button>
<button type="button" class="btn btn-outline-warning btn-sm"
@click="setBatteryUvAll(false)"
:disabled="busy || !hostUp()"
title="UV-Schutz auf Master + alle Slaves ausschalten (cleart Latch)">
Alle aus
</button>
</div>
<p class="text-muted small mt-1 mb-0">
Runtime-NVS pro Pod. Im Energiesparmodus (gelatcht) kein Host-Zugriff.
</p>
<p class="text-muted small mt-2 mb-0" x-show="!hostUp()">
Host nicht verbunden — Eingabe gesperrt.
</p>
@@ -540,6 +570,12 @@
title="Master-Tap-Config an alle Slaves (ESP-NOW)">
Tap-Cfg alle
</button>
<button type="button" class="btn btn-outline-warning btn-sm"
@click="setBatteryUvSlavesOnly(false)"
:disabled="busy || !hostUp() || !(state.clients || []).length"
title="UV-Schutz nur auf allen Slaves ausschalten">
UV Slaves aus
</button>
<button type="button" class="btn btn-outline-danger btn-sm"
@click="resetClientIdMap()"
:disabled="busy || !hostUp()"
@@ -611,6 +647,7 @@
<th>Tap-Cfg</th>
<th>Accel (LSB)</th>
<th>Akku</th>
<th>UV</th>
<th>Accel</th>
<th>Tap-Notify</th>
<th>Tap</th>
@@ -619,7 +656,7 @@
</thead>
<tbody>
<template x-if="!(state.clients || []).length">
<tr><td colspan="12" class="text-muted text-center py-4">No clients</td></tr>
<tr><td colspan="13" class="text-muted text-center py-4">No clients</td></tr>
</template>
<template x-for="c in (state.clients || [])" :key="c.id + c.mac">
<tr>
@@ -648,6 +685,14 @@
:title="accelTitle(c)"></span>
</td>
<td class="small" x-text="formatLipoPair(c)" :title="lipoTitle(c)"></td>
<td>
<label class="tap-toggle mb-0" title="Software-UV-Schutz (NVS auf Slave)">
<input type="checkbox"
:checked="!!c.uv_protection"
@change="setBatteryUv(c.id, $event.target.checked)"
:disabled="busy || !hostUp() || !c.available">
</label>
</td>
<td>
<button type="button"
class="btn btn-sm"
@@ -1783,6 +1828,94 @@
this.busy = false;
}
},
patchBatteryUv(clientId, enabled, latched) {
if (clientId === 0) {
const master = {
...(this.state.master || {}),
uv_protection: !!enabled,
uv_latched: !!latched
};
this.state = { ...this.state, master };
return;
}
const clients = (this.state.clients || []).map((c) =>
c.id === clientId ? { ...c, uv_protection: !!enabled } : c);
this.state = { ...this.state, clients };
},
async setBatteryUv(clientId, enabled) {
this.busy = true;
try {
const r = await fetch('/api/battery-uv', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ write: true, enabled: !!enabled, client_id: clientId })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || `UV-Schutz ${clientId === 0 ? 'Master' : 'Slave ' + clientId} fehlgeschlagen`, false);
return;
}
this.patchBatteryUv(clientId, data.enabled, data.latched);
const who = clientId === 0 ? 'Master' : `Slave ${clientId}`;
this.flash(`${who}: UV-Schutz ${data.enabled ? 'an' : 'aus'}`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async setBatteryUvAll(enabled) {
this.busy = true;
try {
const r = await fetch('/api/battery-uv', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ write: true, enabled: !!enabled, all_clients: true })
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'UV-Schutz für alle fehlgeschlagen', false);
return;
}
this.patchBatteryUv(0, data.enabled, data.latched);
for (const c of (this.state.clients || [])) {
this.patchBatteryUv(c.id, !!enabled, false);
}
this.flash(`UV-Schutz alle ${enabled ? 'an' : 'aus'} (${data.slaves_updated || 0} Slaves)`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
async setBatteryUvSlavesOnly(enabled) {
this.busy = true;
try {
const r = await fetch('/api/battery-uv', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
write: true,
enabled: !!enabled,
all_clients: true,
slaves_only: true
})
});
const data = await r.json();
if (!r.ok || !data.success) {
this.flash(data.error || 'UV-Schutz Slaves fehlgeschlagen', false);
return;
}
for (const c of (this.state.clients || [])) {
this.patchBatteryUv(c.id, !!enabled, false);
}
this.flash(`UV-Schutz Slaves ${enabled ? 'an' : 'aus'} (${data.slaves_updated || 0})`, true);
} catch (e) {
this.flash(String(e), false);
} finally {
this.busy = false;
}
},
patchLiveStream(enabled) {
let clients = this.state.clients || [];
if (!enabled) {