diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md
index 6084af8..ed38b76 100644
--- a/docs/DOCUMENTATION.md
+++ b/docs/DOCUMENTATION.md
@@ -91,15 +91,17 @@ idf.py -p /dev/ttyUSB0 flash monitor
### 4.1 Initialisierungsreihenfolge (`powerpod.c`)
1. `pod_settings_init()` — NVS
-2. DIP → `app_config.master`
-3. I2C + IO-Expander → `app_config.network` (1–8)
-4. `init_bma456()` — optional, bei Fehler weiter ohne Sensor
-5. `pod_settings_apply_accel_deadzone()` wenn Sensor da
-6. OTA-Partition → `app_config.running_partition`
-7. `board_input_init()`
-8. `esp_now_comm_init(&app_config)` — **immer** (Master + Slave)
-9. `led_ring_init()`
-10. **Nur Master:** `cmd_queue` → `init_cmdHandler` → `init_uart` → alle `cmd_*_register()`
+2. `board_input_init_adc_only()` — LiPo-ADC
+3. Bei `POWERPOD_BATTERY_UV_ENABLE`: `battery_uv_evaluate_boot()` → ggf. Energiesparmodus (nur LED + ADC, **kein** weiterer Init)
+4. DIP → `app_config.master`
+5. I2C + IO-Expander → `app_config.network` (1–8)
+6. `init_bma456()` — optional, bei Fehler weiter ohne Sensor
+7. `pod_settings_apply_accel_deadzone()` wenn Sensor da
+8. OTA-Partition → `app_config.running_partition`
+9. `board_input_start_lipo_monitor()` + `board_input_init_button()`
+10. `esp_now_comm_init(&app_config)` — **normaler Pfad** (Master + Slave)
+11. `led_ring_init()`
+12. **Nur Master:** `cmd_queue` → `init_cmdHandler` → `init_uart` → alle `cmd_*_register()`
### 4.2 Master vs. Slave
@@ -397,6 +399,7 @@ Ohne Sensor: `bma456_is_ready() == false`, Firmware läuft weiter.
- Segment-Karten für Ziffern 0–10 in `digit_lookup[]`.
- **Kein** lokaler Demo-Loop — Anzeige nur über UART (`cmd_led_ring.c`) oder ESP-NOW `ESPNOW_LED_RING`.
- Helligkeit: `intensity` 0 → Default ~5 % (`LED_RING_DEFAULT_INTENSITY`).
+- Modi: `0` clear … `5` color, `6` **battery-low** (erste 4 LEDs rot ~10 %, 5 s).
---
@@ -405,12 +408,26 @@ Ohne Sensor: `bma456_is_ready() == false`, Firmware läuft weiter.
`board_input.c`:
- **Taster** GPIO 12 — Logging bei Druck.
-- **LiPo-ADC** GPIO 1 und GPIO 11.
+- **LiPo-ADC** GPIO 1 und GPIO 11 (Pin-Spannung in mV).
- Master: `master_monitor_task` aktualisiert Master-Batterie alle 30 s.
- Slave: `slave_send_battery_report_to_master` nach Join, Heartbeat und Query.
Spannungen in Millivolt in Registry und `BATTERY_STATUS`-UART.
+### Software-UV-Schutz (`battery_uv.c`)
+
+Aktivierbar mit `POWERPOD_BATTERY_UV_ENABLE` in `powerpod.h` (CMake-Override möglich).
+
+| Zustand | ADC-Schwelle | Logik |
+|---------|--------------|-------|
+| UV | < 2300 mV | Ein gültiger Kanal reicht |
+| Geladen | ≥ 3000 mV | Alle gültigen Kanäle |
+
+- NVS-Key `uv_latch` in `pod_settings.c`
+- Boot: ADC früh, bei UV nur LED + ADC-Poll (kein ESP-NOW/WiFi/UART/I2C/Button)
+- Runtime: `battery_uv_poll()` im LiPo-Monitor (2 Samples Debounce)
+- LED-Anzeige: Mode `6` battery-low (`led_ring_show_battery_low`)
+
---
## 12. Persistenz (NVS)
@@ -420,6 +437,7 @@ Spannungen in Millivolt in Registry und `BATTERY_STATUS`-UART.
| Key | Inhalt |
|-----|--------|
| `accel_dz` | Accel-Deadzone LSB |
+| `uv_latch` | Software-UV-Zustand (`1` = Energiesparmodus) |
Gespeichert bei lokalem Set (UART `client_id=0`, ESP-NOW auf Slave). Geladen nach `init_bma456()`.
@@ -493,6 +511,7 @@ Includes in generierten `.pb.c` müssen `"uart_messages.pb.h"` heißen (nicht `m
| `bosch456.c` | Accelerometer + Tap |
| `led_ring.c` | LED-Anzeige |
| `board_input.c` | Taster, ADC |
+| `battery_uv.c` | Software-UV-Schutz, Energiesparmodus |
| `pod_settings.c` | NVS |
| `pod_reboot.c` | Verzögerter Restart |
| `ota_uart.c` | Flash-Puffer UART-OTA |
diff --git a/goTool/README.md b/goTool/README.md
index 4a6b1c4..44ad0cb 100644
--- a/goTool/README.md
+++ b/goTool/README.md
@@ -33,7 +33,7 @@ go run . -port /dev/ttyUSB0 clients
| `serve` | — | Web dashboard at `http://localhost:8080` (WebSocket live updates) |
| `ota` | 16–19 | 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` 0–5); output on UART0 debug, not host UART |
diff --git a/goTool/autotest/runner.go b/goTool/autotest/runner.go
index 4391730..0590c16 100644
--- a/goTool/autotest/runner.go
+++ b/goTool/autotest/runner.go
@@ -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:
diff --git a/goTool/autotest/runner_test.go b/goTool/autotest/runner_test.go
index d49d85a..a5c773b 100644
--- a/goTool/autotest/runner_test.go
+++ b/goTool/autotest/runner_test.go
@@ -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)
diff --git a/goTool/cmd_led_ring.go b/goTool/cmd_led_ring.go
index 3404a54..0deb4fa 100644
--- a/goTool/cmd_led_ring.go
+++ b/goTool/cmd_led_ring.go
@@ -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")
diff --git a/goTool/docs/API_REST.md b/goTool/docs/API_REST.md
index 963c0dc..bf1bfb1 100644
--- a/goTool/docs/API_REST.md
+++ b/goTool/docs/API_REST.md
@@ -96,6 +96,7 @@ Body:
| `digit` | `digit` 0–10 |
| `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.
diff --git a/goTool/docs/API_WEBSOCKET.md b/goTool/docs/API_WEBSOCKET.md
index 916d338..6718886 100644
--- a/goTool/docs/API_WEBSOCKET.md
+++ b/goTool/docs/API_WEBSOCKET.md
@@ -214,11 +214,12 @@ Response `tap_notify_status`:
| `digit` | `digit` 0–10 |
| `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}
diff --git a/goTool/led_ring_api.go b/goTool/led_ring_api.go
index ff9b852..209db2f 100644
--- a/goTool/led_ring_api.go
+++ b/goTool/led_ring_api.go
@@ -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)
}
}
diff --git a/goTool/pb/uart_messages.pb.go b/goTool/pb/uart_messages.pb.go
index b9e545b..87f2063 100644
--- a/goTool/pb/uart_messages.pb.go
+++ b/goTool/pb/uart_messages.pb.go
@@ -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 (0–100 %), 2=digit (0–10), 3=blink, 4=find-me, 5=all LEDs solid color.
+// mode: 0=clear, 1=progress (0–100 %), 2=digit (0–10), 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"`
diff --git a/goTool/testdata/README.md b/goTool/testdata/README.md
index b89f0ce..68b48fc 100644
--- a/goTool/testdata/README.md
+++ b/goTool/testdata/README.md
@@ -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)
diff --git a/goTool/webui/index.html b/goTool/webui/index.html
index 52a286a..062ba01 100644
--- a/goTool/webui/index.html
+++ b/goTool/webui/index.html
@@ -471,7 +471,7 @@
Modi: clear, color (ganzer Ring), progress (0–100 %),
- digit (0–10), blink, find-me.
+ digit (0–10), blink, find-me, battery-low.
Ziel: Master (client_id=0), ein Slave oder alle Slaves (Broadcast).
@@ -484,6 +484,7 @@
+
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt
index 6643a0b..22bc4f5 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -42,6 +42,7 @@ idf_component_register(
"esp_now_proto.c"
"bosch456.c"
"board_input.c"
+ "battery_uv.c"
"pod_settings.c"
"proto/uart_messages.pb.c"
"proto/esp_now_messages.pb.c"
@@ -68,3 +69,5 @@ idf_component_register(
target_compile_definitions(${COMPONENT_LIB}
PRIVATE "POWERPOD_GIT_HASH=\"${POWERPOD_GIT_HASH}\"")
+# Optional: disable software UV protection
+# target_compile_definitions(${COMPONENT_LIB} PRIVATE POWERPOD_BATTERY_UV_ENABLE=0)
diff --git a/main/README.md b/main/README.md
index 07bb61b..159a2a2 100644
--- a/main/README.md
+++ b/main/README.md
@@ -59,14 +59,16 @@ Pins (`powerpod.h`):
> **TODO:** GPIO assignments above are provisional; confirm pinning against the real board before release.
-Startup order:
+Startup order (normal path; UV energy-save skips I2C/BMA456/ESP-NOW/UART/button — see **Software UV protection**):
-1. Read DIP + IO expander → `app_config`
-2. **I2C bus** — IO expander `0x20`; optional **BMA456H** (`init_bma456`, same bus)
-3. `esp_now_comm_init(&app_config)` — WiFi + ESP-NOW
-4. `led_ring_init()`
-5. `board_input_init()` — button press logs, LiPo ADC logs every **10 s**
-6. **Master only:** command queue, UART, registered commands (e.g. VERSION)
+1. `pod_settings_init()` — NVS
+2. LiPo ADC init + optional UV boot check (`battery_uv.c`)
+3. Read DIP + IO expander → `app_config`
+4. **I2C bus** — IO expander `0x20`; optional **BMA456H** (`init_bma456`, same bus)
+5. `esp_now_comm_init(&app_config)` — WiFi + ESP-NOW
+6. `led_ring_init()`
+7. LiPo monitor task + button (`board_input.c`)
+8. **Master only:** command queue, UART, registered commands (e.g. VERSION)
## BMA456 accelerometer (`bosch456.c`)
@@ -440,6 +442,19 @@ Read **cached** LiPo ADC values on the **master** (master local + one entry per
# Host / goTool: all_clients returns master (id 0) + slaves from cache
```
+### Software UV protection (LiPo)
+
+When `POWERPOD_BATTERY_UV_ENABLE` is `1` (default in `powerpod.h`, disable via CMake), the firmware monitors ADC pin voltages on GPIO 1 and 11:
+
+| Condition | ADC threshold | Action |
+|-----------|---------------|--------|
+| Under-voltage | any valid channel < **2300 mV** (3.0 V pack) | NVS latch `uv_latch`, restart into energy-save mode |
+| Charged | all valid channels ≥ **3000 mV** (4.0 V pack) | Clear latch, restart into normal boot |
+
+**Energy-save boot:** LED mode `6` (first 4 LEDs red ~10 % for 5 s), then no WiFi/ESP-NOW, UART, I2C, BMA456, or button — only LiPo ADC polling every 30 s until charged.
+
+**Test LED pattern from host:** `led-ring -mode battery-low` (works even when UV feature is disabled).
+
### LED_RING command
Control the 95-LED ring from the host. The firmware **does not** animate digits locally; only UART updates the display.
@@ -448,7 +463,7 @@ Control the 95-LED ring from the host. The firmware **does not** animate digits
| Field | Meaning |
|-------|---------|
-| `mode` | `0` = clear, `1` = progress, `2` = digit (0–10), `3` = blink, `4` = find-me, `5` = solid color (all LEDs) |
+| `mode` | `0` = clear, `1` = progress, `2` = digit (0–10), `3` = blink, `4` = find-me, `5` = solid color (all LEDs), `6` = battery-low (first 4 LEDs red ~10 % for 5 s) |
| `progress` | 0–100 (% of ring lit, mode `1`) |
| `digit` | 0–10 (mode `2`, segment maps in `led_ring.c`) |
| `r`, `g`, `b` | Color 0–255 |
@@ -470,6 +485,7 @@ go run . -port /dev/ttyUSB0 led-ring -mode blink -g 255 -blink-count 2
go run . -port /dev/ttyUSB0 find-me
go run . -port /dev/ttyUSB0 find-me -client 16
go run . -port /dev/ttyUSB0 led-ring -mode find-me
+go run . -port /dev/ttyUSB0 led-ring -mode battery-low -client 0
go run . -port /dev/ttyUSB0 led-ring -mode color -r 255 -g 0 -b 0 -client 16
go run . -port /dev/ttyUSB0 led-ring -mode digit -digit 5 -all
```
@@ -564,7 +580,8 @@ Target: ESP32-S3. Close serial monitor on the UART adapter port before running `
| `cmd/cmd_tap_notify.c` | UART `TAP_NOTIFY` — ESP-NOW tap notify config |
| `cmd/cmd_cache_status.c` | UART `CACHE_STATUS` — subscribed accel + tap cache poll |
| `board_input.c/h` | Taster GPIO12, LiPo ADC on GPIO1 / GPIO11 |
-| `pod_settings.c/h` | NVS persistence (accel deadzone, …) |
+| `battery_uv.c/h` | Software LiPo UV latch, energy-save boot (`POWERPOD_BATTERY_UV_ENABLE`) |
+| `pod_settings.c/h` | NVS persistence (accel deadzone, UV latch, …) |
| `led_ring.c/h` | LED ring (digit display, progress bar) |
| `cmd/cmd_led_ring.c` | UART `LED_RING` progress command |
| `cmd/cmd_set_log_level.c` | UART `SET_LOG_LEVEL` — runtime ESP-IDF log level |
diff --git a/main/battery_uv.c b/main/battery_uv.c
new file mode 100644
index 0000000..e34f097
--- /dev/null
+++ b/main/battery_uv.c
@@ -0,0 +1,130 @@
+#include "battery_uv.h"
+
+#if POWERPOD_BATTERY_UV_ENABLE
+
+#include "led_ring.h"
+#include "pod_reboot.h"
+#include "pod_settings.h"
+#include "esp_log.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+
+static const char *TAG = "[BATTERY_UV]";
+
+#define UV_DEBOUNCE_SAMPLES 2u
+#define UV_POLL_INTERVAL_MS 30000u
+
+static uint8_t s_uv_streak;
+static uint8_t s_charge_streak;
+
+bool battery_uv_is_latched(void) { return pod_settings_is_uv_latched(); }
+
+bool battery_uv_reading_is_under(const board_lipo_reading_t *reading) {
+ if (reading == NULL) {
+ return false;
+ }
+
+ if (reading->lipo1_valid && reading->lipo1_mv < BATTERY_UV_ADC_MV) {
+ return true;
+ }
+ if (reading->lipo2_valid && reading->lipo2_mv < BATTERY_UV_ADC_MV) {
+ return true;
+ }
+ return false;
+}
+
+bool battery_uv_reading_is_charged(const board_lipo_reading_t *reading) {
+ if (reading == NULL) {
+ return false;
+ }
+
+ bool any_valid = false;
+ if (reading->lipo1_valid) {
+ any_valid = true;
+ if (reading->lipo1_mv < BATTERY_CHARGE_ADC_MV) {
+ return false;
+ }
+ }
+ if (reading->lipo2_valid) {
+ any_valid = true;
+ if (reading->lipo2_mv < BATTERY_CHARGE_ADC_MV) {
+ return false;
+ }
+ }
+ return any_valid;
+}
+
+bool battery_uv_evaluate_boot(void) {
+ board_lipo_reading_t reading;
+ board_input_read_lipo(&reading);
+
+ if (pod_settings_is_uv_latched() && battery_uv_reading_is_charged(&reading)) {
+ ESP_LOGI(TAG, "charged — clearing UV latch");
+ pod_settings_clear_uv_latched();
+ return false;
+ }
+
+ if (pod_settings_is_uv_latched() || battery_uv_reading_is_under(&reading)) {
+ if (!pod_settings_is_uv_latched()) {
+ ESP_LOGW(TAG, "under-voltage at boot — latching UV");
+ } else {
+ ESP_LOGW(TAG, "UV latched — energy-save boot");
+ }
+ pod_settings_set_uv_latched(true);
+ return true;
+ }
+
+ return false;
+}
+
+void battery_uv_poll(const board_lipo_reading_t *reading) {
+ if (reading == NULL || pod_settings_is_uv_latched()) {
+ return;
+ }
+
+ if (battery_uv_reading_is_under(reading)) {
+ s_uv_streak++;
+ ESP_LOGW(TAG, "under-voltage sample %u/%u", (unsigned)s_uv_streak,
+ (unsigned)UV_DEBOUNCE_SAMPLES);
+ if (s_uv_streak >= UV_DEBOUNCE_SAMPLES) {
+ ESP_LOGW(TAG, "UV confirmed — latching and restarting");
+ pod_settings_set_uv_latched(true);
+ pod_schedule_restart();
+ }
+ return;
+ }
+
+ s_uv_streak = 0;
+}
+
+void battery_uv_run_mode(void) {
+ s_charge_streak = 0;
+ s_uv_streak = 0;
+
+ led_ring_init();
+ led_ring_show_battery_low();
+
+ ESP_LOGW(TAG, "energy-save mode — ADC poll every %u ms", UV_POLL_INTERVAL_MS);
+
+ while (1) {
+ board_lipo_reading_t reading;
+ board_input_read_lipo(&reading);
+
+ if (battery_uv_reading_is_charged(&reading)) {
+ s_charge_streak++;
+ ESP_LOGI(TAG, "charge sample %u/%u", (unsigned)s_charge_streak,
+ (unsigned)UV_DEBOUNCE_SAMPLES);
+ if (s_charge_streak >= UV_DEBOUNCE_SAMPLES) {
+ ESP_LOGI(TAG, "charged — clearing UV latch and restarting");
+ pod_settings_clear_uv_latched();
+ pod_schedule_restart();
+ }
+ } else {
+ s_charge_streak = 0;
+ }
+
+ vTaskDelay(pdMS_TO_TICKS(UV_POLL_INTERVAL_MS));
+ }
+}
+
+#endif
diff --git a/main/battery_uv.h b/main/battery_uv.h
new file mode 100644
index 0000000..efe03d2
--- /dev/null
+++ b/main/battery_uv.h
@@ -0,0 +1,51 @@
+#ifndef BATTERY_UV_H
+#define BATTERY_UV_H
+
+#include "board_input.h"
+#include "powerpod.h"
+#include
+
+#if POWERPOD_BATTERY_UV_ENABLE
+
+/** ADC pin voltage (mV) below which UV is triggered (3.0 V pack via 33k/10k divider). */
+#define BATTERY_UV_ADC_MV 2300u
+/** ADC pin voltage (mV) at which charging is detected (4.0 V pack, rounded). */
+#define BATTERY_CHARGE_ADC_MV 3000u
+
+bool battery_uv_is_latched(void);
+bool battery_uv_reading_is_under(const board_lipo_reading_t *reading);
+bool battery_uv_reading_is_charged(const board_lipo_reading_t *reading);
+
+/** true → enter UV energy-save path (does not return). */
+bool battery_uv_evaluate_boot(void);
+
+void battery_uv_poll(const board_lipo_reading_t *reading);
+
+/** Blocks: LED indicator, then minimal ADC polling until charged. */
+void battery_uv_run_mode(void);
+
+#else
+
+static inline bool battery_uv_is_latched(void) { return false; }
+
+static inline bool battery_uv_reading_is_under(const board_lipo_reading_t *reading) {
+ (void)reading;
+ return false;
+}
+
+static inline bool battery_uv_reading_is_charged(const board_lipo_reading_t *reading) {
+ (void)reading;
+ return false;
+}
+
+static inline bool battery_uv_evaluate_boot(void) { return false; }
+
+static inline void battery_uv_poll(const board_lipo_reading_t *reading) {
+ (void)reading;
+}
+
+static inline void battery_uv_run_mode(void) {}
+
+#endif
+
+#endif
diff --git a/main/board_input.c b/main/board_input.c
index 3ef727e..051e711 100644
--- a/main/board_input.c
+++ b/main/board_input.c
@@ -2,6 +2,9 @@
#include "powerpod.h"
#include "client_registry.h"
#include "driver/gpio.h"
+#if POWERPOD_BATTERY_UV_ENABLE
+#include "battery_uv.h"
+#endif
#include "esp_adc/adc_oneshot.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
@@ -114,6 +117,10 @@ static void lipo_monitor_task(void *param) {
reading.lipo2_valid ? "ok" : "n/a",
(unsigned long)reading.lipo2_mv);
+#if POWERPOD_BATTERY_UV_ENABLE
+ battery_uv_poll(&reading);
+#endif
+
vTaskDelay(pdMS_TO_TICKS(LIPO_SAMPLE_INTERVAL_MS));
}
}
@@ -184,7 +191,7 @@ static esp_err_t init_button(void) {
return ESP_OK;
}
-static esp_err_t init_lipo_adc(void) {
+static esp_err_t init_lipo_adc_hw(void) {
memset(&s_lipo1, 0, sizeof(s_lipo1));
memset(&s_lipo2, 0, sizeof(s_lipo2));
@@ -201,6 +208,16 @@ static esp_err_t init_lipo_adc(void) {
return ESP_FAIL;
}
+ return ESP_OK;
+}
+
+esp_err_t board_input_init_adc_only(void) { return init_lipo_adc_hw(); }
+
+esp_err_t board_input_start_lipo_monitor(void) {
+ if (!s_lipo1.ok && !s_lipo2.ok) {
+ return ESP_FAIL;
+ }
+
if (xTaskCreate(lipo_monitor_task, "lipo_mon", 3072, NULL, 1, NULL) != pdPASS) {
return ESP_ERR_NO_MEM;
}
@@ -208,16 +225,23 @@ static esp_err_t init_lipo_adc(void) {
return ESP_OK;
}
+esp_err_t board_input_init_button(void) { return init_button(); }
+
esp_err_t board_input_init(void) {
- esp_err_t err = init_button();
+ esp_err_t err = board_input_init_adc_only();
+ if (err != ESP_OK) {
+ ESP_LOGW(TAG_LIPO, "ADC init failed: %s", esp_err_to_name(err));
+ } else {
+ err = board_input_start_lipo_monitor();
+ if (err != ESP_OK) {
+ ESP_LOGW(TAG_LIPO, "monitor task failed: %s", esp_err_to_name(err));
+ }
+ }
+
+ err = board_input_init_button();
if (err != ESP_OK) {
ESP_LOGW(TAG_BTN, "init failed: %s", esp_err_to_name(err));
}
- err = init_lipo_adc();
- if (err != ESP_OK) {
- ESP_LOGW(TAG_LIPO, "init failed: %s", esp_err_to_name(err));
- }
-
return ESP_OK;
}
diff --git a/main/board_input.h b/main/board_input.h
index dadb9e7..6920697 100644
--- a/main/board_input.h
+++ b/main/board_input.h
@@ -19,6 +19,15 @@ typedef struct {
*/
esp_err_t board_input_init(void);
+/** LiPo ADC channels only (no button, no background task). */
+esp_err_t board_input_init_adc_only(void);
+
+/** Start 10 s LiPo monitor task (normal boot only). */
+esp_err_t board_input_start_lipo_monitor(void);
+
+/** Front-panel button debounce task. */
+esp_err_t board_input_init_button(void);
+
/** On-demand ADC read of both LiPo sense inputs (if configured). */
void board_input_read_lipo(board_lipo_reading_t *out);
diff --git a/main/cmd/cmd_led_ring.c b/main/cmd/cmd_led_ring.c
index c3874f5..3f89bd7 100644
--- a/main/cmd/cmd_led_ring.c
+++ b/main/cmd/cmd_led_ring.c
@@ -13,6 +13,7 @@ static const char *TAG = "[LED_RING_CMD]";
#define LED_RING_MODE_BLINK 3
#define LED_RING_MODE_FIND_ME 4
#define LED_RING_MODE_COLOR 5
+#define LED_RING_MODE_BATTERY_LOW 6
static uint8_t clamp_u8(uint32_t v) {
if (v > 255) {
@@ -90,6 +91,10 @@ bool cmd_led_ring_apply(const alox_LedRingProgressRequest *req) {
led_ring_find_me();
return true;
+ case LED_RING_MODE_BATTERY_LOW:
+ led_ring_show_battery_low();
+ return true;
+
case LED_RING_MODE_BLINK:
cmd.mode = LED_CMD_BLINK;
cmd.r = r;
diff --git a/main/led_ring.c b/main/led_ring.c
index 7640574..299a38a 100644
--- a/main/led_ring.c
+++ b/main/led_ring.c
@@ -5,6 +5,7 @@
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
+#include "freertos/semphr.h"
#include "led_strip.h"
#include
@@ -23,8 +24,12 @@ static led_strip_handle_t led_ring;
#define LED_RING_FIND_ME_ON_MS 300
#define LED_RING_FIND_ME_OFF_MS 150
#define LED_RING_FIND_ME_BLINKS_PER_COLOR 3
+#define LED_RING_BATTERY_LOW_R 255
+#define LED_RING_BATTERY_LOW_G 0
+#define LED_RING_BATTERY_LOW_B 0
static QueueHandle_t led_queue;
+static SemaphoreHandle_t s_battery_low_done;
// Led Matrix Maps
const uint8_t d0[] = {46, 47, 60, 61, 62, 75, 78, 79,
@@ -145,6 +150,22 @@ void vTaskLedRing(void *pvParameters) {
}
}
continue;
+ } else if (cmd.mode == LED_CMD_BATTERY_LOW) {
+ uint8_t r = LED_RING_BATTERY_LOW_R;
+ uint8_t g = LED_RING_BATTERY_LOW_G;
+ uint8_t b = LED_RING_BATTERY_LOW_B;
+ led_ring_scale_rgb(&r, &g, &b, LED_RING_BATTERY_LOW_INTENSITY);
+ for (uint32_t i = 0; i < LED_RING_BATTERY_LOW_LEDS; i++) {
+ led_strip_set_pixel(led_ring, i, r, g, b);
+ }
+ led_strip_refresh(led_ring);
+ vTaskDelay(pdMS_TO_TICKS(LED_RING_BATTERY_LOW_HOLD_MS));
+ led_strip_clear(led_ring);
+ led_strip_refresh(led_ring);
+ if (s_battery_low_done != NULL) {
+ xSemaphoreGive(s_battery_low_done);
+ }
+ continue;
}
led_strip_refresh(led_ring);
}
@@ -153,6 +174,7 @@ void vTaskLedRing(void *pvParameters) {
void led_ring_init(void) {
led_queue = xQueueCreate(10, sizeof(led_command_t));
+ s_battery_low_done = xSemaphoreCreateBinary();
xTaskCreate(vTaskLedRing, "led_task", 4096, NULL, 5, NULL);
}
@@ -224,3 +246,13 @@ void led_ring_find_me(void) {
led_command_t cmd = {.mode = LED_CMD_FIND_ME};
led_ring_send_command(&cmd);
}
+
+void led_ring_show_battery_low(void) {
+ if (led_queue == NULL || s_battery_low_done == NULL) {
+ return;
+ }
+ (void)xSemaphoreTake(s_battery_low_done, 0);
+ led_command_t cmd = {.mode = LED_CMD_BATTERY_LOW};
+ led_ring_send_command(&cmd);
+ (void)xSemaphoreTake(s_battery_low_done, portMAX_DELAY);
+}
diff --git a/main/led_ring.h b/main/led_ring.h
index d982881..bdae9f5 100644
--- a/main/led_ring.h
+++ b/main/led_ring.h
@@ -7,6 +7,10 @@
#define LED_RING_DEFAULT_INTENSITY 13
/** Full brightness for find-me and similar alerts. */
#define LED_RING_FULL_INTENSITY 255
+/** ~10 % brightness for battery-low indicator. */
+#define LED_RING_BATTERY_LOW_INTENSITY 26
+#define LED_RING_BATTERY_LOW_LEDS 4
+#define LED_RING_BATTERY_LOW_HOLD_MS 5000
typedef enum {
LED_CMD_CLEAR,
@@ -14,7 +18,8 @@ typedef enum {
LED_CMD_SET_COLOR,
LED_CMD_PROGRESS,
LED_CMD_BLINK,
- LED_CMD_FIND_ME
+ LED_CMD_FIND_ME,
+ LED_CMD_BATTERY_LOW
} led_mode_t;
typedef struct {
@@ -45,4 +50,7 @@ void led_ring_ota_failed(void);
/** Red / green / blue: 3 blinks each at full intensity. */
void led_ring_find_me(void);
+/** First 4 LEDs red at ~10 % for 5 s, then off (blocking in LED task). */
+void led_ring_show_battery_low(void);
+
#endif
diff --git a/main/pod_settings.c b/main/pod_settings.c
index dc68f19..177b1aa 100644
--- a/main/pod_settings.c
+++ b/main/pod_settings.c
@@ -7,6 +7,7 @@
static const char *TAG = "[SETTINGS]";
static const char *NS = "powerpod";
static const char *KEY_ACCEL_DZ = "accel_dz";
+static const char *KEY_UV_LATCH = "uv_latch";
#define ACCEL_DEADZONE_MAX 4095u
@@ -108,3 +109,53 @@ void pod_settings_apply_accel_deadzone(void) {
bma456_set_accel_deadzone(deadzone);
}
}
+
+bool pod_settings_is_uv_latched(void) {
+ if (!s_nvs_ready) {
+ return false;
+ }
+
+ nvs_handle_t handle;
+ esp_err_t err = nvs_open(NS, NVS_READONLY, &handle);
+ if (err != ESP_OK) {
+ return false;
+ }
+
+ uint8_t value = 0;
+ err = nvs_get_u8(handle, KEY_UV_LATCH, &value);
+ nvs_close(handle);
+
+ return err == ESP_OK && value != 0;
+}
+
+esp_err_t pod_settings_set_uv_latched(bool latched) {
+ esp_err_t err = ensure_nvs();
+ if (err != ESP_OK) {
+ return err;
+ }
+
+ nvs_handle_t handle;
+ err = nvs_open(NS, NVS_READWRITE, &handle);
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "nvs_open failed: %s", esp_err_to_name(err));
+ return err;
+ }
+
+ err = nvs_set_u8(handle, KEY_UV_LATCH, latched ? 1u : 0u);
+ if (err == ESP_OK) {
+ err = nvs_commit(handle);
+ }
+ nvs_close(handle);
+
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "save uv_latch failed: %s", esp_err_to_name(err));
+ return err;
+ }
+
+ ESP_LOGI(TAG, "UV latch %s", latched ? "set" : "cleared");
+ return ESP_OK;
+}
+
+esp_err_t pod_settings_clear_uv_latched(void) {
+ return pod_settings_set_uv_latched(false);
+}
diff --git a/main/pod_settings.h b/main/pod_settings.h
index 5032a7b..469b1ad 100644
--- a/main/pod_settings.h
+++ b/main/pod_settings.h
@@ -2,6 +2,7 @@
#define POD_SETTINGS_H
#include "esp_err.h"
+#include
#include
/** Initialize NVS (idempotent) and log stored settings. Call once early in app_main. */
@@ -16,4 +17,9 @@ uint32_t pod_settings_load_accel_deadzone(void);
/** Apply NVS deadzone to BMA456 when the sensor is present. */
void pod_settings_apply_accel_deadzone(void);
+/** LiPo under-voltage latch persisted across reboot (software UV protection). */
+bool pod_settings_is_uv_latched(void);
+esp_err_t pod_settings_set_uv_latched(bool latched);
+esp_err_t pod_settings_clear_uv_latched(void);
+
#endif
diff --git a/main/powerpod.c b/main/powerpod.c
index 0b72ff5..c38083d 100644
--- a/main/powerpod.c
+++ b/main/powerpod.c
@@ -31,6 +31,7 @@
#include "led_ring.h"
#include "pod_settings.h"
#include "uart.h"
+#include "battery_uv.h"
#include
enum MASTER_STATES {
@@ -78,6 +79,16 @@ void app_main(void) {
ESP_LOGW(TAG, "settings NVS init failed; using defaults");
}
+ if (board_input_init_adc_only() != ESP_OK) {
+ ESP_LOGW(TAG, "LiPo ADC init failed");
+ }
+
+#if POWERPOD_BATTERY_UV_ENABLE
+ if (battery_uv_evaluate_boot()) {
+ battery_uv_run_mode();
+ }
+#endif
+
// Get Master Mode Pin
gpio_reset_pin(DIP_MASTER);
gpio_set_direction(DIP_MASTER, GPIO_MODE_INPUT);
@@ -167,7 +178,8 @@ void app_main(void) {
ESP_LOGI(TAG, "Running Partition: %s (OTA slot %d)",
app_config.running_partition, ota_slot);
- board_input_init();
+ board_input_start_lipo_monitor();
+ board_input_init_button();
err = esp_now_comm_init(&app_config);
if (err != ESP_OK) {
diff --git a/main/powerpod.h b/main/powerpod.h
index 96a8ee6..b2b7081 100644
--- a/main/powerpod.h
+++ b/main/powerpod.h
@@ -1,6 +1,11 @@
#ifndef POWERPOD_H
#define POWERPOD_H
+/** 1 = software LiPo UV latch + energy-save mode; 0 = disabled (LED mode 6 still works). */
+#ifndef POWERPOD_BATTERY_UV_ENABLE
+#define POWERPOD_BATTERY_UV_ENABLE 0
+#endif
+
#define DIP_MASTER 4
#define I2C_SCL 5
#define I2C_SDA 6
diff --git a/main/proto/uart_messages.pb.h b/main/proto/uart_messages.pb.h
index a3fd503..83c9bfa 100644
--- a/main/proto/uart_messages.pb.h
+++ b/main/proto/uart_messages.pb.h
@@ -256,7 +256,7 @@ typedef struct _alox_EspNowEchoPingResponse {
} alox_EspNowEchoPingResponse;
/* Host → master: LED ring on master (client_id=0) and/or slaves via ESP-NOW.
- mode: 0=clear, 1=progress (0–100 %), 2=digit (0–10), 3=blink, 4=find-me, 5=all LEDs solid color. */
+ mode: 0=clear, 1=progress (0–100 %), 2=digit (0–10), 3=blink, 4=find-me, 5=all LEDs solid color, 6=battery-low. */
typedef struct _alox_LedRingProgressRequest {
uint32_t mode;
/* * 0–100: fraction of ring LEDs to light (mode=progress) */
diff --git a/main/proto/uart_messages.proto b/main/proto/uart_messages.proto
index 8be83a5..a7b4cd7 100644
--- a/main/proto/uart_messages.proto
+++ b/main/proto/uart_messages.proto
@@ -280,7 +280,7 @@ message EspNowEchoPingResponse {
}
// Host → master: LED ring on master (client_id=0) and/or slaves via ESP-NOW.
-// mode: 0=clear, 1=progress (0–100 %), 2=digit (0–10), 3=blink, 4=find-me, 5=all LEDs solid color.
+// mode: 0=clear, 1=progress (0–100 %), 2=digit (0–10), 3=blink, 4=find-me, 5=all LEDs solid color, 6=battery-low.
message LedRingProgressRequest {
uint32 mode = 1;
/** 0–100: fraction of ring LEDs to light (mode=progress) */