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) */