fix: back-pressure web commands and report radio failures

Web commands are answered once queued, but the main loop handed all of
them to the radio at once and anything past its free slots was dropped
silently.  Unpair also cleared paired even when the Prog wasn't sent.

- processQueue leaves commands queued until the radio has room.
- Commands that transmit get a 503 straight away during a frequency
  scan instead of being dropped later.
- Unpair restores paired when the Prog can't be sent.
- Failed web commands are logged.
- Note that /setPositions and /setSensor answer with the state before
  the command; the socket events carry the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-11 12:46:40 +02:00
parent 00a084048f
commit 1f394ca9df
4 changed files with 69 additions and 29 deletions

View file

@ -5291,6 +5291,12 @@ bool Transceiver::isQueued(uint32_t seq) {
// Only meaningful for jobs queued at the back. // Only meaningful for jobs queued at the back.
return seq != 0 && (int32_t)(seq - txBackTaken) > 0; return seq != 0 && (int32_t)(seq - txBackTaken) > 0;
} }
bool Transceiver::txSuspended() { return txSuspendMask != 0; }
bool Transceiver::txHasRoom() {
// Without a radio task queueTx() fails straight away so there is no point waiting.
if(!txJobQueue || !txTaskHandle) return true;
return uxQueueSpacesAvailable(txJobQueue) > TX_RESERVED_SLOTS;
}
void Transceiver::flushTx() { void Transceiver::flushTx() {
TxQueueLock lock; TxQueueLock lock;
++txGen; ++txGen;

View file

@ -575,6 +575,8 @@ class Transceiver {
void suspendTx(uint8_t reason); void suspendTx(uint8_t reason);
void resumeTx(uint8_t reason); void resumeTx(uint8_t reason);
bool isQueued(uint32_t seq); bool isQueued(uint32_t seq);
bool txSuspended();
bool txHasRoom();
void beginTransmit(); void beginTransmit();
void endTransmit(); void endTransmit();
void emitFrame(somfy_frame_t *frame, somfy_rx_t *rx = nullptr); void emitFrame(somfy_frame_t *frame, somfy_rx_t *rx = nullptr);

View file

@ -46,21 +46,34 @@ static const char *TAG = "Web";
static QueueHandle_t webCmdQueue = nullptr; static QueueHandle_t webCmdQueue = nullptr;
static void sendQueueFull(AsyncWebServerRequest *request);
static void sendRadioUnavailable(AsyncWebServerRequest *request);
// Hands a command that transmits to the main loop. When it can't be the
// request is answered with the reason and false is returned.
static bool queueRadioCommand(AsyncWebServerRequest *request, const web_command_t &cmd) {
// Nothing can go on air during a frequency scan so tell the client now rather
// than dropping the command once it reaches the radio.
if(somfy.transceiver.txSuspended()) { sendRadioUnavailable(request); return false; }
if(!webServer.queueCommand(cmd)) { sendQueueFull(request); return false; }
return true;
}
// Radio work for these handlers runs on the main loop via the command queue. // Radio work for these handlers runs on the main loop via the command queue.
static bool queueSetMyPosition(SomfyShade *shade, int8_t pos, int8_t tilt) { static bool queueSetMyPosition(AsyncWebServerRequest *request, SomfyShade *shade, int8_t pos, int8_t tilt) {
web_command_t cmd = {}; web_command_t cmd = {};
cmd.type = web_cmd_t::set_my_position; cmd.type = web_cmd_t::set_my_position;
cmd.shadeId = shade->getShadeId(); cmd.shadeId = shade->getShadeId();
cmd.position = pos; cmd.position = pos;
cmd.tiltPosition = tilt; cmd.tiltPosition = tilt;
return webServer.queueCommand(cmd); return queueRadioCommand(request, cmd);
} }
static bool queueUnpairShade(SomfyShade *shade) { static bool queueUnpairShade(AsyncWebServerRequest *request, SomfyShade *shade) {
web_command_t cmd = {}; web_command_t cmd = {};
cmd.type = web_cmd_t::unpair_shade; cmd.type = web_cmd_t::unpair_shade;
cmd.shadeId = shade->getShadeId(); cmd.shadeId = shade->getShadeId();
if(!webServer.queueCommand(cmd)) return false; cmd.wasPaired = shade->paired;
// Reflect the change in the response; the main loop sets it again and saves. if(!queueRadioCommand(request, cmd)) return false;
// Reflect the change in the response. The main loop saves it once the Prog
// is queued or puts it back if it can't be.
shade->paired = false; shade->paired = false;
return true; return true;
} }
@ -121,19 +134,24 @@ bool Web::queueCommand(const web_command_t &cmd) {
void Web::processQueue() { void Web::processQueue() {
if(!webCmdQueue) return; if(!webCmdQueue) return;
web_command_t cmd; web_command_t cmd;
while(xQueueReceive(webCmdQueue, &cmd, 0) == pdTRUE) { // The client was already told the command was accepted so leave it queued
// until the radio has room rather than have the radio drop it. While TX is
// suspended nothing gets through anyway so don't hold anything back then.
while((somfy.transceiver.txSuspended() || somfy.transceiver.txHasRoom()) &&
xQueueReceive(webCmdQueue, &cmd, 0) == pdTRUE) {
bool sent = true;
switch(cmd.type) { switch(cmd.type) {
case web_cmd_t::shade_command: { case web_cmd_t::shade_command: {
SomfyShade *shade = somfy.getShadeById(cmd.shadeId); SomfyShade *shade = somfy.getShadeById(cmd.shadeId);
if(shade) { if(shade) {
if(cmd.target <= 100) shade->moveToTarget(shade->transformPosition(cmd.target)); if(cmd.target <= 100) sent = shade->moveToTarget(shade->transformPosition(cmd.target));
else shade->sendCommand(cmd.command, cmd.repeat > 0 ? cmd.repeat : shade->repeats, cmd.stepSize); else sent = shade->sendCommand(cmd.command, cmd.repeat > 0 ? cmd.repeat : shade->repeats, cmd.stepSize);
} }
break; break;
} }
case web_cmd_t::group_command: { case web_cmd_t::group_command: {
SomfyGroup *group = somfy.getGroupById(cmd.groupId); SomfyGroup *group = somfy.getGroupById(cmd.groupId);
if(group) group->sendCommand(cmd.command, cmd.repeat >= 0 ? cmd.repeat : group->repeats, cmd.stepSize); if(group) sent = group->sendCommand(cmd.command, cmd.repeat >= 0 ? cmd.repeat : group->repeats, cmd.stepSize);
break; break;
} }
case web_cmd_t::tilt_command: { case web_cmd_t::tilt_command: {
@ -148,16 +166,16 @@ void Web::processQueue() {
SomfyShade *shade = somfy.getShadeById(cmd.shadeId); SomfyShade *shade = somfy.getShadeById(cmd.shadeId);
if(shade) { if(shade) {
if(shade->shadeType == shade_types::garage1 && cmd.command == somfy_commands::Prog) cmd.command = somfy_commands::Toggle; if(shade->shadeType == shade_types::garage1 && cmd.command == somfy_commands::Prog) cmd.command = somfy_commands::Toggle;
if(!shade->isLastCommand(cmd.command)) shade->sendCommand(cmd.command, cmd.repeat >= 0 ? cmd.repeat : shade->repeats, cmd.stepSize); if(!shade->isLastCommand(cmd.command)) sent = shade->sendCommand(cmd.command, cmd.repeat >= 0 ? cmd.repeat : shade->repeats, cmd.stepSize);
else shade->repeatFrame(cmd.repeat >= 0 ? cmd.repeat : shade->repeats); else sent = shade->repeatFrame(cmd.repeat >= 0 ? cmd.repeat : shade->repeats);
} }
break; break;
} }
case web_cmd_t::group_repeat: { case web_cmd_t::group_repeat: {
SomfyGroup *group = somfy.getGroupById(cmd.groupId); SomfyGroup *group = somfy.getGroupById(cmd.groupId);
if(group) { if(group) {
if(!group->isLastCommand(cmd.command)) group->sendCommand(cmd.command, cmd.repeat >= 0 ? cmd.repeat : group->repeats, cmd.stepSize); if(!group->isLastCommand(cmd.command)) sent = group->sendCommand(cmd.command, cmd.repeat >= 0 ? cmd.repeat : group->repeats, cmd.stepSize);
else group->repeatFrame(cmd.repeat >= 0 ? cmd.repeat : group->repeats); else sent = group->repeatFrame(cmd.repeat >= 0 ? cmd.repeat : group->repeats);
} }
break; break;
} }
@ -173,7 +191,7 @@ void Web::processQueue() {
case web_cmd_t::shade_sensor: { case web_cmd_t::shade_sensor: {
SomfyShade *shade = somfy.getShadeById(cmd.shadeId); SomfyShade *shade = somfy.getShadeById(cmd.shadeId);
if(shade) { if(shade) {
shade->sendSensorCommand(cmd.windy, cmd.sunny, cmd.repeat >= 0 ? (uint8_t)cmd.repeat : shade->repeats); sent = shade->sendSensorCommand(cmd.windy, cmd.sunny, cmd.repeat >= 0 ? (uint8_t)cmd.repeat : shade->repeats);
shade->emitState(); shade->emitState();
} }
break; break;
@ -181,7 +199,7 @@ void Web::processQueue() {
case web_cmd_t::group_sensor: { case web_cmd_t::group_sensor: {
SomfyGroup *group = somfy.getGroupById(cmd.groupId); SomfyGroup *group = somfy.getGroupById(cmd.groupId);
if(group) { if(group) {
group->sendSensorCommand(cmd.windy, cmd.sunny, cmd.repeat >= 0 ? (uint8_t)cmd.repeat : group->repeats); sent = group->sendSensorCommand(cmd.windy, cmd.sunny, cmd.repeat >= 0 ? (uint8_t)cmd.repeat : group->repeats);
group->emitState(); group->emitState();
} }
break; break;
@ -194,13 +212,21 @@ void Web::processQueue() {
case web_cmd_t::unpair_shade: { case web_cmd_t::unpair_shade: {
SomfyShade *shade = somfy.getShadeById(cmd.shadeId); SomfyShade *shade = somfy.getShadeById(cmd.shadeId);
if(shade) { if(shade) {
shade->sendCommand(somfy_commands::Prog, shade->bitLength == 56 ? 7 : 1); sent = shade->sendCommand(somfy_commands::Prog, shade->bitLength == 56 ? 7 : 1);
shade->paired = false; if(sent) {
shade->save(); shade->paired = false;
shade->save();
}
else {
// The motor is still paired so undo what the response showed.
shade->paired = cmd.wasPaired;
shade->emitState();
}
} }
break; break;
} }
} }
if(!sent) ESP_LOGW(TAG, "Radio didn't take web command %u", static_cast<uint8_t>(cmd.type));
} }
} }
bool Web::isAuthenticated(AsyncWebServerRequest *request, bool cfg) { bool Web::isAuthenticated(AsyncWebServerRequest *request, bool cfg) {
@ -830,7 +856,7 @@ void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json)
cmd.command = command; cmd.command = command;
cmd.repeat = repeat; cmd.repeat = repeat;
cmd.stepSize = stepSize; cmd.stepSize = stepSize;
if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } if(!queueRadioCommand(request, cmd)) return;
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -871,7 +897,7 @@ void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json)
cmd.command = command; cmd.command = command;
cmd.repeat = repeat; cmd.repeat = repeat;
cmd.stepSize = stepSize; cmd.stepSize = stepSize;
if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } if(!queueRadioCommand(request, cmd)) return;
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -908,7 +934,7 @@ void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) {
cmd.shadeId = shadeId; cmd.shadeId = shadeId;
cmd.target = target; cmd.target = target;
cmd.command = command; cmd.command = command;
if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } if(!queueRadioCommand(request, cmd)) return;
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -949,7 +975,7 @@ void Web::handleRepeatCommand(AsyncWebServerRequest *request, JsonVariant &json)
cmd.command = command; cmd.command = command;
cmd.repeat = repeat; cmd.repeat = repeat;
cmd.stepSize = stepSize; cmd.stepSize = stepSize;
if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } if(!queueRadioCommand(request, cmd)) return;
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginArray(); resp.beginArray();
@ -967,7 +993,7 @@ void Web::handleRepeatCommand(AsyncWebServerRequest *request, JsonVariant &json)
cmd.command = command; cmd.command = command;
cmd.repeat = repeat; cmd.repeat = repeat;
cmd.stepSize = stepSize; cmd.stepSize = stepSize;
if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } if(!queueRadioCommand(request, cmd)) return;
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -1062,6 +1088,8 @@ void Web::handleSetPositions(AsyncWebServerRequest *request, JsonVariant &json)
cmd.position = pos; cmd.position = pos;
cmd.tiltPosition = tiltPos; cmd.tiltPosition = tiltPos;
if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } if(!this->queueCommand(cmd)) { sendQueueFull(request); return; }
// The main loop applies the command after we answer so this is the state
// before it. Clients get the result from the shadeState socket event.
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -1104,7 +1132,9 @@ void Web::handleSetSensor(AsyncWebServerRequest *request, JsonVariant &json) {
cmd.sunny = sunny; cmd.sunny = sunny;
cmd.windy = windy; cmd.windy = windy;
cmd.repeat = repeat; cmd.repeat = repeat;
if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } if(!queueRadioCommand(request, cmd)) return;
// The main loop applies the command after we answer so this is the state
// before it. Clients get the result from the shadeState socket event.
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -1123,7 +1153,8 @@ void Web::handleSetSensor(AsyncWebServerRequest *request, JsonVariant &json) {
cmd.sunny = sunny; cmd.sunny = sunny;
cmd.windy = windy; cmd.windy = windy;
cmd.repeat = repeat; cmd.repeat = repeat;
if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } if(!queueRadioCommand(request, cmd)) return;
// As above, this is the state before the command and groupState follows.
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -1644,7 +1675,7 @@ void Web::begin() {
if(tilt < 0) tilt = shade->myPos; if(tilt < 0) tilt = shade->myPos;
if(shade->tiltType == tilt_types::none) tilt = -1; if(shade->tiltType == tilt_types::none) tilt = -1;
if(pos >= 0 && pos <= 100) if(pos >= 0 && pos <= 100)
if(!queueSetMyPosition(shade, shade->transformPosition(pos), shade->transformPosition(tilt))) { sendQueueFull(request); return; } if(!queueSetMyPosition(request, shade, shade->transformPosition(pos), shade->transformPosition(tilt))) return;
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -1671,7 +1702,7 @@ void Web::begin() {
if(tilt < 0) tilt = shade->myPos; if(tilt < 0) tilt = shade->myPos;
if(shade->tiltType == tilt_types::none) tilt = -1; if(shade->tiltType == tilt_types::none) tilt = -1;
if(pos >= 0 && pos <= 100) if(pos >= 0 && pos <= 100)
if(!queueSetMyPosition(shade, shade->transformPosition(pos), shade->transformPosition(tilt))) { sendQueueFull(request); return; } if(!queueSetMyPosition(request, shade, shade->transformPosition(pos), shade->transformPosition(tilt))) return;
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -1796,7 +1827,7 @@ void Web::begin() {
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}"));
} }
else { else {
if(!queueUnpairShade(shade)) { sendQueueFull(request); return; } if(!queueUnpairShade(request, shade)) return;
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();
@ -1815,7 +1846,7 @@ void Web::begin() {
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}"));
} }
else { else {
if(!queueUnpairShade(shade)) { sendQueueFull(request); return; } if(!queueUnpairShade(request, shade)) return;
AsyncJsonResp resp; AsyncJsonResp resp;
resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginResponse(request, g_async_content, sizeof(g_async_content));
resp.beginObject(); resp.beginObject();

View file

@ -35,6 +35,7 @@ struct web_command_t {
int8_t tiltPosition; // for setPositions/tilt int8_t tiltPosition; // for setPositions/tilt
int8_t sunny; // for sensor int8_t sunny; // for sensor
int8_t windy; // for sensor int8_t windy; // for sensor
bool wasPaired; // for unpair, restored if the Prog can't be sent
}; };
class Web { class Web {