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
+3
View File
@@ -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)
+26 -9
View File
@@ -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 &lt; **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 (010), `3` = blink, `4` = find-me, `5` = solid color (all LEDs) |
| `mode` | `0` = clear, `1` = progress, `2` = digit (010), `3` = blink, `4` = find-me, `5` = solid color (all LEDs), `6` = battery-low (first 4 LEDs red ~10 % for 5 s) |
| `progress` | 0100 (% of ring lit, mode `1`) |
| `digit` | 010 (mode `2`, segment maps in `led_ring.c`) |
| `r`, `g`, `b` | Color 0255 |
@@ -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 |
+130
View File
@@ -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
+51
View File
@@ -0,0 +1,51 @@
#ifndef BATTERY_UV_H
#define BATTERY_UV_H
#include "board_input.h"
#include "powerpod.h"
#include <stdbool.h>
#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
+31 -7
View File
@@ -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;
}
+9
View File
@@ -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);
+5
View File
@@ -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;
+32
View File
@@ -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 <stdint.h>
@@ -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);
}
+9 -1
View File
@@ -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
+51
View File
@@ -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);
}
+6
View File
@@ -2,6 +2,7 @@
#define POD_SETTINGS_H
#include "esp_err.h"
#include <stdbool.h>
#include <stdint.h>
/** 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
+13 -1
View File
@@ -31,6 +31,7 @@
#include "led_ring.h"
#include "pod_settings.h"
#include "uart.h"
#include "battery_uv.h"
#include <stdint.h>
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) {
+5
View File
@@ -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
+1 -1
View File
@@ -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 (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. */
typedef struct _alox_LedRingProgressRequest {
uint32_t mode;
/* * 0100: fraction of ring LEDs to light (mode=progress) */
+1 -1
View File
@@ -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 (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.
message LedRingProgressRequest {
uint32 mode = 1;
/** 0100: fraction of ring LEDs to light (mode=progress) */