fix: move radio TX to a dedicated task so HA WebSocket stays connected

Web command handlers ran on the async_tcp task and blocked up to 3s
waiting for loop() to bit-bang radio frames. async_tcp also services the
WebSocket on port 8080, so pongs stalled and Home Assistant
(ping_timeout=15) disconnected when several covers were commanded.

- Add a radio_tx FreeRTOS task with a job queue; sendFrame/repeatFrame
  and repeater frames enqueue instead of transmitting inline.
- Serialize CC1101 access with a recursive mutex (TX task, apply, end,
  frequency scan, RSSI read).
- Make Web::queueCommand fire-and-forget; drop the done semaphore.
- Route /setMyPosition and /unpairShade through the command queue.
- Clamp repeats to MAX_TX_REPEATS (40).
- Pin AsyncTCP to core 0 on dual-core targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-10 20:13:31 +02:00
parent b117c34cb8
commit 9a9afa5e86
5 changed files with 202 additions and 81 deletions

View file

@ -45,14 +45,29 @@ static const char _encoding_json[] = "application/json";
static const char *TAG = "Web";
static QueueHandle_t webCmdQueue = nullptr;
static SemaphoreHandle_t webCmdDone = nullptr;
// Radio work for these handlers runs on the main loop via the command queue.
static void queueSetMyPosition(SomfyShade *shade, int8_t pos, int8_t tilt) {
web_command_t cmd = {};
cmd.type = web_cmd_t::set_my_position;
cmd.shadeId = shade->getShadeId();
cmd.position = pos;
cmd.tiltPosition = tilt;
webServer.queueCommand(cmd);
}
static void queueUnpairShade(SomfyShade *shade) {
web_command_t cmd = {};
cmd.type = web_cmd_t::unpair_shade;
cmd.shadeId = shade->getShadeId();
// Reflect the change in the response; the main loop sets it again and saves.
if(webServer.queueCommand(cmd)) shade->paired = false;
}
AsyncWebServer asyncServer(80);
AsyncWebServer asyncApiServer(8081);
void Web::startup() {
ESP_LOGI(TAG, "Launching web server...");
if(!webCmdQueue) webCmdQueue = xQueueCreate(WEB_CMD_QUEUE_SIZE, sizeof(web_command_t));
if(!webCmdDone) webCmdDone = xSemaphoreCreateBinary();
asyncServer.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) {
AsyncJsonResponse *response = new AsyncJsonResponse();
@ -74,22 +89,19 @@ void Web::loop() {
delay(1);
}
bool Web::queueCommand(const web_command_t &cmd) {
if(!webCmdQueue || !webCmdDone) return false;
// Clear any stale signal
xSemaphoreTake(webCmdDone, 0);
if(xQueueSend(webCmdQueue, &cmd, pdMS_TO_TICKS(100)) != pdTRUE) {
if(!webCmdQueue) return false;
// This runs on the async_tcp task which also services the sockets so it must
// never wait on the main loop or the radio.
web_command_t c = cmd;
if(c.repeat > MAX_TX_REPEATS) c.repeat = MAX_TX_REPEATS;
if(xQueueSend(webCmdQueue, &c, 0) != pdTRUE) {
ESP_LOGE(TAG, "Command queue full, dropping command");
return false;
}
// Wait for main loop to process it
if(xSemaphoreTake(webCmdDone, pdMS_TO_TICKS(WEB_CMD_TIMEOUT_MS)) != pdTRUE) {
ESP_LOGW(TAG, "Command queue timeout waiting for processing");
return false;
}
return true;
}
void Web::processQueue() {
if(!webCmdQueue || !webCmdDone) return;
if(!webCmdQueue) return;
web_command_t cmd;
while(xQueueReceive(webCmdQueue, &cmd, 0) == pdTRUE) {
switch(cmd.type) {
@ -156,8 +168,21 @@ void Web::processQueue() {
}
break;
}
case web_cmd_t::set_my_position: {
SomfyShade *shade = somfy.getShadeById(cmd.shadeId);
if(shade) shade->setMyPosition(cmd.position, cmd.tiltPosition);
break;
}
case web_cmd_t::unpair_shade: {
SomfyShade *shade = somfy.getShadeById(cmd.shadeId);
if(shade) {
shade->sendCommand(somfy_commands::Prog, shade->bitLength == 56 ? 7 : 1);
shade->paired = false;
shade->save();
}
break;
}
}
xSemaphoreGive(webCmdDone);
}
}
bool Web::isAuthenticated(AsyncWebServerRequest *request, bool cfg) {
@ -1601,7 +1626,7 @@ void Web::begin() {
if(tilt < 0) tilt = shade->myPos;
if(shade->tiltType == tilt_types::none) tilt = -1;
if(pos >= 0 && pos <= 100)
shade->setMyPosition(shade->transformPosition(pos), shade->transformPosition(tilt));
queueSetMyPosition(shade, shade->transformPosition(pos), shade->transformPosition(tilt));
AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();
@ -1628,7 +1653,7 @@ void Web::begin() {
if(tilt < 0) tilt = shade->myPos;
if(shade->tiltType == tilt_types::none) tilt = -1;
if(pos >= 0 && pos <= 100)
shade->setMyPosition(shade->transformPosition(pos), shade->transformPosition(tilt));
queueSetMyPosition(shade, shade->transformPosition(pos), shade->transformPosition(tilt));
AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();
@ -1753,12 +1778,7 @@ void Web::begin() {
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}"));
}
else {
if(shade->bitLength == 56)
shade->sendCommand(somfy_commands::Prog, 7);
else
shade->sendCommand(somfy_commands::Prog, 1);
shade->paired = false;
shade->save();
queueUnpairShade(shade);
AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();
@ -1777,12 +1797,7 @@ void Web::begin() {
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}"));
}
else {
if(shade->bitLength == 56)
shade->sendCommand(somfy_commands::Prog, 7);
else
shade->sendCommand(somfy_commands::Prog, 1);
shade->paired = false;
shade->save();
queueUnpairShade(shade);
AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject();