fix: never leave radio TX suspended after a scan or aborted OTA

- endFrequencyScan always resumes TX; applying radio settings ends a scan.
- Frequency scans time out after 5 minutes.
- Reboot when an OTA upload is abandoned mid-transfer.
- /sendRemoteCommand returns 503 when the radio can't take the frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-11 12:23:20 +02:00
parent e55d9e48f7
commit 2a54740f52
2 changed files with 43 additions and 6 deletions

View file

@ -4553,6 +4553,10 @@ int currRSSI = -100;
float markFreq = 433.0f; float markFreq = 433.0f;
int markRSSI = -100; int markRSSI = -100;
uint32_t lastScan = 0; uint32_t lastScan = 0;
static uint32_t scanStart = 0;
// A scan nobody ended (closed browser tab) stops on its own after this long so
// the radio doesn't stay unable to transmit.
#define SCAN_MAX_MS 300000
void Transceiver::beginFrequencyScan() { void Transceiver::beginFrequencyScan() {
// The scan retunes the radio so nothing may transmit until it ends. This // The scan retunes the radio so nothing may transmit until it ends. This
// also aborts a transmission in flight so the lock below is never held long. // also aborts a transmission in flight so the lock below is never held long.
@ -4561,6 +4565,7 @@ void Transceiver::beginFrequencyScan() {
if(this->config.enabled) { if(this->config.enabled) {
this->disableReceive(); this->disableReceive();
rxmode = 3; rxmode = 3;
scanStart = millis();
pinMode(this->config.RXPin, INPUT); pinMode(this->config.RXPin, INPUT);
interruptPin = digitalPinToInterrupt(this->config.RXPin); interruptPin = digitalPinToInterrupt(this->config.RXPin);
ELECHOUSE_cc1101.setRxBW(this->config.rxBandwidth); // Set the Receive Bandwidth in kHz. Value from 58.03 to 812.50. Default is 812.50 kHz. ELECHOUSE_cc1101.setRxBW(this->config.rxBandwidth); // Set the Receive Bandwidth in kHz. Value from 58.03 to 812.50. Default is 812.50 kHz.
@ -4608,9 +4613,10 @@ void Transceiver::endFrequencyScan() {
if(interruptPin > 0) detachInterrupt(interruptPin); if(interruptPin > 0) detachInterrupt(interruptPin);
interruptPin = 0; interruptPin = 0;
this->config.apply(); this->config.apply();
this->resumeTx(TX_SUSPEND_SCAN);
this->emitFrequencyScan(); this->emitFrequencyScan();
} }
// Even if something else already ended the scan, nothing may keep TX suspended.
this->resumeTx(TX_SUSPEND_SCAN);
} }
void Transceiver::emitFrequencyScan(uint8_t num) { void Transceiver::emitFrequencyScan(uint8_t num) {
JsonSockEvent *json = sockEmit.beginEmit("frequencyScan"); JsonSockEvent *json = sockEmit.beginEmit("frequencyScan");
@ -4770,7 +4776,8 @@ bool Transceiver::save() {
} }
bool Transceiver::end() { bool Transceiver::end() {
// Nothing may transmit (and re-enable receive) after this. It is not // Nothing may transmit (and re-enable receive) after this. It is not
// resumed since the only caller is OTA which always reboots. // resumed since the only caller is OTA, which reboots when the upload
// completes, fails or is abandoned.
this->suspendTx(TX_SUSPEND_OTA); this->suspendTx(TX_SUSPEND_OTA);
RadioLock lock(portMAX_DELAY, true); RadioLock lock(portMAX_DELAY, true);
this->disableReceive(); this->disableReceive();
@ -5011,7 +5018,13 @@ void transceiver_config_t::apply() {
// be reconfigured. Queued frames are already encoded and go out with the // be reconfigured. Queued frames are already encoded and go out with the
// new settings. // new settings.
RadioLock lock(portMAX_DELAY, true); RadioLock lock(portMAX_DELAY, true);
// Reconfiguring the radio ends a frequency scan so TX may resume.
const bool wasScanning = rxmode == 3;
somfy.transceiver.disableReceive(); somfy.transceiver.disableReceive();
if(wasScanning) {
somfy.transceiver.resumeTx(TX_SUSPEND_SCAN);
somfy.transceiver.emitFrequencyScan();
}
bit_length = this->type; bit_length = this->type;
if(this->enabled) { if(this->enabled) {
bool radioInit = true; bool radioInit = true;
@ -5136,6 +5149,10 @@ void Transceiver::loop() {
noiseCount = 0; noiseCount = 0;
} }
} }
if(rxmode == 3 && millis() - scanStart > SCAN_MAX_MS) {
ESP_LOGW(TAG, "Frequency scan timed out");
this->endFrequencyScan();
}
if(rxmode == 3) { if(rxmode == 3) {
if(this->receive(&rx)) if(this->receive(&rx))
this->processFrequencyScan(true); this->processFrequencyScan(true);

View file

@ -64,10 +64,24 @@ static bool queueUnpairShade(SomfyShade *shade) {
shade->paired = false; shade->paired = false;
return true; return true;
} }
// The radio and MQTT are shut down once an upload starts and only a reboot
// brings them back. The completion handler schedules it but never runs if the
// client goes away mid-upload, so make sure the device reboots then too.
static void onUploadDisconnect() {
if(webServer.uploadSuccess || rebootDelay.reboot) return;
ESP_LOGW(TAG, "Upload abandoned, rebooting");
if(Update.isRunning()) Update.abort();
rebootDelay.reboot = true;
rebootDelay.rebootTime = millis() + 500;
}
// Sent when a command can't be handed to the main loop. // Sent when a command can't be handed to the main loop.
static void sendQueueFull(AsyncWebServerRequest *request) { static void sendQueueFull(AsyncWebServerRequest *request) {
request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Command queue full\"}")); request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Command queue full\"}"));
} }
// Sent when the radio can't take a frame (suspended or queue full).
static void sendRadioUnavailable(AsyncWebServerRequest *request) {
request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Radio TX unavailable\"}"));
}
AsyncWebServer asyncServer(80); AsyncWebServer asyncServer(80);
AsyncWebServer asyncApiServer(8081); AsyncWebServer asyncApiServer(8081);
@ -2136,6 +2150,7 @@ void Web::begin() {
else { else {
somfy.transceiver.end(); somfy.transceiver.end();
mqtt.end(); mqtt.end();
request->onDisconnect(onUploadDisconnect);
} }
} }
if(len > 0) { if(len > 0) {
@ -2201,6 +2216,7 @@ void Web::begin() {
else { else {
somfy.transceiver.end(); somfy.transceiver.end();
mqtt.end(); mqtt.end();
request->onDisconnect(onUploadDisconnect);
} }
} }
if(len > 0) { if(len > 0) {
@ -2331,8 +2347,10 @@ void Web::begin() {
frame.cmd = translateSomfyCommand(scmd.c_str()); frame.cmd = translateSomfyCommand(scmd.c_str());
} }
if(frame.remoteAddress > 0 && frame.rollingCode > 0) { if(frame.remoteAddress > 0 && frame.rollingCode > 0) {
somfy.sendFrame(frame, repeats); if(somfy.sendFrame(frame, repeats))
request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}"));
else
sendRadioUnavailable(request);
} }
else else
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}")); request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}"));
@ -2348,8 +2366,10 @@ void Web::begin() {
if(asyncHasParam(request, "repeats")) repeats = atoi(asyncParam(request, "repeats").c_str()); if(asyncHasParam(request, "repeats")) repeats = atoi(asyncParam(request, "repeats").c_str());
} }
if(frame.remoteAddress > 0 && frame.rollingCode > 0) { if(frame.remoteAddress > 0 && frame.rollingCode > 0) {
somfy.sendFrame(frame, repeats); if(somfy.sendFrame(frame, repeats))
request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}"));
else
sendRadioUnavailable(request);
} }
else else
request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}")); request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}"));