Add LiPo battery monitoring with ESP-NOW cache and dashboard API.

Slaves report pack voltages every 30s; the master caches them for fast
BATTERY_STATUS reads. goTool exposes REST/WebSocket and shows values in
the dashboard, with a nanopb fix so optional lipo submessages encode.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 20:14:28 +02:00
co-authored by Cursor
parent eb67a46158
commit 3cb0b5bbe9
30 changed files with 1618 additions and 150 deletions
+191 -1
View File
@@ -1,6 +1,7 @@
#include "bosch456.h"
#include "client_registry.h"
#include "esp_now_comm.h"
#include "board_input.h"
#include "cmd_led_ring.h"
#include "led_ring.h"
#include "ota_espnow.h"
@@ -48,6 +49,16 @@ static uint32_t s_last_discover_ms;
static SemaphoreHandle_t s_send_done;
static bool s_send_cb_ready;
#define ESPNOW_BATTERY_INTERVAL_MS 30000
#define SLAVE_BATTERY_AFTER_JOIN_MS 150
typedef enum {
SLAVE_TX_SLAVE_INFO = 1,
SLAVE_TX_BATTERY,
} slave_tx_op_t;
static QueueHandle_t s_slave_tx_queue;
static uint32_t now_ms(void) {
return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
}
@@ -90,6 +101,7 @@ static esp_err_t ensure_broadcast_peer(void) { return ensure_peer(ESPNOW_BCAST);
static esp_err_t send_message_ex(const uint8_t *dest_mac,
const alox_EspNowMessage *msg, bool wait_done);
static void slave_send_battery_report_to_master(void);
static void fill_presence(alox_EspNowSlavePresence *presence) {
presence->network = s_config.network;
@@ -210,6 +222,21 @@ static esp_err_t send_find_me(const uint8_t *dest_mac, uint32_t client_id) {
return send_message(dest_mac, &msg);
}
static esp_err_t send_battery_report(const uint8_t *dest_mac,
const alox_EspNowBatteryReport *report) {
if (report == NULL) {
return ESP_ERR_INVALID_ARG;
}
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_BATTERY_REPORT;
msg.which_payload = alox_EspNowMessage_battery_report_tag;
msg.payload.battery_report = *report;
return send_message(dest_mac, &msg);
}
static esp_err_t send_led_ring(const uint8_t *dest_mac, uint32_t client_id,
const alox_LedRingProgressRequest *req) {
if (req == NULL) {
@@ -470,6 +497,47 @@ static void slave_reset_join(void) {
s_accel_stream_enabled = false;
memset(s_master_mac, 0, sizeof(s_master_mac));
s_last_discover_ms = 0;
if (s_slave_tx_queue != NULL) {
xQueueReset(s_slave_tx_queue);
}
}
static void slave_queue_tx(slave_tx_op_t op) {
if (s_slave_tx_queue == NULL) {
return;
}
if (xQueueSend(s_slave_tx_queue, &op, 0) != pdTRUE) {
ESP_LOGW(TAG, "slave tx queue full (op=%d)", (int)op);
}
}
static void slave_tx_task(void *param) {
(void)param;
slave_tx_op_t op;
ESP_LOGI(TAG, "slave tx task ready");
while (1) {
if (xQueueReceive(s_slave_tx_queue, &op, portMAX_DELAY) != pdTRUE) {
continue;
}
if (!s_slave_joined) {
continue;
}
switch (op) {
case SLAVE_TX_SLAVE_INFO:
send_presence(s_master_mac, alox_EspNowMessageType_ESPNOW_SLAVE_INFO);
break;
case SLAVE_TX_BATTERY:
vTaskDelay(pdMS_TO_TICKS(SLAVE_BATTERY_AFTER_JOIN_MS));
slave_send_battery_report_to_master();
break;
default:
break;
}
}
}
static void handle_slave_unicast_test(const uint8_t *master_mac,
@@ -499,6 +567,77 @@ static void handle_slave_restart(const uint8_t *master_mac,
pod_schedule_restart();
}
static void slave_send_battery_report_to_master(void) {
if (!s_slave_joined) {
return;
}
board_lipo_reading_t reading;
board_input_read_lipo(&reading);
alox_EspNowBatteryReport report = alox_EspNowBatteryReport_init_zero;
report.client_id = s_own_mac[5];
report.lipo1_valid = reading.lipo1_valid;
report.lipo2_valid = reading.lipo2_valid;
report.lipo1_mv = reading.lipo1_mv;
report.lipo2_mv = reading.lipo2_mv;
esp_err_t err = send_battery_report(s_master_mac, &report);
if (err != ESP_OK) {
ESP_LOGW(TAG, "battery report send failed id=%lu: %s",
(unsigned long)report.client_id, esp_err_to_name(err));
} else {
ESP_LOGI(TAG, "battery report sent id=%lu L1=%s %lu mV L2=%s %lu mV",
(unsigned long)report.client_id,
report.lipo1_valid ? "ok" : "n/a",
(unsigned long)report.lipo1_mv,
report.lipo2_valid ? "ok" : "n/a",
(unsigned long)report.lipo2_mv);
}
}
static void handle_slave_battery_query(const uint8_t *master_mac,
const alox_EspNowBatteryQuery *query) {
uint32_t my_id = s_own_mac[5];
if (query->client_id != 0 && query->client_id != my_id) {
return;
}
if (s_slave_joined && !mac_equal(master_mac, s_master_mac)) {
return;
}
slave_send_battery_report_to_master();
}
static void handle_master_battery_report(const uint8_t *mac,
const alox_EspNowBatteryReport *report) {
if (report == NULL || mac == NULL) {
return;
}
esp_err_t err = client_registry_update_battery(
mac, report->client_id, report->lipo1_valid, report->lipo1_mv,
report->lipo2_valid, report->lipo2_mv);
if (err == ESP_ERR_NOT_FOUND) {
ESP_LOGW(TAG, "battery report from unregistered slave id=%lu",
(unsigned long)report->client_id);
return;
}
if (err != ESP_OK) {
ESP_LOGW(TAG, "battery report id=%lu rejected: %s",
(unsigned long)report->client_id, esp_err_to_name(err));
return;
}
ESP_LOGI(TAG, "battery cached id=%lu L1=%s %lu mV L2=%s %lu mV",
(unsigned long)report->client_id,
report->lipo1_valid ? "ok" : "n/a",
(unsigned long)report->lipo1_mv, report->lipo2_valid ? "ok" : "n/a",
(unsigned long)report->lipo2_mv);
}
static void handle_slave_led_ring(const uint8_t *master_mac,
const alox_EspNowLedRing *msg) {
uint32_t my_id = s_own_mac[5];
@@ -670,7 +809,9 @@ static void handle_discover(const uint8_t *sender_mac,
ESP_LOGI(TAG, "joined network %u, master %s", (unsigned)discover->network,
mac_str);
send_presence(sender_mac, alox_EspNowMessageType_ESPNOW_SLAVE_INFO);
/* Do not esp_now_send from recv callback — defer to slave_tx_task. */
slave_queue_tx(SLAVE_TX_SLAVE_INFO);
slave_queue_tx(SLAVE_TX_BATTERY);
}
static void slave_check_master_timeout(void) {
@@ -716,6 +857,7 @@ static void slave_accel_stream_task(void *param) {
static void slave_heartbeat_task(void *param) {
(void)param;
uint32_t last_battery_ms = 0;
ESP_LOGI(TAG, "slave heartbeat task (interval %u ms)",
(unsigned)ESPNOW_HEARTBEAT_INTERVAL_MS);
@@ -726,22 +868,43 @@ static void slave_heartbeat_task(void *param) {
slave_check_master_timeout();
if (!s_slave_joined) {
last_battery_ms = 0;
continue;
}
send_presence(s_master_mac, alox_EspNowMessageType_ESPNOW_HEARTBEAT);
uint32_t now = now_ms();
if (last_battery_ms == 0 ||
(now - last_battery_ms) >= ESPNOW_BATTERY_INTERVAL_MS) {
slave_send_battery_report_to_master();
last_battery_ms = now;
}
}
}
static void master_monitor_task(void *param) {
(void)param;
uint32_t last_local_battery_ms = 0;
ESP_LOGI(TAG, "master monitor task (timeout %u ms)",
(unsigned)ESPNOW_CLIENT_TIMEOUT_MS);
board_lipo_reading_t reading;
board_input_read_lipo(&reading);
client_registry_set_master_battery(&reading);
last_local_battery_ms = now_ms();
while (1) {
vTaskDelay(pdMS_TO_TICKS(ESPNOW_HEARTBEAT_INTERVAL_MS));
client_registry_check_timeouts(ESPNOW_CLIENT_TIMEOUT_MS);
uint32_t t = now_ms();
if (t - last_local_battery_ms >= ESPNOW_BATTERY_INTERVAL_MS) {
board_input_read_lipo(&reading);
client_registry_set_master_battery(&reading);
last_local_battery_ms = t;
}
}
}
@@ -779,6 +942,12 @@ static void espnow_recv_cb(const esp_now_recv_info_t *info, const uint8_t *data,
}
handle_slave_accel_stream(info->src_addr, &msg.payload.accel_stream);
break;
case alox_EspNowMessage_battery_query_tag:
if (!s_slave_joined || !mac_equal(info->src_addr, s_master_mac)) {
break;
}
handle_slave_battery_query(info->src_addr, &msg.payload.battery_query);
break;
case alox_EspNowMessage_led_ring_tag:
if (!s_slave_joined || !mac_equal(info->src_addr, s_master_mac)) {
break;
@@ -838,6 +1007,17 @@ static void espnow_recv_cb(const esp_now_recv_info_t *info, const uint8_t *data,
return;
}
if (msg.which_payload == alox_EspNowMessage_battery_report_tag) {
ensure_peer(info->src_addr);
handle_master_battery_report(info->src_addr, &msg.payload.battery_report);
return;
}
if (msg.type == alox_EspNowMessageType_ESPNOW_BATTERY_REPORT &&
msg.which_payload != alox_EspNowMessage_battery_report_tag) {
ESP_LOGW(TAG, "master: BATTERY_REPORT type but which=%u", msg.which_payload);
}
const alox_EspNowSlavePresence *presence = esp_now_proto_get_presence(&msg);
if (presence != NULL) {
/* Registry key is the ESP-NOW sender MAC, not the optional protobuf mac field. */
@@ -933,6 +1113,16 @@ esp_err_t esp_now_comm_init(const app_config_t *config) {
return ESP_FAIL;
}
} else {
s_slave_tx_queue = xQueueCreate(4, sizeof(slave_tx_op_t));
if (s_slave_tx_queue == NULL) {
ESP_LOGE(TAG, "failed to create slave tx queue");
return ESP_ERR_NO_MEM;
}
if (xTaskCreate(slave_tx_task, "espnow_stx", 4096, NULL, 5, NULL) !=
pdPASS) {
ESP_LOGE(TAG, "failed to create slave tx task");
return ESP_FAIL;
}
if (xTaskCreate(slave_heartbeat_task, "espnow_hb", 4096, NULL, 4, NULL) !=
pdPASS) {
ESP_LOGE(TAG, "failed to create heartbeat task");