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
+1
View File
@@ -38,6 +38,7 @@ go run . -port /dev/ttyUSB0 clients
| `find-me` | 22 | Locate pod (`-client 0` master, `>0` slave via ESP-NOW) |
| `restart` | 23 | Reboot master or slave (`-client 0` / `>0`) |
| `log-level` | `0x1f` | Get/set master ESP-IDF log level for tag `"*"` (`-set`, `-level` 0–5); output on UART0 debug, not host UART |
| `battery-uv` | `0x24` | Get/set software LiPo UV protection (`-set -enable`/`-enable=false`, `-client`, `-all`) |
| `input-send-mode` | `0x23` | Get/set slave accel/tap ESP-NOW TX path (`-set -mode 0..3`, `-all` or `-client N`): best_effort/fast/wait/reliable |
`clients` requires slaves to have responded to master discover broadcasts first.
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"powerpod/gotool/pb"
)
type batteryUvAPIRequest struct {
Write bool `json:"write"`
Enabled bool `json:"enabled"`
ClientID uint32 `json:"client_id"`
AllClients bool `json:"all_clients"`
// SlavesOnly: with all_clients, push to ESP-NOW slaves only (master NVS unchanged).
SlavesOnly bool `json:"slaves_only"`
}
type batteryUvAPIResponse struct {
ClientID uint32 `json:"client_id"`
Success bool `json:"success"`
Enabled bool `json:"enabled"`
Latched bool `json:"latched"`
SlavesUpdated uint32 `json:"slaves_updated"`
Error string `json:"error,omitempty"`
}
func mountBatteryUvAPI(mux *http.ServeMux, link *managedSerial) {
mux.HandleFunc("/api/battery-uv", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
serveBatteryUvGet(w, r, link)
case http.MethodPost:
serveBatteryUvPost(w, r, link)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
}
func serveBatteryUvGet(w http.ResponseWriter, r *http.Request, link *managedSerial) {
clientID, err := parseUintQuery(r, "client_id", 0)
if err != nil {
writeJSON(w, http.StatusBadRequest, batteryUvAPIResponse{Error: err.Error()})
return
}
resp, err := link.BatteryUv(&pb.BatteryUvRequest{
Write: false,
ClientId: clientID,
})
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, batteryUvAPIResponse{
ClientID: clientID,
Error: err.Error(),
})
return
}
writeJSON(w, http.StatusOK, batteryUvAPIResponse{
ClientID: resp.GetClientId(),
Success: resp.GetSuccess(),
Enabled: resp.GetEnabled(),
Latched: resp.GetLatched(),
SlavesUpdated: resp.GetSlavesUpdated(),
})
}
func serveBatteryUvPost(w http.ResponseWriter, r *http.Request, link *managedSerial) {
var body batteryUvAPIRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, batteryUvAPIResponse{Error: "invalid JSON"})
return
}
if body.AllClients && body.SlavesOnly {
updated, err := applyBatteryUvToSlaves(link, body.Enabled)
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, batteryUvAPIResponse{
Enabled: body.Enabled,
Error: err.Error(),
})
return
}
writeJSON(w, http.StatusOK, batteryUvAPIResponse{
Success: updated > 0,
Enabled: body.Enabled,
SlavesUpdated: updated,
})
return
}
resp, err := link.BatteryUv(&pb.BatteryUvRequest{
Write: true,
Enabled: body.Enabled,
ClientId: body.ClientID,
AllClients: body.AllClients,
})
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, batteryUvAPIResponse{
ClientID: body.ClientID,
Enabled: body.Enabled,
Error: err.Error(),
})
return
}
writeJSON(w, http.StatusOK, batteryUvAPIResponse{
ClientID: resp.GetClientId(),
Success: resp.GetSuccess(),
Enabled: resp.GetEnabled(),
Latched: resp.GetLatched(),
SlavesUpdated: resp.GetSlavesUpdated(),
})
}
func applyBatteryUvToSlaves(link *managedSerial, enabled bool) (uint32, error) {
clients, err := link.listClients()
if err != nil {
return 0, err
}
var updated uint32
for _, c := range clients {
resp, err := link.BatteryUv(&pb.BatteryUvRequest{
Write: true,
Enabled: enabled,
ClientId: c.GetId(),
})
if err != nil {
continue
}
if resp.GetSuccess() {
updated++
}
}
if len(clients) == 0 {
return 0, fmt.Errorf("no slaves registered")
}
if updated == 0 {
return 0, fmt.Errorf("UV protection not applied to any slave")
}
return updated, nil
}
+1
View File
@@ -105,6 +105,7 @@ func mountServeAPI(mux *http.ServeMux, link *managedSerial, hub *wsHub, streamCt
mountInputSendModeAPI(mux, link)
mountTapAPI(mux, link, hub, tapCtl)
mountTapConfigAPI(mux, link)
mountBatteryUvAPI(mux, link)
mountLedRingAPI(mux, link)
mountBatteryAPI(mux, link)
mux.HandleFunc("/api/deadzone", func(w http.ResponseWriter, r *http.Request) {
+69
View File
@@ -290,6 +290,44 @@ func (m *managedSerial) tapConfigExchange(poll bool, req *pb.TapConfigRequest) (
return r, nil
}
func (m *managedSerial) BatteryUv(req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
return m.batteryUvExchange(false, req)
}
func (m *managedSerial) BatteryUvPoll(req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
return m.batteryUvExchange(true, req)
}
func (m *managedSerial) batteryUvExchange(poll bool, req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_BATTERY_UV,
Payload: &pb.UartMessage_BatteryUvRequest{BatteryUvRequest: req},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_BATTERY_UV)}, body...)
var respPayload []byte
if poll {
respPayload, err = m.exchangePayloadPoll(payload, "BATTERY_UV")
} else {
respPayload, err = m.exchangePayload(payload, "BATTERY_UV")
}
if err != nil {
return nil, err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetBatteryUvResponse()
if r == nil {
return nil, fmt.Errorf("missing battery_uv_response")
}
return r, nil
}
func (m *managedSerial) accelDeadzoneExchange(poll bool, req *pb.AccelDeadzoneRequest) (*pb.AccelDeadzoneResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_ACCEL_DEADZONE,
@@ -510,6 +548,33 @@ func (s *serialPort) tapConfig(req *pb.TapConfigRequest) (*pb.TapConfigResponse,
return r, nil
}
func (s *serialPort) batteryUv(req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
msg := &pb.UartMessage{
Type: pb.MessageType_BATTERY_UV,
Payload: &pb.UartMessage_BatteryUvRequest{
BatteryUvRequest: req,
},
}
body, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("encode: %w", err)
}
payload := append([]byte{byte(pb.MessageType_BATTERY_UV)}, body...)
respPayload, err := s.exchangePayload(payload, "BATTERY_UV")
if err != nil {
return nil, err
}
var respMsg pb.UartMessage
if err := proto.Unmarshal(respPayload[1:], &respMsg); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
r := respMsg.GetBatteryUvResponse()
if r == nil {
return nil, fmt.Errorf("missing battery_uv_response")
}
return r, nil
}
func (s *serialPort) espnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
req := &pb.EspNowUnicastTestRequest{ClientId: clientID, Seq: seq}
msg := &pb.UartMessage{
@@ -681,6 +746,10 @@ func (s *serialPort) TapConfig(req *pb.TapConfigRequest) (*pb.TapConfigResponse,
return s.tapConfig(req)
}
func (s *serialPort) BatteryUv(req *pb.BatteryUvRequest) (*pb.BatteryUvResponse, error) {
return s.batteryUv(req)
}
func (s *serialPort) EspnowUnicastTest(clientID, seq uint32) (*pb.EspNowUnicastTestResponse, error) {
return s.espnowUnicastTest(clientID, seq)
}
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"flag"
"fmt"
"powerpod/gotool/pb"
)
func runBatteryUv(sp *serialPort, args []string) error {
fs := flag.NewFlagSet("battery-uv", flag.ExitOnError)
write := fs.Bool("set", false, "write UV protection enable (default: read)")
enabled := fs.Bool("enable", false, "enable software UV protection")
clientID := fs.Uint("client", 0, "client id (0=local master)")
all := fs.Bool("all", false, "apply to master + all registered slaves")
if err := fs.Parse(args); err != nil {
return err
}
r, err := sp.batteryUv(&pb.BatteryUvRequest{
Write: *write,
Enabled: *enabled,
ClientId: uint32(*clientID),
AllClients: *all,
})
if err != nil {
return err
}
fmt.Printf("client_id=%d success=%v enabled=%v latched=%v slaves_updated=%d\n",
r.GetClientId(), r.GetSuccess(), r.GetEnabled(), r.GetLatched(),
r.GetSlavesUpdated())
return nil
}
+23
View File
@@ -20,6 +20,8 @@ type MasterView struct {
RunningPartition string `json:"running_partition,omitempty"`
Deadzone uint32 `json:"deadzone,omitempty"`
TapConfig *TapConfigView `json:"tap_config,omitempty"`
UvProtection *bool `json:"uv_protection,omitempty"`
UvLatched bool `json:"uv_latched,omitempty"`
Lipo1 lipoReadingJSON `json:"lipo1"`
Lipo2 lipoReadingJSON `json:"lipo2"`
BatteryAgeMs uint32 `json:"battery_age_ms,omitempty"`
@@ -58,6 +60,7 @@ type ClientView struct {
TapNotifySingle bool `json:"tap_notify_single"`
TapNotifyDouble bool `json:"tap_notify_double"`
TapNotifyTriple bool `json:"tap_notify_triple"`
UvProtection bool `json:"uv_protection"`
LastTap string `json:"last_tap,omitempty"`
LastTapAt int64 `json:"last_tap_at,omitempty"`
Lipo1 lipoReadingJSON `json:"lipo1"`
@@ -700,6 +703,11 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
if tc, err := readTapConfigPoll(link, 0); err == nil {
st.Master.TapConfig = tc
}
if uv, err := readBatteryUvPoll(link, 0); err == nil {
en := uv.GetEnabled()
st.Master.UvProtection = &en
st.Master.UvLatched = uv.GetLatched()
}
clients, err := link.listClientsPoll()
if err != nil {
@@ -726,6 +734,7 @@ func pollDashboard(link *managedSerial, portName string, last *DashboardState, s
TapNotifySingle: c.GetTapNotifySingle(),
TapNotifyDouble: c.GetTapNotifyDouble(),
TapNotifyTriple: c.GetTapNotifyTriple(),
UvProtection: c.GetUvProtectionEnabled(),
}
st.Clients = append(st.Clients, cv)
}
@@ -928,6 +937,20 @@ func tapConfigViewFromPB(r *pb.TapConfigResponse) *TapConfigView {
}
}
func readBatteryUvPoll(link *managedSerial, clientID uint32) (*pb.BatteryUvResponse, error) {
r, err := link.BatteryUvPoll(&pb.BatteryUvRequest{
Write: false,
ClientId: clientID,
})
if err != nil {
return nil, err
}
if !r.GetSuccess() {
return nil, fmt.Errorf("battery uv read failed for client %d", clientID)
}
return r, nil
}
func readTapConfigPoll(link *managedSerial, clientID uint32) (*TapConfigView, error) {
r, err := link.TapConfigPoll(&pb.TapConfigRequest{
Write: false,
+26
View File
@@ -400,6 +400,31 @@ go run . -port /dev/ttyUSB0 log-level -set -level 3
Dashboard: Master card → dropdown **ESP Log-Level** with **Lesen** / **Setzen**.
### Battery UV protection
Runtime enable for software LiPo under-voltage protection (NVS `uv_en` per pod). Disable also clears `uv_latch`. `all_clients` applies to master + every registered slave; `slaves_only` with `all_clients` skips the master.
```http
GET /api/battery-uv?client_id=0
POST /api/battery-uv
Content-Type: application/json
{"write": true, "enabled": false, "client_id": 0}
{"write": true, "enabled": false, "all_clients": true}
{"write": true, "enabled": false, "all_clients": true, "slaves_only": true}
```
```json
{"success": true, "client_id": 0, "enabled": false, "latched": false, "slaves_updated": 0}
```
```bash
go run . -port /dev/ttyUSB0 battery-uv
go run . -port /dev/ttyUSB0 battery-uv -set -enable=false -client 0
go run . -port /dev/ttyUSB0 battery-uv -set -enable=false -all
```
Dashboard: Master **UV-Schutz** checkbox + **Alle an/aus**; per-slave **UV** column.
### Input send mode (slave accel/tap ESP-NOW TX path)
Debug A/B for how slaves transmit `ACCEL_SAMPLE` / `TAP_EVENT`. Default on slaves is `best_effort` (0). Master remembers the last mode for GET; write pushes via ESP-NOW.
@@ -486,6 +511,7 @@ Same as external API:
| Unicast test | `POST /api/unicast-test` |
| Echo ping | `POST /api/echo-ping` (per-slave latency; UI shows Host ms + ESP ms) |
| Master log level | `GET` / `POST /api/log-level` or CLI `log-level` |
| UV protection | `GET` / `POST /api/battery-uv` or CLI `battery-uv` |
| Input-Send mode | `GET` / `PUT /api/input-send-mode` or CLI `input-send-mode` |
| Tap notify S/D/T | `PUT /api/clients/{id}/tap-notify` |
| Tap receive (UI) | Live stream + tap notify; see WebSocket doc for external API |
+8
View File
@@ -35,6 +35,7 @@ func usage() {
fmt.Fprintf(os.Stderr, " find-me blink LED ring red/green/blue (3× each, full brightness)\n")
fmt.Fprintf(os.Stderr, " restart reboot master or slave (ESP-NOW)\n")
fmt.Fprintf(os.Stderr, " log-level get/set master ESP-IDF log level (global)\n")
fmt.Fprintf(os.Stderr, " battery-uv get/set software LiPo UV protection (per pod / all)\n")
fmt.Fprintf(os.Stderr, " input-send-mode get/set slave accel/tap ESP-NOW send path\n\n")
flag.PrintDefaults()
}
@@ -138,6 +139,13 @@ func main() {
break
}
runErr = runTapConfig(sp, flag.Args()[1:])
case "battery-uv", "battery_uv":
sp, ok := h.(*serialPort)
if !ok {
runErr = fmt.Errorf("%s over BLE not wired yet; use -port", cmd)
break
}
runErr = runBatteryUv(sp, flag.Args()[1:])
case "tap-notify", "tap_notify":
sp, ok := h.(*serialPort)
if !ok {
File diff suppressed because it is too large Load Diff
+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) {