diff --git a/platformio.ini b/platformio.ini index a353743..3333977 100644 --- a/platformio.ini +++ b/platformio.ini @@ -39,14 +39,23 @@ monitor_filters = esp32_exception_decoder log2file +; Dual-core targets: keep the async web/socket task on core 0 so the radio +; task on core 1 (with loop()) never competes with network handling. +[dualcore] +build_flags = + ${env.build_flags} + -DCONFIG_ASYNC_TCP_RUNNING_CORE=0 + [env:esp32dev] board = esp32dev board_build.partitions = esp32_3MB.csv +build_flags = ${dualcore.build_flags} [env:esp32devdbg] board = esp32dev build_type = debug board_build.partitions = esp32_3MB.csv +build_flags = ${dualcore.build_flags} [env:esp32c3] board = esp32-c3-devkitm-1 @@ -54,6 +63,7 @@ board_build.partitions = esp32_3MB.csv [env:esp32s3] board = esp32-s3-devkitc-1 +build_flags = ${dualcore.build_flags} [env:esp32c6] platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip diff --git a/src/Somfy.cpp b/src/Somfy.cpp index 20cc19b..935441b 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -2,6 +2,8 @@ #include #include #include +#include +#include #include #include "driver/gpio.h" #include "esp_log.h" @@ -36,6 +38,22 @@ uint8_t rxmode = 0; // Indicates whether the radio is in receive mode. Just to #define SETMY_REPEATS 35 #define TILT_REPEATS 15 #define TX_QUEUE_DELAY 100 +#define TX_TASK_PRIORITY 5 +#define TX_TASK_STACK 4096 + +// Serializes all access to the CC1101 (SPI) and the TX/RX pins between the +// radio task, the main loop and the async web server task. +static SemaphoreHandle_t radioMutex = nullptr; +static QueueHandle_t txJobQueue = nullptr; +static TaskHandle_t txTaskHandle = nullptr; +struct RadioLock { + bool locked = true; // Before the mutex exists there is nothing to serialize against. + bool owned = false; + RadioLock(TickType_t wait = portMAX_DELAY) { + if(radioMutex) locked = owned = xSemaphoreTakeRecursive(radioMutex, wait) == pdTRUE; + } + ~RadioLock() { if(owned) xSemaphoreGiveRecursive(radioMutex); } +}; int sort_asc(const void *cmp1, const void *cmp2) { int a = *((uint8_t *)cmp1); @@ -246,7 +264,11 @@ void somfy_frame_t::decodeFrame(somfy_rx_t *rx) { this->hwsync = rx->cpt_synchro_hw; this->pulseCount = rx->pulseCount; this->bitLength = rx->bit_length; - this->rssi = ELECHOUSE_cc1101.getRssi(); + { + // Don't stall the main loop behind a transmission just to read the RSSI. + RadioLock lock(0); + if(lock.locked) this->rssi = ELECHOUSE_cc1101.getRssi(); + } this->decodeFrame(rx->payload); } byte somfy_frame_t::encode80Byte7(byte start, uint8_t repeat) { @@ -1147,7 +1169,6 @@ void SomfyShade::checkMovement() { if(!isAtTarget()) { ESP_LOGD(TAG, "We are not at our tilt target: %.2f", this->tiltTarget); if(this->target != 100.0) SomfyRemote::sendCommand(somfy_commands::My, this->repeats); - delay(100); // We now need to move the tilt to the position we requested. this->moveToTiltTarget(this->tiltTarget); } @@ -1199,7 +1220,6 @@ void SomfyShade::checkMovement() { if(!isAtTarget()) { ESP_LOGD(TAG, "We are not at our tilt target: %.2f", this->tiltTarget); if(this->target != 0.0) SomfyRemote::sendCommand(somfy_commands::My, this->repeats); - delay(100); // We now need to move the tilt to the position we requested. this->moveToTiltTarget(this->tiltTarget); } @@ -1313,7 +1333,6 @@ void SomfyShade::checkMovement() { } } if(this->settingMyPos && this->isAtTarget()) { - delay(200); // Set this position before sending the command. If you don't the processFrame function // will send the shade back to its original My position. if(this->tiltType != tilt_types::none) { @@ -3835,33 +3854,30 @@ void SomfyRemote::repeatFrame(uint8_t repeat) { this->triggerGPIOs(this->lastFrame); return; } - somfy.transceiver.beginTransmit(); + if(repeat > MAX_TX_REPEATS) repeat = MAX_TX_REPEATS; + // The radio task encodes the first frame with the current repeat counter and + // then counts up from there for each repeat frame. byte frm[10]; this->lastFrame.encodeFrame(frm); - this->lastFrame.repeats++; - somfy.transceiver.sendFrame(frm, this->bitLength == 56 ? 2 : 12, this->bitLength); - for(uint8_t i = 0; i < repeat; i++) { - this->lastFrame.repeats++; - if(this->lastFrame.bitLength == 80) this->lastFrame.encode80BitFrame(&frm[0], this->lastFrame.repeats); - somfy.transceiver.sendFrame(frm, this->bitLength == 56 ? 7 : 6, this->bitLength); - esp_task_wdt_reset(); - } - somfy.transceiver.endTransmit(); + somfy_tx_job_t job; + job.frame = this->lastFrame; + job.repeats = repeat; + job.firstRepeat = this->lastFrame.repeats + 2; + this->lastFrame.repeats += repeat + 1; + somfy.transceiver.queueTx(job); //somfy.processFrame(this->lastFrame, true); } void SomfyShadeController::sendFrame(somfy_frame_t &frame, uint8_t repeat) { - somfy.transceiver.beginTransmit(); + if(repeat > MAX_TX_REPEATS) repeat = MAX_TX_REPEATS; + // Encode once here so any side effects of the encoding on the frame + // (valid flag, default step size) are visible to the caller immediately. byte frm[10]; frame.encodeFrame(frm); - this->transceiver.sendFrame(frm, frame.bitLength == 56 ? 2 : 12, frame.bitLength); - for(uint8_t i = 0; i < repeat; i++) { - // For each 80-bit frame we need to adjust the byte encoding for the - // silence. - if(frame.bitLength == 80) frame.encode80BitFrame(&frm[0], i + 1); - this->transceiver.sendFrame(frm, frame.bitLength == 56 ? 7 : 6, frame.bitLength); - esp_task_wdt_reset(); - } - this->transceiver.endTransmit(); + somfy_tx_job_t job; + job.frame = frame; + job.repeats = repeat; + job.firstRepeat = 1; + this->transceiver.queueTx(job); } bool SomfyShadeController::deleteShade(uint8_t shadeId) { for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { @@ -4344,6 +4360,7 @@ float markFreq = 433.0f; int markRSSI = -100; uint32_t lastScan = 0; void Transceiver::beginFrequencyScan() { + RadioLock lock; if(this->config.enabled) { this->disableReceive(); rxmode = 3; @@ -4360,6 +4377,7 @@ void Transceiver::beginFrequencyScan() { } } void Transceiver::processFrequencyScan(bool received) { + RadioLock lock; if(this->config.enabled && rxmode == 3) { if(received) { currRSSI = ELECHOUSE_cc1101.getRssi(); @@ -4387,6 +4405,7 @@ void Transceiver::processFrequencyScan(bool received) { } } void Transceiver::endFrequencyScan() { + RadioLock lock; if(rxmode == 3) { rxmode = 0; if(interruptPin > 0) detachInterrupt(interruptPin); @@ -4545,6 +4564,8 @@ bool Transceiver::save() { return true; } bool Transceiver::end() { + this->flushTx(); + RadioLock lock; this->disableReceive(); return true; } @@ -4779,6 +4800,10 @@ void transceiver_config_t::load() { //this->printBuffer = somfy.transceiver.printBuffer; } void transceiver_config_t::apply() { + // Frames queued under the old settings are discarded and any frame that is + // in flight completes before the radio is reconfigured. + somfy.transceiver.flushTx(); + RadioLock lock; somfy.transceiver.disableReceive(); bit_length = this->type; if(this->enabled) { @@ -4879,9 +4904,11 @@ void transceiver_config_t::apply() { //somfy.transceiver.printBuffer = this->printBuffer; } bool Transceiver::begin() { + if(!radioMutex) radioMutex = xSemaphoreCreateRecursiveMutex(); this->config.load(); this->config.apply(); rx_queue.init(); + this->startTxTask(); return true; } void Transceiver::loop() { @@ -4914,37 +4941,85 @@ void Transceiver::loop() { somfy.processWaitingFrame(); // Check to see if there is anything in the buffer if(tx_queue.length > 0 && millis() > tx_queue.delay_time && somfy_rx.cpt_synchro_hw == 0) { - this->beginTransmit(); - somfy_tx_t tx; - - tx_queue.pop(&tx); - { - char frame_buf[128]; - int fpos = snprintf(frame_buf, sizeof(frame_buf), "Sending frame %d - %d-BIT [", tx.hwsync, tx.bit_length); - for(uint8_t j = 0; j < 10; j++) { - fpos += snprintf(frame_buf + fpos, sizeof(frame_buf) - fpos, "%d%s", tx.payload[j], j < 9 ? ", " : ""); - } - snprintf(frame_buf + fpos, sizeof(frame_buf) - fpos, "]"); - ESP_LOGD(TAG, "%s", frame_buf); - } - this->sendFrame(tx.payload, tx.hwsync, tx.bit_length); + // Repeater frames are handed to the radio task like any other transmission. + somfy_tx_job_t job; + job.raw = true; + tx_queue.pop(&job.rawTx); + this->queueTx(job); tx_queue.delay_time = millis() + TX_QUEUE_DELAY; - - /* - while(tx_queue.length > 0 && tx_queue.pop(&tx)) { - Serial.printf("Sending frame %d - %d-BIT [", tx.hwsync, tx.bit_length); - for(uint8_t j = 0; j < 10; j++) { - Serial.print(tx.payload[j]); - if(j < 9) Serial.print(", "); - } - Serial.println("]"); - this->sendFrame(tx.payload, tx.hwsync, tx.bit_length); - } - */ - this->endTransmit(); } } } +bool Transceiver::queueTx(const somfy_tx_job_t &job) { + if(!txJobQueue) { + ESP_LOGE(TAG, "Radio task not started, dropping transmission"); + return false; + } + if(xQueueSend(txJobQueue, &job, 0) != pdTRUE) { + ESP_LOGE(TAG, "Radio TX queue full, dropping transmission"); + return false; + } + return true; +} +void Transceiver::flushTx() { + if(txJobQueue) xQueueReset(txJobQueue); +} +void Transceiver::transmitJob(somfy_tx_job_t &job) { + if(job.raw) { + somfy_tx_t &tx = job.rawTx; + char frame_buf[128]; + int fpos = snprintf(frame_buf, sizeof(frame_buf), "Sending frame %d - %d-BIT [", tx.hwsync, tx.bit_length); + for(uint8_t j = 0; j < 10; j++) { + fpos += snprintf(frame_buf + fpos, sizeof(frame_buf) - fpos, "%d%s", tx.payload[j], j < 9 ? ", " : ""); + } + snprintf(frame_buf + fpos, sizeof(frame_buf) - fpos, "]"); + ESP_LOGD(TAG, "%s", frame_buf); + this->beginTransmit(); + this->sendFrame(tx.payload, tx.hwsync, tx.bit_length); + this->endTransmit(); + return; + } + somfy_frame_t &frame = job.frame; + byte frm[10]; + frame.encodeFrame(frm); + this->beginTransmit(); + this->sendFrame(frm, frame.bitLength == 56 ? 2 : 12, frame.bitLength); + for(uint8_t i = 0; i < job.repeats; i++) { + // For each 80-bit frame we need to adjust the byte encoding for the + // silence. + if(frame.bitLength == 80) frame.encode80BitFrame(&frm[0], (uint8_t)(job.firstRepeat + i)); + this->sendFrame(frm, frame.bitLength == 56 ? 7 : 6, frame.bitLength); + } + this->endTransmit(); +} +void Transceiver::txTask(void *arg) { + Transceiver *trans = static_cast(arg); + somfy_tx_job_t job; + for(;;) { + if(xQueueReceive(txJobQueue, &job, portMAX_DELAY) != pdTRUE) continue; + { + RadioLock lock; + trans->transmitJob(job); + } + // Give the motors a full frame beat between separate transmissions. + vTaskDelay(pdMS_TO_TICKS(TX_QUEUE_DELAY)); + } +} +void Transceiver::startTxTask() { + if(txTaskHandle) return; + if(!txJobQueue) txJobQueue = xQueueCreate(TX_JOB_QUEUE_SIZE, sizeof(somfy_tx_job_t)); + if(!txJobQueue) { + ESP_LOGE(TAG, "Unable to create radio TX queue"); + return; + } +#if CONFIG_FREERTOS_UNICORE + xTaskCreate(Transceiver::txTask, "radio_tx", TX_TASK_STACK, this, TX_TASK_PRIORITY, &txTaskHandle); +#else + // Run beside loop() so the network stack on the other core is never held up. + xTaskCreatePinnedToCore(Transceiver::txTask, "radio_tx", TX_TASK_STACK, this, TX_TASK_PRIORITY, &txTaskHandle, ARDUINO_RUNNING_CORE); +#endif + if(!txTaskHandle) ESP_LOGE(TAG, "Unable to start radio TX task"); +} somfy_frame_t& Transceiver::lastFrame() { return this->frame; } void Transceiver::beginTransmit() { if(this->config.enabled) { diff --git a/src/Somfy.h b/src/Somfy.h index b70163e..07753c1 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -201,6 +201,19 @@ struct somfy_frame_t { bool isSynonym(somfy_frame_t &f); void copy(somfy_frame_t &f); }; +// Upper bound on repeats for a single transmission. Each frame is ~140ms of +// radio time so this keeps a single job to roughly 6 seconds. +#define MAX_TX_REPEATS 40 +#define TX_JOB_QUEUE_SIZE 32 +// A transmission handed off to the radio task. The frame is a copy so the +// radio task never touches the remote's lastFrame. +struct somfy_tx_job_t { + somfy_frame_t frame; + uint8_t repeats = 0; // Number of repeat frames after the first frame. + uint8_t firstRepeat = 1; // 80-bit repeat counter used for the first repeat frame. + bool raw = false; // When true rawTx holds a pre-encoded frame (repeater). + somfy_tx_t rawTx; +}; class SomfyRoom { public: @@ -488,8 +501,11 @@ struct transceiver_config_t { class Transceiver { private: static void handleReceive(); + static void txTask(void *arg); bool _received = false; somfy_frame_t frame; + void startTxTask(); + void transmitJob(somfy_tx_job_t &job); public: transceiver_config_t config; bool printBuffer = false; @@ -505,6 +521,8 @@ class Transceiver { void disableReceive(); somfy_frame_t& lastFrame(); void sendFrame(byte *frame, uint8_t sync, uint8_t bitLength = 56); + bool queueTx(const somfy_tx_job_t &job); + void flushTx(); void beginTransmit(); void endTransmit(); void emitFrame(somfy_frame_t *frame, somfy_rx_t *rx = nullptr); diff --git a/src/Web.cpp b/src/Web.cpp index 27a6ace..699b4a5 100644 --- a/src/Web.cpp +++ b/src/Web.cpp @@ -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(); diff --git a/src/Web.h b/src/Web.h index c764dd1..1041969 100644 --- a/src/Web.h +++ b/src/Web.h @@ -6,8 +6,9 @@ #ifndef webserver_h #define webserver_h -#define WEB_CMD_QUEUE_SIZE 8 -#define WEB_CMD_TIMEOUT_MS 3000 +// Commands are queued by the async web server and drained by the main loop. +// Big enough for Home Assistant firing a command at every shade at once. +#define WEB_CMD_QUEUE_SIZE 32 enum class web_cmd_t : uint8_t { shade_command, // moveToTarget or sendCommand @@ -18,6 +19,8 @@ enum class web_cmd_t : uint8_t { set_positions, // set shade position directly shade_sensor, // shade sensor command group_sensor, // group sensor command + set_my_position, // shade setMyPosition (position/tiltPosition) + unpair_shade, // shade unpair (Prog) and clear paired flag }; struct web_command_t { @@ -67,8 +70,8 @@ class Web { void handleBackup(AsyncWebServerRequest *request); void handleReboot(AsyncWebServerRequest *request); void handleNotFound(AsyncWebServerRequest *request); + bool queueCommand(const web_command_t &cmd); private: void processQueue(); - bool queueCommand(const web_command_t &cmd); }; #endif