Add software LiPo UV protection with energy-save boot.

Detect under-voltage on GPIO1/11, persist UV state in NVS, enter a minimal
power mode without ESP-NOW, and recover after charge; expose LED mode 6
battery-low for host testing via goTool.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 18:22:52 +02:00
co-authored by Cursor
parent be18b758ed
commit f006f8b912
26 changed files with 418 additions and 37 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ go run . -port /dev/ttyUSB0 clients
| `serve` | — | Web dashboard at `http://localhost:8080` (WebSocket live updates) |
| `ota` | 1619 | UART firmware upload to master; firmware then pushes to slaves via ESP-NOW |
| `ota-progress` | 21 | Query per-slave ESP-NOW OTA progress on the master (`-client N`, default all) |
| `led-ring` | 8 | LED ring: `-mode clear\|color\|progress\|digit\|blink\|find-me`, `-client`, `-all` |
| `led-ring` | 8 | LED ring: `-mode clear\|color\|progress\|digit\|blink\|find-me\|battery-low`, `-client`, `-all` |
| `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` 05); output on UART0 debug, not host UART |
+2
View File
@@ -347,6 +347,8 @@ func ledRingModeValue(mode string) (uint32, error) {
return 3, nil
case "find_me", "findme":
return 4, nil
case "battery_low", "batterylow":
return 6, nil
case "color", "solid", "fill":
return 5, nil
default:
+1
View File
@@ -12,6 +12,7 @@ func TestLedRingModeValue(t *testing.T) {
{"digit", 2},
{"blink", 3},
{"find-me", 4},
{"battery-low", 6},
}
for _, tc := range tests {
got, err := ledRingModeValue(tc.mode)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
func runLedRing(sp *serialPort, args []string) error {
fs := flag.NewFlagSet("led-ring", flag.ExitOnError)
mode := fs.String("mode", "progress", "clear, color, progress, digit, blink, or find-me")
mode := fs.String("mode", "progress", "clear, color, progress, digit, blink, find-me, or battery-low")
clientID := fs.Uint("client", 0, "0=master ring, >0=slave via ESP-NOW")
allClients := fs.Bool("all", false, "broadcast to all slaves")
slavesOnly := fs.Bool("slaves-only", false, "with -all: do not change master ring")
+1
View File
@@ -96,6 +96,7 @@ Body:
| `digit` | `digit` 010 |
| `blink` | `blink_ms`, `blink_count` |
| `find-me` | Locate pod |
| `battery-low` | First 4 LEDs red at ~10 % for 5 s (UV indicator test) |
Use `client_id` (`0` = master) or `all_clients` (+ optional `slaves_only`) for broadcast.
+2 -1
View File
@@ -214,11 +214,12 @@ Response `tap_notify_status`:
| `digit` | `digit` 010 |
| `blink` | `blink_ms`, `blink_count` |
| `find-me` | Locate pod |
| `battery-low` | UV indicator (4 LEDs, 5 s) |
Target: `client_id` (`0` = master) or `all_clients` (+ optional `slaves_only`).
Response `led_ring_status``mode` is numeric: 0=clear, 1=progress, 2=digit, 3=blink, 4=find-me, 5=color.
Response `led_ring_status``mode` is numeric: 0=clear, 1=progress, 2=digit, 3=blink, 4=find-me, 5=color, 6=battery-low.
```json
{"type":"led_ring_status","success":true,"mode":5,"client_id":16,"slaves_updated":1}
+4 -1
View File
@@ -14,6 +14,7 @@ const (
ledRingModeBlink = 3
ledRingModeFindMe = 4
ledRingModeColor = 5
ledRingModeBatteryLow = 6
)
type ledRingAPIRequest struct {
@@ -56,8 +57,10 @@ func ledRingModeFromString(s string) (uint32, error) {
return ledRingModeBlink, nil
case "find-me", "find_me", "findme":
return ledRingModeFindMe, nil
case "battery-low", "battery_low", "batterylow":
return ledRingModeBatteryLow, nil
default:
return 0, fmt.Errorf("unknown mode %q (clear, color, progress, digit, blink, find-me)", s)
return 0, fmt.Errorf("unknown mode %q (clear, color, progress, digit, blink, find-me, battery-low)", s)
}
}
+1 -1
View File
@@ -2526,7 +2526,7 @@ func (x *EspNowEchoPingResponse) GetEspRttUs() uint32 {
}
// Host → master: LED ring on master (client_id=0) and/or slaves via ESP-NOW.
// mode: 0=clear, 1=progress (0100 %), 2=digit (010), 3=blink, 4=find-me, 5=all LEDs solid color.
// mode: 0=clear, 1=progress (0100 %), 2=digit (010), 3=blink, 4=find-me, 5=all LEDs solid color, 6=battery-low.
type LedRingProgressRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Mode uint32 `protobuf:"varint,1,opt,name=mode,proto3" json:"mode,omitempty"`
+1 -1
View File
@@ -44,7 +44,7 @@ Ordered steps: UART commands, delays, or esptool reset.
**unicast_test**`input`: `slave` or `client_id`, `seq`
`expect`: `success`, `seq`
**led_ring**`input`: `mode` (`clear`, `progress`, `digit`, `blink`, `find_me`), `progress`, `digit`, `r`/`g`/`b`, `intensity`, `blink_ms`, `blink_count`
**led_ring**`input`: `mode` (`clear`, `progress`, `digit`, `blink`, `find_me`, `battery_low`), `progress`, `digit`, `r`/`g`/`b`, `intensity`, `blink_ms`, `blink_count`
`expect`: `success`, `mode`, `progress`, `digit`
**find_me**`input`: `client` / `client_id` or `slave` (`0` = master ring)
+2 -1
View File
@@ -471,7 +471,7 @@
<div class="card-body">
<p class="text-muted small mb-3">
Modi: <code>clear</code>, <code>color</code> (ganzer Ring), <code>progress</code> (0100&nbsp;%),
<code>digit</code> (010), <code>blink</code>, <code>find-me</code>.
<code>digit</code> (010), <code>blink</code>, <code>find-me</code>, <code>battery-low</code>.
Ziel: Master (<code>client_id=0</code>), ein Slave oder alle Slaves (Broadcast).
</p>
<div class="row g-3 align-items-end">
@@ -484,6 +484,7 @@
<option value="digit">Ziffer/Symbol</option>
<option value="blink">Blink</option>
<option value="find-me">Find me</option>
<option value="battery-low">Battery low</option>
</select>
</div>
<div class="col-md-4">