Distribute master OTA images to slaves over ESP-NOW.

After UART OTA the master reads the staged partition in 4 KiB blocks (200 B ESP-NOW chunks), waits for per-slave block ACKs, and fixes the final partial block. Slaves reuse ota_uart; send pacing and logging improve reliability.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
simon 2026-05-19 01:00:56 +02:00
parent 4bf43d8a5e
commit 9b7bda8551
13 changed files with 835 additions and 33 deletions

View File

@ -23,7 +23,8 @@ proto_generate_uart:
-I ../../libs/nanopb/generator/proto uart_messages.proto
proto_generate_espnow:
python libs/nanopb/generator/nanopb_generator.py main/proto/esp_now_messages.proto
cd main/proto && python ../../libs/nanopb/generator/nanopb_generator.py \
-I ../../libs/nanopb/generator/proto esp_now_messages.proto
proto_generate: proto_generate_uart proto_generate_espnow

View File

@ -21,6 +21,7 @@ idf_component_register(
"cmd_espnow_unicast_test.c"
"cmd_ota.c"
"ota_uart.c"
"ota_espnow.c"
"client_registry.c"
"esp_now_comm.c"
"esp_now_proto.c"

View File

@ -110,6 +110,12 @@ Schema: `proto/esp_now_messages.proto`. Encode/decode: `esp_now_proto.c`. The ES
| `ESPNOW_SLAVE_INFO` | Slave → master | `EspNowSlavePresence` |
| `ESPNOW_HEARTBEAT` | Slave → master | `EspNowSlavePresence` (same fields) |
| `ESPNOW_SET_ACCEL_DEADZONE` | Master → slave | `EspNowAccelDeadzone` (`deadzone` LSB) |
| `ESPNOW_OTA_START` | Master → slave (unicast) | `EspNowOtaStart` (`total_size`) |
| `ESPNOW_OTA_PAYLOAD` | Master → slave | `EspNowOtaPayload` (`seq`, up to 200 B `data`) |
| `ESPNOW_OTA_END` | Master → slave | `EspNowOtaEnd` |
| `ESPNOW_OTA_STATUS` | Slave → master | `EspNowOtaStatus` (same status codes as UART OTA) |
After a successful UART OTA on the master, `ota_espnow.c` reads the staged image from flash in **4 KiB** blocks (sent as **200 B** ESP-NOW chunks) and waits for **block_ack** from every available slave before continuing. Slaves use the same `ota_uart` 4 KiB buffer as UART OTA.
`EspNowSlavePresence`: `network`, `mac` (6 bytes), `version`, `slave_id`, `available`, `used`.
@ -186,9 +192,9 @@ Host and master speak nanopb-encoded `UartMessage` inside UART frames (byte 0 =
| 6 | `ACCEL_DEADZONE` | Implemented (`cmd_accel_deadzone.c`) — get/set accel filter LSB |
| 16 | `OTA_START` | Implemented (`cmd_ota.c`) — begin UART OTA on inactive slot |
| 17 | `OTA_PAYLOAD` | Implemented — up to 200 B per frame; device buffers 4 KiB |
| 18 | `OTA_END` | Implemented — flush, `esp_ota_end`, set boot partition |
| 18 | `OTA_END` | Implemented — flush, `esp_ota_end`, push image to slaves via ESP-NOW, set boot |
| 19 | `OTA_STATUS` | Device → host (prepare/ready/block ACK/success/failed) |
| 20 | `OTA_START_ESPNOW` | Planned |
| 20 | `OTA_START_ESPNOW` | Implemented — re-distribute staged image to slaves only |
Regenerate C code:

View File

@ -1,4 +1,5 @@
#include "cmd_ota.h"
#include "ota_espnow.h"
#include "ota_uart.h"
#include "uart_cmd.h"
#include "esp_log.h"
@ -10,7 +11,6 @@ static const char *TAG = "[OTA_CMD]";
#define OTA_PREPARE_STACK 8192
#define OTA_PREPARE_PRIO 5
static void send_ota_status(ota_uart_status_t status, uint32_t err_code) {
alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_OTA_STATUS,
@ -128,22 +128,35 @@ static void handle_ota_payload(const uint8_t *data, size_t len) {
}
}
static void handle_ota_end(const uint8_t *data, size_t len) {
(void)data;
(void)len;
if (!ota_uart_is_active()) {
send_ota_status(OTA_UART_ST_FAILED, 20);
return;
}
static esp_err_t finish_master_ota_and_distribute(void) {
uint32_t written = ota_uart_bytes_written();
int slot = ota_uart_target_slot();
bool success = false;
esp_err_t err = ota_uart_finish(&success);
esp_err_t err = ota_uart_finish(false, &success);
if (err != ESP_OK || !success) {
send_ota_status(OTA_UART_ST_FAILED, (uint32_t)err);
return;
return err;
}
const esp_partition_t *part = NULL;
uint32_t image_size = 0;
if (!ota_uart_get_staged_image(&part, &image_size)) {
send_ota_status(OTA_UART_ST_FAILED, 30);
return ESP_ERR_INVALID_STATE;
}
err = ota_espnow_distribute(part, image_size);
if (err != ESP_OK) {
ESP_LOGE(TAG, "slave OTA distribution failed: %s", esp_err_to_name(err));
ota_uart_clear_staged();
send_ota_status(OTA_UART_ST_FAILED, 31);
return err;
}
err = ota_uart_apply_boot();
if (err != ESP_OK) {
send_ota_status(OTA_UART_ST_FAILED, (uint32_t)err);
return err;
}
alox_UartMessage response;
@ -154,10 +167,61 @@ static void handle_ota_end(const uint8_t *data, size_t len) {
response.payload.ota_status.target_slot = slot >= 0 ? (uint32_t)slot : 0;
response.payload.ota_status.error = 0;
uart_cmd_send(&response, TAG);
return ESP_OK;
}
static void handle_ota_end(const uint8_t *data, size_t len) {
(void)data;
(void)len;
if (!ota_uart_is_active()) {
send_ota_status(OTA_UART_ST_FAILED, 20);
return;
}
(void)finish_master_ota_and_distribute();
}
static void handle_ota_start_espnow(const uint8_t *data, size_t len) {
(void)data;
(void)len;
if (ota_uart_is_active()) {
send_ota_status(OTA_UART_ST_FAILED, 40);
return;
}
const esp_partition_t *part = NULL;
uint32_t image_size = 0;
if (!ota_uart_get_staged_image(&part, &image_size)) {
send_ota_status(OTA_UART_ST_FAILED, 41);
return;
}
esp_err_t err = ota_espnow_distribute(part, image_size);
if (err != ESP_OK) {
send_ota_status(OTA_UART_ST_FAILED, 42);
return;
}
err = ota_uart_apply_boot();
if (err != ESP_OK) {
send_ota_status(OTA_UART_ST_FAILED, (uint32_t)err);
return;
}
alox_UartMessage response;
uart_cmd_init_response(&response, alox_MessageType_OTA_STATUS,
alox_UartMessage_ota_status_tag);
response.payload.ota_status.status = (uint32_t)OTA_UART_ST_SUCCESS;
response.payload.ota_status.bytes_written = image_size;
response.payload.ota_status.error = 0;
uart_cmd_send(&response, TAG);
}
void cmd_ota_register(void) {
uart_cmd_register(alox_MessageType_OTA_START, handle_ota_start);
uart_cmd_register(alox_MessageType_OTA_PAYLOAD, handle_ota_payload);
uart_cmd_register(alox_MessageType_OTA_END, handle_ota_end);
uart_cmd_register(alox_MessageType_OTA_START_ESPNOW, handle_ota_start_espnow);
}

View File

@ -1,6 +1,7 @@
#include "bosch456.h"
#include "client_registry.h"
#include "esp_now_comm.h"
#include "ota_espnow.h"
#include "esp_now_proto.h"
#include "esp_err.h"
#include "esp_event.h"
@ -12,6 +13,7 @@
#include "freertos/FreeRTOS.h"
#include "freertos/idf_additions.h"
#include "nvs_flash.h"
#include "ota_uart.h"
#include <stdio.h>
#include <string.h>
@ -38,6 +40,9 @@ static bool s_slave_joined;
static uint8_t s_master_mac[ESP_NOW_ETH_ALEN];
static uint32_t s_last_discover_ms;
static SemaphoreHandle_t s_send_done;
static bool s_send_cb_ready;
static uint32_t now_ms(void) {
return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
}
@ -78,6 +83,9 @@ static esp_err_t ensure_peer(const uint8_t *mac) {
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 fill_presence(alox_EspNowSlavePresence *presence) {
presence->network = s_config.network;
presence->version = POWERPOD_FW_VERSION;
@ -87,8 +95,22 @@ static void fill_presence(alox_EspNowSlavePresence *presence) {
esp_now_proto_setup_presence_encode(presence, s_own_mac);
}
static void espnow_send_done_cb(const esp_now_send_info_t *tx_info,
esp_now_send_status_t status) {
(void)tx_info;
(void)status;
if (s_send_done != NULL) {
xSemaphoreGive(s_send_done);
}
}
static esp_err_t send_message(const uint8_t *dest_mac,
const alox_EspNowMessage *msg) {
return send_message_ex(dest_mac, msg, false);
}
static esp_err_t send_message_ex(const uint8_t *dest_mac,
const alox_EspNowMessage *msg, bool wait_done) {
uint8_t buf[ESPNOW_PB_MAX_SIZE];
size_t len = 0;
@ -98,14 +120,31 @@ static esp_err_t send_message(const uint8_t *dest_mac,
return err;
}
if (len > ESP_NOW_MAX_DATA_LEN) {
ESP_LOGW(TAG, "encoded len %u > ESP-NOW max %u", (unsigned)len,
(unsigned)ESP_NOW_MAX_DATA_LEN);
return ESP_ERR_INVALID_SIZE;
}
if (ensure_peer(dest_mac) != ESP_OK) {
return ESP_FAIL;
}
if (wait_done && s_send_cb_ready && s_send_done != NULL) {
xSemaphoreTake(s_send_done, 0);
}
err = esp_now_send(dest_mac, buf, len);
if (err != ESP_OK) {
ESP_LOGW(TAG, "send type=%u failed: %s", (unsigned)msg->type,
esp_err_to_name(err));
return err;
}
if (wait_done && s_send_cb_ready && s_send_done != NULL) {
if (xSemaphoreTake(s_send_done, pdMS_TO_TICKS(50)) != pdTRUE) {
ESP_LOGW(TAG, "send type=%u done timeout", (unsigned)msg->type);
}
}
return err;
}
@ -132,6 +171,96 @@ static esp_err_t send_unicast_test(const uint8_t *dest_mac, uint32_t seq) {
return send_message(dest_mac, &msg);
}
static esp_err_t send_ota_start(const uint8_t *dest_mac, uint32_t total_size) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_OTA_START;
msg.which_payload = alox_EspNowMessage_ota_start_tag;
msg.payload.ota_start.total_size = total_size;
return send_message_ex(dest_mac, &msg, true);
}
static esp_err_t send_ota_payload(const uint8_t *dest_mac, uint32_t seq,
const uint8_t *data, size_t len) {
if (data == NULL || len == 0 || len > OTA_UART_HOST_CHUNK_SIZE) {
return ESP_ERR_INVALID_ARG;
}
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_OTA_PAYLOAD;
msg.which_payload = alox_EspNowMessage_ota_payload_tag;
msg.payload.ota_payload.seq = seq;
msg.payload.ota_payload.data.size = len;
memcpy(msg.payload.ota_payload.data.bytes, data, len);
return send_message_ex(dest_mac, &msg, true);
}
static esp_err_t send_ota_end(const uint8_t *dest_mac) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_OTA_END;
msg.which_payload = alox_EspNowMessage_ota_end_tag;
return send_message_ex(dest_mac, &msg, true);
}
static esp_err_t send_ota_status(const uint8_t *dest_mac, uint32_t status,
uint32_t bytes_written, uint32_t error) {
alox_EspNowMessage msg = alox_EspNowMessage_init_zero;
msg.type = alox_EspNowMessageType_ESPNOW_OTA_STATUS;
msg.which_payload = alox_EspNowMessage_ota_status_tag;
msg.payload.ota_status.status = status;
msg.payload.ota_status.bytes_written = bytes_written;
msg.payload.ota_status.error = error;
return send_message_ex(dest_mac, &msg, true);
}
esp_err_t esp_now_comm_send_ota_start(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t total_size) {
if (mac == NULL || !s_config.master) {
return ESP_ERR_INVALID_STATE;
}
return send_ota_start(mac, total_size);
}
esp_err_t esp_now_comm_send_ota_payload(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t seq, const uint8_t *data,
size_t len) {
if (mac == NULL || !s_config.master) {
return ESP_ERR_INVALID_STATE;
}
return send_ota_payload(mac, seq, data, len);
}
esp_err_t esp_now_comm_send_ota_end(const uint8_t mac[CLIENT_MAC_LEN]) {
if (mac == NULL || !s_config.master) {
return ESP_ERR_INVALID_STATE;
}
return send_ota_end(mac);
}
esp_err_t esp_now_comm_send_ota_status(const uint8_t master_mac[CLIENT_MAC_LEN],
uint32_t status, uint32_t bytes_written,
uint32_t error) {
if (master_mac == NULL || s_config.master) {
return ESP_ERR_INVALID_STATE;
}
return send_ota_status(master_mac, status, bytes_written, error);
}
bool esp_now_comm_get_master_mac(uint8_t mac_out[CLIENT_MAC_LEN]) {
if (mac_out == NULL || s_config.master || !s_slave_joined) {
return false;
}
memcpy(mac_out, s_master_mac, CLIENT_MAC_LEN);
return true;
}
esp_err_t esp_now_comm_send_unicast_test(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t seq) {
if (mac == NULL || !s_config.master) {
@ -363,6 +492,20 @@ static void espnow_recv_cb(const esp_now_recv_info_t *info, const uint8_t *data,
case alox_EspNowMessage_accel_deadzone_tag:
handle_slave_accel_deadzone(info->src_addr, &msg.payload.accel_deadzone);
break;
case alox_EspNowMessage_ota_start_tag:
case alox_EspNowMessage_ota_payload_tag:
case alox_EspNowMessage_ota_end_tag:
if (!s_slave_joined || !mac_equal(info->src_addr, s_master_mac)) {
break;
}
if (msg.which_payload == alox_EspNowMessage_ota_start_tag) {
ota_espnow_slave_on_start(info->src_addr, &msg.payload.ota_start);
} else if (msg.which_payload == alox_EspNowMessage_ota_payload_tag) {
ota_espnow_slave_on_payload(info->src_addr, &msg.payload.ota_payload);
} else {
ota_espnow_slave_on_end(info->src_addr);
}
break;
default:
ESP_LOGW(TAG, "slave: unhandled ESP-NOW which=%u type=%u", msg.which_payload,
(unsigned)msg.type);
@ -378,6 +521,12 @@ static void espnow_recv_cb(const esp_now_recv_info_t *info, const uint8_t *data,
return;
}
if (msg.which_payload == alox_EspNowMessage_ota_status_tag) {
ensure_peer(info->src_addr);
ota_espnow_master_on_status(info->src_addr, &msg.payload.ota_status);
return;
}
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. */
@ -463,6 +612,14 @@ esp_err_t esp_now_comm_init(const app_config_t *config) {
ESP_ERROR_CHECK(esp_now_init());
ESP_ERROR_CHECK(esp_now_register_recv_cb(espnow_recv_cb));
s_send_done = xSemaphoreCreateBinary();
if (s_send_done != NULL &&
esp_now_register_send_cb(espnow_send_done_cb) == ESP_OK) {
s_send_cb_ready = true;
} else {
ESP_LOGW(TAG, "ESP-NOW send-done callback unavailable (OTA may drop packets)");
}
if (config->master) {
ESP_ERROR_CHECK(ensure_broadcast_peer());
if (xTaskCreate(master_discover_task, "espnow_disc", 4096, NULL, 4,

View File

@ -15,4 +15,20 @@ esp_err_t esp_now_comm_send_accel_deadzone(const uint8_t mac[CLIENT_MAC_LEN],
esp_err_t esp_now_comm_send_unicast_test(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t seq);
/** Master → slave OTA (unicast). */
esp_err_t esp_now_comm_send_ota_start(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t total_size);
esp_err_t esp_now_comm_send_ota_payload(const uint8_t mac[CLIENT_MAC_LEN],
uint32_t seq, const uint8_t *data,
size_t len);
esp_err_t esp_now_comm_send_ota_end(const uint8_t mac[CLIENT_MAC_LEN]);
/** Slave → master OTA status. */
esp_err_t esp_now_comm_send_ota_status(const uint8_t master_mac[CLIENT_MAC_LEN],
uint32_t status, uint32_t bytes_written,
uint32_t error);
/** Slave: MAC of joined master (false if not joined). */
bool esp_now_comm_get_master_mac(uint8_t mac_out[CLIENT_MAC_LEN]);
#endif

336
main/ota_espnow.c Normal file
View File

@ -0,0 +1,336 @@
#include "ota_espnow.h"
#include "app_config.h"
#include "client_registry.h"
#include "esp_log.h"
#include "esp_now_comm.h"
#include "esp_now_messages.pb.h"
#include "esp_partition.h"
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "freertos/idf_additions.h"
#include "ota_uart.h"
#include <string.h>
static const char *TAG = "[OTA_ESPNOW]";
#define OTA_ESPNOW_PREPARE_STACK 8192
#define OTA_ESPNOW_PREPARE_PRIO 5
#define OTA_PREPARE_TIMEOUT_MS 120000u
#define OTA_BLOCK_TIMEOUT_MS 30000u
#define OTA_END_TIMEOUT_MS 60000u
#define OTA_PAYLOAD_DELAY_MS 3
#define OTA_ST_PREPARING 1u
#define OTA_ST_READY 2u
#define OTA_ST_BLOCK_ACK 3u
#define OTA_ST_SUCCESS 4u
#define OTA_ST_FAILED 5u
#define OTA_MAX_TARGETS CLIENT_REGISTRY_MAX
static EventGroupHandle_t s_eg;
typedef struct {
uint8_t count;
uint8_t mac[OTA_MAX_TARGETS][6];
uint32_t id[OTA_MAX_TARGETS];
uint32_t expected_bytes;
} ota_dist_t;
static ota_dist_t s_dist;
static int find_target_index(const uint8_t mac[6]) {
for (uint8_t i = 0; i < s_dist.count; i++) {
if (memcmp(s_dist.mac[i], mac, 6) == 0) {
return (int)i;
}
}
return -1;
}
static uint32_t all_target_bits(void) {
if (s_dist.count == 0 || s_dist.count > 31) {
return 0;
}
return (1u << s_dist.count) - 1u;
}
static bool wait_target_bits(uint32_t want_bits, uint32_t timeout_ms) {
if (s_eg == NULL || want_bits == 0) {
return false;
}
EventBits_t got =
xEventGroupWaitBits(s_eg, want_bits, pdTRUE, pdTRUE,
pdMS_TO_TICKS(timeout_ms));
return (got & want_bits) == want_bits;
}
static void send_slave_status(const uint8_t master_mac[6], uint32_t status,
uint32_t bytes_written, uint32_t error) {
esp_now_comm_send_ota_status(master_mac, status, bytes_written, error);
}
static void ota_slave_prepare_task(void *param) {
uint32_t total_size = (uint32_t)(uintptr_t)param;
uint8_t master_mac[6];
if (!esp_now_comm_get_master_mac(master_mac)) {
vTaskDelete(NULL);
return;
}
send_slave_status(master_mac, OTA_ST_PREPARING, 0, 0);
int slot = ota_uart_prepare(total_size);
if (slot < 0) {
send_slave_status(master_mac, OTA_ST_FAILED, 0, 1);
vTaskDelete(NULL);
return;
}
send_slave_status(master_mac, OTA_ST_READY, 0, 0);
vTaskDelete(NULL);
}
void ota_espnow_slave_on_start(const uint8_t master_mac[6],
const alox_EspNowOtaStart *start) {
if (start == NULL || start->total_size == 0) {
return;
}
ESP_LOGI(TAG, "ESP-NOW OTA_START (%lu bytes)", (unsigned long)start->total_size);
if (ota_uart_is_active()) {
send_slave_status(master_mac, OTA_ST_FAILED, 0, 4);
return;
}
if (xTaskCreate(ota_slave_prepare_task, "ota_esp_prep", OTA_ESPNOW_PREPARE_STACK,
(void *)(uintptr_t)start->total_size, OTA_ESPNOW_PREPARE_PRIO,
NULL) != pdPASS) {
send_slave_status(master_mac, OTA_ST_FAILED, 0, 5);
}
}
void ota_espnow_slave_on_payload(const uint8_t master_mac[6],
const alox_EspNowOtaPayload *payload) {
if (payload == NULL || payload->data.size == 0) {
send_slave_status(master_mac, OTA_ST_FAILED, 0, 11);
return;
}
if (!ota_uart_is_active()) {
ESP_LOGW(TAG, "OTA_PAYLOAD seq=%lu but no active session",
(unsigned long)payload->seq);
send_slave_status(master_mac, OTA_ST_FAILED, 0, 12);
return;
}
if (payload->seq == 0) {
ESP_LOGI(TAG, "ESP-NOW OTA payloads started");
}
ota_feed_result_t r =
ota_uart_feed(payload->data.bytes, payload->data.size);
if (r == OTA_FEED_ERROR) {
send_slave_status(master_mac, OTA_ST_FAILED, ota_uart_bytes_written(), 13);
return;
}
if (r == OTA_FEED_BLOCK_WRITTEN) {
uint32_t written = ota_uart_bytes_written();
ESP_LOGI(TAG, "block written %lu bytes -> ack master", (unsigned long)written);
send_slave_status(master_mac, OTA_ST_BLOCK_ACK, written, 0);
}
}
void ota_espnow_slave_on_end(const uint8_t master_mac[6]) {
ESP_LOGI(TAG, "ESP-NOW OTA_END");
if (!ota_uart_is_active()) {
send_slave_status(master_mac, OTA_ST_FAILED, 0, 20);
return;
}
uint32_t written = ota_uart_bytes_written();
bool success = false;
esp_err_t err = ota_uart_finish(true, &success);
if (err != ESP_OK || !success) {
send_slave_status(master_mac, OTA_ST_FAILED, written, (uint32_t)err);
return;
}
send_slave_status(master_mac, OTA_ST_SUCCESS, written, 0);
ESP_LOGI(TAG, "slave OTA success (%lu bytes), reboot to run",
(unsigned long)written);
}
void ota_espnow_master_on_status(const uint8_t slave_mac[6],
const alox_EspNowOtaStatus *status) {
if (status == NULL || s_eg == NULL) {
return;
}
int idx = find_target_index(slave_mac);
if (idx < 0) {
return;
}
uint32_t bit = (1u << (unsigned)idx);
switch (status->status) {
case OTA_ST_READY:
xEventGroupSetBits(s_eg, bit);
break;
case OTA_ST_BLOCK_ACK:
if (status->bytes_written >= s_dist.expected_bytes) {
xEventGroupSetBits(s_eg, bit);
} else {
ESP_LOGW(TAG, "slave %lu block ack early (%lu < %lu)",
(unsigned long)s_dist.id[idx], (unsigned long)status->bytes_written,
(unsigned long)s_dist.expected_bytes);
}
break;
case OTA_ST_SUCCESS:
xEventGroupSetBits(s_eg, bit);
break;
case OTA_ST_FAILED:
ESP_LOGW(TAG, "slave %lu OTA failed (err=%lu)",
(unsigned long)s_dist.id[idx], (unsigned long)status->error);
break;
default:
break;
}
}
static size_t collect_targets(void) {
memset(&s_dist, 0, sizeof(s_dist));
size_t n = client_registry_count();
for (size_t i = 0; i < n && s_dist.count < OTA_MAX_TARGETS; i++) {
const client_info_t *c = client_registry_at(i);
if (c == NULL || !c->available) {
continue;
}
uint8_t slot = s_dist.count;
memcpy(s_dist.mac[slot], c->mac, 6);
s_dist.id[slot] = c->id;
s_dist.count++;
}
return s_dist.count;
}
static esp_err_t distribute_image(const esp_partition_t *partition,
uint32_t size) {
if (s_eg == NULL) {
s_eg = xEventGroupCreate();
if (s_eg == NULL) {
return ESP_ERR_NO_MEM;
}
}
ESP_LOGI(TAG, "distributing %lu bytes from %s to %u slave(s)",
(unsigned long)size, partition->label, (unsigned)s_dist.count);
uint32_t target_mask = all_target_bits();
esp_err_t err;
xEventGroupClearBits(s_eg, target_mask);
for (uint8_t i = 0; i < s_dist.count; i++) {
err = esp_now_comm_send_ota_start(s_dist.mac[i], size);
if (err != ESP_OK) {
ESP_LOGW(TAG, "OTA_START to slave %lu failed",
(unsigned long)s_dist.id[i]);
return err;
}
}
if (!wait_target_bits(target_mask, OTA_PREPARE_TIMEOUT_MS)) {
ESP_LOGE(TAG, "timeout waiting for slave OTA ready");
return ESP_ERR_TIMEOUT;
}
uint8_t block_buf[OTA_UART_FLASH_BLOCK_SIZE];
uint32_t offset = 0;
uint32_t seq = 0;
while (offset < size) {
uint32_t block_len = size - offset;
if (block_len > OTA_UART_FLASH_BLOCK_SIZE) {
block_len = OTA_UART_FLASH_BLOCK_SIZE;
}
err = esp_partition_read(partition, offset, block_buf, block_len);
if (err != ESP_OK) {
ESP_LOGE(TAG, "partition read @%lu failed: %s", (unsigned long)offset,
esp_err_to_name(err));
return err;
}
uint32_t sent = 0;
while (sent < block_len) {
uint32_t chunk = block_len - sent;
if (chunk > OTA_UART_HOST_CHUNK_SIZE) {
chunk = OTA_UART_HOST_CHUNK_SIZE;
}
for (uint8_t i = 0; i < s_dist.count; i++) {
err = esp_now_comm_send_ota_payload(s_dist.mac[i], seq,
block_buf + sent, chunk);
if (err != ESP_OK) {
return err;
}
}
seq++;
sent += chunk;
vTaskDelay(pdMS_TO_TICKS(OTA_PAYLOAD_DELAY_MS));
}
const bool full_block = (block_len >= OTA_UART_FLASH_BLOCK_SIZE);
s_dist.expected_bytes = offset + block_len;
if (full_block) {
xEventGroupClearBits(s_eg, target_mask);
if (!wait_target_bits(target_mask, OTA_BLOCK_TIMEOUT_MS)) {
ESP_LOGE(TAG, "timeout block ack @%lu bytes",
(unsigned long)s_dist.expected_bytes);
return ESP_ERR_TIMEOUT;
}
ESP_LOGI(TAG, "block ack @%lu/%lu (%lu%%)",
(unsigned long)s_dist.expected_bytes, (unsigned long)size,
(unsigned long)(s_dist.expected_bytes * 100 / size));
} else {
ESP_LOGI(TAG, "final partial block %lu bytes (flush on OTA_END)",
(unsigned long)block_len);
}
offset += block_len;
}
xEventGroupClearBits(s_eg, target_mask);
for (uint8_t i = 0; i < s_dist.count; i++) {
err = esp_now_comm_send_ota_end(s_dist.mac[i]);
if (err != ESP_OK) {
return err;
}
}
if (!wait_target_bits(target_mask, OTA_END_TIMEOUT_MS)) {
ESP_LOGE(TAG, "timeout waiting for slave OTA success");
return ESP_ERR_TIMEOUT;
}
ESP_LOGI(TAG, "ESP-NOW OTA complete for %u slave(s)", (unsigned)s_dist.count);
return ESP_OK;
}
esp_err_t ota_espnow_distribute(const esp_partition_t *partition, uint32_t size) {
if (partition == NULL || size == 0) {
return ESP_ERR_INVALID_ARG;
}
if (collect_targets() == 0) {
ESP_LOGI(TAG, "no available slaves — skip ESP-NOW OTA");
return ESP_OK;
}
return distribute_image(partition, size);
}

23
main/ota_espnow.h Normal file
View File

@ -0,0 +1,23 @@
#ifndef OTA_ESPNOW_H
#define OTA_ESPNOW_H
#include "esp_err.h"
#include "esp_now_messages.pb.h"
#include "esp_partition.h"
#include <stdint.h>
/** Master: read staged image from partition and push to all available slaves. */
esp_err_t ota_espnow_distribute(const esp_partition_t *partition, uint32_t size);
/** Master: handle slave → master OTA status / block ACK. */
void ota_espnow_master_on_status(const uint8_t slave_mac[6],
const alox_EspNowOtaStatus *status);
/** Slave: handle master → slave OTA commands. */
void ota_espnow_slave_on_start(const uint8_t master_mac[6],
const alox_EspNowOtaStart *start);
void ota_espnow_slave_on_payload(const uint8_t master_mac[6],
const alox_EspNowOtaPayload *payload);
void ota_espnow_slave_on_end(const uint8_t master_mac[6]);
#endif

View File

@ -19,6 +19,13 @@ typedef struct {
static ota_uart_state_t s_ota;
static struct {
bool valid;
const esp_partition_t *partition;
uint32_t size;
int target_slot;
} s_staged;
static int partition_slot(const esp_partition_t *part) {
if (part == NULL) {
return -1;
@ -144,7 +151,38 @@ ota_feed_result_t ota_uart_feed(const uint8_t *data, size_t len) {
uint32_t ota_uart_bytes_written(void) { return s_ota.written; }
esp_err_t ota_uart_finish(bool *success_out) {
bool ota_uart_get_staged_image(const esp_partition_t **partition_out,
uint32_t *size_out) {
if (!s_staged.valid || s_staged.partition == NULL) {
return false;
}
if (partition_out != NULL) {
*partition_out = s_staged.partition;
}
if (size_out != NULL) {
*size_out = s_staged.size;
}
return true;
}
void ota_uart_clear_staged(void) { memset(&s_staged, 0, sizeof(s_staged)); }
esp_err_t ota_uart_apply_boot(void) {
if (!s_staged.valid || s_staged.partition == NULL) {
return ESP_ERR_INVALID_STATE;
}
esp_err_t err = esp_ota_set_boot_partition(s_staged.partition);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_set_boot_partition failed: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "boot partition set to %s (slot %d)",
s_staged.partition->label, s_staged.target_slot);
ota_uart_clear_staged();
return ESP_OK;
}
esp_err_t ota_uart_finish(bool set_boot, bool *success_out) {
if (success_out != NULL) {
*success_out = false;
}
@ -165,20 +203,27 @@ esp_err_t ota_uart_finish(bool *success_out) {
return err;
}
err = esp_ota_set_boot_partition(s_ota.update_partition);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_set_boot_partition failed: %s", esp_err_to_name(err));
ota_uart_clear_staged();
s_staged.valid = true;
s_staged.partition = s_ota.update_partition;
s_staged.size = s_ota.written;
s_staged.target_slot = s_ota.target_slot;
ESP_LOGI(TAG, "OTA image staged: %lu bytes on %s (slot %d)",
(unsigned long)s_staged.size, s_staged.partition->label,
s_staged.target_slot);
memset(&s_ota, 0, sizeof(s_ota));
if (set_boot) {
err = ota_uart_apply_boot();
if (err != ESP_OK) {
return err;
}
ESP_LOGI(TAG, "OTA complete: %lu bytes written to %s (slot %d), reboot to run",
(unsigned long)s_ota.written, s_ota.update_partition->label,
s_ota.target_slot);
}
if (success_out != NULL) {
*success_out = true;
}
memset(&s_ota, 0, sizeof(s_ota));
return ESP_OK;
}

View File

@ -2,6 +2,7 @@
#define OTA_UART_H
#include "esp_err.h"
#include "esp_partition.h"
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
@ -39,7 +40,19 @@ ota_feed_result_t ota_uart_feed(const uint8_t *data, size_t len);
uint32_t ota_uart_bytes_written(void);
/** Flush remainder, esp_ota_end, set boot partition on success. */
esp_err_t ota_uart_finish(bool *success_out);
/**
* Flush remainder and esp_ota_end. When set_boot is false, the staged image
* remains readable via ota_uart_get_staged_image() until ota_uart_apply_boot().
*/
esp_err_t ota_uart_finish(bool set_boot, bool *success_out);
/** Staged partition after ota_uart_finish(false, …); valid until apply_boot or abort. */
bool ota_uart_get_staged_image(const esp_partition_t **partition_out,
uint32_t *size_out);
/** Set boot partition to the last staged image (after slave distribution). */
esp_err_t ota_uart_apply_boot(void);
void ota_uart_clear_staged(void);
#endif

View File

@ -18,6 +18,18 @@ PB_BIND(alox_EspNowSlavePresence, alox_EspNowSlavePresence, AUTO)
PB_BIND(alox_EspNowAccelDeadzone, alox_EspNowAccelDeadzone, AUTO)
PB_BIND(alox_EspNowOtaStart, alox_EspNowOtaStart, AUTO)
PB_BIND(alox_EspNowOtaPayload, alox_EspNowOtaPayload, AUTO)
PB_BIND(alox_EspNowOtaEnd, alox_EspNowOtaEnd, AUTO)
PB_BIND(alox_EspNowOtaStatus, alox_EspNowOtaStatus, AUTO)
PB_BIND(alox_EspNowMessage, alox_EspNowMessage, AUTO)

View File

@ -16,7 +16,11 @@ typedef enum _alox_EspNowMessageType {
alox_EspNowMessageType_ESPNOW_SLAVE_INFO = 2,
alox_EspNowMessageType_ESPNOW_HEARTBEAT = 3,
alox_EspNowMessageType_ESPNOW_SET_ACCEL_DEADZONE = 4,
alox_EspNowMessageType_ESPNOW_UNICAST_TEST = 5
alox_EspNowMessageType_ESPNOW_UNICAST_TEST = 5,
alox_EspNowMessageType_ESPNOW_OTA_START = 6,
alox_EspNowMessageType_ESPNOW_OTA_PAYLOAD = 7,
alox_EspNowMessageType_ESPNOW_OTA_END = 8,
alox_EspNowMessageType_ESPNOW_OTA_STATUS = 9
} alox_EspNowMessageType;
/* Struct definitions */
@ -42,6 +46,30 @@ typedef struct _alox_EspNowAccelDeadzone {
uint32_t client_id; /* 0 = all slaves; otherwise only matching slave_id applies */
} alox_EspNowAccelDeadzone;
/* Master → slave: begin OTA (erase inactive slot; slave replies ESPNOW_OTA_STATUS). */
typedef struct _alox_EspNowOtaStart {
uint32_t total_size;
} alox_EspNowOtaStart;
typedef PB_BYTES_ARRAY_T(200) alox_EspNowOtaPayload_data_t;
/* Master → slave: firmware chunk (up to 200 bytes); slave buffers 4 KiB before flash write. */
typedef struct _alox_EspNowOtaPayload {
uint32_t seq;
alox_EspNowOtaPayload_data_t data;
} alox_EspNowOtaPayload;
/* Master → slave: finalize OTA (flush, esp_ota_end, set boot partition). */
typedef struct _alox_EspNowOtaEnd {
char dummy_field;
} alox_EspNowOtaEnd;
/* Slave → master: status / block ACK (same status codes as UART OtaStatusPayload). */
typedef struct _alox_EspNowOtaStatus {
uint32_t status;
uint32_t bytes_written;
uint32_t error;
} alox_EspNowOtaStatus;
typedef struct _alox_EspNowMessage {
alox_EspNowMessageType type;
pb_size_t which_payload;
@ -51,6 +79,10 @@ typedef struct _alox_EspNowMessage {
alox_EspNowSlavePresence heartbeat;
alox_EspNowAccelDeadzone accel_deadzone;
alox_EspNowUnicastTest unicast_test;
alox_EspNowOtaStart ota_start;
alox_EspNowOtaPayload ota_payload;
alox_EspNowOtaEnd ota_end;
alox_EspNowOtaStatus ota_status;
} payload;
} alox_EspNowMessage;
@ -61,8 +93,12 @@ extern "C" {
/* Helper constants for enums */
#define _alox_EspNowMessageType_MIN alox_EspNowMessageType_ESPNOW_UNKNOWN
#define _alox_EspNowMessageType_MAX alox_EspNowMessageType_ESPNOW_UNICAST_TEST
#define _alox_EspNowMessageType_ARRAYSIZE ((alox_EspNowMessageType)(alox_EspNowMessageType_ESPNOW_UNICAST_TEST+1))
#define _alox_EspNowMessageType_MAX alox_EspNowMessageType_ESPNOW_OTA_STATUS
#define _alox_EspNowMessageType_ARRAYSIZE ((alox_EspNowMessageType)(alox_EspNowMessageType_ESPNOW_OTA_STATUS+1))
@ -76,11 +112,19 @@ extern "C" {
#define alox_EspNowDiscover_init_default {0}
#define alox_EspNowSlavePresence_init_default {0, {{NULL}, NULL}, 0, 0, 0, 0}
#define alox_EspNowAccelDeadzone_init_default {0, 0}
#define alox_EspNowOtaStart_init_default {0}
#define alox_EspNowOtaPayload_init_default {0, {0, {0}}}
#define alox_EspNowOtaEnd_init_default {0}
#define alox_EspNowOtaStatus_init_default {0, 0, 0}
#define alox_EspNowMessage_init_default {_alox_EspNowMessageType_MIN, 0, {alox_EspNowDiscover_init_default}}
#define alox_EspNowUnicastTest_init_zero {0}
#define alox_EspNowDiscover_init_zero {0}
#define alox_EspNowSlavePresence_init_zero {0, {{NULL}, NULL}, 0, 0, 0, 0}
#define alox_EspNowAccelDeadzone_init_zero {0, 0}
#define alox_EspNowOtaStart_init_zero {0}
#define alox_EspNowOtaPayload_init_zero {0, {0, {0}}}
#define alox_EspNowOtaEnd_init_zero {0}
#define alox_EspNowOtaStatus_init_zero {0, 0, 0}
#define alox_EspNowMessage_init_zero {_alox_EspNowMessageType_MIN, 0, {alox_EspNowDiscover_init_zero}}
/* Field tags (for use in manual encoding/decoding) */
@ -94,12 +138,22 @@ extern "C" {
#define alox_EspNowSlavePresence_used_tag 6
#define alox_EspNowAccelDeadzone_deadzone_tag 1
#define alox_EspNowAccelDeadzone_client_id_tag 2
#define alox_EspNowOtaStart_total_size_tag 1
#define alox_EspNowOtaPayload_seq_tag 1
#define alox_EspNowOtaPayload_data_tag 2
#define alox_EspNowOtaStatus_status_tag 1
#define alox_EspNowOtaStatus_bytes_written_tag 2
#define alox_EspNowOtaStatus_error_tag 3
#define alox_EspNowMessage_type_tag 1
#define alox_EspNowMessage_discover_tag 2
#define alox_EspNowMessage_slave_info_tag 3
#define alox_EspNowMessage_heartbeat_tag 4
#define alox_EspNowMessage_accel_deadzone_tag 5
#define alox_EspNowMessage_unicast_test_tag 6
#define alox_EspNowMessage_ota_start_tag 7
#define alox_EspNowMessage_ota_payload_tag 8
#define alox_EspNowMessage_ota_end_tag 9
#define alox_EspNowMessage_ota_status_tag 10
/* Struct field encoding specification for nanopb */
#define alox_EspNowUnicastTest_FIELDLIST(X, a) \
@ -128,13 +182,40 @@ X(a, STATIC, SINGULAR, UINT32, client_id, 2)
#define alox_EspNowAccelDeadzone_CALLBACK NULL
#define alox_EspNowAccelDeadzone_DEFAULT NULL
#define alox_EspNowOtaStart_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, total_size, 1)
#define alox_EspNowOtaStart_CALLBACK NULL
#define alox_EspNowOtaStart_DEFAULT NULL
#define alox_EspNowOtaPayload_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, seq, 1) \
X(a, STATIC, SINGULAR, BYTES, data, 2)
#define alox_EspNowOtaPayload_CALLBACK NULL
#define alox_EspNowOtaPayload_DEFAULT NULL
#define alox_EspNowOtaEnd_FIELDLIST(X, a) \
#define alox_EspNowOtaEnd_CALLBACK NULL
#define alox_EspNowOtaEnd_DEFAULT NULL
#define alox_EspNowOtaStatus_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UINT32, status, 1) \
X(a, STATIC, SINGULAR, UINT32, bytes_written, 2) \
X(a, STATIC, SINGULAR, UINT32, error, 3)
#define alox_EspNowOtaStatus_CALLBACK NULL
#define alox_EspNowOtaStatus_DEFAULT NULL
#define alox_EspNowMessage_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UENUM, type, 1) \
X(a, STATIC, ONEOF, MESSAGE, (payload,discover,payload.discover), 2) \
X(a, STATIC, ONEOF, MESSAGE, (payload,slave_info,payload.slave_info), 3) \
X(a, STATIC, ONEOF, MESSAGE, (payload,heartbeat,payload.heartbeat), 4) \
X(a, STATIC, ONEOF, MESSAGE, (payload,accel_deadzone,payload.accel_deadzone), 5) \
X(a, STATIC, ONEOF, MESSAGE, (payload,unicast_test,payload.unicast_test), 6)
X(a, STATIC, ONEOF, MESSAGE, (payload,unicast_test,payload.unicast_test), 6) \
X(a, STATIC, ONEOF, MESSAGE, (payload,ota_start,payload.ota_start), 7) \
X(a, STATIC, ONEOF, MESSAGE, (payload,ota_payload,payload.ota_payload), 8) \
X(a, STATIC, ONEOF, MESSAGE, (payload,ota_end,payload.ota_end), 9) \
X(a, STATIC, ONEOF, MESSAGE, (payload,ota_status,payload.ota_status), 10)
#define alox_EspNowMessage_CALLBACK NULL
#define alox_EspNowMessage_DEFAULT NULL
#define alox_EspNowMessage_payload_discover_MSGTYPE alox_EspNowDiscover
@ -142,11 +223,19 @@ X(a, STATIC, ONEOF, MESSAGE, (payload,unicast_test,payload.unicast_test),
#define alox_EspNowMessage_payload_heartbeat_MSGTYPE alox_EspNowSlavePresence
#define alox_EspNowMessage_payload_accel_deadzone_MSGTYPE alox_EspNowAccelDeadzone
#define alox_EspNowMessage_payload_unicast_test_MSGTYPE alox_EspNowUnicastTest
#define alox_EspNowMessage_payload_ota_start_MSGTYPE alox_EspNowOtaStart
#define alox_EspNowMessage_payload_ota_payload_MSGTYPE alox_EspNowOtaPayload
#define alox_EspNowMessage_payload_ota_end_MSGTYPE alox_EspNowOtaEnd
#define alox_EspNowMessage_payload_ota_status_MSGTYPE alox_EspNowOtaStatus
extern const pb_msgdesc_t alox_EspNowUnicastTest_msg;
extern const pb_msgdesc_t alox_EspNowDiscover_msg;
extern const pb_msgdesc_t alox_EspNowSlavePresence_msg;
extern const pb_msgdesc_t alox_EspNowAccelDeadzone_msg;
extern const pb_msgdesc_t alox_EspNowOtaStart_msg;
extern const pb_msgdesc_t alox_EspNowOtaPayload_msg;
extern const pb_msgdesc_t alox_EspNowOtaEnd_msg;
extern const pb_msgdesc_t alox_EspNowOtaStatus_msg;
extern const pb_msgdesc_t alox_EspNowMessage_msg;
/* Defines for backwards compatibility with code written before nanopb-0.4.0 */
@ -154,14 +243,22 @@ extern const pb_msgdesc_t alox_EspNowMessage_msg;
#define alox_EspNowDiscover_fields &alox_EspNowDiscover_msg
#define alox_EspNowSlavePresence_fields &alox_EspNowSlavePresence_msg
#define alox_EspNowAccelDeadzone_fields &alox_EspNowAccelDeadzone_msg
#define alox_EspNowOtaStart_fields &alox_EspNowOtaStart_msg
#define alox_EspNowOtaPayload_fields &alox_EspNowOtaPayload_msg
#define alox_EspNowOtaEnd_fields &alox_EspNowOtaEnd_msg
#define alox_EspNowOtaStatus_fields &alox_EspNowOtaStatus_msg
#define alox_EspNowMessage_fields &alox_EspNowMessage_msg
/* Maximum encoded size of messages (where known) */
/* alox_EspNowSlavePresence_size depends on runtime parameters */
/* alox_EspNowMessage_size depends on runtime parameters */
#define ALOX_MAIN_PROTO_ESP_NOW_MESSAGES_PB_H_MAX_SIZE alox_EspNowAccelDeadzone_size
#define ALOX_MAIN_PROTO_ESP_NOW_MESSAGES_PB_H_MAX_SIZE alox_EspNowOtaPayload_size
#define alox_EspNowAccelDeadzone_size 12
#define alox_EspNowDiscover_size 6
#define alox_EspNowOtaEnd_size 0
#define alox_EspNowOtaPayload_size 209
#define alox_EspNowOtaStart_size 6
#define alox_EspNowOtaStatus_size 18
#define alox_EspNowUnicastTest_size 6
#ifdef __cplusplus

View File

@ -1,5 +1,7 @@
syntax = "proto3";
import "nanopb.proto";
package alox;
enum EspNowMessageType {
@ -9,6 +11,10 @@ enum EspNowMessageType {
ESPNOW_HEARTBEAT = 3;
ESPNOW_SET_ACCEL_DEADZONE = 4;
ESPNOW_UNICAST_TEST = 5;
ESPNOW_OTA_START = 6;
ESPNOW_OTA_PAYLOAD = 7;
ESPNOW_OTA_END = 8;
ESPNOW_OTA_STATUS = 9;
}
message EspNowUnicastTest {
@ -33,6 +39,27 @@ message EspNowAccelDeadzone {
uint32 client_id = 2; // 0 = all slaves; otherwise only matching slave_id applies
}
// Master slave: begin OTA (erase inactive slot; slave replies ESPNOW_OTA_STATUS).
message EspNowOtaStart {
uint32 total_size = 1;
}
// Master slave: firmware chunk (up to 200 bytes); slave buffers 4 KiB before flash write.
message EspNowOtaPayload {
uint32 seq = 1;
bytes data = 2 [(nanopb).max_size = 200];
}
// Master slave: finalize OTA (flush, esp_ota_end, set boot partition).
message EspNowOtaEnd {}
// Slave master: status / block ACK (same status codes as UART OtaStatusPayload).
message EspNowOtaStatus {
uint32 status = 1;
uint32 bytes_written = 2;
uint32 error = 3;
}
message EspNowMessage {
EspNowMessageType type = 1;
oneof payload {
@ -41,5 +68,9 @@ message EspNowMessage {
EspNowSlavePresence heartbeat = 4;
EspNowAccelDeadzone accel_deadzone = 5;
EspNowUnicastTest unicast_test = 6;
EspNowOtaStart ota_start = 7;
EspNowOtaPayload ota_payload = 8;
EspNowOtaEnd ota_end = 9;
EspNowOtaStatus ota_status = 10;
}
}