fix(box): no reboot when switching radio stations

Switching station without pressing Stop rebooted: a StreamBuffer allows a
single sender, but the previous station's HTTP task lingered (blocked in recv,
or in a full-buffer send) past stop()'s wait, so starting the next station made
two senders -> FreeRTOS assert -> reboot. Fixes: close the live HTTP socket
from stop() (mutex-guarded) so a blocked recv returns at once; short net/decode
timeouts; a per-play generation guard so an old task never touches the buffer;
and play() waits for the old session to be fully gone before starting.
This commit is contained in:
Clément Saillant
2026-06-15 12:28:31 +02:00
parent 3cb3bdb4eb
commit ea19aba53d
+57 -16
View File
@@ -34,6 +34,7 @@
#include "freertos/task.h"
#include "freertos/idf_additions.h"
#include "freertos/stream_buffer.h"
#include "freertos/semphr.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_codec_dev.h"
@@ -48,8 +49,8 @@ static const char *TAG = "radio";
// --- Tuning ------------------------------------------------------------------
#define NET_STREAM_BUF_BYTES (32 * 1024) // ~32 KB of buffered compressed MP3
#define NET_CHUNK_TIMEOUT_MS 2000 // max wait pushing a network chunk
#define DEC_READ_TIMEOUT_MS 2000 // max wait pulling MP3 from buffer
#define NET_CHUNK_TIMEOUT_MS 200 // short: re-check s_stop fast even when the buffer is full
#define DEC_READ_TIMEOUT_MS 300 // short: the decode task notices a stop promptly
#define DEC_READ_CHUNK 4096 // bytes pulled per decode iteration
#define DEC_OUT_CAP_INIT 8192 // initial PCM output buffer
#define HTTP_RX_BUFFER 2048 // esp_http_client receive buffer
@@ -59,11 +60,14 @@ static volatile bool s_stop; // request both tasks to unwind
static volatile bool s_http_running; // HTTP task alive
static volatile bool s_dec_running; // decode task alive
static volatile bool s_playing; // public "is playing" state
static volatile uint32_t s_gen; // bumped per play(); old-session tasks bail on mismatch
static StreamBufferHandle_t s_net_buf; // compressed MP3 bytes: HTTP -> decoder
static TaskHandle_t s_http_task;
static TaskHandle_t s_dec_task;
static char s_url[512];
static esp_http_client_handle_t s_client; // live client; closed by stop() to unblock recv
static SemaphoreHandle_t s_client_mux; // guards s_client across tasks
// Register the MP3 frame decoder + simple-decoder container parsers once.
static void register_decoders_once(void)
@@ -88,12 +92,13 @@ static void register_decoders_once(void)
static esp_err_t http_event(esp_http_client_event_t *evt)
{
if (evt->event_id == HTTP_EVENT_ON_DATA && evt->data_len > 0) {
static bool first = false;
if (!first) { first = true; lisael_udp_send("RADIO: first net data, len=%d", evt->data_len); }
const uint8_t *p = (const uint8_t *)evt->data;
size_t remaining = (size_t)evt->data_len;
while (remaining > 0) {
if (s_stop) {
// Abort if a stop was requested OR a newer session has taken over
// (gen mismatch) — never let an old task become a 2nd StreamBuffer
// sender, which trips a FreeRTOS assert (-> reboot).
if (s_stop || (uint32_t)(intptr_t)evt->user_data != s_gen) {
return ESP_FAIL; // abort perform() -> HTTP task returns
}
size_t sent = xStreamBufferSend(s_net_buf, p, remaining,
@@ -101,8 +106,7 @@ static esp_err_t http_event(esp_http_client_event_t *evt)
p += sent;
remaining -= sent;
// If sent < remaining we timed out with the buffer full (decoder
// stalled / paused) — loop again, re-checking s_stop. No spin: the
// send call itself blocks for up to NET_CHUNK_TIMEOUT_MS.
// stalled / paused) — loop again, re-checking the guards. No spin.
}
}
return ESP_OK;
@@ -110,13 +114,13 @@ static esp_err_t http_event(esp_http_client_event_t *evt)
static void http_task(void *arg)
{
(void)arg;
s_http_running = true;
ESP_LOGI(TAG, "http: GET %s", s_url);
esp_http_client_config_t cfg = {
.url = s_url,
.event_handler = http_event,
.user_data = arg, // this session's generation (see http_event)
.timeout_ms = 15000,
.buffer_size = HTTP_RX_BUFFER,
// Follow 3xx automatically: RadioKing /play/<slug> endpoints redirect to
@@ -131,14 +135,23 @@ static void http_task(void *arg)
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (client) {
lisael_udp_send("RADIO: http perform start");
// Publish the handle so stop() can close the socket and unblock a recv
// that would otherwise sit until the socket timeout.
xSemaphoreTake(s_client_mux, portMAX_DELAY);
s_client = client;
xSemaphoreGive(s_client_mux);
esp_err_t err = esp_http_client_perform(client);
int status = esp_http_client_get_status_code(client);
lisael_udp_send("RADIO: http ended err=%s status=%d",
esp_err_to_name(err), status);
if (err != ESP_OK && !s_stop) {
ESP_LOGW(TAG, "http perform ended: %s", esp_err_to_name(err));
}
// Reclaim the handle under the lock before freeing it, so stop() never
// calls close() on a handle we are cleaning up.
xSemaphoreTake(s_client_mux, portMAX_DELAY);
s_client = NULL;
xSemaphoreGive(s_client_mux);
esp_http_client_cleanup(client);
} else {
lisael_udp_send("RADIO: http client init FAILED");
@@ -158,7 +171,7 @@ static void http_task(void *arg)
// ----------------------------------------------------------------------------
static void decode_task(void *arg)
{
(void)arg;
uint32_t mygen = (uint32_t)(intptr_t)arg;
s_dec_running = true;
register_decoders_once();
@@ -190,7 +203,7 @@ static void decode_task(void *arg)
ESP_LOGI(TAG, "decode task started");
while (!s_stop) {
while (!s_stop && mygen == s_gen) {
// Block until at least 1 byte is available (or timeout). When the HTTP
// task has exited and drained the buffer this returns 0 -> we leave.
size_t n = xStreamBufferReceive(s_net_buf, inbuf, DEC_READ_CHUNK,
@@ -209,7 +222,7 @@ static void decode_task(void *arg)
.len = (uint32_t)n,
.eos = false,
};
while (raw.len > 0 && !s_stop) {
while (raw.len > 0 && !s_stop && mygen == s_gen) {
esp_audio_simple_dec_out_t frame = { .buffer = out, .len = out_cap };
esp_audio_err_t e = esp_audio_simple_dec_process(dec, &raw, &frame);
if (e == ESP_AUDIO_ERR_BUFF_NOT_ENOUGH) {
@@ -301,6 +314,23 @@ esp_err_t lisael_radio_play(const char *url)
lisael_radio_stop();
lisael_audio_stop_all();
// Wait until the previous session's tasks are COMPLETELY gone before reusing
// the shared StreamBuffer/codec. Otherwise an old task could still be a
// sender/receiver on the buffer when the new one starts -> two senders ->
// FreeRTOS assert -> reboot (the exact "switch station without Stop" crash).
// The short NET/DEC timeouts make this quick.
for (int i = 0; i < 200 && (s_http_running || s_dec_running); i++) {
vTaskDelay(pdMS_TO_TICKS(10));
}
if (s_http_running || s_dec_running) {
ESP_LOGW(TAG, "previous radio session still winding down — not starting");
return ESP_FAIL;
}
if (!s_client_mux) {
s_client_mux = xSemaphoreCreateMutex();
}
// Lazily create the network->decoder StreamBuffer IN PSRAM: it is ~32 KB
// and internal RAM is nearly exhausted (xStreamBufferCreate would fail with
// NO_MEM, which is exactly why the radio never started).
@@ -319,6 +349,7 @@ esp_err_t lisael_radio_play(const char *url)
strlcpy(s_url, url, sizeof(s_url));
s_stop = false;
s_playing = true;
uint32_t gen = ++s_gen; // this session's id; old tasks bail on mismatch
lisael_audio_set_source(LISAEL_AUDIO_RADIO);
// Priority 4 == LVGL task priority. We MUST NOT run above the UI task or a
@@ -327,10 +358,10 @@ esp_err_t lisael_radio_play(const char *url)
// responsive without preempting LVGL's own time slice. pin to no core so
// the scheduler can balance with Wi-Fi/LVGL.
BaseType_t ok1 = xTaskCreatePinnedToCoreWithCaps(decode_task, "radio_dec", 8192,
NULL, 4, &s_dec_task,
(void *)(intptr_t)gen, 4, &s_dec_task,
tskNO_AFFINITY, MALLOC_CAP_SPIRAM);
BaseType_t ok2 = xTaskCreatePinnedToCoreWithCaps(http_task, "radio_http", 8192,
NULL, 4, &s_http_task,
(void *)(intptr_t)gen, 4, &s_http_task,
tskNO_AFFINITY, MALLOC_CAP_SPIRAM);
if (ok1 != pdPASS || ok2 != pdPASS) {
ESP_LOGE(TAG, "task create failed");
@@ -351,6 +382,16 @@ void lisael_radio_stop(void)
}
s_stop = true;
// Close the live HTTP socket so a perform() blocked in recv() returns at
// once (otherwise it lingers until the socket timeout, and a fast station
// switch would start a 2nd session on the shared buffer -> assert/reboot).
if (s_client_mux) {
xSemaphoreTake(s_client_mux, portMAX_DELAY);
if (s_client) {
esp_http_client_close(s_client);
}
xSemaphoreGive(s_client_mux);
}
// Nudge the decode task in case it is blocked on an empty StreamBuffer.
if (s_net_buf) {
xStreamBufferReset(s_net_buf);