From 9a9afa5e8667287eb6456717c7aaedc864d2b1b5 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sat, 10 Oct 2026 20:13:31 +0200 Subject: [PATCH 01/53] 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 --- platformio.ini | 10 +++ src/Somfy.cpp | 177 +++++++++++++++++++++++++++++++++++-------------- src/Somfy.h | 18 +++++ src/Web.cpp | 69 +++++++++++-------- src/Web.h | 9 ++- 5 files changed, 202 insertions(+), 81 deletions(-) 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 From 9d16d6cc6dedcc7aa3dfda35874d565cb1b9dd5d Mon Sep 17 00:00:00 2001 From: cjkas Date: Sat, 10 Oct 2026 20:39:18 +0200 Subject: [PATCH 02/53] fix: address radio TX task review findings - Abort in-flight transmissions on flush so config save, frequency scan and OTA never wait on the radio lock for a whole job - Suspend TX during frequency scan and OTA - Refuse commands the radio queue cannot take before consuming a rolling code or moving simulated shade state; shift movement start when a job goes on air late - Apply radio config changes under the radio lock - Re-check receiver idle before repeater transmissions - Return 503 from web handlers when the command queue is full - Restore 200 ms settle delay before the set-My command - Report RSSI 0 instead of a stale value when the radio is busy - Encode each frame once and centralise the repeat clamp Co-Authored-By: Claude Sonnet 5.5 --- src/Somfy.cpp | 203 ++++++++++++++++++++++++++++++++++++++------------ src/Somfy.h | 30 ++++++-- src/Web.cpp | 42 ++++++----- 3 files changed, 204 insertions(+), 71 deletions(-) diff --git a/src/Somfy.cpp b/src/Somfy.cpp index 935441b..fe9cd06 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include "driver/gpio.h" #include "esp_log.h" @@ -40,12 +41,20 @@ uint8_t rxmode = 0; // Indicates whether the radio is in receive mode. Just to #define TX_QUEUE_DELAY 100 #define TX_TASK_PRIORITY 5 #define TX_TASK_STACK 4096 +#define TX_RX_IDLE_WAIT 300 // Max ms a repeater frame waits for an incoming frame to finish. // 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 QueueHandle_t txStartQueue = nullptr; static TaskHandle_t txTaskHandle = nullptr; +// Bumped by flushTx(). Queued jobs from an older generation are dropped and a +// job in flight stops after its current frame so nobody waits on the radio lock +// for a whole transmission. +static std::atomic txGen{0}; +// Set while the radio must not transmit at all (frequency scan, OTA). +static volatile bool txSuspended = false; struct RadioLock { bool locked = true; // Before the mutex exists there is nothing to serialize against. bool owned = false; @@ -54,6 +63,16 @@ struct RadioLock { } ~RadioLock() { if(owned) xSemaphoreGiveRecursive(radioMutex); } }; +// Encodes the first frame of a transmission into the job. Encoding here keeps +// its side effects on the frame (valid flag, default step size) visible to the +// caller and the radio task only re-encodes the 80-bit repeat frames. +static void encodeTxJob(somfy_tx_job_t &job, somfy_frame_t &frame, uint8_t repeat) { + frame.encodeFrame(job.tx.payload); + job.tx.hwsync = frame.bitLength == 56 ? 2 : 12; + job.tx.bit_length = frame.bitLength; + job.frame = frame; + job.repeats = repeat; +} int sort_asc(const void *cmp1, const void *cmp2) { int a = *((uint8_t *)cmp1); @@ -266,8 +285,9 @@ void somfy_frame_t::decodeFrame(somfy_rx_t *rx) { this->bitLength = rx->bit_length; { // Don't stall the main loop behind a transmission just to read the RSSI. + // The frame object is reused so clear the RSSI rather than reporting a stale one. RadioLock lock(0); - if(lock.locked) this->rssi = ELECHOUSE_cc1101.getRssi(); + this->rssi = lock.locked ? ELECHOUSE_cc1101.getRssi() : 0; } this->decodeFrame(rx->payload); } @@ -1347,7 +1367,8 @@ void SomfyShade::checkMovement() { if(this->myPos == this->currentPos) this->p_myPos(-1); else this->p_myPos(this->currentPos); } - SomfyRemote::sendCommand(somfy_commands::My, SETMY_REPEATS); + // Give the motor time to settle at the target before it is told to store it. + SomfyRemote::sendCommandAfter(somfy_commands::My, SETMY_REPEATS, 0, 200); this->settingMyPos = false; this->commitMyPosition(); this->emitState(); @@ -2731,6 +2752,14 @@ void SomfyShade::setTiltMovement(int8_t dir) { this->emitState(); } } +void SomfyShade::shiftMovementStart(uint32_t queuedAt, uint32_t startedAt) { + // The movement was started when the command was queued but the motor only + // starts once the frame is on air. Move the start forward so the simulated + // position does not run ahead of the motor. + const uint32_t wait = startedAt - queuedAt; + if(this->direction != 0 && (uint32_t)((uint32_t)this->moveStart - queuedAt) < wait) this->moveStart = startedAt; + if(this->tiltDirection != 0 && (uint32_t)((uint32_t)this->tiltStart - queuedAt) < wait) this->tiltStart = startedAt; +} void SomfyShade::setMovement(int8_t dir) { int8_t currDir = this->direction; int8_t currTiltDir = this->tiltDirection; @@ -2868,6 +2897,8 @@ void SomfyShade::moveToMyPosition() { void SomfyShade::sendCommand(somfy_commands cmd) { this->sendCommand(cmd, this->repeats); } void SomfyShade::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { ESP_LOGD(TAG, "Send command start"); + // Don't move the simulated state for a command the radio can't take. + if(!this->canTransmit()) return; // This sendCommand function will always be called externally. sendCommand at the remote level // is expected to be called internally when the motor needs commanded. if(this->bitLength == 0) this->bitLength = somfy.transceiver.config.type; @@ -2951,6 +2982,7 @@ void SomfyShade::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSiz void SomfyGroup::sendCommand(somfy_commands cmd) { this->sendCommand(cmd, this->repeats); } void SomfyGroup::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { ESP_LOGI(TAG, "[Group %u] sendCommand cmd=%s repeat=%u", this->getGroupId(), translateSomfyCommand(cmd).c_str(), repeat); + if(!this->canTransmit()) return; // This sendCommand function will always be called externally. sendCommand at the remote level // is expected to be called internally when the motor needs commanded. if(this->bitLength == 0) this->bitLength = somfy.transceiver.config.type; @@ -2985,6 +3017,7 @@ void SomfyGroup::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSiz } void SomfyShade::sendTiltCommand(somfy_commands cmd) { + if(!this->canTransmit()) return; if(cmd == somfy_commands::Up) { SomfyRemote::sendCommand(cmd, this->tiltType == tilt_types::tiltmotor ? TILT_REPEATS : this->repeats); this->p_tiltTarget(0.0f); @@ -2999,6 +3032,7 @@ void SomfyShade::sendTiltCommand(somfy_commands cmd) { } } void SomfyShade::moveToTiltTarget(float target) { + if(!this->canTransmit()) return; somfy_commands cmd = somfy_commands::My; if(target < this->currentTiltPos) cmd = somfy_commands::Up; @@ -3030,6 +3064,7 @@ void SomfyShade::moveToTarget(float pos, float tilt) { this->emitState(); return; } + if(!this->canTransmit()) return; if(this->tiltType == tilt_types::tiltonly) { this->p_target(100.0f); this->p_myPos(-1.0f); @@ -3438,6 +3473,27 @@ void SomfyShadeController::processFrame(somfy_frame_t &frame, bool internal) { if(this->shades[i].getShadeId() != 255) this->shades[i].processFrame(frame, internal); } } +void SomfyShadeController::shiftMovementStart(uint32_t remoteAddress, uint32_t queuedAt, uint32_t startedAt) { + // Same matching as SomfyShade::processFrame for shade and linked remotes. + for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { + SomfyShade *shade = &this->shades[i]; + if(shade->getShadeId() == 255) continue; + bool hasRemote = shade->getRemoteAddress() == remoteAddress; + for(uint8_t j = 0; !hasRemote && j < SOMFY_MAX_LINKED_REMOTES; j++) + hasRemote = shade->linkedRemotes[j].getRemoteAddress() == remoteAddress; + if(hasRemote) shade->shiftMovementStart(queuedAt, startedAt); + } + // Group commands move their member shades through processInternalCommand. + for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { + SomfyGroup *group = &this->groups[i]; + if(group->getGroupId() == 255 || group->getRemoteAddress() != remoteAddress) continue; + for(uint8_t j = 0; j < SOMFY_MAX_GROUPED_SHADES; j++) { + if(group->linkedShades[j] == 0) continue; + SomfyShade *shade = this->getShadeById(group->linkedShades[j]); + if(shade) shade->shiftMovementStart(queuedAt, startedAt); + } + } +} void SomfyShadeController::processWaitingFrame() { for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) if(this->shades[i].getShadeId() != 255) this->shades[i].processWaitingFrame(); @@ -3783,6 +3839,7 @@ somfy_commands SomfyRemote::transformCommand(somfy_commands cmd) { return cmd; } void SomfyRemote::sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repeat) { + if(!this->canTransmit()) return; uint8_t flags = (this->flags >> 4) & 0x0F; if(isWindy > 0) flags |= 0x01; if(isSunny > 0) flags |= 0x02; @@ -3800,11 +3857,20 @@ void SomfyRemote::sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repe ESP_LOGD(TAG, "CMD:%s ADDR:%d RCODE:%d REPEAT:%d", translateSomfyCommand(this->lastFrame.cmd).c_str(), this->lastFrame.remoteAddress, this->lastFrame.rollingCode, repeat); - somfy.sendFrame(this->lastFrame, repeat); - somfy.processFrame(this->lastFrame, true); + if(somfy.sendFrame(this->lastFrame, repeat)) somfy.processFrame(this->lastFrame, true); +} +bool SomfyRemote::canTransmit() { + // GPIO remotes don't use the radio. + if(this->proto == radio_proto::GP_Relay || this->proto == radio_proto::GP_Remote) return true; + if(somfy.transceiver.canQueueTx()) return true; + ESP_LOGE(TAG, "Radio TX unavailable, dropping command for %d", this->getRemoteAddress()); + return false; } void SomfyRemote::sendCommand(somfy_commands cmd) { this->sendCommand(cmd, this->repeats); } -void SomfyRemote::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { +void SomfyRemote::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { this->sendCommandAfter(cmd, repeat, stepSize, 0); } +void SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs) { + // Check before taking a rolling code so a dropped command doesn't consume one. + if(!this->canTransmit()) return; this->lastFrame.rollingCode = this->getNextRollingCode(); this->lastFrame.remoteAddress = this->getRemoteAddress(); this->lastFrame.cmd = this->transformCommand(cmd); @@ -3836,7 +3902,8 @@ void SomfyRemote::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSi ESP_LOGD(TAG, "CMD:%s ADDR:%d RCODE:%d REPEAT:%d", translateSomfyCommand(this->lastFrame.cmd).c_str(), this->lastFrame.remoteAddress, this->lastFrame.rollingCode, repeat); - somfy.sendFrame(this->lastFrame, repeat); + // Only simulate the command if it is actually going on air. + if(!somfy.sendFrame(this->lastFrame, repeat, delayMs)) return; } somfy.processFrame(this->lastFrame, true); } @@ -3854,30 +3921,22 @@ void SomfyRemote::repeatFrame(uint8_t repeat) { this->triggerGPIOs(this->lastFrame); return; } - 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); + repeat = txRepeats(repeat); + // The first frame is encoded with the current repeat counter and the radio + // task counts up from there for each repeat frame. somfy_tx_job_t job; - job.frame = this->lastFrame; - job.repeats = repeat; + encodeTxJob(job, this->lastFrame, 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) { - 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); +bool SomfyShadeController::sendFrame(somfy_frame_t &frame, uint8_t repeat, uint16_t delayMs) { somfy_tx_job_t job; - job.frame = frame; - job.repeats = repeat; + encodeTxJob(job, frame, txRepeats(repeat)); job.firstRepeat = 1; - this->transceiver.queueTx(job); + if(delayMs > 0) job.notBefore = millis() + delayMs; + return this->transceiver.queueTx(job); } bool SomfyShadeController::deleteShade(uint8_t shadeId) { for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { @@ -4360,6 +4419,9 @@ float markFreq = 433.0f; int markRSSI = -100; uint32_t lastScan = 0; void Transceiver::beginFrequencyScan() { + // 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. + if(this->config.enabled) this->suspendTx(); RadioLock lock; if(this->config.enabled) { this->disableReceive(); @@ -4411,6 +4473,7 @@ void Transceiver::endFrequencyScan() { if(interruptPin > 0) detachInterrupt(interruptPin); interruptPin = 0; this->config.apply(); + this->resumeTx(); this->emitFrequencyScan(); } } @@ -4542,7 +4605,14 @@ bool Transceiver::toJSON(JsonObject& obj) { bool Transceiver::fromJSON(JsonObject& obj) { if (!obj["config"].isNull()) { JsonObject objConfig = obj["config"]; - this->config.fromJSON(objConfig); + // Parse into a copy so the radio task never sees a half updated config, + // then swap it in once any transmission in flight has stopped. + transceiver_config_t cfg = this->config; + cfg.fromJSON(objConfig); + this->flushTx(); + RadioLock lock; + if(this->config.enabled && this->config.TXPin != cfg.TXPin) pinMode(this->config.TXPin, INPUT); + this->config = cfg; } return true; } @@ -4564,7 +4634,9 @@ bool Transceiver::save() { return true; } bool Transceiver::end() { - this->flushTx(); + // Nothing may transmit (and re-enable receive) after this. It is not + // resumed since the only caller is OTA which always reboots. + this->suspendTx(); RadioLock lock; this->disableReceive(); return true; @@ -4913,6 +4985,11 @@ bool Transceiver::begin() { } void Transceiver::loop() { somfy_rx_t rx; + // Jobs that went on air late. The shades were set moving when the job was + // queued so line their movement up with the motor. + somfy_tx_start_t started; + while(txStartQueue && xQueueReceive(txStartQueue, &started, 0) == pdTRUE) + somfy.shiftMovementStart(started.remoteAddress, started.queuedAt, started.startedAt); if (noiseDetected && rxmode != 3 && this->config.noiseDetection) { if (millis() - noiseStart > 100) { gpio_intr_enable((gpio_num_t)interruptPin); @@ -4944,51 +5021,64 @@ void Transceiver::loop() { // 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); + tx_queue.pop(&job.tx); + { + somfy_tx_t &tx = job.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->queueTx(job); tx_queue.delay_time = millis() + TX_QUEUE_DELAY; } } } -bool Transceiver::queueTx(const somfy_tx_job_t &job) { +bool Transceiver::queueTx(somfy_tx_job_t &job) { if(!txJobQueue) { ESP_LOGE(TAG, "Radio task not started, dropping transmission"); return false; } + if(txSuspended) { + ESP_LOGW(TAG, "Radio TX suspended, dropping transmission"); + return false; + } + job.gen = txGen; + job.queuedAt = millis(); if(xQueueSend(txJobQueue, &job, 0) != pdTRUE) { ESP_LOGE(TAG, "Radio TX queue full, dropping transmission"); return false; } return true; } +bool Transceiver::canQueueTx() { + return txJobQueue && txTaskHandle && !txSuspended && uxQueueSpacesAvailable(txJobQueue) > 0; +} void Transceiver::flushTx() { + ++txGen; if(txJobQueue) xQueueReset(txJobQueue); } +void Transceiver::suspendTx() { + txSuspended = true; + this->flushTx(); +} +void Transceiver::resumeTx() { txSuspended = false; } +// cpt_synchro_hw is updated by the RX interrupt and read here from the radio task. +static bool rxInProgress() { return *(volatile uint8_t *)&somfy_rx.cpt_synchro_hw != 0; } 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); + somfy_tx_t &tx = job.tx; this->beginTransmit(); - this->sendFrame(frm, frame.bitLength == 56 ? 2 : 12, frame.bitLength); + this->sendFrame(tx.payload, tx.hwsync, tx.bit_length); for(uint8_t i = 0; i < job.repeats; i++) { + // Stop early when flushed so whoever flushed gets the radio within a frame. + if(job.gen != txGen) break; // 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); + if(tx.bit_length == 80) job.frame.encode80BitFrame(tx.payload, (uint8_t)(job.firstRepeat + i)); + this->sendFrame(tx.payload, tx.bit_length == 56 ? 7 : 6, tx.bit_length); } this->endTransmit(); } @@ -4997,8 +5087,28 @@ void Transceiver::txTask(void *arg) { somfy_tx_job_t job; for(;;) { if(xQueueReceive(txJobQueue, &job, portMAX_DELAY) != pdTRUE) continue; + if(txSuspended || job.gen != txGen) continue; + if(job.notBefore != 0) { + int32_t wait = (int32_t)(job.notBefore - millis()); + if(wait > 0) vTaskDelay(pdMS_TO_TICKS(wait)); + } + if(job.raw) { + // Don't step on a remote that is transmitting right now. + for(uint32_t waited = 0; rxInProgress() && waited < TX_RX_IDLE_WAIT; waited += 5) + vTaskDelay(pdMS_TO_TICKS(5)); + } { RadioLock lock; + // The queue may have been flushed while we waited. + if(txSuspended || job.gen != txGen) continue; + const uint32_t startedAt = millis(); + if(!job.raw && txStartQueue && startedAt - job.queuedAt > TX_QUEUE_DELAY * 2) { + somfy_tx_start_t started; + started.remoteAddress = job.frame.remoteAddress; + started.queuedAt = job.queuedAt; + started.startedAt = startedAt; + xQueueSend(txStartQueue, &started, 0); + } trans->transmitJob(job); } // Give the motors a full frame beat between separate transmissions. @@ -5008,6 +5118,7 @@ void Transceiver::txTask(void *arg) { void Transceiver::startTxTask() { if(txTaskHandle) return; if(!txJobQueue) txJobQueue = xQueueCreate(TX_JOB_QUEUE_SIZE, sizeof(somfy_tx_job_t)); + if(!txStartQueue) txStartQueue = xQueueCreate(TX_JOB_QUEUE_SIZE, sizeof(somfy_tx_start_t)); if(!txJobQueue) { ESP_LOGE(TAG, "Unable to create radio TX queue"); return; diff --git a/src/Somfy.h b/src/Somfy.h index 07753c1..0b7a435 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -205,14 +205,25 @@ struct somfy_frame_t { // 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. +inline uint8_t txRepeats(uint8_t repeat) { return repeat > MAX_TX_REPEATS ? MAX_TX_REPEATS : repeat; } +// A transmission handed off to the radio task. The first frame is encoded by +// the producer into tx. The frame is a copy so the radio task never touches the +// remote's lastFrame and is only used to re-encode 80-bit repeat frames. struct somfy_tx_job_t { + somfy_tx_t tx; // Encoded first frame with its hw sync count and bit length. 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; + bool raw = false; // Repeater frame. Waits for the receiver to be idle. + uint32_t gen = 0; // Flush generation when queued. Stale jobs are dropped. + uint32_t queuedAt = 0; // millis() when queued. + uint32_t notBefore = 0; // millis() before which the job must not go on air (0 = none). +}; +// Reported by the radio task when a queued job finally goes on air. +struct somfy_tx_start_t { + uint32_t remoteAddress = 0; + uint32_t queuedAt = 0; + uint32_t startedAt = 0; }; class SomfyRoom { @@ -264,8 +275,10 @@ class SomfyRemote { void setSimMy(bool bSimMy); virtual void sendCommand(somfy_commands cmd); virtual void sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0); + void sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs); void sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repeat); void repeatFrame(uint8_t repeat); + bool canTransmit(); virtual uint16_t p_lastRollingCode(uint16_t code); somfy_commands transformCommand(somfy_commands cmd); virtual void triggerGPIOs(somfy_frame_t &frame); @@ -331,6 +344,7 @@ class SomfyShade : public SomfyRemote { void checkMovement(); void processFrame(somfy_frame_t &frame, bool internal = false); void processInternalCommand(somfy_commands cmd, uint8_t repeat = 1); + void shiftMovementStart(uint32_t queuedAt, uint32_t startedAt); void setTiltMovement(int8_t dir); void setMovement(int8_t dir); void setTarget(float target); @@ -521,8 +535,11 @@ 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); + bool queueTx(somfy_tx_job_t &job); + bool canQueueTx(); void flushTx(); + void suspendTx(); + void resumeTx(); void beginTransmit(); void endTransmit(); void emitFrame(somfy_frame_t *frame, somfy_rx_t *rx = nullptr); @@ -578,8 +595,9 @@ class SomfyShadeController { SomfyGroup * getGroupById(uint8_t groupId); SomfyShade * findShadeByRemoteAddress(uint32_t address); SomfyGroup * findGroupByRemoteAddress(uint32_t address); - void sendFrame(somfy_frame_t &frame, uint8_t repeats = 0); + bool sendFrame(somfy_frame_t &frame, uint8_t repeats = 0, uint16_t delayMs = 0); void processFrame(somfy_frame_t &frame, bool internal = false); + void shiftMovementStart(uint32_t remoteAddress, uint32_t queuedAt, uint32_t startedAt); void emitState(uint8_t num = 255); void publish(); void processWaitingFrame(); diff --git a/src/Web.cpp b/src/Web.cpp index 699b4a5..c780449 100644 --- a/src/Web.cpp +++ b/src/Web.cpp @@ -47,20 +47,26 @@ static const char *TAG = "Web"; static QueueHandle_t webCmdQueue = 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) { +static bool 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); + return webServer.queueCommand(cmd); } -static void queueUnpairShade(SomfyShade *shade) { +static bool queueUnpairShade(SomfyShade *shade) { web_command_t cmd = {}; cmd.type = web_cmd_t::unpair_shade; cmd.shadeId = shade->getShadeId(); + if(!webServer.queueCommand(cmd)) return false; // Reflect the change in the response; the main loop sets it again and saves. - if(webServer.queueCommand(cmd)) shade->paired = false; + shade->paired = false; + return true; +} +// Sent when a command can't be handed to the main loop. +static void sendQueueFull(AsyncWebServerRequest *request) { + request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Command queue full\"}")); } AsyncWebServer asyncServer(80); @@ -92,9 +98,7 @@ bool Web::queueCommand(const web_command_t &cmd) { 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) { + if(xQueueSend(webCmdQueue, &cmd, 0) != pdTRUE) { ESP_LOGE(TAG, "Command queue full, dropping command"); return false; } @@ -812,7 +816,7 @@ void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json) cmd.command = command; cmd.repeat = repeat; cmd.stepSize = stepSize; - this->queueCommand(cmd); + if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -853,7 +857,7 @@ void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json) cmd.command = command; cmd.repeat = repeat; cmd.stepSize = stepSize; - this->queueCommand(cmd); + if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -890,7 +894,7 @@ void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) { cmd.shadeId = shadeId; cmd.target = target; cmd.command = command; - this->queueCommand(cmd); + if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -931,7 +935,7 @@ void Web::handleRepeatCommand(AsyncWebServerRequest *request, JsonVariant &json) cmd.command = command; cmd.repeat = repeat; cmd.stepSize = stepSize; - this->queueCommand(cmd); + if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginArray(); @@ -949,7 +953,7 @@ void Web::handleRepeatCommand(AsyncWebServerRequest *request, JsonVariant &json) cmd.command = command; cmd.repeat = repeat; cmd.stepSize = stepSize; - this->queueCommand(cmd); + if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1043,7 +1047,7 @@ void Web::handleSetPositions(AsyncWebServerRequest *request, JsonVariant &json) cmd.shadeId = shadeId; cmd.position = pos; cmd.tiltPosition = tiltPos; - this->queueCommand(cmd); + if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1086,7 +1090,7 @@ void Web::handleSetSensor(AsyncWebServerRequest *request, JsonVariant &json) { cmd.sunny = sunny; cmd.windy = windy; cmd.repeat = repeat; - this->queueCommand(cmd); + if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1105,7 +1109,7 @@ void Web::handleSetSensor(AsyncWebServerRequest *request, JsonVariant &json) { cmd.sunny = sunny; cmd.windy = windy; cmd.repeat = repeat; - this->queueCommand(cmd); + if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1626,7 +1630,7 @@ void Web::begin() { if(tilt < 0) tilt = shade->myPos; if(shade->tiltType == tilt_types::none) tilt = -1; if(pos >= 0 && pos <= 100) - queueSetMyPosition(shade, shade->transformPosition(pos), shade->transformPosition(tilt)); + if(!queueSetMyPosition(shade, shade->transformPosition(pos), shade->transformPosition(tilt))) { sendQueueFull(request); return; } AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1653,7 +1657,7 @@ void Web::begin() { if(tilt < 0) tilt = shade->myPos; if(shade->tiltType == tilt_types::none) tilt = -1; if(pos >= 0 && pos <= 100) - queueSetMyPosition(shade, shade->transformPosition(pos), shade->transformPosition(tilt)); + if(!queueSetMyPosition(shade, shade->transformPosition(pos), shade->transformPosition(tilt))) { sendQueueFull(request); return; } AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1778,7 +1782,7 @@ void Web::begin() { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); } else { - queueUnpairShade(shade); + if(!queueUnpairShade(shade)) { sendQueueFull(request); return; } AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1797,7 +1801,7 @@ void Web::begin() { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); } else { - queueUnpairShade(shade); + if(!queueUnpairShade(shade)) { sendQueueFull(request); return; } AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); From a426efe53f8291a8f8fc2c56e3ac74eed609afc1 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 12:08:48 +0200 Subject: [PATCH 03/53] fix: make radio TX queue flush-safe and report job outcomes - Serialize queueTx and flushTx so a queued job can't be lost to a flush. - Settings changes preempt only the job in flight; the queue survives. - Flush drains the queue and reports each job as dropped. - Track suspend reasons (scan, OTA) separately. - Report started/dropped for every job on a single event queue. - Add priority jobs that go to the front and may use reserved slots. - Remove the MAX_TX_REPEATS clamp. Co-Authored-By: Claude Opus 5.5 --- src/Somfy.cpp | 149 +++++++++++++++++++++++++++++++++----------------- src/Somfy.h | 30 ++++++---- 2 files changed, 117 insertions(+), 62 deletions(-) diff --git a/src/Somfy.cpp b/src/Somfy.cpp index fe9cd06..73de0fb 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -46,23 +46,48 @@ uint8_t rxmode = 0; // Indicates whether the radio is in receive mode. Just to // 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; +// Makes queueing a job and flushing the queue atomic with respect to each other +// so a job queueTx() reported as queued can't be silently lost to a flush. +static SemaphoreHandle_t txQueueMutex = nullptr; static QueueHandle_t txJobQueue = nullptr; -static QueueHandle_t txStartQueue = nullptr; +static QueueHandle_t txEventQueue = nullptr; static TaskHandle_t txTaskHandle = nullptr; -// Bumped by flushTx(). Queued jobs from an older generation are dropped and a -// job in flight stops after its current frame so nobody waits on the radio lock -// for a whole transmission. +// Bumped by flushTx(). A job from an older generation that the radio task +// already took off the queue is dropped, and one in flight stops after its +// current frame. static std::atomic txGen{0}; -// Set while the radio must not transmit at all (frequency scan, OTA). -static volatile bool txSuspended = false; +// Number of tasks waiting for the radio to reconfigure it. A job in flight +// stops after its current frame so they never wait for a whole transmission. +static std::atomic txPreemptWaiters{0}; +// TX_SUSPEND_* reasons the radio must not transmit for (frequency scan, OTA). +static std::atomic txSuspendMask{0}; 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; + // preempt cuts short a transmission in flight instead of waiting for it. + RadioLock(TickType_t wait = portMAX_DELAY, bool preempt = false) { + if(!radioMutex) return; + if(preempt) ++txPreemptWaiters; + locked = owned = xSemaphoreTakeRecursive(radioMutex, wait) == pdTRUE; + if(preempt) --txPreemptWaiters; } ~RadioLock() { if(owned) xSemaphoreGiveRecursive(radioMutex); } }; +struct TxQueueLock { + TxQueueLock() { if(txQueueMutex) xSemaphoreTake(txQueueMutex, portMAX_DELAY); } + ~TxQueueLock() { if(txQueueMutex) xSemaphoreGive(txQueueMutex); } +}; +static void postTxEvent(tx_event_t type, const somfy_tx_job_t &job, uint32_t at) { + // Repeater frames don't belong to any of our remotes. + if(job.raw || !txEventQueue) return; + somfy_tx_event_t evt; + evt.type = type; + evt.cmd = job.frame.cmd; + evt.remoteAddress = job.frame.remoteAddress; + evt.queuedAt = job.queuedAt; + evt.at = at; + if(xQueueSend(txEventQueue, &evt, 0) != pdTRUE) ESP_LOGW(TAG, "Radio TX event queue full"); +} // Encodes the first frame of a transmission into the job. Encoding here keeps // its side effects on the frame (valid flag, default step size) visible to the // caller and the radio task only re-encodes the 80-bit repeat frames. @@ -3921,7 +3946,6 @@ void SomfyRemote::repeatFrame(uint8_t repeat) { this->triggerGPIOs(this->lastFrame); return; } - repeat = txRepeats(repeat); // The first frame is encoded with the current repeat counter and the radio // task counts up from there for each repeat frame. somfy_tx_job_t job; @@ -3931,10 +3955,11 @@ void SomfyRemote::repeatFrame(uint8_t repeat) { somfy.transceiver.queueTx(job); //somfy.processFrame(this->lastFrame, true); } -bool SomfyShadeController::sendFrame(somfy_frame_t &frame, uint8_t repeat, uint16_t delayMs) { +bool SomfyShadeController::sendFrame(somfy_frame_t &frame, uint8_t repeat, uint16_t delayMs, bool priority) { somfy_tx_job_t job; - encodeTxJob(job, frame, txRepeats(repeat)); + encodeTxJob(job, frame, repeat); job.firstRepeat = 1; + job.priority = priority; if(delayMs > 0) job.notBefore = millis() + delayMs; return this->transceiver.queueTx(job); } @@ -4421,8 +4446,8 @@ uint32_t lastScan = 0; void Transceiver::beginFrequencyScan() { // 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. - if(this->config.enabled) this->suspendTx(); - RadioLock lock; + if(this->config.enabled) this->suspendTx(TX_SUSPEND_SCAN); + RadioLock lock(portMAX_DELAY, true); if(this->config.enabled) { this->disableReceive(); rxmode = 3; @@ -4473,7 +4498,7 @@ void Transceiver::endFrequencyScan() { if(interruptPin > 0) detachInterrupt(interruptPin); interruptPin = 0; this->config.apply(); - this->resumeTx(); + this->resumeTx(TX_SUSPEND_SCAN); this->emitFrequencyScan(); } } @@ -4606,11 +4631,11 @@ bool Transceiver::fromJSON(JsonObject& obj) { if (!obj["config"].isNull()) { JsonObject objConfig = obj["config"]; // Parse into a copy so the radio task never sees a half updated config, - // then swap it in once any transmission in flight has stopped. + // then swap it in once any transmission in flight has stopped. Queued + // jobs are kept and go out with the new settings. transceiver_config_t cfg = this->config; cfg.fromJSON(objConfig); - this->flushTx(); - RadioLock lock; + RadioLock lock(portMAX_DELAY, true); if(this->config.enabled && this->config.TXPin != cfg.TXPin) pinMode(this->config.TXPin, INPUT); this->config = cfg; } @@ -4636,8 +4661,8 @@ bool Transceiver::save() { bool Transceiver::end() { // Nothing may transmit (and re-enable receive) after this. It is not // resumed since the only caller is OTA which always reboots. - this->suspendTx(); - RadioLock lock; + this->suspendTx(TX_SUSPEND_OTA); + RadioLock lock(portMAX_DELAY, true); this->disableReceive(); return true; } @@ -4872,10 +4897,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; + // A transmission in flight stops after its current frame so the radio can + // be reconfigured. Queued frames are already encoded and go out with the + // new settings. + RadioLock lock(portMAX_DELAY, true); somfy.transceiver.disableReceive(); bit_length = this->type; if(this->enabled) { @@ -4977,6 +5002,7 @@ void transceiver_config_t::apply() { } bool Transceiver::begin() { if(!radioMutex) radioMutex = xSemaphoreCreateRecursiveMutex(); + if(!txQueueMutex) txQueueMutex = xSemaphoreCreateMutex(); this->config.load(); this->config.apply(); rx_queue.init(); @@ -4985,11 +5011,16 @@ bool Transceiver::begin() { } void Transceiver::loop() { somfy_rx_t rx; - // Jobs that went on air late. The shades were set moving when the job was - // queued so line their movement up with the motor. - somfy_tx_start_t started; - while(txStartQueue && xQueueReceive(txStartQueue, &started, 0) == pdTRUE) - somfy.shiftMovementStart(started.remoteAddress, started.queuedAt, started.startedAt); + somfy_tx_event_t evt; + while(txEventQueue && xQueueReceive(txEventQueue, &evt, 0) == pdTRUE) { + if(evt.type == tx_event_t::started) { + // The shades were set moving when the job was queued so line their + // movement up with the motor. + somfy.shiftMovementStart(evt.remoteAddress, evt.queuedAt, evt.at); + } + else + ESP_LOGW(TAG, "Radio TX dropped %s for %d", translateSomfyCommand(evt.cmd).c_str(), evt.remoteAddress); + } if (noiseDetected && rxmode != 3 && this->config.noiseDetection) { if (millis() - noiseStart > 100) { gpio_intr_enable((gpio_num_t)interruptPin); @@ -5042,30 +5073,44 @@ bool Transceiver::queueTx(somfy_tx_job_t &job) { ESP_LOGE(TAG, "Radio task not started, dropping transmission"); return false; } - if(txSuspended) { + TxQueueLock lock; + if(txSuspendMask) { ESP_LOGW(TAG, "Radio TX suspended, dropping transmission"); return false; } + // Normal jobs leave the reserved slots free for stops. + if(uxQueueSpacesAvailable(txJobQueue) <= (job.priority ? 0 : TX_RESERVED_SLOTS)) { + ESP_LOGE(TAG, "Radio TX queue full, dropping transmission"); + return false; + } job.gen = txGen; job.queuedAt = millis(); - if(xQueueSend(txJobQueue, &job, 0) != pdTRUE) { - ESP_LOGE(TAG, "Radio TX queue full, dropping transmission"); - return false; - } + // We are the only producer while holding the lock so the space check holds. + if(job.priority) xQueueSendToFront(txJobQueue, &job, 0); + else xQueueSendToBack(txJobQueue, &job, 0); return true; } -bool Transceiver::canQueueTx() { - return txJobQueue && txTaskHandle && !txSuspended && uxQueueSpacesAvailable(txJobQueue) > 0; +bool Transceiver::canQueueTx(bool priority) { + return txJobQueue && txTaskHandle && !txSuspendMask && uxQueueSpacesAvailable(txJobQueue) > (priority ? 0 : TX_RESERVED_SLOTS); } void Transceiver::flushTx() { + TxQueueLock lock; ++txGen; - if(txJobQueue) xQueueReset(txJobQueue); + if(!txJobQueue) return; + // Drain rather than reset so every discarded job is reported as dropped. + somfy_tx_job_t job; + const uint32_t now = millis(); + while(xQueueReceive(txJobQueue, &job, 0) == pdTRUE) postTxEvent(tx_event_t::dropped, job, now); } -void Transceiver::suspendTx() { - txSuspended = true; +void Transceiver::suspendTx(uint8_t reason) { + { + // Set under the queue lock so nothing is queued between this and the flush. + TxQueueLock lock; + txSuspendMask |= reason; + } this->flushTx(); } -void Transceiver::resumeTx() { txSuspended = false; } +void Transceiver::resumeTx(uint8_t reason) { txSuspendMask &= ~(uint32_t)reason; } // cpt_synchro_hw is updated by the RX interrupt and read here from the radio task. static bool rxInProgress() { return *(volatile uint8_t *)&somfy_rx.cpt_synchro_hw != 0; } void Transceiver::transmitJob(somfy_tx_job_t &job) { @@ -5073,8 +5118,9 @@ void Transceiver::transmitJob(somfy_tx_job_t &job) { this->beginTransmit(); this->sendFrame(tx.payload, tx.hwsync, tx.bit_length); for(uint8_t i = 0; i < job.repeats; i++) { - // Stop early when flushed so whoever flushed gets the radio within a frame. - if(job.gen != txGen) break; + // Stop early when flushed or when someone is waiting to reconfigure the + // radio so they get it within a frame. + if(job.gen != txGen || txPreemptWaiters > 0) break; // For each 80-bit frame we need to adjust the byte encoding for the // silence. if(tx.bit_length == 80) job.frame.encode80BitFrame(tx.payload, (uint8_t)(job.firstRepeat + i)); @@ -5087,7 +5133,11 @@ void Transceiver::txTask(void *arg) { somfy_tx_job_t job; for(;;) { if(xQueueReceive(txJobQueue, &job, portMAX_DELAY) != pdTRUE) continue; - if(txSuspended || job.gen != txGen) continue; + // Flushed after we took it off the queue. + if(txSuspendMask || job.gen != txGen) { + postTxEvent(tx_event_t::dropped, job, millis()); + continue; + } if(job.notBefore != 0) { int32_t wait = (int32_t)(job.notBefore - millis()); if(wait > 0) vTaskDelay(pdMS_TO_TICKS(wait)); @@ -5100,15 +5150,11 @@ void Transceiver::txTask(void *arg) { { RadioLock lock; // The queue may have been flushed while we waited. - if(txSuspended || job.gen != txGen) continue; - const uint32_t startedAt = millis(); - if(!job.raw && txStartQueue && startedAt - job.queuedAt > TX_QUEUE_DELAY * 2) { - somfy_tx_start_t started; - started.remoteAddress = job.frame.remoteAddress; - started.queuedAt = job.queuedAt; - started.startedAt = startedAt; - xQueueSend(txStartQueue, &started, 0); + if(txSuspendMask || job.gen != txGen) { + postTxEvent(tx_event_t::dropped, job, millis()); + continue; } + postTxEvent(tx_event_t::started, job, millis()); trans->transmitJob(job); } // Give the motors a full frame beat between separate transmissions. @@ -5118,7 +5164,8 @@ void Transceiver::txTask(void *arg) { void Transceiver::startTxTask() { if(txTaskHandle) return; if(!txJobQueue) txJobQueue = xQueueCreate(TX_JOB_QUEUE_SIZE, sizeof(somfy_tx_job_t)); - if(!txStartQueue) txStartQueue = xQueueCreate(TX_JOB_QUEUE_SIZE, sizeof(somfy_tx_start_t)); + // Every queued job reports once, plus room for the jobs a flush drains. + if(!txEventQueue) txEventQueue = xQueueCreate(TX_JOB_QUEUE_SIZE * 2, sizeof(somfy_tx_event_t)); if(!txJobQueue) { ESP_LOGE(TAG, "Unable to create radio TX queue"); return; diff --git a/src/Somfy.h b/src/Somfy.h index 0b7a435..105785b 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -201,11 +201,12 @@ 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 -inline uint8_t txRepeats(uint8_t repeat) { return repeat > MAX_TX_REPEATS ? MAX_TX_REPEATS : repeat; } +// Slots only priority jobs (stops) may use so a stop always fits in the queue. +#define TX_RESERVED_SLOTS 4 +// Reasons the radio must not transmit. TX resumes once every reason is cleared. +#define TX_SUSPEND_SCAN 0x01 +#define TX_SUSPEND_OTA 0x02 // A transmission handed off to the radio task. The first frame is encoded by // the producer into tx. The frame is a copy so the radio task never touches the // remote's lastFrame and is only used to re-encode 80-bit repeat frames. @@ -215,15 +216,22 @@ struct somfy_tx_job_t { 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; // Repeater frame. Waits for the receiver to be idle. + bool priority = false; // Goes to the front of the queue and may use the reserved slots. uint32_t gen = 0; // Flush generation when queued. Stale jobs are dropped. uint32_t queuedAt = 0; // millis() when queued. uint32_t notBefore = 0; // millis() before which the job must not go on air (0 = none). }; -// Reported by the radio task when a queued job finally goes on air. -struct somfy_tx_start_t { +enum class tx_event_t : uint8_t { + started, // The job is going on air. + dropped // The job was flushed and will never go on air. +}; +// Reported by the radio task for every queued non-repeater job. +struct somfy_tx_event_t { + tx_event_t type = tx_event_t::started; + somfy_commands cmd = somfy_commands::My; uint32_t remoteAddress = 0; uint32_t queuedAt = 0; - uint32_t startedAt = 0; + uint32_t at = 0; // millis() when the job started or was dropped. }; class SomfyRoom { @@ -536,10 +544,10 @@ class Transceiver { somfy_frame_t& lastFrame(); void sendFrame(byte *frame, uint8_t sync, uint8_t bitLength = 56); bool queueTx(somfy_tx_job_t &job); - bool canQueueTx(); + bool canQueueTx(bool priority = false); void flushTx(); - void suspendTx(); - void resumeTx(); + void suspendTx(uint8_t reason); + void resumeTx(uint8_t reason); void beginTransmit(); void endTransmit(); void emitFrame(somfy_frame_t *frame, somfy_rx_t *rx = nullptr); @@ -595,7 +603,7 @@ class SomfyShadeController { SomfyGroup * getGroupById(uint8_t groupId); SomfyShade * findShadeByRemoteAddress(uint32_t address); SomfyGroup * findGroupByRemoteAddress(uint32_t address); - bool sendFrame(somfy_frame_t &frame, uint8_t repeats = 0, uint16_t delayMs = 0); + bool sendFrame(somfy_frame_t &frame, uint8_t repeats = 0, uint16_t delayMs = 0, bool priority = false); void processFrame(somfy_frame_t &frame, bool internal = false); void shiftMovementStart(uint32_t remoteAddress, uint32_t queuedAt, uint32_t startedAt); void emitState(uint8_t num = 255); From ad15ecacd4feedfb62fe43e04560c2878f9dc660 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 12:14:29 +0200 Subject: [PATCH 04/53] fix: propagate radio TX result to callers instead of pre-checking - Send functions return whether the radio took the command. - Shade, group and tilt state only change when the command was queued. - Rolling code is committed only after the frame is queued. - repeatFrame advances the repeat counter only on success. - Remove canTransmit/canQueueTx; queueTx is the single authority. Co-Authored-By: Claude Opus 5.5 --- src/Somfy.cpp | 225 +++++++++++++++++++++++++------------------------- src/Somfy.h | 29 +++---- 2 files changed, 129 insertions(+), 125 deletions(-) diff --git a/src/Somfy.cpp b/src/Somfy.cpp index 73de0fb..bd804dd 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -2894,8 +2894,8 @@ void SomfyShade::setMyPosition(int8_t pos, int8_t tilt) { } } } -void SomfyShade::moveToMyPosition() { - if(!this->isIdle()) return; +bool SomfyShade::moveToMyPosition() { + if(!this->isIdle()) return true; ESP_LOGI(TAG, "Moving to My Position"); if(this->tiltType == tilt_types::tiltonly) { this->p_currentPos(100.0f); @@ -2903,27 +2903,25 @@ void SomfyShade::moveToMyPosition() { } if(this->currentPos == this->myPos) { if(this->tiltType != tilt_types::none) { - if(this->currentTiltPos == this->myTiltPos) return; // Nothing to see here since we are already here. + if(this->currentTiltPos == this->myTiltPos) return true; // Nothing to see here since we are already here. } else - return; + return true; } - if(this->myPos == -1 && (this->tiltType == tilt_types::none || this->myTiltPos == -1)) return; - if(this->tiltType != tilt_types::tiltonly && this->myPos >= 0.0f && this->myPos <= 100.0f) this->p_target(this->myPos); - if(this->myTiltPos >= 0.0f && this->myTiltPos <= 100.0f) this->p_tiltTarget(this->myTiltPos); + if(this->myPos == -1 && (this->tiltType == tilt_types::none || this->myTiltPos == -1)) return true; this->settingPos = false; if(this->simMy()) { ESP_LOGI(TAG, "Moving to simulated favorite"); - this->moveToTarget(this->myPos, this->myTiltPos); + if(!this->moveToTarget(this->myPos, this->myTiltPos)) return false; } - else - SomfyRemote::sendCommand(somfy_commands::My, this->repeats); + else if(!SomfyRemote::sendCommand(somfy_commands::My, this->repeats)) return false; + if(this->tiltType != tilt_types::tiltonly && this->myPos >= 0.0f && this->myPos <= 100.0f) this->p_target(this->myPos); + if(this->myTiltPos >= 0.0f && this->myTiltPos <= 100.0f) this->p_tiltTarget(this->myTiltPos); + return true; } -void SomfyShade::sendCommand(somfy_commands cmd) { this->sendCommand(cmd, this->repeats); } -void SomfyShade::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { +bool SomfyShade::sendCommand(somfy_commands cmd) { return this->sendCommand(cmd, this->repeats); } +bool SomfyShade::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { ESP_LOGD(TAG, "Send command start"); - // Don't move the simulated state for a command the radio can't take. - if(!this->canTransmit()) return; // This sendCommand function will always be called externally. sendCommand at the remote level // is expected to be called internally when the motor needs commanded. if(this->bitLength == 0) this->bitLength = somfy.transceiver.config.type; @@ -2932,21 +2930,22 @@ void SomfyShade::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSiz if((cmd == somfy_commands::Up && this->direction == -1) || (cmd == somfy_commands::Down && this->direction == 1)) { ESP_LOGD(TAG, "Same command as dir"); - SomfyRemote::sendCommand(cmd, repeat); + if(!SomfyRemote::sendCommand(cmd, repeat)) return false; this->p_target(this->currentPos); this->p_tiltTarget(this->currentTiltPos); this->setMovement(0); - return; + return true; } + // The simulated state is only moved for commands the radio took. if(cmd == somfy_commands::Up) { if(this->tiltType == tilt_types::euromode) { // In euromode we need to long press for 2 seconds on the // up command. - SomfyRemote::sendCommand(cmd, TILT_REPEATS); - this->p_target(0.0f); + if(!SomfyRemote::sendCommand(cmd, TILT_REPEATS)) return false; + this->p_target(0.0f); } else { - SomfyRemote::sendCommand(cmd, repeat); + if(!SomfyRemote::sendCommand(cmd, repeat)) return false; if(this->tiltType == tilt_types::tiltonly) { this->p_target(100.0f); this->p_tiltTarget(0.0f); @@ -2960,11 +2959,11 @@ void SomfyShade::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSiz if(this->tiltType == tilt_types::euromode) { // In euromode we need to long press for 2 seconds on the // down command. - SomfyRemote::sendCommand(cmd, TILT_REPEATS); - this->p_target(100.0f); + if(!SomfyRemote::sendCommand(cmd, TILT_REPEATS)) return false; + this->p_target(100.0f); } else { - SomfyRemote::sendCommand(cmd, repeat); + if(!SomfyRemote::sendCommand(cmd, repeat)) return false; if(this->tiltType == tilt_types::tiltonly) { this->p_target(100.0f); this->p_tiltTarget(100.0f); @@ -2975,43 +2974,45 @@ void SomfyShade::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSiz } } else if(cmd == somfy_commands::My) { - if(this->isToggle() || this->shadeType == shade_types::drycontact) - SomfyRemote::sendCommand(cmd, repeat); - else if(this->shadeType == shade_types::drycontact2){ + if(this->isToggle() || this->shadeType == shade_types::drycontact) { + if(!SomfyRemote::sendCommand(cmd, repeat)) return false; + } + else if(this->shadeType == shade_types::drycontact2){ ESP_LOGD(TAG, "Send command start 1"); - return; + return true; } else if(this->isIdle()) { - this->moveToMyPosition(); + bool sent = this->moveToMyPosition(); ESP_LOGD(TAG, "Send command end 2"); - return; + return sent; } else { - SomfyRemote::sendCommand(cmd, repeat); + if(!SomfyRemote::sendCommand(cmd, repeat)) return false; if(this->tiltType != tilt_types::tiltonly) this->p_target(this->currentPos); this->p_tiltTarget(this->currentTiltPos); } } else if(cmd == somfy_commands::Toggle) { - if(this->bitLength != 80) SomfyRemote::sendCommand(somfy_commands::My, repeat, stepSize); - else SomfyRemote::sendCommand(somfy_commands::Toggle, repeat); + if(this->bitLength != 80) { if(!SomfyRemote::sendCommand(somfy_commands::My, repeat, stepSize)) return false; } + else if(!SomfyRemote::sendCommand(somfy_commands::Toggle, repeat)) return false; } else if(this->isToggle() && cmd == somfy_commands::Prog) { - SomfyRemote::sendCommand(somfy_commands::Toggle, repeat, stepSize); + if(!SomfyRemote::sendCommand(somfy_commands::Toggle, repeat, stepSize)) return false; } else { - SomfyRemote::sendCommand(cmd, repeat, stepSize); + if(!SomfyRemote::sendCommand(cmd, repeat, stepSize)) return false; } ESP_LOGD(TAG, "Send command end"); + return true; } -void SomfyGroup::sendCommand(somfy_commands cmd) { this->sendCommand(cmd, this->repeats); } -void SomfyGroup::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { +bool SomfyGroup::sendCommand(somfy_commands cmd) { return this->sendCommand(cmd, this->repeats); } +bool SomfyGroup::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { ESP_LOGI(TAG, "[Group %u] sendCommand cmd=%s repeat=%u", this->getGroupId(), translateSomfyCommand(cmd).c_str(), repeat); - if(!this->canTransmit()) return; // This sendCommand function will always be called externally. sendCommand at the remote level // is expected to be called internally when the motor needs commanded. if(this->bitLength == 0) this->bitLength = somfy.transceiver.config.type; - SomfyRemote::sendCommand(cmd, repeat, stepSize); + // Leave the group and its shades alone if the radio didn't take the command. + if(!SomfyRemote::sendCommand(cmd, repeat, stepSize)) return false; switch(cmd) { case somfy_commands::My: @@ -3039,25 +3040,21 @@ void SomfyGroup::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSiz } this->updateFlags(); this->emitState(); - -} + return true; +} void SomfyShade::sendTiltCommand(somfy_commands cmd) { - if(!this->canTransmit()) return; + const uint8_t repeat = this->tiltType == tilt_types::tiltmotor ? TILT_REPEATS : this->repeats; if(cmd == somfy_commands::Up) { - SomfyRemote::sendCommand(cmd, this->tiltType == tilt_types::tiltmotor ? TILT_REPEATS : this->repeats); - this->p_tiltTarget(0.0f); + if(SomfyRemote::sendCommand(cmd, repeat)) this->p_tiltTarget(0.0f); } else if(cmd == somfy_commands::Down) { - SomfyRemote::sendCommand(cmd, this->tiltType == tilt_types::tiltmotor ? TILT_REPEATS : this->repeats); - this->p_tiltTarget(100.0f); + if(SomfyRemote::sendCommand(cmd, repeat)) this->p_tiltTarget(100.0f); } else if(cmd == somfy_commands::My) { - SomfyRemote::sendCommand(cmd, this->tiltType == tilt_types::tiltmotor ? TILT_REPEATS : this->repeats); - this->p_tiltTarget(this->currentTiltPos); + if(SomfyRemote::sendCommand(cmd, repeat)) this->p_tiltTarget(this->currentTiltPos); } } void SomfyShade::moveToTiltTarget(float target) { - if(!this->canTransmit()) return; somfy_commands cmd = somfy_commands::My; if(target < this->currentTiltPos) cmd = somfy_commands::Up; @@ -3069,7 +3066,7 @@ void SomfyShade::moveToTiltTarget(float target) { if(cmd != somfy_commands::My) { ESP_LOGI(TAG, "Moving Tilt to %.2f%% from %.2f%% using %s", target, this->currentTiltPos, translateSomfyCommand(cmd).c_str()); - SomfyRemote::sendCommand(cmd, this->tiltType == tilt_types::tiltmotor ? TILT_REPEATS : this->repeats); + if(!SomfyRemote::sendCommand(cmd, this->tiltType == tilt_types::tiltmotor ? TILT_REPEATS : this->repeats)) return; } // If the blind is currently moving then the command to stop it // will occur on its own when the tilt target is set. @@ -3078,7 +3075,7 @@ void SomfyShade::moveToTiltTarget(float target) { } if(cmd != somfy_commands::My) this->settingTiltPos = true; } -void SomfyShade::moveToTarget(float pos, float tilt) { +bool SomfyShade::moveToTarget(float pos, float tilt) { ESP_LOGI(TAG, "[Shade %u] moveToTarget(pos=%.2f, tilt=%.2f) settingPos=%d direction=%d currentTarget=%.2f currentPos=%.2f", this->getShadeId(), pos, tilt, this->settingPos, this->direction, this->target, this->currentPos); somfy_commands cmd = somfy_commands::My; @@ -3087,10 +3084,10 @@ void SomfyShade::moveToTarget(float pos, float tilt) { this->p_target(pos); this->p_currentPos(pos); this->emitState(); - return; + return true; } - if(!this->canTransmit()) return; if(this->tiltType == tilt_types::tiltonly) { + // A tilt only shade is always at 100%. this->p_target(100.0f); this->p_myPos(-1.0f); this->p_currentPos(100.0f); @@ -3116,7 +3113,7 @@ void SomfyShade::moveToTarget(float pos, float tilt) { ESP_LOGI(TAG, "Moving to %.2f%% from %.2f%% using %s", pos, this->currentPos, translateSomfyCommand(cmd).c_str()); } - SomfyRemote::sendCommand(cmd, this->tiltType == tilt_types::euromode ? TILT_REPEATS : this->repeats); + if(!SomfyRemote::sendCommand(cmd, this->tiltType == tilt_types::euromode ? TILT_REPEATS : this->repeats)) return false; this->settingPos = true; this->p_target(pos); if(tilt >= 0) { @@ -3124,6 +3121,7 @@ void SomfyShade::moveToTarget(float pos, float tilt) { this->settingTiltPos = true; } } + return true; } bool SomfyShade::save() { #ifdef USE_NVS @@ -3863,74 +3861,74 @@ somfy_commands SomfyRemote::transformCommand(somfy_commands cmd) { } return cmd; } -void SomfyRemote::sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repeat) { - if(!this->canTransmit()) return; +bool SomfyRemote::sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repeat) { uint8_t flags = (this->flags >> 4) & 0x0F; if(isWindy > 0) flags |= 0x01; if(isSunny > 0) flags |= 0x02; if(isWindy == 0) flags &= ~0x01; if(isSunny == 0) flags &= ~0x02; - // Now ship this off as an 80 bit command. - this->lastFrame.remoteAddress = this->getRemoteAddress(); - this->lastFrame.repeats = repeat; - this->lastFrame.bitLength = this->bitLength; - this->lastFrame.rollingCode = (uint16_t)flags; - this->lastFrame.encKey = 160; // Sensor commands are always encryption code 160. - this->lastFrame.cmd = somfy_commands::Sensor; - this->lastFrame.processed = false; + // Now ship this off as an 80 bit command. Built aside so lastFrame only + // changes when the frame is queued. + somfy_frame_t frame = this->lastFrame; + frame.remoteAddress = this->getRemoteAddress(); + frame.repeats = repeat; + frame.bitLength = this->bitLength; + frame.rollingCode = (uint16_t)flags; + frame.encKey = 160; // Sensor commands are always encryption code 160. + frame.cmd = somfy_commands::Sensor; + frame.processed = false; ESP_LOGD(TAG, "CMD:%s ADDR:%d RCODE:%d REPEAT:%d", - translateSomfyCommand(this->lastFrame.cmd).c_str(), - this->lastFrame.remoteAddress, this->lastFrame.rollingCode, repeat); - if(somfy.sendFrame(this->lastFrame, repeat)) somfy.processFrame(this->lastFrame, true); + translateSomfyCommand(frame.cmd).c_str(), + frame.remoteAddress, frame.rollingCode, repeat); + if(!somfy.sendFrame(frame, repeat)) return false; + this->lastFrame = frame; + somfy.processFrame(this->lastFrame, true); + return true; } -bool SomfyRemote::canTransmit() { - // GPIO remotes don't use the radio. - if(this->proto == radio_proto::GP_Relay || this->proto == radio_proto::GP_Remote) return true; - if(somfy.transceiver.canQueueTx()) return true; - ESP_LOGE(TAG, "Radio TX unavailable, dropping command for %d", this->getRemoteAddress()); - return false; -} -void SomfyRemote::sendCommand(somfy_commands cmd) { this->sendCommand(cmd, this->repeats); } -void SomfyRemote::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { this->sendCommandAfter(cmd, repeat, stepSize, 0); } -void SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs) { - // Check before taking a rolling code so a dropped command doesn't consume one. - if(!this->canTransmit()) return; - this->lastFrame.rollingCode = this->getNextRollingCode(); - this->lastFrame.remoteAddress = this->getRemoteAddress(); - this->lastFrame.cmd = this->transformCommand(cmd); - this->lastFrame.repeats = repeat; - this->lastFrame.bitLength = this->bitLength; - this->lastFrame.stepSize = stepSize; - this->lastFrame.valid = true; +bool SomfyRemote::sendCommand(somfy_commands cmd) { return this->sendCommand(cmd, this->repeats); } +bool SomfyRemote::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { return this->sendCommandAfter(cmd, repeat, stepSize, 0); } +bool SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs) { + // Build the frame aside and only take the rolling code once the frame is + // queued so a dropped command leaves the remote untouched. + somfy_frame_t frame = this->lastFrame; + frame.rollingCode = this->peekNextRollingCode(); + frame.remoteAddress = this->getRemoteAddress(); + frame.cmd = this->transformCommand(cmd); + frame.repeats = repeat; + frame.bitLength = this->bitLength; + frame.stepSize = stepSize; + frame.valid = true; // Match the encKey to the rolling code. These keys range from 160 to 175. - this->lastFrame.encKey = 0xA0 | static_cast(this->lastFrame.rollingCode & 0x000F); - this->lastFrame.proto = this->proto; - if(this->lastFrame.bitLength == 0) this->lastFrame.bitLength = bit_length; - if(this->lastFrame.rollingCode == 0) ESP_LOGE(TAG, "Setting rcode to 0"); - this->p_lastRollingCode(this->lastFrame.rollingCode); + frame.encKey = 0xA0 | static_cast(frame.rollingCode & 0x000F); + frame.proto = this->proto; + if(frame.bitLength == 0) frame.bitLength = bit_length; + if(frame.rollingCode == 0) ESP_LOGE(TAG, "Setting rcode to 0"); // We have to set the processed to clear this if we are sending // another command. - this->lastFrame.processed = false; + frame.processed = false; if(this->proto == radio_proto::GP_Relay) { ESP_LOGD(TAG, "CMD:%s ADDR:%d RCODE:%d SETTING GPIO", - translateSomfyCommand(this->lastFrame.cmd).c_str(), - this->lastFrame.remoteAddress, this->lastFrame.rollingCode); + translateSomfyCommand(frame.cmd).c_str(), + frame.remoteAddress, frame.rollingCode); } else if(this->proto == radio_proto::GP_Remote) { ESP_LOGD(TAG, "CMD:%s ADDR:%d RCODE:%d TRIGGER GPIO", - translateSomfyCommand(this->lastFrame.cmd).c_str(), - this->lastFrame.remoteAddress, this->lastFrame.rollingCode); - this->triggerGPIOs(this->lastFrame); + translateSomfyCommand(frame.cmd).c_str(), + frame.remoteAddress, frame.rollingCode); + this->triggerGPIOs(frame); } else { ESP_LOGD(TAG, "CMD:%s ADDR:%d RCODE:%d REPEAT:%d", - translateSomfyCommand(this->lastFrame.cmd).c_str(), - this->lastFrame.remoteAddress, this->lastFrame.rollingCode, repeat); + translateSomfyCommand(frame.cmd).c_str(), + frame.remoteAddress, frame.rollingCode, repeat); // Only simulate the command if it is actually going on air. - if(!somfy.sendFrame(this->lastFrame, repeat, delayMs)) return; + if(!somfy.sendFrame(frame, repeat, delayMs)) return false; } + this->commitRollingCode(frame.rollingCode); + this->lastFrame = frame; somfy.processFrame(this->lastFrame, true); + return true; } bool SomfyRemote::isLastCommand(somfy_commands cmd) { if(this->lastFrame.cmd != cmd || this->lastFrame.rollingCode != this->lastRollingCode) { @@ -3939,21 +3937,23 @@ bool SomfyRemote::isLastCommand(somfy_commands cmd) { } return true; } -void SomfyRemote::repeatFrame(uint8_t repeat) { +bool SomfyRemote::repeatFrame(uint8_t repeat) { if(this->proto == radio_proto::GP_Relay) - return; + return true; else if(this->proto == radio_proto::GP_Remote) { this->triggerGPIOs(this->lastFrame); - return; + return true; } // The first frame is encoded with the current repeat counter and the radio // task counts up from there for each repeat frame. somfy_tx_job_t job; encodeTxJob(job, this->lastFrame, repeat); job.firstRepeat = this->lastFrame.repeats + 2; + // Only advance the counter for frames that will go on air. + if(!somfy.transceiver.queueTx(job)) return false; this->lastFrame.repeats += repeat + 1; - somfy.transceiver.queueTx(job); //somfy.processFrame(this->lastFrame, true); + return true; } bool SomfyShadeController::sendFrame(somfy_frame_t &frame, uint8_t repeat, uint16_t delayMs, bool priority) { somfy_tx_job_t job; @@ -4027,17 +4027,23 @@ bool SomfyShadeController::deleteGroup(uint8_t groupId) { } bool SomfyShadeController::loadShadesFile(const char *filename) { return ShadeConfigFile::load(this, filename); } -uint16_t SomfyRemote::getNextRollingCode() { +// The next rolling code is only read here. It is consumed by commitRollingCode +// once the frame using it has been queued. +uint16_t SomfyRemote::peekNextRollingCode() { if(!pref.begin("ShadeCodes")) { - ESP_LOGE(TAG, "getNextRollingCode() pref.begin(ShadeCodes) FAILED"); + ESP_LOGE(TAG, "peekNextRollingCode() pref.begin(ShadeCodes) FAILED"); } uint16_t code = pref.getUShort(this->m_remotePrefId, 0); - code++; + pref.end(); + return (uint16_t)(code + 1); +} +void SomfyRemote::commitRollingCode(uint16_t code) { + if(!pref.begin("ShadeCodes")) { + ESP_LOGE(TAG, "commitRollingCode() pref.begin(ShadeCodes) FAILED"); + } pref.putUShort(this->m_remotePrefId, code); pref.end(); this->p_lastRollingCode(code); - //Serial.printf("Getting Next Rolling code %d\n", this->lastRollingCode); - return code; } uint16_t SomfyRemote::p_lastRollingCode(uint16_t code) { uint16_t old = this->lastRollingCode; @@ -5069,7 +5075,7 @@ void Transceiver::loop() { } } bool Transceiver::queueTx(somfy_tx_job_t &job) { - if(!txJobQueue) { + if(!txJobQueue || !txTaskHandle) { ESP_LOGE(TAG, "Radio task not started, dropping transmission"); return false; } @@ -5090,9 +5096,6 @@ bool Transceiver::queueTx(somfy_tx_job_t &job) { else xQueueSendToBack(txJobQueue, &job, 0); return true; } -bool Transceiver::canQueueTx(bool priority) { - return txJobQueue && txTaskHandle && !txSuspendMask && uxQueueSpacesAvailable(txJobQueue) > (priority ? 0 : TX_RESERVED_SLOTS); -} void Transceiver::flushTx() { TxQueueLock lock; ++txGen; diff --git a/src/Somfy.h b/src/Somfy.h index 105785b..5f7acf1 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -273,7 +273,8 @@ class SomfyRemote { char *getRemotePrefId() {return m_remotePrefId;} virtual void setRemoteAddress(uint32_t address); virtual uint32_t getRemoteAddress(); - virtual uint16_t getNextRollingCode(); + uint16_t peekNextRollingCode(); + void commitRollingCode(uint16_t code); virtual uint16_t setRollingCode(uint16_t code); bool hasSunSensor(); bool hasLight(); @@ -281,12 +282,13 @@ class SomfyRemote { void setSunSensor(bool bHasSensor); void setLight(bool bHasLight); void setSimMy(bool bSimMy); - virtual void sendCommand(somfy_commands cmd); - virtual void sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0); - void sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs); - void sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repeat); - void repeatFrame(uint8_t repeat); - bool canTransmit(); + // The send functions return false only when the radio could not take the + // command. Nothing about the remote or shade state has changed in that case. + virtual bool sendCommand(somfy_commands cmd); + virtual bool sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0); + bool sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs); + bool sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repeat); + bool repeatFrame(uint8_t repeat); virtual uint16_t p_lastRollingCode(uint16_t code); somfy_commands transformCommand(somfy_commands cmd); virtual void triggerGPIOs(somfy_frame_t &frame); @@ -358,11 +360,11 @@ class SomfyShade : public SomfyRemote { void setTarget(float target); bool isAtTarget(); bool isToggle(); - void moveToTarget(float pos, float tilt = -1.0f); + bool moveToTarget(float pos, float tilt = -1.0f); void moveToTiltTarget(float target); void sendTiltCommand(somfy_commands cmd); - void sendCommand(somfy_commands cmd); - void sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0); + bool sendCommand(somfy_commands cmd); + bool sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0); bool linkRemote(uint32_t remoteAddress, uint16_t rollingCode = 0); bool unlinkRemote(uint32_t remoteAddress); void emitState(const char *evt = "shadeState"); @@ -370,7 +372,7 @@ class SomfyShade : public SomfyRemote { void emitCommand(somfy_commands cmd, const char *source, uint32_t sourceAddress, const char *evt = "shadeCommand"); void emitCommand(uint8_t num, somfy_commands cmd, const char *source, uint32_t sourceAddress, const char *evt = "shadeCommand"); void setMyPosition(int8_t pos, int8_t tilt = -1); - void moveToMyPosition(); + bool moveToMyPosition(); void processWaitingFrame(); void publish(); void unpublish(); @@ -437,8 +439,8 @@ class SomfyGroup : public SomfyRemote { void updateFlags(); void emitState(const char *evt = "groupState"); void emitState(uint8_t num, const char *evt = "groupState"); - void sendCommand(somfy_commands cmd); - void sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0); + bool sendCommand(somfy_commands cmd); + bool sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0); int8_t p_direction(int8_t dir); bool publish(const char *topic, uint8_t val, bool retain = false); bool publish(const char *topic, int8_t val, bool retain = false); @@ -544,7 +546,6 @@ class Transceiver { somfy_frame_t& lastFrame(); void sendFrame(byte *frame, uint8_t sync, uint8_t bitLength = 56); bool queueTx(somfy_tx_job_t &job); - bool canQueueTx(bool priority = false); void flushTx(); void suspendTx(uint8_t reason); void resumeTx(uint8_t reason); From e55d9e48f7819a38b96a31c3402e1c1c5abeb403 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 12:21:25 +0200 Subject: [PATCH 05/53] fix: keep shade simulation in step with what goes on air - Hold a shade's simulated movement until its command is on air. - Send stops at the target as priority jobs. - If a stop can't be sent or is dropped, simulate the run to the limit. - Roll back targets when a movement command is dropped. - Only store a favorite position when SETMY was queued. Co-Authored-By: Claude Opus 5.5 --- src/Somfy.cpp | 242 +++++++++++++++++++++++++++++++++++--------------- src/Somfy.h | 27 ++++-- 2 files changed, 193 insertions(+), 76 deletions(-) diff --git a/src/Somfy.cpp b/src/Somfy.cpp index bd804dd..9a18c25 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -61,6 +61,11 @@ static std::atomic txGen{0}; static std::atomic txPreemptWaiters{0}; // TX_SUSPEND_* reasons the radio must not transmit for (frequency scan, OTA). static std::atomic txSuspendMask{0}; +// Last sequence number handed to a queued job. Only touched under TxQueueLock. +static uint32_t txSeq = 0; +// How long a shade's simulated movement waits for its command to go on air +// before it starts anyway in case the event was lost. +#define TX_PENDING_TIMEOUT 30000 struct RadioLock { bool locked = true; // Before the mutex exists there is nothing to serialize against. bool owned = false; @@ -84,7 +89,7 @@ static void postTxEvent(tx_event_t type, const somfy_tx_job_t &job, uint32_t at) evt.type = type; evt.cmd = job.frame.cmd; evt.remoteAddress = job.frame.remoteAddress; - evt.queuedAt = job.queuedAt; + evt.seq = job.seq; evt.at = at; if(xQueueSend(txEventQueue, &evt, 0) != pdTRUE) ESP_LOGW(TAG, "Radio TX event queue full"); } @@ -98,6 +103,21 @@ static void encodeTxJob(somfy_tx_job_t &job, somfy_frame_t &frame, uint8_t repea job.frame = frame; job.repeats = repeat; } +// Commands that can set a shade moving. +static bool startsMovement(somfy_commands cmd) { + switch(cmd) { + case somfy_commands::My: + case somfy_commands::Up: + case somfy_commands::Down: + case somfy_commands::StepUp: + case somfy_commands::StepDown: + case somfy_commands::Toggle: + case somfy_commands::Favorite: + return true; + default: + return false; + } +} int sort_asc(const void *cmp1, const void *cmp2) { int a = *((uint8_t *)cmp1); @@ -1124,6 +1144,17 @@ void SomfyShade::checkMovement() { uint8_t currPos = floor(this->currentPos); uint8_t currTiltPos = floor(this->currentTiltPos); if(this->direction != 0) this->lastMovement = this->direction; + if(this->txPendingSeq != 0) { + // The command that set us moving hasn't gone on air yet so the motor hasn't + // started. Hold the simulated position until it does. + if((uint32_t)curTime - this->txPendingSince < TX_PENDING_TIMEOUT) { + this->moveStart = this->tiltStart = curTime; + if(currDir != this->direction || currTiltDir != this->tiltDirection) this->emitState(); + return; + } + ESP_LOGW(TAG, "[Shade %u] Timed out waiting for the radio, moving anyway", this->shadeId); + this->txPendingSeq = 0; + } if (sunFlag) { if (isSunny && !isWindy) { // It is sunny and there is no wind so we should be extended if (this->noWindDone @@ -1211,14 +1242,15 @@ void SomfyShade::checkMovement() { // If we need to stop the shade do this before we indicate that we are // not moving otherwise the my function will kick in. if(this->settingPos) { - 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); - // We now need to move the tilt to the position we requested. - this->moveToTiltTarget(this->tiltTarget); + // At the end limit the motor stops by itself. If the stop doesn't go + // out the shade is now running on to the limit so leave the tilt be. + if(this->target == 100.0 || this->sendStop(1, false)) { + if(!isAtTarget()) { + ESP_LOGD(TAG, "We are not at our tilt target: %.2f", this->tiltTarget); + // We now need to move the tilt to the position we requested. + this->moveToTiltTarget(this->tiltTarget); + } } - else - if(this->target != 100.0) SomfyRemote::sendCommand(somfy_commands::My, this->repeats); } this->p_direction(0); this->tiltStart = curTime; @@ -1262,14 +1294,15 @@ void SomfyShade::checkMovement() { // If we need to stop the shade do this before we indicate that we are // not moving otherwise the my function will kick in. if(this->settingPos) { - 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); - // We now need to move the tilt to the position we requested. - this->moveToTiltTarget(this->tiltTarget); + // At the end limit the motor stops by itself. If the stop doesn't go + // out the shade is now running on to the limit so leave the tilt be. + if(this->target == 0.0 || this->sendStop(-1, false)) { + if(!isAtTarget()) { + ESP_LOGD(TAG, "We are not at our tilt target: %.2f", this->tiltTarget); + // We now need to move the tilt to the position we requested. + this->moveToTiltTarget(this->tiltTarget); + } } - else - if(this->target != 0.0) SomfyRemote::sendCommand(somfy_commands::My, this->repeats); } this->p_direction(0); this->tiltStart = curTime; @@ -1314,11 +1347,11 @@ void SomfyShade::checkMovement() { if(this->tiltType == tilt_types::integrated) { // If this is an integrated tilt mechanism the we will simply let it finish. If it is not then we will stop it. //Serial.printf("Sending My -- tiltTarget: %.2f, tiltDirection: %d\n", this->tiltTarget, this->tiltDirection); - if(this->tiltTarget != 100.0f || this->currentPos != 100.0f) SomfyRemote::sendCommand(somfy_commands::My, this->repeats); + if(this->tiltTarget != 100.0f || this->currentPos != 100.0f) this->sendStop(1, true); } else { // This is a tilt motor so let it complete if it is going to 100. - if(this->tiltTarget != 100.0f) SomfyRemote::sendCommand(somfy_commands::My, this->repeats); + if(this->tiltTarget != 100.0f) this->sendStop(1, true); } } this->p_tiltDirection(0); @@ -1364,11 +1397,11 @@ void SomfyShade::checkMovement() { if(this->tiltType == tilt_types::integrated) { // If this is an integrated tilt mechanism the we will simply let it finish. If it is not then we will stop it. //Serial.printf("Sending My -- tiltTarget: %.2f, tiltDirection: %d\n", this->tiltTarget, this->tiltDirection); - if(this->tiltTarget != 0.0 || this->currentPos != 0.0) SomfyRemote::sendCommand(somfy_commands::My, this->repeats); + if(this->tiltTarget != 0.0 || this->currentPos != 0.0) this->sendStop(-1, true); } else { // This is a tilt motor so let it complete if it is going to 0. - if(this->tiltTarget != 0.0) SomfyRemote::sendCommand(somfy_commands::My, this->repeats); + if(this->tiltTarget != 0.0) this->sendStop(-1, true); } } this->p_tiltDirection(0); @@ -1380,6 +1413,8 @@ void SomfyShade::checkMovement() { if(this->settingMyPos && this->isAtTarget()) { // Set this position before sending the command. If you don't the processFrame function // will send the shade back to its original My position. + const float oldMyPos = this->myPos; + const float oldMyTiltPos = this->myTiltPos; if(this->tiltType != tilt_types::none) { if(this->myTiltPos == this->currentTiltPos && this->myPos == this->currentPos) this->myPos = this->myTiltPos = -1; else { @@ -1393,9 +1428,13 @@ void SomfyShade::checkMovement() { else this->p_myPos(this->currentPos); } // Give the motor time to settle at the target before it is told to store it. - SomfyRemote::sendCommandAfter(somfy_commands::My, SETMY_REPEATS, 0, 200); + if(SomfyRemote::sendCommandAfter(somfy_commands::My, SETMY_REPEATS, 0, 200)) this->commitMyPosition(); + else { + // The motor never learned it so keep the favorite it has. + this->p_myPos(oldMyPos); + this->p_myTiltPos(oldMyTiltPos); + } this->settingMyPos = false; - this->commitMyPosition(); this->emitState(); } else if(currDir != this->direction || currPos != floor(this->currentPos) || currTiltDir != this->tiltDirection || currTiltPos != floor(this->currentTiltPos)) { @@ -2777,13 +2816,71 @@ void SomfyShade::setTiltMovement(int8_t dir) { this->emitState(); } } -void SomfyShade::shiftMovementStart(uint32_t queuedAt, uint32_t startedAt) { - // The movement was started when the command was queued but the motor only - // starts once the frame is on air. Move the start forward so the simulated - // position does not run ahead of the motor. - const uint32_t wait = startedAt - queuedAt; - if(this->direction != 0 && (uint32_t)((uint32_t)this->moveStart - queuedAt) < wait) this->moveStart = startedAt; - if(this->tiltDirection != 0 && (uint32_t)((uint32_t)this->tiltStart - queuedAt) < wait) this->tiltStart = startedAt; +void SomfyShade::markTxPending(uint32_t seq) { + // Only a shade the command set moving has to wait for it. + if(this->isAtTarget()) return; + this->txPendingSeq = seq; + this->txPendingSince = millis(); +} +void SomfyShade::processTxEvent(const somfy_tx_event_t &evt) { + if(evt.seq == 0) return; + if(this->txPendingSeq == evt.seq) { + this->txPendingSeq = 0; + if(evt.type == tx_event_t::started) { + // The motor starts now so the simulated movement does too. + this->moveStart = this->tiltStart = evt.at; + } + else { + // The motor never got the command so it hasn't moved. + ESP_LOGW(TAG, "[Shade %u] Command dropped by the radio, staying at %.2f%%", this->shadeId, this->currentPos); + this->settingPos = this->settingTiltPos = this->settingMyPos = false; + this->p_target(this->currentPos); + this->p_tiltTarget(this->currentTiltPos); + this->emitState(); + } + } + if(this->txStopSeq == evt.seq) { + this->txStopSeq = 0; + // Unless something has moved the shade since, the motor never got the stop + // and is still running. + if(evt.type == tx_event_t::dropped && this->direction == 0 && this->tiltDirection == 0) { + ESP_LOGW(TAG, "[Shade %u] Stop dropped by the radio, running on to the limit", this->shadeId); + this->runToLimit(this->txStopDir, this->txStopTilt, this->txStopAt); + this->emitState(); + } + } +} +bool SomfyShade::sendStop(int8_t dir, bool tilt) { + const uint32_t now = millis(); + // Stops jump the radio queue so the motor stops as close to the target as it can. + if(SomfyRemote::sendCommandAfter(somfy_commands::My, this->repeats, 0, 0, true)) { + this->txStopSeq = this->lastTxSeq; + this->txStopAt = now; + this->txStopDir = dir; + this->txStopTilt = tilt; + return true; + } + ESP_LOGW(TAG, "[Shade %u] Stop not sent, running on to the limit", this->shadeId); + this->runToLimit(dir, tilt, now); + return false; +} +void SomfyShade::runToLimit(int8_t dir, bool tilt, uint32_t since) { + // The motor didn't get a stop at the target so it keeps going until its end + // limit. Carry on simulating that from where and when it should have stopped. + if(tilt) { + this->startTiltPos = this->currentTiltPos; + this->tiltStart = since; + this->settingTiltPos = false; + this->p_tiltTarget(dir > 0 ? 100.0f : 0.0f); + } + else { + this->startPos = this->currentPos; + this->moveStart = since; + this->settingPos = false; + this->p_target(dir > 0 ? 100.0f : 0.0f); + } + // The favorite can't be set where the shade won't stop. + this->settingMyPos = false; } void SomfyShade::setMovement(int8_t dir) { int8_t currDir = this->direction; @@ -2805,28 +2902,26 @@ void SomfyShade::setMyPosition(int8_t pos, int8_t tilt) { if(this->tiltType == tilt_types::tiltonly) { this->p_myPos(-1.0f); if(tilt != floor(this->currentTiltPos)) { - this->settingMyPos = true; + // The favorite is set once we get there, if the move went out. if(tilt == floor(this->myTiltPos)) - this->moveToMyPosition(); - else - this->moveToTarget(100, tilt); + this->settingMyPos = this->moveToMyPosition(); + else + this->settingMyPos = this->moveToTarget(100, tilt); } else if(tilt == floor(this->myTiltPos)) { // Of so we need to clear the my position. These motors are finicky so send // a my command to ensure we are actually at the my position then send the clear // command. There really is no other way to do this. if(this->currentTiltPos != this->myTiltPos) { - this->settingMyPos = true; - this->moveToMyPosition(); + this->settingMyPos = this->moveToMyPosition(); } - else { - SomfyRemote::sendCommand(somfy_commands::My, this->repeats); + else if(SomfyRemote::sendCommand(somfy_commands::My, this->repeats)) { this->settingPos = false; this->settingMyPos = true; } } - else { - SomfyRemote::sendCommand(somfy_commands::My, SETMY_REPEATS); + else if(SomfyRemote::sendCommand(somfy_commands::My, SETMY_REPEATS)) { + // Only store a favorite the motor was told about. this->p_myTiltPos(this->currentTiltPos); } this->commitMyPosition(); @@ -2835,28 +2930,26 @@ void SomfyShade::setMyPosition(int8_t pos, int8_t tilt) { else if(this->tiltType != tilt_types::none) { if(tilt < 0) tilt = 0; if(pos != floor(this->currentPos) || tilt != floor(this->currentTiltPos)) { - this->settingMyPos = true; + // The favorite is set once we get there, if the move went out. if(pos == floor(this->myPos) && tilt == floor(this->myTiltPos)) - this->moveToMyPosition(); + this->settingMyPos = this->moveToMyPosition(); else - this->moveToTarget(pos, tilt); + this->settingMyPos = this->moveToTarget(pos, tilt); } else if(pos == floor(this->myPos) && tilt == floor(this->myTiltPos)) { // Of so we need to clear the my position. These motors are finicky so send // a my command to ensure we are actually at the my position then send the clear // command. There really is no other way to do this. if(this->currentPos != this->myPos || this->currentTiltPos != this->myTiltPos) { - this->settingMyPos = true; - this->moveToMyPosition(); + this->settingMyPos = this->moveToMyPosition(); } - else { - SomfyRemote::sendCommand(somfy_commands::My, this->repeats); + else if(SomfyRemote::sendCommand(somfy_commands::My, this->repeats)) { this->settingPos = false; this->settingMyPos = true; } } - else { - SomfyRemote::sendCommand(somfy_commands::My, SETMY_REPEATS); + else if(SomfyRemote::sendCommand(somfy_commands::My, SETMY_REPEATS)) { + // Only store a favorite the motor was told about. this->p_myPos(this->currentPos); this->p_myTiltPos(this->currentTiltPos); } @@ -2865,28 +2958,26 @@ void SomfyShade::setMyPosition(int8_t pos, int8_t tilt) { } else { if(pos != floor(this->currentPos)) { - this->settingMyPos = true; + // The favorite is set once we get there, if the move went out. if(pos == floor(this->myPos)) - this->moveToMyPosition(); + this->settingMyPos = this->moveToMyPosition(); else - this->moveToTarget(pos); + this->settingMyPos = this->moveToTarget(pos); } else if(pos == floor(this->myPos)) { // Of so we need to clear the my position. These motors are finicky so send // a my command to ensure we are actually at the my position then send the clear // command. There really is no other way to do this. if(this->myPos != this->currentPos) { - this->settingMyPos = true; - this->moveToMyPosition(); + this->settingMyPos = this->moveToMyPosition(); } - else { - SomfyRemote::sendCommand(somfy_commands::My, this->repeats); + else if(SomfyRemote::sendCommand(somfy_commands::My, this->repeats)) { this->settingPos = false; this->settingMyPos = true; } } - else { - SomfyRemote::sendCommand(somfy_commands::My, SETMY_REPEATS); + else if(SomfyRemote::sendCommand(somfy_commands::My, SETMY_REPEATS)) { + // Only store a favorite the motor was told about. this->p_myPos(currentPos); this->p_myTiltPos(-1); this->commitMyPosition(); @@ -3038,6 +3129,8 @@ bool SomfyGroup::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSiz } } } + // The member shades only started moving above. + if(this->lastTxSeq != 0 && startsMovement(this->lastFrame.cmd)) somfy.markTxPending(this->getRemoteAddress(), this->lastTxSeq); this->updateFlags(); this->emitState(); return true; @@ -3496,7 +3589,7 @@ void SomfyShadeController::processFrame(somfy_frame_t &frame, bool internal) { if(this->shades[i].getShadeId() != 255) this->shades[i].processFrame(frame, internal); } } -void SomfyShadeController::shiftMovementStart(uint32_t remoteAddress, uint32_t queuedAt, uint32_t startedAt) { +void SomfyShadeController::markTxPending(uint32_t remoteAddress, uint32_t seq) { // Same matching as SomfyShade::processFrame for shade and linked remotes. for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { SomfyShade *shade = &this->shades[i]; @@ -3504,7 +3597,7 @@ void SomfyShadeController::shiftMovementStart(uint32_t remoteAddress, uint32_t q bool hasRemote = shade->getRemoteAddress() == remoteAddress; for(uint8_t j = 0; !hasRemote && j < SOMFY_MAX_LINKED_REMOTES; j++) hasRemote = shade->linkedRemotes[j].getRemoteAddress() == remoteAddress; - if(hasRemote) shade->shiftMovementStart(queuedAt, startedAt); + if(hasRemote) shade->markTxPending(seq); } // Group commands move their member shades through processInternalCommand. for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { @@ -3513,10 +3606,14 @@ void SomfyShadeController::shiftMovementStart(uint32_t remoteAddress, uint32_t q for(uint8_t j = 0; j < SOMFY_MAX_GROUPED_SHADES; j++) { if(group->linkedShades[j] == 0) continue; SomfyShade *shade = this->getShadeById(group->linkedShades[j]); - if(shade) shade->shiftMovementStart(queuedAt, startedAt); + if(shade) shade->markTxPending(seq); } } } +void SomfyShadeController::processTxEvent(const somfy_tx_event_t &evt) { + for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) + if(this->shades[i].getShadeId() != 255) this->shades[i].processTxEvent(evt); +} void SomfyShadeController::processWaitingFrame() { for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) if(this->shades[i].getShadeId() != 255) this->shades[i].processWaitingFrame(); @@ -3888,7 +3985,7 @@ bool SomfyRemote::sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repe } bool SomfyRemote::sendCommand(somfy_commands cmd) { return this->sendCommand(cmd, this->repeats); } bool SomfyRemote::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { return this->sendCommandAfter(cmd, repeat, stepSize, 0); } -bool SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs) { +bool SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority) { // Build the frame aside and only take the rolling code once the frame is // queued so a dropped command leaves the remote untouched. somfy_frame_t frame = this->lastFrame; @@ -3907,6 +4004,7 @@ bool SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t s // We have to set the processed to clear this if we are sending // another command. frame.processed = false; + uint32_t seq = 0; if(this->proto == radio_proto::GP_Relay) { ESP_LOGD(TAG, "CMD:%s ADDR:%d RCODE:%d SETTING GPIO", translateSomfyCommand(frame.cmd).c_str(), @@ -3923,11 +4021,15 @@ bool SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t s translateSomfyCommand(frame.cmd).c_str(), frame.remoteAddress, frame.rollingCode, repeat); // Only simulate the command if it is actually going on air. - if(!somfy.sendFrame(frame, repeat, delayMs)) return false; + if(!somfy.sendFrame(frame, repeat, delayMs, priority, &seq)) return false; } this->commitRollingCode(frame.rollingCode); this->lastFrame = frame; + this->lastTxSeq = seq; somfy.processFrame(this->lastFrame, true); + // Shades this set moving wait for the frame to go on air. Stops are sent + // ahead of everything else so they don't need to. + if(seq != 0 && !priority && startsMovement(frame.cmd)) somfy.markTxPending(frame.remoteAddress, seq); return true; } bool SomfyRemote::isLastCommand(somfy_commands cmd) { @@ -3955,13 +4057,15 @@ bool SomfyRemote::repeatFrame(uint8_t repeat) { //somfy.processFrame(this->lastFrame, true); return true; } -bool SomfyShadeController::sendFrame(somfy_frame_t &frame, uint8_t repeat, uint16_t delayMs, bool priority) { +bool SomfyShadeController::sendFrame(somfy_frame_t &frame, uint8_t repeat, uint16_t delayMs, bool priority, uint32_t *seq) { somfy_tx_job_t job; encodeTxJob(job, frame, repeat); job.firstRepeat = 1; job.priority = priority; if(delayMs > 0) job.notBefore = millis() + delayMs; - return this->transceiver.queueTx(job); + if(!this->transceiver.queueTx(job)) return false; + if(seq) *seq = job.seq; + return true; } bool SomfyShadeController::deleteShade(uint8_t shadeId) { for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { @@ -5019,13 +5123,10 @@ void Transceiver::loop() { somfy_rx_t rx; somfy_tx_event_t evt; while(txEventQueue && xQueueReceive(txEventQueue, &evt, 0) == pdTRUE) { - if(evt.type == tx_event_t::started) { - // The shades were set moving when the job was queued so line their - // movement up with the motor. - somfy.shiftMovementStart(evt.remoteAddress, evt.queuedAt, evt.at); - } - else + if(evt.type == tx_event_t::dropped) ESP_LOGW(TAG, "Radio TX dropped %s for %d", translateSomfyCommand(evt.cmd).c_str(), evt.remoteAddress); + // Line the simulated shades up with what actually went on air. + somfy.processTxEvent(evt); } if (noiseDetected && rxmode != 3 && this->config.noiseDetection) { if (millis() - noiseStart > 100) { @@ -5090,7 +5191,8 @@ bool Transceiver::queueTx(somfy_tx_job_t &job) { return false; } job.gen = txGen; - job.queuedAt = millis(); + if(++txSeq == 0) ++txSeq; // 0 means no job. + job.seq = txSeq; // We are the only producer while holding the lock so the space check holds. if(job.priority) xQueueSendToFront(txJobQueue, &job, 0); else xQueueSendToBack(txJobQueue, &job, 0); diff --git a/src/Somfy.h b/src/Somfy.h index 5f7acf1..3377b8c 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -218,7 +218,7 @@ struct somfy_tx_job_t { bool raw = false; // Repeater frame. Waits for the receiver to be idle. bool priority = false; // Goes to the front of the queue and may use the reserved slots. uint32_t gen = 0; // Flush generation when queued. Stale jobs are dropped. - uint32_t queuedAt = 0; // millis() when queued. + uint32_t seq = 0; // Assigned when queued so the job's events can be matched up. uint32_t notBefore = 0; // millis() before which the job must not go on air (0 = none). }; enum class tx_event_t : uint8_t { @@ -230,7 +230,7 @@ struct somfy_tx_event_t { tx_event_t type = tx_event_t::started; somfy_commands cmd = somfy_commands::My; uint32_t remoteAddress = 0; - uint32_t queuedAt = 0; + uint32_t seq = 0; uint32_t at = 0; // millis() when the job started or was dropped. }; @@ -266,6 +266,7 @@ class SomfyRemote { somfy_frame_t lastFrame; bool flipCommands = false; uint16_t lastRollingCode = 0; + uint32_t lastTxSeq = 0; // Radio job of the last command sent, 0 if it didn't use the radio. uint8_t flags = 0; uint8_t bitLength = 0; uint8_t repeats = 1; @@ -286,7 +287,7 @@ class SomfyRemote { // command. Nothing about the remote or shade state has changed in that case. virtual bool sendCommand(somfy_commands cmd); virtual bool sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0); - bool sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs); + bool sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority = false); bool sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repeat); bool repeatFrame(uint8_t repeat); virtual uint16_t p_lastRollingCode(uint16_t code); @@ -318,6 +319,18 @@ class SomfyShade : public SomfyRemote { bool settingPos = false; bool settingTiltPos = false; uint32_t awaitMy = 0; + // Radio job that set the shade moving. The simulated position is held + // until it goes on air. 0 when nothing is waiting. + uint32_t txPendingSeq = 0; + uint32_t txPendingSince = 0; + // Stop sent when the simulated target was reached. If it never goes on + // air the motor runs on to its end limit. 0 once it is on air. + uint32_t txStopSeq = 0; + uint32_t txStopAt = 0; + int8_t txStopDir = 0; + bool txStopTilt = false; + bool sendStop(int8_t dir, bool tilt); + void runToLimit(int8_t dir, bool tilt, uint32_t since); public: uint8_t roomId = 0; int8_t sortOrder = 0; @@ -354,7 +367,8 @@ class SomfyShade : public SomfyRemote { void checkMovement(); void processFrame(somfy_frame_t &frame, bool internal = false); void processInternalCommand(somfy_commands cmd, uint8_t repeat = 1); - void shiftMovementStart(uint32_t queuedAt, uint32_t startedAt); + void markTxPending(uint32_t seq); + void processTxEvent(const somfy_tx_event_t &evt); void setTiltMovement(int8_t dir); void setMovement(int8_t dir); void setTarget(float target); @@ -604,9 +618,10 @@ class SomfyShadeController { SomfyGroup * getGroupById(uint8_t groupId); SomfyShade * findShadeByRemoteAddress(uint32_t address); SomfyGroup * findGroupByRemoteAddress(uint32_t address); - bool sendFrame(somfy_frame_t &frame, uint8_t repeats = 0, uint16_t delayMs = 0, bool priority = false); + bool sendFrame(somfy_frame_t &frame, uint8_t repeats = 0, uint16_t delayMs = 0, bool priority = false, uint32_t *seq = nullptr); void processFrame(somfy_frame_t &frame, bool internal = false); - void shiftMovementStart(uint32_t remoteAddress, uint32_t queuedAt, uint32_t startedAt); + void markTxPending(uint32_t remoteAddress, uint32_t seq); + void processTxEvent(const somfy_tx_event_t &evt); void emitState(uint8_t num = 255); void publish(); void processWaitingFrame(); From 2a54740f529cbc67e446bbca94a49b41f6889738 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 12:23:20 +0200 Subject: [PATCH 06/53] 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 --- src/Somfy.cpp | 21 +++++++++++++++++++-- src/Web.cpp | 28 ++++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/Somfy.cpp b/src/Somfy.cpp index 9a18c25..e6bde09 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -4553,6 +4553,10 @@ int currRSSI = -100; float markFreq = 433.0f; int markRSSI = -100; 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() { // 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. @@ -4561,6 +4565,7 @@ void Transceiver::beginFrequencyScan() { if(this->config.enabled) { this->disableReceive(); rxmode = 3; + scanStart = millis(); pinMode(this->config.RXPin, INPUT); 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. @@ -4608,9 +4613,10 @@ void Transceiver::endFrequencyScan() { if(interruptPin > 0) detachInterrupt(interruptPin); interruptPin = 0; this->config.apply(); - this->resumeTx(TX_SUSPEND_SCAN); 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) { JsonSockEvent *json = sockEmit.beginEmit("frequencyScan"); @@ -4770,7 +4776,8 @@ bool Transceiver::save() { } bool Transceiver::end() { // 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); RadioLock lock(portMAX_DELAY, true); this->disableReceive(); @@ -5011,7 +5018,13 @@ void transceiver_config_t::apply() { // be reconfigured. Queued frames are already encoded and go out with the // new settings. RadioLock lock(portMAX_DELAY, true); + // Reconfiguring the radio ends a frequency scan so TX may resume. + const bool wasScanning = rxmode == 3; somfy.transceiver.disableReceive(); + if(wasScanning) { + somfy.transceiver.resumeTx(TX_SUSPEND_SCAN); + somfy.transceiver.emitFrequencyScan(); + } bit_length = this->type; if(this->enabled) { bool radioInit = true; @@ -5136,6 +5149,10 @@ void Transceiver::loop() { noiseCount = 0; } } + if(rxmode == 3 && millis() - scanStart > SCAN_MAX_MS) { + ESP_LOGW(TAG, "Frequency scan timed out"); + this->endFrequencyScan(); + } if(rxmode == 3) { if(this->receive(&rx)) this->processFrequencyScan(true); diff --git a/src/Web.cpp b/src/Web.cpp index c780449..6211d5e 100644 --- a/src/Web.cpp +++ b/src/Web.cpp @@ -64,10 +64,24 @@ static bool queueUnpairShade(SomfyShade *shade) { shade->paired = false; 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. static void sendQueueFull(AsyncWebServerRequest *request) { 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 asyncApiServer(8081); @@ -2136,6 +2150,7 @@ void Web::begin() { else { somfy.transceiver.end(); mqtt.end(); + request->onDisconnect(onUploadDisconnect); } } if(len > 0) { @@ -2201,6 +2216,7 @@ void Web::begin() { else { somfy.transceiver.end(); mqtt.end(); + request->onDisconnect(onUploadDisconnect); } } if(len > 0) { @@ -2331,8 +2347,10 @@ void Web::begin() { frame.cmd = translateSomfyCommand(scmd.c_str()); } if(frame.remoteAddress > 0 && frame.rollingCode > 0) { - somfy.sendFrame(frame, repeats); - request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); + if(somfy.sendFrame(frame, repeats)) + request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); + else + sendRadioUnavailable(request); } else 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(frame.remoteAddress > 0 && frame.rollingCode > 0) { - somfy.sendFrame(frame, repeats); - request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); + if(somfy.sendFrame(frame, repeats)) + request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); + else + sendRadioUnavailable(request); } else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}")); From 130b5b6b0a79eef83e2f7998fd5152330493e087 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 12:46:39 +0200 Subject: [PATCH 07/53] fix: hold simulation only for shades a command starts moving markTxPending ran inside sendCommandAfter before callers set the new target, so isAtTarget() kept idle shades from ever being held, while shades already moving were frozen although their motor kept running. Hold a shade only when it was idle before the command and release the hold when the command didn't set it moving. The single mark in sendCommandAfter now covers group members too, so the second pass in SomfyGroup::sendCommand is gone. Co-Authored-By: Claude Opus 5.5 --- src/Somfy.cpp | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Somfy.cpp b/src/Somfy.cpp index e6bde09..ecf0994 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -1144,6 +1144,10 @@ void SomfyShade::checkMovement() { uint8_t currPos = floor(this->currentPos); uint8_t currTiltPos = floor(this->currentTiltPos); if(this->direction != 0) this->lastMovement = this->direction; + if(this->txPendingSeq != 0 && this->direction == 0 && this->tiltDirection == 0) { + // The command didn't set the shade moving so there is nothing to hold. + this->txPendingSeq = 0; + } if(this->txPendingSeq != 0) { // The command that set us moving hasn't gone on air yet so the motor hasn't // started. Hold the simulated position until it does. @@ -2817,8 +2821,13 @@ void SomfyShade::setTiltMovement(int8_t dir) { } } void SomfyShade::markTxPending(uint32_t seq) { - // Only a shade the command set moving has to wait for it. - if(this->isAtTarget()) return; + // The motor starts on the first of several queued commands to go on air. + if(this->txPendingSeq != 0) return; + // This runs before the caller sets the new target so direction still says + // whether the shade was moving. A moving motor carries on with its earlier + // command so its simulation must not be held. A reversal is simulated as + // soon as it is queued even though the motor turns once it is on air. + if(this->direction != 0 || this->tiltDirection != 0) return; this->txPendingSeq = seq; this->txPendingSince = millis(); } @@ -3129,8 +3138,6 @@ bool SomfyGroup::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSiz } } } - // The member shades only started moving above. - if(this->lastTxSeq != 0 && startsMovement(this->lastFrame.cmd)) somfy.markTxPending(this->getRemoteAddress(), this->lastTxSeq); this->updateFlags(); this->emitState(); return true; @@ -4027,8 +4034,8 @@ bool SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t s this->lastFrame = frame; this->lastTxSeq = seq; somfy.processFrame(this->lastFrame, true); - // Shades this set moving wait for the frame to go on air. Stops are sent - // ahead of everything else so they don't need to. + // Idle shades (and group members) this sets moving wait for the frame to go + // on air. Stops are sent ahead of everything else so they don't need to. if(seq != 0 && !priority && startsMovement(frame.cmd)) somfy.markTxPending(frame.remoteAddress, seq); return true; } From 44227bfd8719e0859dc99e23a34633e1418ad011 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 12:46:40 +0200 Subject: [PATCH 08/53] fix: keep rolling codes in order when a stop is queued A priority stop always went to the front of the radio queue, ahead of an earlier frame of the same remote that was still waiting. The older rolling code then went out last and the motor rejected it. Split "may use the reserved slots" (priority) from "goes to the front" (front) and only send a stop to the front when its remote has nothing queued at the back. The radio task and flushes track which back jobs have been taken so Transceiver::isQueued() can tell. Co-Authored-By: Claude Opus 5.5 --- src/Somfy.cpp | 28 +++++++++++++++++++++++++--- src/Somfy.h | 7 +++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/Somfy.cpp b/src/Somfy.cpp index ecf0994..23a1eac 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -63,6 +63,15 @@ static std::atomic txPreemptWaiters{0}; static std::atomic txSuspendMask{0}; // Last sequence number handed to a queued job. Only touched under TxQueueLock. static uint32_t txSeq = 0; +// Sequence number of the last job queued at the back that the radio task took +// (or a flush dropped). Those jobs go out in order so any later one is still +// waiting in the queue. +static std::atomic txBackTaken{0}; +// The radio task and a flush both move txBackTaken. Never let it go backwards. +static void advanceBackTaken(uint32_t seq) { + uint32_t cur = txBackTaken; + while((int32_t)(seq - cur) > 0 && !txBackTaken.compare_exchange_weak(cur, seq)) {} +} // How long a shade's simulated movement waits for its command to go on air // before it starts anyway in case the event was lost. #define TX_PENDING_TIMEOUT 30000 @@ -4027,8 +4036,13 @@ bool SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t s ESP_LOGD(TAG, "CMD:%s ADDR:%d RCODE:%d REPEAT:%d", translateSomfyCommand(frame.cmd).c_str(), frame.remoteAddress, frame.rollingCode, repeat); + // A stop jumps the queue unless an earlier frame of this remote is still + // waiting. Overtaking it would put the rolling codes out of order and the + // motor would reject the older one. + const bool front = priority && !somfy.transceiver.isQueued(this->lastBackTxSeq); // Only simulate the command if it is actually going on air. - if(!somfy.sendFrame(frame, repeat, delayMs, priority, &seq)) return false; + if(!somfy.sendFrame(frame, repeat, delayMs, priority, front, &seq)) return false; + if(!front) this->lastBackTxSeq = seq; } this->commitRollingCode(frame.rollingCode); this->lastFrame = frame; @@ -4061,14 +4075,16 @@ bool SomfyRemote::repeatFrame(uint8_t repeat) { // Only advance the counter for frames that will go on air. if(!somfy.transceiver.queueTx(job)) return false; this->lastFrame.repeats += repeat + 1; + this->lastBackTxSeq = job.seq; //somfy.processFrame(this->lastFrame, true); return true; } -bool SomfyShadeController::sendFrame(somfy_frame_t &frame, uint8_t repeat, uint16_t delayMs, bool priority, uint32_t *seq) { +bool SomfyShadeController::sendFrame(somfy_frame_t &frame, uint8_t repeat, uint16_t delayMs, bool priority, bool front, uint32_t *seq) { somfy_tx_job_t job; encodeTxJob(job, frame, repeat); job.firstRepeat = 1; job.priority = priority; + job.front = priority && front; if(delayMs > 0) job.notBefore = millis() + delayMs; if(!this->transceiver.queueTx(job)) return false; if(seq) *seq = job.seq; @@ -5218,13 +5234,18 @@ bool Transceiver::queueTx(somfy_tx_job_t &job) { if(++txSeq == 0) ++txSeq; // 0 means no job. job.seq = txSeq; // We are the only producer while holding the lock so the space check holds. - if(job.priority) xQueueSendToFront(txJobQueue, &job, 0); + if(job.front) xQueueSendToFront(txJobQueue, &job, 0); else xQueueSendToBack(txJobQueue, &job, 0); return true; } +bool Transceiver::isQueued(uint32_t seq) { + // Only meaningful for jobs queued at the back. + return seq != 0 && (int32_t)(seq - txBackTaken) > 0; +} void Transceiver::flushTx() { TxQueueLock lock; ++txGen; + advanceBackTaken(txSeq); if(!txJobQueue) return; // Drain rather than reset so every discarded job is reported as dropped. somfy_tx_job_t job; @@ -5262,6 +5283,7 @@ void Transceiver::txTask(void *arg) { somfy_tx_job_t job; for(;;) { if(xQueueReceive(txJobQueue, &job, portMAX_DELAY) != pdTRUE) continue; + if(!job.front) advanceBackTaken(job.seq); // Flushed after we took it off the queue. if(txSuspendMask || job.gen != txGen) { postTxEvent(tx_event_t::dropped, job, millis()); diff --git a/src/Somfy.h b/src/Somfy.h index 3377b8c..07dcaa8 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -216,7 +216,8 @@ struct somfy_tx_job_t { 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; // Repeater frame. Waits for the receiver to be idle. - bool priority = false; // Goes to the front of the queue and may use the reserved slots. + bool priority = false; // May use the reserved slots. + bool front = false; // Goes to the front of the queue. Only for priority jobs. uint32_t gen = 0; // Flush generation when queued. Stale jobs are dropped. uint32_t seq = 0; // Assigned when queued so the job's events can be matched up. uint32_t notBefore = 0; // millis() before which the job must not go on air (0 = none). @@ -267,6 +268,7 @@ class SomfyRemote { bool flipCommands = false; uint16_t lastRollingCode = 0; uint32_t lastTxSeq = 0; // Radio job of the last command sent, 0 if it didn't use the radio. + uint32_t lastBackTxSeq = 0; // Last radio job queued at the back of the queue. uint8_t flags = 0; uint8_t bitLength = 0; uint8_t repeats = 1; @@ -563,6 +565,7 @@ class Transceiver { void flushTx(); void suspendTx(uint8_t reason); void resumeTx(uint8_t reason); + bool isQueued(uint32_t seq); void beginTransmit(); void endTransmit(); void emitFrame(somfy_frame_t *frame, somfy_rx_t *rx = nullptr); @@ -618,7 +621,7 @@ class SomfyShadeController { SomfyGroup * getGroupById(uint8_t groupId); SomfyShade * findShadeByRemoteAddress(uint32_t address); SomfyGroup * findGroupByRemoteAddress(uint32_t address); - bool sendFrame(somfy_frame_t &frame, uint8_t repeats = 0, uint16_t delayMs = 0, bool priority = false, uint32_t *seq = nullptr); + bool sendFrame(somfy_frame_t &frame, uint8_t repeats = 0, uint16_t delayMs = 0, bool priority = false, bool front = false, uint32_t *seq = nullptr); void processFrame(somfy_frame_t &frame, bool internal = false); void markTxPending(uint32_t remoteAddress, uint32_t seq); void processTxEvent(const somfy_tx_event_t &evt); From 2a45dba37aa9dedc4d6fea21330e8d531df1e873 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 12:46:40 +0200 Subject: [PATCH 09/53] fix: cap repeats per radio job to keep loop under the WDT The radio task bit-bangs a whole job on loop()'s core at a higher priority. A job with 127+ repeats ran for 18-36s without loop() ever running and the 15s task watchdog rebooted the device. Limit a job to 50 repeats (about 7s) in encodeTxJob, which every send path goes through, and advance the 80-bit repeat counter by the number of frames actually queued. Co-Authored-By: Claude Opus 5.5 --- src/Somfy.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Somfy.cpp b/src/Somfy.cpp index 23a1eac..b5def87 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -38,6 +38,10 @@ uint8_t rxmode = 0; // Indicates whether the radio is in receive mode. Just to #define SETMY_REPEATS 35 #define TILT_REPEATS 15 +// The radio task bit-bangs a whole job on loop()'s core at a higher priority +// so loop() only runs between jobs. A frame takes about 140ms so this keeps a +// job around 7s, well inside the 15s task watchdog loop() is subscribed to. +#define TX_MAX_REPEATS 50 #define TX_QUEUE_DELAY 100 #define TX_TASK_PRIORITY 5 #define TX_TASK_STACK 4096 @@ -110,6 +114,10 @@ static void encodeTxJob(somfy_tx_job_t &job, somfy_frame_t &frame, uint8_t repea job.tx.hwsync = frame.bitLength == 56 ? 2 : 12; job.tx.bit_length = frame.bitLength; job.frame = frame; + if(repeat > TX_MAX_REPEATS) { + ESP_LOGW(TAG, "Limiting %u repeats to %u", repeat, TX_MAX_REPEATS); + repeat = TX_MAX_REPEATS; + } job.repeats = repeat; } // Commands that can set a shade moving. @@ -4074,8 +4082,8 @@ bool SomfyRemote::repeatFrame(uint8_t repeat) { job.firstRepeat = this->lastFrame.repeats + 2; // Only advance the counter for frames that will go on air. if(!somfy.transceiver.queueTx(job)) return false; - this->lastFrame.repeats += repeat + 1; this->lastBackTxSeq = job.seq; + this->lastFrame.repeats += job.repeats + 1; //somfy.processFrame(this->lastFrame, true); return true; } From 352f36e478371dd055af28b0af6e9bfedc6e1a98 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 12:46:40 +0200 Subject: [PATCH 10/53] fix: commit favorite only once SETMY is fully on air The favorite was persisted as soon as the long My press was queued. A flush or radio reconfiguration could drop or cut it short and the app kept a favorite the motor never stored. The radio task now reports finished/aborted for every job. Setting a favorite goes through sendSetMy, which commits it on finished and puts the previous one back otherwise. The new favorite is set before the My is sent so the simulation no longer heads for the old one. Co-Authored-By: Claude Opus 5.5 --- src/Somfy.cpp | 83 ++++++++++++++++++++++++++++++++++++++------------- src/Somfy.h | 10 ++++++- 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/src/Somfy.cpp b/src/Somfy.cpp index b5def87..a4b2cc3 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -1449,12 +1449,7 @@ void SomfyShade::checkMovement() { else this->p_myPos(this->currentPos); } // Give the motor time to settle at the target before it is told to store it. - if(SomfyRemote::sendCommandAfter(somfy_commands::My, SETMY_REPEATS, 0, 200)) this->commitMyPosition(); - else { - // The motor never learned it so keep the favorite it has. - this->p_myPos(oldMyPos); - this->p_myTiltPos(oldMyTiltPos); - } + this->sendSetMy(200, oldMyPos, oldMyTiltPos); this->settingMyPos = false; this->emitState(); } @@ -2865,6 +2860,17 @@ void SomfyShade::processTxEvent(const somfy_tx_event_t &evt) { this->emitState(); } } + if(this->txMySeq == evt.seq && evt.type != tx_event_t::started) { + this->txMySeq = 0; + if(evt.type == tx_event_t::finished) this->commitMyPosition(); + else { + // The motor only stores the favorite when it gets the whole long press. + ESP_LOGW(TAG, "[Shade %u] Favorite not sent in full, keeping the previous one", this->shadeId); + this->p_myPos(this->txMyOldPos); + this->p_myTiltPos(this->txMyOldTiltPos); + this->emitState(); + } + } if(this->txStopSeq == evt.seq) { this->txStopSeq = 0; // Unless something has moved the shade since, the motor never got the stop @@ -2890,6 +2896,22 @@ bool SomfyShade::sendStop(int8_t dir, bool tilt) { this->runToLimit(dir, tilt, now); return false; } +bool SomfyShade::sendSetMy(uint16_t delayMs, float oldPos, float oldTiltPos) { + // The new favorite is already in myPos/myTiltPos. It is committed once the + // whole long press is on air, see processTxEvent. + if(SomfyRemote::sendCommandAfter(somfy_commands::My, SETMY_REPEATS, 0, delayMs)) { + this->txMySeq = this->lastTxSeq; + this->txMyOldPos = oldPos; + this->txMyOldTiltPos = oldTiltPos; + // Without the radio (GPIO remotes) there is nothing to wait for. + if(this->txMySeq == 0) this->commitMyPosition(); + return true; + } + // The motor never learned it so keep the favorite it has. + this->p_myPos(oldPos); + this->p_myTiltPos(oldTiltPos); + return false; +} void SomfyShade::runToLimit(int8_t dir, bool tilt, uint32_t since) { // The motor didn't get a stop at the target so it keeps going until its end // limit. Carry on simulating that from where and when it should have stopped. @@ -2946,9 +2968,14 @@ void SomfyShade::setMyPosition(int8_t pos, int8_t tilt) { this->settingMyPos = true; } } - else if(SomfyRemote::sendCommand(somfy_commands::My, SETMY_REPEATS)) { - // Only store a favorite the motor was told about. + else { + // Set it before sending so processFrame doesn't head for the old one. It + // is only committed once the motor was told about it. + const float oldTiltPos = this->myTiltPos; this->p_myTiltPos(this->currentTiltPos); + this->sendSetMy(0, this->myPos, oldTiltPos); + this->emitState(); + return; } this->commitMyPosition(); this->emitState(); @@ -2974,10 +3001,16 @@ void SomfyShade::setMyPosition(int8_t pos, int8_t tilt) { this->settingMyPos = true; } } - else if(SomfyRemote::sendCommand(somfy_commands::My, SETMY_REPEATS)) { - // Only store a favorite the motor was told about. + else { + // Set it before sending so processFrame doesn't head for the old one. + // It is only committed once the motor was told about it. + const float oldPos = this->myPos; + const float oldTiltPos = this->myTiltPos; this->p_myPos(this->currentPos); this->p_myTiltPos(this->currentTiltPos); + this->sendSetMy(0, oldPos, oldTiltPos); + this->emitState(); + return; } this->commitMyPosition(); this->emitState(); @@ -3002,12 +3035,14 @@ void SomfyShade::setMyPosition(int8_t pos, int8_t tilt) { this->settingMyPos = true; } } - else if(SomfyRemote::sendCommand(somfy_commands::My, SETMY_REPEATS)) { - // Only store a favorite the motor was told about. - this->p_myPos(currentPos); + else { + // Set it before sending so processFrame doesn't head for the old one. It + // is only committed once the motor was told about it. + const float oldPos = this->myPos; + const float oldTiltPos = this->myTiltPos; + this->p_myPos(this->currentPos); this->p_myTiltPos(-1); - this->commitMyPosition(); - this->emitState(); + if(this->sendSetMy(0, oldPos, oldTiltPos)) this->emitState(); } } } @@ -5169,6 +5204,8 @@ void Transceiver::loop() { while(txEventQueue && xQueueReceive(txEventQueue, &evt, 0) == pdTRUE) { if(evt.type == tx_event_t::dropped) ESP_LOGW(TAG, "Radio TX dropped %s for %d", translateSomfyCommand(evt.cmd).c_str(), evt.remoteAddress); + else if(evt.type == tx_event_t::aborted) + ESP_LOGW(TAG, "Radio TX cut short %s for %d", translateSomfyCommand(evt.cmd).c_str(), evt.remoteAddress); // Line the simulated shades up with what actually went on air. somfy.processTxEvent(evt); } @@ -5271,20 +5308,25 @@ void Transceiver::suspendTx(uint8_t reason) { void Transceiver::resumeTx(uint8_t reason) { txSuspendMask &= ~(uint32_t)reason; } // cpt_synchro_hw is updated by the RX interrupt and read here from the radio task. static bool rxInProgress() { return *(volatile uint8_t *)&somfy_rx.cpt_synchro_hw != 0; } -void Transceiver::transmitJob(somfy_tx_job_t &job) { +bool Transceiver::transmitJob(somfy_tx_job_t &job) { somfy_tx_t &tx = job.tx; + bool complete = true; this->beginTransmit(); this->sendFrame(tx.payload, tx.hwsync, tx.bit_length); for(uint8_t i = 0; i < job.repeats; i++) { // Stop early when flushed or when someone is waiting to reconfigure the // radio so they get it within a frame. - if(job.gen != txGen || txPreemptWaiters > 0) break; + if(job.gen != txGen || txPreemptWaiters > 0) { + complete = false; + break; + } // For each 80-bit frame we need to adjust the byte encoding for the // silence. if(tx.bit_length == 80) job.frame.encode80BitFrame(tx.payload, (uint8_t)(job.firstRepeat + i)); this->sendFrame(tx.payload, tx.bit_length == 56 ? 7 : 6, tx.bit_length); } this->endTransmit(); + return complete; } void Transceiver::txTask(void *arg) { Transceiver *trans = static_cast(arg); @@ -5314,7 +5356,8 @@ void Transceiver::txTask(void *arg) { continue; } postTxEvent(tx_event_t::started, job, millis()); - trans->transmitJob(job); + const bool complete = trans->transmitJob(job); + postTxEvent(complete ? tx_event_t::finished : tx_event_t::aborted, job, millis()); } // Give the motors a full frame beat between separate transmissions. vTaskDelay(pdMS_TO_TICKS(TX_QUEUE_DELAY)); @@ -5323,8 +5366,8 @@ void Transceiver::txTask(void *arg) { void Transceiver::startTxTask() { if(txTaskHandle) return; if(!txJobQueue) txJobQueue = xQueueCreate(TX_JOB_QUEUE_SIZE, sizeof(somfy_tx_job_t)); - // Every queued job reports once, plus room for the jobs a flush drains. - if(!txEventQueue) txEventQueue = xQueueCreate(TX_JOB_QUEUE_SIZE * 2, sizeof(somfy_tx_event_t)); + // Every job reports when it starts and ends, plus room for the jobs a flush drains. + if(!txEventQueue) txEventQueue = xQueueCreate(TX_JOB_QUEUE_SIZE * 3, sizeof(somfy_tx_event_t)); if(!txJobQueue) { ESP_LOGE(TAG, "Unable to create radio TX queue"); return; diff --git a/src/Somfy.h b/src/Somfy.h index 07dcaa8..3630dbf 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -224,6 +224,8 @@ struct somfy_tx_job_t { }; enum class tx_event_t : uint8_t { started, // The job is going on air. + finished, // Every frame of the job went on air. + aborted, // The job was cut short by a flush or a radio reconfiguration. dropped // The job was flushed and will never go on air. }; // Reported by the radio task for every queued non-repeater job. @@ -331,7 +333,13 @@ class SomfyShade : public SomfyRemote { uint32_t txStopAt = 0; int8_t txStopDir = 0; bool txStopTilt = false; + // Command telling the motor to store the favorite. The favorite is only + // committed once all of it is on air and restored if it never is. + uint32_t txMySeq = 0; + float txMyOldPos = -1.0f; + float txMyOldTiltPos = -1.0f; bool sendStop(int8_t dir, bool tilt); + bool sendSetMy(uint16_t delayMs, float oldPos, float oldTiltPos); void runToLimit(int8_t dir, bool tilt, uint32_t since); public: uint8_t roomId = 0; @@ -545,7 +553,7 @@ class Transceiver { bool _received = false; somfy_frame_t frame; void startTxTask(); - void transmitJob(somfy_tx_job_t &job); + bool transmitJob(somfy_tx_job_t &job); public: transceiver_config_t config; bool printBuffer = false; From 00a084048fa51ae7b227939d66232b6affa56264 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 12:46:40 +0200 Subject: [PATCH 11/53] fix: swap restored radio config under the radio lock Restoring a backup wrote the transceiver config in place from the async task while the radio task could be transmitting with it. Read the record into a copy and swap it in through the new Transceiver::setConfig, which Transceiver::fromJSON now uses as well. Co-Authored-By: Claude Opus 5.5 --- src/ConfigFile.cpp | 5 ++++- src/Somfy.cpp | 16 ++++++++++------ src/Somfy.h | 1 + 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/ConfigFile.cpp b/src/ConfigFile.cpp index 5dc1801..cd56e9e 100644 --- a/src/ConfigFile.cpp +++ b/src/ConfigFile.cpp @@ -608,7 +608,10 @@ bool ShadeConfigFile::restoreFile(SomfyShadeController *s, const char *filename, if(opts.shades) s->commit(); if(opts.transceiver) { - this->readTransRecord(s->transceiver.config); + // Read into a copy so the radio task never transmits on a half restored config. + transceiver_config_t cfg = s->transceiver.config; + this->readTransRecord(cfg); + s->transceiver.setConfig(cfg); s->transceiver.save(); } if(opts.settings || opts.network) settings.save(); diff --git a/src/Somfy.cpp b/src/Somfy.cpp index a4b2cc3..238a514 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -4812,17 +4812,21 @@ bool Transceiver::toJSON(JsonObject& obj) { bool Transceiver::fromJSON(JsonObject& obj) { if (!obj["config"].isNull()) { JsonObject objConfig = obj["config"]; - // Parse into a copy so the radio task never sees a half updated config, - // then swap it in once any transmission in flight has stopped. Queued - // jobs are kept and go out with the new settings. + // Parse into a copy so the radio task never sees a half updated config. transceiver_config_t cfg = this->config; cfg.fromJSON(objConfig); - RadioLock lock(portMAX_DELAY, true); - if(this->config.enabled && this->config.TXPin != cfg.TXPin) pinMode(this->config.TXPin, INPUT); - this->config = cfg; + this->setConfig(cfg); } return true; } +void Transceiver::setConfig(const transceiver_config_t &cfg) { + // Swap the whole config in once any transmission in flight has stopped so the + // radio task never sees a half updated one. Queued jobs are kept and go out + // with the new settings. + RadioLock lock(portMAX_DELAY, true); + if(this->config.enabled && this->config.TXPin != cfg.TXPin) pinMode(this->config.TXPin, INPUT); + this->config = cfg; +} bool Transceiver::usesPin(uint8_t pin) { if(this->config.enabled) { if(this->config.SCKPin == pin || diff --git a/src/Somfy.h b/src/Somfy.h index 3630dbf..bf7dd46 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -559,6 +559,7 @@ class Transceiver { bool printBuffer = false; //bool toJSON(JsonObject& obj); bool fromJSON(JsonObject& obj); + void setConfig(const transceiver_config_t &cfg); bool save(); bool begin(); void loop(); From 1f394ca9df17b8dbdc0b29dce06ac26910f8876b Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 12:46:40 +0200 Subject: [PATCH 12/53] 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 --- src/Somfy.cpp | 6 ++++ src/Somfy.h | 2 ++ src/Web.cpp | 89 ++++++++++++++++++++++++++++++++++----------------- src/Web.h | 1 + 4 files changed, 69 insertions(+), 29 deletions(-) diff --git a/src/Somfy.cpp b/src/Somfy.cpp index 238a514..f1f1dd7 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -5291,6 +5291,12 @@ bool Transceiver::isQueued(uint32_t seq) { // Only meaningful for jobs queued at the back. 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() { TxQueueLock lock; ++txGen; diff --git a/src/Somfy.h b/src/Somfy.h index bf7dd46..570c0ca 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -575,6 +575,8 @@ class Transceiver { void suspendTx(uint8_t reason); void resumeTx(uint8_t reason); bool isQueued(uint32_t seq); + bool txSuspended(); + bool txHasRoom(); 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 6211d5e..604c2dc 100644 --- a/src/Web.cpp +++ b/src/Web.cpp @@ -46,21 +46,34 @@ static const char *TAG = "Web"; 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. -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 = {}; cmd.type = web_cmd_t::set_my_position; cmd.shadeId = shade->getShadeId(); cmd.position = pos; 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 = {}; cmd.type = web_cmd_t::unpair_shade; cmd.shadeId = shade->getShadeId(); - if(!webServer.queueCommand(cmd)) return false; - // Reflect the change in the response; the main loop sets it again and saves. + cmd.wasPaired = shade->paired; + 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; return true; } @@ -121,19 +134,24 @@ bool Web::queueCommand(const web_command_t &cmd) { void Web::processQueue() { if(!webCmdQueue) return; 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) { case web_cmd_t::shade_command: { SomfyShade *shade = somfy.getShadeById(cmd.shadeId); if(shade) { - if(cmd.target <= 100) shade->moveToTarget(shade->transformPosition(cmd.target)); - else shade->sendCommand(cmd.command, cmd.repeat > 0 ? cmd.repeat : shade->repeats, cmd.stepSize); + if(cmd.target <= 100) sent = shade->moveToTarget(shade->transformPosition(cmd.target)); + else sent = shade->sendCommand(cmd.command, cmd.repeat > 0 ? cmd.repeat : shade->repeats, cmd.stepSize); } break; } case web_cmd_t::group_command: { 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; } case web_cmd_t::tilt_command: { @@ -148,16 +166,16 @@ void Web::processQueue() { SomfyShade *shade = somfy.getShadeById(cmd.shadeId); if(shade) { 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); - else shade->repeatFrame(cmd.repeat >= 0 ? cmd.repeat : shade->repeats); + if(!shade->isLastCommand(cmd.command)) sent = shade->sendCommand(cmd.command, cmd.repeat >= 0 ? cmd.repeat : shade->repeats, cmd.stepSize); + else sent = shade->repeatFrame(cmd.repeat >= 0 ? cmd.repeat : shade->repeats); } break; } case web_cmd_t::group_repeat: { SomfyGroup *group = somfy.getGroupById(cmd.groupId); if(group) { - if(!group->isLastCommand(cmd.command)) group->sendCommand(cmd.command, cmd.repeat >= 0 ? cmd.repeat : group->repeats, cmd.stepSize); - else group->repeatFrame(cmd.repeat >= 0 ? cmd.repeat : group->repeats); + if(!group->isLastCommand(cmd.command)) sent = group->sendCommand(cmd.command, cmd.repeat >= 0 ? cmd.repeat : group->repeats, cmd.stepSize); + else sent = group->repeatFrame(cmd.repeat >= 0 ? cmd.repeat : group->repeats); } break; } @@ -173,7 +191,7 @@ void Web::processQueue() { case web_cmd_t::shade_sensor: { SomfyShade *shade = somfy.getShadeById(cmd.shadeId); 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(); } break; @@ -181,7 +199,7 @@ void Web::processQueue() { case web_cmd_t::group_sensor: { SomfyGroup *group = somfy.getGroupById(cmd.groupId); 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(); } break; @@ -194,13 +212,21 @@ void Web::processQueue() { 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(); + sent = shade->sendCommand(somfy_commands::Prog, shade->bitLength == 56 ? 7 : 1); + if(sent) { + shade->paired = false; + shade->save(); + } + else { + // The motor is still paired so undo what the response showed. + shade->paired = cmd.wasPaired; + shade->emitState(); + } } break; } } + if(!sent) ESP_LOGW(TAG, "Radio didn't take web command %u", static_cast(cmd.type)); } } bool Web::isAuthenticated(AsyncWebServerRequest *request, bool cfg) { @@ -830,7 +856,7 @@ void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json) cmd.command = command; cmd.repeat = repeat; cmd.stepSize = stepSize; - if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } + if(!queueRadioCommand(request, cmd)) return; AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -871,7 +897,7 @@ void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json) cmd.command = command; cmd.repeat = repeat; cmd.stepSize = stepSize; - if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } + if(!queueRadioCommand(request, cmd)) return; AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -908,7 +934,7 @@ void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) { cmd.shadeId = shadeId; cmd.target = target; cmd.command = command; - if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } + if(!queueRadioCommand(request, cmd)) return; AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -949,7 +975,7 @@ void Web::handleRepeatCommand(AsyncWebServerRequest *request, JsonVariant &json) cmd.command = command; cmd.repeat = repeat; cmd.stepSize = stepSize; - if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } + if(!queueRadioCommand(request, cmd)) return; AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginArray(); @@ -967,7 +993,7 @@ void Web::handleRepeatCommand(AsyncWebServerRequest *request, JsonVariant &json) cmd.command = command; cmd.repeat = repeat; cmd.stepSize = stepSize; - if(!this->queueCommand(cmd)) { sendQueueFull(request); return; } + if(!queueRadioCommand(request, cmd)) return; AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1062,6 +1088,8 @@ void Web::handleSetPositions(AsyncWebServerRequest *request, JsonVariant &json) cmd.position = pos; cmd.tiltPosition = tiltPos; 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; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1104,7 +1132,9 @@ void Web::handleSetSensor(AsyncWebServerRequest *request, JsonVariant &json) { cmd.sunny = sunny; cmd.windy = windy; 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; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1123,7 +1153,8 @@ void Web::handleSetSensor(AsyncWebServerRequest *request, JsonVariant &json) { cmd.sunny = sunny; cmd.windy = windy; 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; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1644,7 +1675,7 @@ void Web::begin() { if(tilt < 0) tilt = shade->myPos; if(shade->tiltType == tilt_types::none) tilt = -1; 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; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1671,7 +1702,7 @@ void Web::begin() { if(tilt < 0) tilt = shade->myPos; if(shade->tiltType == tilt_types::none) tilt = -1; 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; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1796,7 +1827,7 @@ void Web::begin() { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); } else { - if(!queueUnpairShade(shade)) { sendQueueFull(request); return; } + if(!queueUnpairShade(request, shade)) return; AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -1815,7 +1846,7 @@ void Web::begin() { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); } else { - if(!queueUnpairShade(shade)) { sendQueueFull(request); return; } + if(!queueUnpairShade(request, shade)) return; 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 1041969..cba5111 100644 --- a/src/Web.h +++ b/src/Web.h @@ -35,6 +35,7 @@ struct web_command_t { int8_t tiltPosition; // for setPositions/tilt int8_t sunny; // for sensor int8_t windy; // for sensor + bool wasPaired; // for unpair, restored if the Prog can't be sent }; class Web { From 52c67b12f85cfc3d6bf498cc75cca42fe742bbb7 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 12:54:39 +0200 Subject: [PATCH 13/53] test: add native Unity env with Utils tests Adds an [env:native] for host-side unit tests (pio test -e native) with a minimal Arduino.h stub, and covers the Utils helpers: _trim, toBoolean, SETCHARPROP, sortArray and the Timestamp conversions. The firmware envs now extend a shared [esp32] section instead of [env] so the native env doesn't inherit the Arduino framework, the ESP32 libraries or the build scripts. The half-hour calcTZOffset cases fail: +05:30 comes back as 2300. Co-Authored-By: Claude Opus 5.5 --- platformio.ini | 29 ++++- test/stubs/Arduino.h | 20 ++++ test/test_utils/test_utils.cpp | 197 +++++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 test/stubs/Arduino.h create mode 100644 test/test_utils/test_utils.cpp diff --git a/platformio.ini b/platformio.ini index 3333977..a794091 100644 --- a/platformio.ini +++ b/platformio.ini @@ -12,9 +12,13 @@ default_envs = esp32devdbg src_dir = src -[env] +; Shared by every firmware env (they extend it). Kept out of [env] so the +; native test env doesn't inherit the Arduino framework and ESP32 libraries. +[esp32] platform = espressif32 @ 6.13.0 framework = arduino +; Unit tests only run on the PC: pio test -e native +test_ignore = * lib_deps = bblanchon/ArduinoJson@^7.2.2 lsatan/SmartRC-CC1101-Driver-Lib@^2.5.7 @@ -43,35 +47,40 @@ monitor_filters = ; task on core 1 (with loop()) never competes with network handling. [dualcore] build_flags = - ${env.build_flags} + ${esp32.build_flags} -DCONFIG_ASYNC_TCP_RUNNING_CORE=0 [env:esp32dev] +extends = esp32 board = esp32dev board_build.partitions = esp32_3MB.csv build_flags = ${dualcore.build_flags} [env:esp32devdbg] +extends = esp32 board = esp32dev build_type = debug board_build.partitions = esp32_3MB.csv build_flags = ${dualcore.build_flags} [env:esp32c3] +extends = esp32 board = esp32-c3-devkitm-1 board_build.partitions = esp32_3MB.csv [env:esp32s3] +extends = esp32 board = esp32-s3-devkitc-1 build_flags = ${dualcore.build_flags} [env:esp32c6] +extends = esp32 platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip board = seeed_xiao_esp32c6 build_type = debug board_build.partitions = esp32_huge_app.csv -build_flags = - ${env.build_flags} +build_flags = + ${esp32.build_flags} -DCONFIG_FREERTOS_HZ=1000 -include "driver/gpio.h" lib_deps = @@ -86,3 +95,15 @@ lib_deps = ; High-level wrappers/servers ESP32Async/ESPAsyncWebServer +; Host-side unit tests: pio test -e native (needs gcc/g++ on PATH). +; Only the sources the tests cover are built; test/stubs stands in for Arduino.h. +[env:native] +platform = native +test_framework = unity +test_build_src = yes +build_src_filter = -<*> + +build_flags = + -std=gnu++17 + -Isrc + -Itest/stubs + -D_POSIX_THREAD_SAFE_FUNCTIONS diff --git a/test/stubs/Arduino.h b/test/stubs/Arduino.h new file mode 100644 index 0000000..3755257 --- /dev/null +++ b/test/stubs/Arduino.h @@ -0,0 +1,20 @@ +// Minimal stand-in for the Arduino core so firmware sources can be built for +// the native test env (pio test -e native). Only add what tested code uses. +#ifndef arduino_stub_h +#define arduino_stub_h +#include +#include +#include +#include +#include +#include +#include + +// Fixed so anything formatted from it is deterministic. +inline unsigned long millis() { return 0; } +inline bool getLocalTime(struct tm *info, uint32_t ms = 5000) { + (void)ms; + time_t now = time(nullptr); + return localtime_r(&now, info) != nullptr; +} +#endif diff --git a/test/test_utils/test_utils.cpp b/test/test_utils/test_utils.cpp new file mode 100644 index 0000000..0a14039 --- /dev/null +++ b/test/test_utils/test_utils.cpp @@ -0,0 +1,197 @@ +#include +#include "Utils.h" + +// The C runtime only picks up TZ after it is re-read. +static void setTZ(const char *tz) { +#ifdef _WIN32 + _putenv_s("TZ", tz); + _tzset(); +#else + setenv("TZ", tz, 1); + tzset(); +#endif +} + +// 2024-03-05T07:08:09Z +static const time_t SAMPLE_UTC = 1709622489; + +void setUp() { setTZ("UTC0"); } +void tearDown() {} + +/********************************************************************* + * _trim + ********************************************************************/ +static void test_trim_strips_whitespace_and_quotes() { + char str[] = " \t\"Living Room\"\r\n"; + _trim(str); + TEST_ASSERT_EQUAL_STRING("Living Room", str); +} +static void test_trim_keeps_clean_string() { + char str[] = "Kitchen"; + _trim(str); + TEST_ASSERT_EQUAL_STRING("Kitchen", str); +} +static void test_trim_all_whitespace_becomes_empty() { + char str[] = " \t\r\n\" "; + _trim(str); + TEST_ASSERT_EQUAL_STRING("", str); +} +static void test_trim_empty_string() { + char str[] = ""; + _trim(str); + TEST_ASSERT_EQUAL_STRING("", str); +} + +/********************************************************************* + * toBoolean + ********************************************************************/ +static void test_toBoolean_missing_uses_default() { + TEST_ASSERT_TRUE(toBoolean(nullptr, true)); + TEST_ASSERT_FALSE(toBoolean(nullptr, false)); + TEST_ASSERT_TRUE(toBoolean("", true)); + TEST_ASSERT_FALSE(toBoolean("", false)); +} +static void test_toBoolean_true_values() { + TEST_ASSERT_TRUE(toBoolean("true", false)); + TEST_ASSERT_TRUE(toBoolean("True", false)); + TEST_ASSERT_TRUE(toBoolean("1", false)); +} +static void test_toBoolean_false_values() { + TEST_ASSERT_FALSE(toBoolean("false", true)); + TEST_ASSERT_FALSE(toBoolean("False", true)); + TEST_ASSERT_FALSE(toBoolean("0", true)); +} +static void test_toBoolean_unknown_uses_default() { + TEST_ASSERT_TRUE(toBoolean("yes", true)); + TEST_ASSERT_FALSE(toBoolean("yes", false)); +} + +/********************************************************************* + * SETCHARPROP + ********************************************************************/ +static void test_setcharprop_copies_short_value() { + char prop[8]; + SETCHARPROP(prop, "abc", sizeof(prop)); + TEST_ASSERT_EQUAL_STRING("abc", prop); +} +static void test_setcharprop_truncates_long_value() { + char prop[5]; + SETCHARPROP(prop, "abcdefgh", sizeof(prop)); + TEST_ASSERT_EQUAL_STRING("abcd", prop); +} + +/********************************************************************* + * sortArray / sortArrayReverse + ********************************************************************/ +static bool absLarger(int a, int b) { return abs(a) > abs(b); } + +static void test_sortArray_ascending() { + int arr[] = {5, -1, 3, 0, 3}; + const int expected[] = {-1, 0, 3, 3, 5}; + sortArray(arr, 5); + TEST_ASSERT_EQUAL_INT_ARRAY(expected, arr, 5); +} +static void test_sortArrayReverse_descending() { + int arr[] = {5, -1, 3, 0, 3}; + const int expected[] = {5, 3, 3, 0, -1}; + sortArrayReverse(arr, 5); + TEST_ASSERT_EQUAL_INT_ARRAY(expected, arr, 5); +} +static void test_sortArray_custom_comparator() { + int arr[] = {-4, 1, 3, -2}; + const int expected[] = {1, -2, 3, -4}; + sortArray(arr, 4, absLarger); + TEST_ASSERT_EQUAL_INT_ARRAY(expected, arr, 4); + const int reversed[] = {-4, 3, -2, 1}; + sortArrayReverse(arr, 4, absLarger); + TEST_ASSERT_EQUAL_INT_ARRAY(reversed, arr, 4); +} +static void test_sortArray_empty_and_single() { + int one[] = {42}; + sortArray(one, 1); + TEST_ASSERT_EQUAL_INT(42, one[0]); + int none[] = {7}; + sortArray(none, 0); + TEST_ASSERT_EQUAL_INT(7, none[0]); +} +static void test_sortArray_moves_free_ids_last() { + // Free shade id slots are 255 and must end up after the used ones. + uint8_t ids[] = {3, 255, 1, 255, 2}; + const uint8_t expected[] = {1, 2, 3, 255, 255}; + sortArray(ids, sizeof(ids)); + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, ids, sizeof(ids)); +} + +/********************************************************************* + * Timestamp + ********************************************************************/ +static struct tm sampleTm() { + struct tm dt = {}; + dt.tm_year = 2024 - 1900; + dt.tm_mon = 2; + dt.tm_mday = 5; + dt.tm_hour = 7; + dt.tm_min = 8; + dt.tm_sec = 9; + return dt; +} +static void test_formatISO_offsets() { + Timestamp ts; + struct tm dt = sampleTm(); + TEST_ASSERT_EQUAL_STRING("2024-03-05T07:08:09.000+0000", ts.formatISO(&dt, 0)); + TEST_ASSERT_EQUAL_STRING("2024-03-05T07:08:09.000-0500", ts.formatISO(&dt, -500)); + TEST_ASSERT_EQUAL_STRING("2024-03-05T07:08:09.000+0530", ts.formatISO(&dt, 530)); +} +static void test_calcTZOffset_whole_hours() { + time_t t = SAMPLE_UTC; + setTZ("UTC0"); + TEST_ASSERT_EQUAL_INT(0, Timestamp::calcTZOffset(&t)); + setTZ("EST5"); + TEST_ASSERT_EQUAL_INT(-500, Timestamp::calcTZOffset(&t)); +} +static void test_calcTZOffset_half_hour_east() { + time_t t = SAMPLE_UTC; + setTZ("IST-5:30"); + TEST_ASSERT_EQUAL_INT(530, Timestamp::calcTZOffset(&t)); +} +static void test_calcTZOffset_half_hour_west() { + time_t t = SAMPLE_UTC; + setTZ("NST3:30"); + TEST_ASSERT_EQUAL_INT(-330, Timestamp::calcTZOffset(&t)); +} +static void test_parseUTCTime_in_utc() { + setTZ("UTC0"); + TEST_ASSERT_EQUAL_INT64((int64_t)SAMPLE_UTC, (int64_t)Timestamp::parseUTCTime("2024-03-05T07:08:09Z")); +} +static void test_parseUTCTime_ignores_local_zone() { + setTZ("IST-5:30"); + TEST_ASSERT_EQUAL_INT64((int64_t)SAMPLE_UTC, (int64_t)Timestamp::parseUTCTime("2024-03-05T07:08:09Z")); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_trim_strips_whitespace_and_quotes); + RUN_TEST(test_trim_keeps_clean_string); + RUN_TEST(test_trim_all_whitespace_becomes_empty); + RUN_TEST(test_trim_empty_string); + RUN_TEST(test_toBoolean_missing_uses_default); + RUN_TEST(test_toBoolean_true_values); + RUN_TEST(test_toBoolean_false_values); + RUN_TEST(test_toBoolean_unknown_uses_default); + RUN_TEST(test_setcharprop_copies_short_value); + RUN_TEST(test_setcharprop_truncates_long_value); + RUN_TEST(test_sortArray_ascending); + RUN_TEST(test_sortArrayReverse_descending); + RUN_TEST(test_sortArray_custom_comparator); + RUN_TEST(test_sortArray_empty_and_single); + RUN_TEST(test_sortArray_moves_free_ids_last); + RUN_TEST(test_formatISO_offsets); + RUN_TEST(test_calcTZOffset_whole_hours); + RUN_TEST(test_calcTZOffset_half_hour_east); + RUN_TEST(test_calcTZOffset_half_hour_west); + RUN_TEST(test_parseUTCTime_in_utc); + RUN_TEST(test_parseUTCTime_ignores_local_zone); + return UNITY_END(); +} From aa23b4624df1405b82bd49319ecf6a779d952341 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 12:54:48 +0200 Subject: [PATCH 14/53] ci: run native unit tests before firmware builds Adds a "Unit tests" job running pio test -e native on ubuntu-latest. The firmware build matrix waits for it, so a failing test also stops release uploads. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index dcd067d..bbc23e1 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -8,7 +8,28 @@ on: types: [published] jobs: + test: + name: Unit tests + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install PlatformIO + run: pip install platformio + + - name: Run native unit tests + run: pio test -e native + build: + # Don't build or publish firmware that fails its tests. + needs: test permissions: write-all name: ${{ matrix.name }} runs-on: ubuntu-latest From 02df2daaab8d7e040f3efa5fb12b59aedecaa5d0 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 12:54:56 +0200 Subject: [PATCH 15/53] fix: report half-hour time zones correctly in ISO timestamps calcTZOffset added the leftover seconds instead of minutes to the hhmm offset, so +05:30 came out as 2300 and ISO timestamps showed +2300. Co-Authored-By: Claude Opus 5.5 --- src/Utils.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Utils.cpp b/src/Utils.cpp index 5d461a3..74af2ea 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -120,8 +120,9 @@ int Timestamp::calcTZOffset(time_t *dt) { localtime_r(dt, &tmLocal); long diff = mktime(&tmLocal) - mktime(&tmUTC); if(tmLocal.tm_isdst) diff += 3600; + // Encoded as hhmm, e.g. +05:30 is 530 and -03:30 is -330. int hrs = (int)((diff/3600) * 100); - int mins = diff - (hrs * 36); + int mins = (int)((diff % 3600) / 60); return hrs + mins; } int Timestamp::tzOffset() { From bb16c90a5a9019b10cf9499c1ffeda6f4a06d949 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 13:18:37 +0200 Subject: [PATCH 16/53] refactor: move the Somfy frame codec into SomfyFrame.cpp The command and protocol enums, somfy_frame_t and its encode/decode methods, and both translateSomfyCommand overloads move verbatim out of Somfy.cpp/Somfy.h, which now include SomfyFrame.h. The codec doesn't depend on the radio, storage or network, so on its own it can be unit tested on the PC. decodeFrame(somfy_rx_t*) stays in Somfy.cpp since it reads the RSSI from the CC1101. No behaviour change. Co-Authored-By: Claude Opus 5.5 --- src/Somfy.cpp | 400 -------------------------------------------- src/Somfy.h | 62 +------ src/SomfyFrame.cpp | 406 +++++++++++++++++++++++++++++++++++++++++++++ src/SomfyFrame.h | 70 ++++++++ 4 files changed, 477 insertions(+), 461 deletions(-) create mode 100644 src/SomfyFrame.cpp create mode 100644 src/SomfyFrame.h diff --git a/src/Somfy.cpp b/src/Somfy.cpp index f1f1dd7..06c33d7 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -149,198 +149,6 @@ static volatile bool noiseDetected = false; static volatile unsigned long noiseStart = 0; static volatile uint32_t noiseCount = 0; static uint8_t bit_length = 56; -somfy_commands translateSomfyCommand(const String& string) { - if (string.equalsIgnoreCase("My")) return somfy_commands::My; - else if (string.equalsIgnoreCase("Up")) return somfy_commands::Up; - else if (string.equalsIgnoreCase("MyUp")) return somfy_commands::MyUp; - else if (string.equalsIgnoreCase("Down")) return somfy_commands::Down; - else if (string.equalsIgnoreCase("MyDown")) return somfy_commands::MyDown; - else if (string.equalsIgnoreCase("UpDown")) return somfy_commands::UpDown; - else if (string.equalsIgnoreCase("MyUpDown")) return somfy_commands::MyUpDown; - else if (string.equalsIgnoreCase("Prog")) return somfy_commands::Prog; - else if (string.equalsIgnoreCase("SunFlag")) return somfy_commands::SunFlag; - else if (string.equalsIgnoreCase("StepUp")) return somfy_commands::StepUp; - else if (string.equalsIgnoreCase("StepDown")) return somfy_commands::StepDown; - else if (string.equalsIgnoreCase("Flag")) return somfy_commands::Flag; - else if (string.equalsIgnoreCase("Sensor")) return somfy_commands::Sensor; - else if (string.equalsIgnoreCase("Toggle")) return somfy_commands::Toggle; - else if (string.equalsIgnoreCase("Favorite")) return somfy_commands::Favorite; - else if (string.equalsIgnoreCase("Stop")) return somfy_commands::Stop; - else if (string.startsWith("fav") || string.startsWith("FAV")) return somfy_commands::Favorite; - else if (string.startsWith("mud") || string.startsWith("MUD")) return somfy_commands::MyUpDown; - else if (string.startsWith("md") || string.startsWith("MD")) return somfy_commands::MyDown; - else if (string.startsWith("ud") || string.startsWith("UD")) return somfy_commands::UpDown; - else if (string.startsWith("mu") || string.startsWith("MU")) return somfy_commands::MyUp; - else if (string.startsWith("su") || string.startsWith("SU")) return somfy_commands::StepUp; - else if (string.startsWith("sd") || string.startsWith("SD")) return somfy_commands::StepDown; - else if (string.startsWith("sen") || string.startsWith("SEN")) return somfy_commands::Sensor; - else if (string.startsWith("p") || string.startsWith("P")) return somfy_commands::Prog; - else if (string.startsWith("u") || string.startsWith("U")) return somfy_commands::Up; - else if (string.startsWith("d") || string.startsWith("D")) return somfy_commands::Down; - else if (string.startsWith("m") || string.startsWith("M")) return somfy_commands::My; - else if (string.startsWith("f") || string.startsWith("F")) return somfy_commands::Flag; - else if (string.startsWith("s") || string.startsWith("S")) return somfy_commands::SunFlag; - else if (string.startsWith("t") || string.startsWith("T")) return somfy_commands::Toggle; - else if (string.length() == 1) return static_cast(strtol(string.c_str(), nullptr, 16)); - else return somfy_commands::My; -} -String translateSomfyCommand(const somfy_commands cmd) { - switch (cmd) { - case somfy_commands::Up: - return "Up"; - case somfy_commands::Down: - return "Down"; - case somfy_commands::My: - return "My"; - case somfy_commands::MyUp: - return "My+Up"; - case somfy_commands::UpDown: - return "Up+Down"; - case somfy_commands::MyDown: - return "My+Down"; - case somfy_commands::MyUpDown: - return "My+Up+Down"; - case somfy_commands::Prog: - return "Prog"; - case somfy_commands::SunFlag: - return "Sun Flag"; - case somfy_commands::Flag: - return "Flag"; - case somfy_commands::StepUp: - return "Step Up"; - case somfy_commands::StepDown: - return "Step Down"; - case somfy_commands::Sensor: - return "Sensor"; - case somfy_commands::Toggle: - return "Toggle"; - case somfy_commands::Favorite: - return "Favorite"; - case somfy_commands::Stop: - return "Stop"; - default: - return "Unknown(" + String((uint8_t)cmd) + ")"; - } -} -byte somfy_frame_t::calc80Checksum(byte b0, byte b1, byte b2) { - byte cs80 = 0; - cs80 = (((b0 & 0xF0) >> 4) ^ ((b1 & 0xF0) >> 4)); - cs80 ^= ((b2 & 0xF0) >> 4); - cs80 ^= (b0 & 0x0F); - cs80 ^= (b1 & 0x0F); - return cs80; -} - -void somfy_frame_t::decodeFrame(byte* frame) { - byte decoded[10]; - decoded[0] = frame[0]; - // The last 3 bytes are not encoded even on 80-bits. Go figure. - decoded[7] = frame[7]; - decoded[8] = frame[8]; - decoded[9] = frame[9]; - for (byte i = 1; i < 7; i++) { - decoded[i] = frame[i] ^ frame[i - 1]; - } - byte checksum = 0; - // We only want the upper nibble for the command byte. - for (byte i = 0; i < 7; i++) { - if (i == 1) checksum = checksum ^ (decoded[i] >> 4); - else checksum = checksum ^ decoded[i] ^ (decoded[i] >> 4); - } - checksum &= 0b1111; // We keep the last 4 bits only - - this->checksum = decoded[1] & 0b1111; - this->encKey = decoded[0]; - // Lets first determine the protocol. - this->rawCmd = decoded[1] >> 4; - this->cmd = (somfy_commands)(this->rawCmd); - if(this->cmd == somfy_commands::RTWProto) { - if(this->encKey >= 160) { - this->proto = radio_proto::RTS; - if(this->encKey == 164) this->cmd = somfy_commands::Toggle; - } - else if(this->encKey > 148) { - this->proto = radio_proto::RTV; - this->cmd = (somfy_commands)(this->encKey - 148); - } - else if(this->encKey >= 133) { - this->proto = radio_proto::RTW; - this->cmd = (somfy_commands)(this->encKey - 132); - } - } - else this->proto = radio_proto::RTS; - // We reuse this memory address so we must reset the processed - // flag. This will ensure we can see frames on the first beat. - this->processed = false; - this->rollingCode = decoded[3] + (decoded[2] << 8); - this->remoteAddress = (decoded[6] + (decoded[5] << 8) + (decoded[4] << 16)); - this->valid = this->checksum == checksum && this->remoteAddress > 0 && this->remoteAddress < 16777215; - if (this->cmd != somfy_commands::Sensor && this->valid) this->valid = (this->rollingCode > 0); - // Next lets process some of the RTS extensions for 80-bit frames - if(this->valid && this->proto == radio_proto::RTS && this->bitLength == 80) { - // Do a parity checksum on the 80 bit data. - if((decoded[9] & 0x0F) != this->calc80Checksum(decoded[7], decoded[8], decoded[9])) this->valid = false; - if(this->valid) { - // Translate extensions for stop and favorite. - if(this->cmd == somfy_commands::My) this->cmd = (somfy_commands)((decoded[1] >> 4) | ((decoded[8] & 0x0F) << 4)); - // Bit packing to get the step size prohibits translation on the byte. - else if(this->cmd == somfy_commands::StepDown) this->cmd = (somfy_commands)((decoded[1] >> 4) | ((decoded[8] & 0x08) << 4)); - } - } - if (this->valid) { - - // Check for valid command. - switch (this->cmd) { - //case somfy_commands::Unknown0: - case somfy_commands::My: - case somfy_commands::Up: - case somfy_commands::MyUp: - case somfy_commands::Down: - case somfy_commands::MyDown: - case somfy_commands::UpDown: - case somfy_commands::MyUpDown: - case somfy_commands::Prog: - case somfy_commands::Flag: - case somfy_commands::SunFlag: - case somfy_commands::Sensor: - break; - case somfy_commands::UnknownD: - case somfy_commands::RTWProto: - this->valid = false; - break; - case somfy_commands::StepUp: - case somfy_commands::StepDown: - // Decode the step size. - this->stepSize = ((decoded[8] & 0x07) << 4) | ((decoded[9] & 0xF0) >> 4); - break; - case somfy_commands::Toggle: - case somfy_commands::Favorite: - case somfy_commands::Stop: - break; - default: - this->valid = false; - break; - } - } - if(this->valid && this->encKey == 0) this->valid = false; - if (!this->valid) { - ESP_LOGW(TAG, "INVALID FRAME KEY:%d ADDR:%d CMD:%s RCODE:%d", - this->encKey, this->remoteAddress, - translateSomfyCommand(this->cmd).c_str(), this->rollingCode); - ESP_LOGD(TAG, " KEY 1 2 3 4 5 6 "); - ESP_LOGD(TAG, "--------------------------------"); - char enc_buf[64], dec_buf[64]; - int enc_pos = 0, dec_pos = 0; - enc_pos += snprintf(enc_buf + enc_pos, sizeof(enc_buf) - enc_pos, "ENC "); - dec_pos += snprintf(dec_buf + dec_pos, sizeof(dec_buf) - dec_pos, "DEC "); - for (byte i = 0; i < 10; i++) { - enc_pos += snprintf(enc_buf + enc_pos, sizeof(enc_buf) - enc_pos, "%3d ", frame[i]); - dec_pos += snprintf(dec_buf + dec_pos, sizeof(dec_buf) - dec_pos, "%3d ", decoded[i]); - } - ESP_LOGD(TAG, "%s", enc_buf); - ESP_LOGD(TAG, "%s", dec_buf); - } -} void somfy_frame_t::decodeFrame(somfy_rx_t *rx) { this->hwsync = rx->cpt_synchro_hw; this->pulseCount = rx->pulseCount; @@ -353,214 +161,6 @@ void somfy_frame_t::decodeFrame(somfy_rx_t *rx) { } this->decodeFrame(rx->payload); } -byte somfy_frame_t::encode80Byte7(byte start, uint8_t repeat) { - while((repeat * 4) + start > 255) repeat -= 15; - return start + (repeat * 4); -} -void somfy_frame_t::encode80BitFrame(byte *frame, uint8_t repeat) { - switch(this->cmd) { - // Step up and down commands encode the step size into the last 3 bytes. - case somfy_commands::StepUp: - if(repeat == 0) frame[1] = (static_cast(somfy_commands::StepDown) << 4) | (frame[1] & 0x0F); - if(this->stepSize == 0) this->stepSize = 1; - frame[7] = 132; // For simplicity this appears to be constant. - frame[8] = ((this->stepSize & 0x70) >> 4) | 0x38; - frame[9] = ((this->stepSize & 0x0F) << 4); - frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]); - break; - case somfy_commands::StepDown: - if(repeat == 0) frame[1] = (static_cast(somfy_commands::StepDown) << 4) | (frame[1] & 0x0F); - if(this->stepSize == 0) this->stepSize = 1; - frame[7] = 132; // For simplicity this appears to be constant. - frame[8] = ((this->stepSize & 0x70) >> 4) | 0x30; - frame[9] = ((this->stepSize & 0x0F) << 4); - frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]); - break; - case somfy_commands::Favorite: - if(repeat == 0) frame[1] = (static_cast(somfy_commands::My) << 4) | (frame[1] & 0x0F); - frame[7] = repeat > 0 ? 132 : 196; - frame[8] = 44; - frame[9] = 0x90; - frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]); - break; - case somfy_commands::Stop: - if(repeat == 0) frame[1] = (static_cast(somfy_commands::My) << 4) | (frame[1] & 0x0F); - frame[7] = repeat > 0 ? 132 : 196; - frame[8] = 47; - frame[9] = 0xF0; - frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]); - break; - case somfy_commands::Toggle: - frame[0] = 164; - frame[1] |= 0xF0; - frame[7] = this->encode80Byte7(196, repeat); - frame[8] = 0; - frame[9] = 0x10; - frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]); - break; - case somfy_commands::Up: - frame[7] = this->encode80Byte7(196, repeat); - frame[8] = 32; - frame[9] = 0x00; - frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]); - break; - case somfy_commands::Down: - frame[7] = this->encode80Byte7(196, repeat); - frame[8] = 44; - frame[9] = 0x80; - frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]); - break; - case somfy_commands::Prog: - case somfy_commands::UpDown: - case somfy_commands::MyDown: - case somfy_commands::MyUp: - case somfy_commands::MyUpDown: - case somfy_commands::My: - frame[7] = this->encode80Byte7(196, repeat); - frame[8] = 0x00; - frame[9] = 0x10; - frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]); - break; - - default: - break; - } -} -void somfy_frame_t::encodeFrame(byte *frame) { - const byte btn = static_cast(cmd); - this->valid = true; - frame[0] = this->encKey; // Encryption key. Doesn't matter much - frame[1] = (btn & 0x0F) << 4; // Which button did you press? The 4 LSB will be the checksum - frame[2] = this->rollingCode >> 8; // Rolling code (big endian) - frame[3] = this->rollingCode; // Rolling code - frame[4] = this->remoteAddress >> 16; // Remote address - frame[5] = this->remoteAddress >> 8; // Remote address - frame[6] = this->remoteAddress; // Remote address - frame[7] = 132; - frame[8] = 0; - frame[9] = 29; - // Ok so if this is an RTW things are a bit different. - if(this->proto == radio_proto::RTW) { - frame[1] = 0xF0; - switch(this->cmd) { - case somfy_commands::My: - frame[0] = 133; - break; - case somfy_commands::Up: - frame[0] = 134; - break; - case somfy_commands::MyUp: - frame[0] = 135; - break; - case somfy_commands::Down: - frame[0] = 136; - break; - case somfy_commands::MyDown: - frame[0] = 137; - break; - case somfy_commands::UpDown: - frame[0] = 138; - break; - case somfy_commands::MyUpDown: - frame[0] = 139; - break; - case somfy_commands::Prog: - frame[0] = 140; - break; - case somfy_commands::SunFlag: - frame[0] = 141; - break; - case somfy_commands::Flag: - frame[0] = 142; - break; - default: - break; - } - } - else if(this->proto == radio_proto::RTV) { - frame[1] = 0xF0; - switch(this->cmd) { - case somfy_commands::My: - frame[0] = 149; - break; - case somfy_commands::Up: - frame[0] = 150; - break; - case somfy_commands::MyUp: - frame[0] = 151; - break; - case somfy_commands::Down: - frame[0] = 152; - break; - case somfy_commands::MyDown: - frame[0] = 153; - break; - case somfy_commands::UpDown: - frame[0] = 154; - break; - case somfy_commands::MyUpDown: - frame[0] = 155; - break; - case somfy_commands::Prog: - frame[0] = 156; - break; - case somfy_commands::SunFlag: - frame[0] = 157; - break; - case somfy_commands::Flag: - frame[0] = 158; - break; - default: - break; - } - - } - else { - if(this->bitLength == 80) this->encode80BitFrame(&frame[0], this->repeats); - } - byte checksum = 0; - - for (byte i = 0; i < 7; i++) { - checksum = checksum ^ frame[i] ^ (frame[i] >> 4); - } - checksum &= 0b1111; // We keep the last 4 bits only - // Checksum integration - frame[1] |= checksum; - // Obfuscation: a XOR of all the bytes - for (byte i = 1; i < 7; i++) { - frame[i] ^= frame[i - 1]; - } -} -void somfy_frame_t::print() { - ESP_LOGD(TAG, "----------- Receiving -------------"); - ESP_LOGD(TAG, "RSSI:%d LQI:%d", this->rssi, this->lqi); - ESP_LOGD(TAG, "CMD:%s ADDR:%d RCODE:%d", - translateSomfyCommand(this->cmd).c_str(), this->remoteAddress, this->rollingCode); - ESP_LOGD(TAG, "KEY:%02X CS:%d", this->encKey, this->checksum); -} -bool somfy_frame_t::isSynonym(somfy_frame_t &frame) { return this->remoteAddress == frame.remoteAddress && this->cmd != frame.cmd && this->rollingCode == frame.rollingCode; } -bool somfy_frame_t::isRepeat(somfy_frame_t &frame) { return this->remoteAddress == frame.remoteAddress && this->cmd == frame.cmd && this->rollingCode == frame.rollingCode; } -void somfy_frame_t::copy(somfy_frame_t &frame) { - if(this->isRepeat(frame)) { - this->repeats++; - this->rssi = frame.rssi; - this->lqi = frame.lqi; - } - else { - this->synonym = this->isSynonym(frame); - this->valid = frame.valid; - if(!this->synonym) this->processed = frame.processed; - this->rssi = frame.rssi; - this->lqi = frame.lqi; - this->cmd = frame.cmd; - this->remoteAddress = frame.remoteAddress; - this->rollingCode = frame.rollingCode; - this->encKey = frame.encKey; - this->checksum = frame.checksum; - this->hwsync = frame.hwsync; - this->repeats = frame.repeats; - } -} void SomfyShadeController::end() { this->transceiver.disableReceive(); } SomfyShadeController::SomfyShadeController() { memset(this->m_shadeIds, 255, sizeof(this->m_shadeIds)); diff --git a/src/Somfy.h b/src/Somfy.h index 570c0ca..b680b0b 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -21,35 +21,7 @@ #define SOMFY_NO_WIND_REMOTE_TIMEOUT SECS_TO_MILLIS(30) -enum class radio_proto : byte { // Ordinal byte 0-255 - RTS = 0x00, - RTW = 0x01, - RTV = 0x02, - GP_Relay = 0x08, - GP_Remote = 0x09 -}; -enum class somfy_commands : byte { - Unknown0 = 0x0, - My = 0x1, - Up = 0x2, - MyUp = 0x3, - Down = 0x4, - MyDown = 0x5, - UpDown = 0x6, - MyUpDown = 0x7, - Prog = 0x8, - SunFlag = 0x9, - Flag = 0xA, - StepDown = 0xB, - Toggle = 0xC, - UnknownD = 0xD, - Sensor = 0xE, - RTWProto = 0xF, // RTW Protocol - // Command extensions for 80 bit frames - StepUp = 0x8B, - Favorite = 0xC1, - Stop = 0xF1 -}; +#include "SomfyFrame.h" enum class group_types : byte { channel = 0x00 }; @@ -79,8 +51,6 @@ enum class tilt_types : byte { tiltonly = 0x03, euromode = 0x04 }; -String translateSomfyCommand(const somfy_commands cmd); -somfy_commands translateSomfyCommand(const String& string); #define MAX_TIMINGS 300 #define MAX_RX_BUFFER 3 @@ -171,36 +141,6 @@ struct somfy_relay_t { uint8_t sync = 0; byte frame[10] = {0}; }; -struct somfy_frame_t { - bool valid = false; - bool processed = false; - bool synonym = false; - radio_proto proto = radio_proto::RTS; - int rssi = 0; - byte lqi = 0x0; - somfy_commands cmd; - uint32_t remoteAddress = 0; - uint16_t rollingCode = 0; - uint8_t encKey = 0xA7; - uint8_t checksum = 0; - uint8_t hwsync = 0; - uint8_t repeats = 0; - uint32_t await = 0; - uint8_t bitLength = 56; - uint16_t pulseCount = 0; - uint8_t stepSize = 0; - uint8_t rawCmd = 0; - void print(); - void encode80BitFrame(byte *frame, uint8_t repeat); - byte calc80Checksum(byte b0, byte b1, byte b2); - byte encode80Byte7(byte start, uint8_t repeat); - void encodeFrame(byte *frame); - void decodeFrame(byte* frame); - void decodeFrame(somfy_rx_t *rx); - bool isRepeat(somfy_frame_t &f); - bool isSynonym(somfy_frame_t &f); - void copy(somfy_frame_t &f); -}; #define TX_JOB_QUEUE_SIZE 32 // Slots only priority jobs (stops) may use so a stop always fits in the queue. #define TX_RESERVED_SLOTS 4 diff --git a/src/SomfyFrame.cpp b/src/SomfyFrame.cpp new file mode 100644 index 0000000..9e58f7d --- /dev/null +++ b/src/SomfyFrame.cpp @@ -0,0 +1,406 @@ +#include +#include "esp_log.h" +#include "SomfyFrame.h" + +static const char *TAG = "Somfy"; + +somfy_commands translateSomfyCommand(const String& string) { + if (string.equalsIgnoreCase("My")) return somfy_commands::My; + else if (string.equalsIgnoreCase("Up")) return somfy_commands::Up; + else if (string.equalsIgnoreCase("MyUp")) return somfy_commands::MyUp; + else if (string.equalsIgnoreCase("Down")) return somfy_commands::Down; + else if (string.equalsIgnoreCase("MyDown")) return somfy_commands::MyDown; + else if (string.equalsIgnoreCase("UpDown")) return somfy_commands::UpDown; + else if (string.equalsIgnoreCase("MyUpDown")) return somfy_commands::MyUpDown; + else if (string.equalsIgnoreCase("Prog")) return somfy_commands::Prog; + else if (string.equalsIgnoreCase("SunFlag")) return somfy_commands::SunFlag; + else if (string.equalsIgnoreCase("StepUp")) return somfy_commands::StepUp; + else if (string.equalsIgnoreCase("StepDown")) return somfy_commands::StepDown; + else if (string.equalsIgnoreCase("Flag")) return somfy_commands::Flag; + else if (string.equalsIgnoreCase("Sensor")) return somfy_commands::Sensor; + else if (string.equalsIgnoreCase("Toggle")) return somfy_commands::Toggle; + else if (string.equalsIgnoreCase("Favorite")) return somfy_commands::Favorite; + else if (string.equalsIgnoreCase("Stop")) return somfy_commands::Stop; + else if (string.startsWith("fav") || string.startsWith("FAV")) return somfy_commands::Favorite; + else if (string.startsWith("mud") || string.startsWith("MUD")) return somfy_commands::MyUpDown; + else if (string.startsWith("md") || string.startsWith("MD")) return somfy_commands::MyDown; + else if (string.startsWith("ud") || string.startsWith("UD")) return somfy_commands::UpDown; + else if (string.startsWith("mu") || string.startsWith("MU")) return somfy_commands::MyUp; + else if (string.startsWith("su") || string.startsWith("SU")) return somfy_commands::StepUp; + else if (string.startsWith("sd") || string.startsWith("SD")) return somfy_commands::StepDown; + else if (string.startsWith("sen") || string.startsWith("SEN")) return somfy_commands::Sensor; + else if (string.startsWith("p") || string.startsWith("P")) return somfy_commands::Prog; + else if (string.startsWith("u") || string.startsWith("U")) return somfy_commands::Up; + else if (string.startsWith("d") || string.startsWith("D")) return somfy_commands::Down; + else if (string.startsWith("m") || string.startsWith("M")) return somfy_commands::My; + else if (string.startsWith("f") || string.startsWith("F")) return somfy_commands::Flag; + else if (string.startsWith("s") || string.startsWith("S")) return somfy_commands::SunFlag; + else if (string.startsWith("t") || string.startsWith("T")) return somfy_commands::Toggle; + else if (string.length() == 1) return static_cast(strtol(string.c_str(), nullptr, 16)); + else return somfy_commands::My; +} +String translateSomfyCommand(const somfy_commands cmd) { + switch (cmd) { + case somfy_commands::Up: + return "Up"; + case somfy_commands::Down: + return "Down"; + case somfy_commands::My: + return "My"; + case somfy_commands::MyUp: + return "My+Up"; + case somfy_commands::UpDown: + return "Up+Down"; + case somfy_commands::MyDown: + return "My+Down"; + case somfy_commands::MyUpDown: + return "My+Up+Down"; + case somfy_commands::Prog: + return "Prog"; + case somfy_commands::SunFlag: + return "Sun Flag"; + case somfy_commands::Flag: + return "Flag"; + case somfy_commands::StepUp: + return "Step Up"; + case somfy_commands::StepDown: + return "Step Down"; + case somfy_commands::Sensor: + return "Sensor"; + case somfy_commands::Toggle: + return "Toggle"; + case somfy_commands::Favorite: + return "Favorite"; + case somfy_commands::Stop: + return "Stop"; + default: + return "Unknown(" + String((uint8_t)cmd) + ")"; + } +} +byte somfy_frame_t::calc80Checksum(byte b0, byte b1, byte b2) { + byte cs80 = 0; + cs80 = (((b0 & 0xF0) >> 4) ^ ((b1 & 0xF0) >> 4)); + cs80 ^= ((b2 & 0xF0) >> 4); + cs80 ^= (b0 & 0x0F); + cs80 ^= (b1 & 0x0F); + return cs80; +} + +void somfy_frame_t::decodeFrame(byte* frame) { + byte decoded[10]; + decoded[0] = frame[0]; + // The last 3 bytes are not encoded even on 80-bits. Go figure. + decoded[7] = frame[7]; + decoded[8] = frame[8]; + decoded[9] = frame[9]; + for (byte i = 1; i < 7; i++) { + decoded[i] = frame[i] ^ frame[i - 1]; + } + byte checksum = 0; + // We only want the upper nibble for the command byte. + for (byte i = 0; i < 7; i++) { + if (i == 1) checksum = checksum ^ (decoded[i] >> 4); + else checksum = checksum ^ decoded[i] ^ (decoded[i] >> 4); + } + checksum &= 0b1111; // We keep the last 4 bits only + + this->checksum = decoded[1] & 0b1111; + this->encKey = decoded[0]; + // Lets first determine the protocol. + this->rawCmd = decoded[1] >> 4; + this->cmd = (somfy_commands)(this->rawCmd); + if(this->cmd == somfy_commands::RTWProto) { + if(this->encKey >= 160) { + this->proto = radio_proto::RTS; + if(this->encKey == 164) this->cmd = somfy_commands::Toggle; + } + else if(this->encKey > 148) { + this->proto = radio_proto::RTV; + this->cmd = (somfy_commands)(this->encKey - 148); + } + else if(this->encKey >= 133) { + this->proto = radio_proto::RTW; + this->cmd = (somfy_commands)(this->encKey - 132); + } + } + else this->proto = radio_proto::RTS; + // We reuse this memory address so we must reset the processed + // flag. This will ensure we can see frames on the first beat. + this->processed = false; + this->rollingCode = decoded[3] + (decoded[2] << 8); + this->remoteAddress = (decoded[6] + (decoded[5] << 8) + (decoded[4] << 16)); + this->valid = this->checksum == checksum && this->remoteAddress > 0 && this->remoteAddress < 16777215; + if (this->cmd != somfy_commands::Sensor && this->valid) this->valid = (this->rollingCode > 0); + // Next lets process some of the RTS extensions for 80-bit frames + if(this->valid && this->proto == radio_proto::RTS && this->bitLength == 80) { + // Do a parity checksum on the 80 bit data. + if((decoded[9] & 0x0F) != this->calc80Checksum(decoded[7], decoded[8], decoded[9])) this->valid = false; + if(this->valid) { + // Translate extensions for stop and favorite. + if(this->cmd == somfy_commands::My) this->cmd = (somfy_commands)((decoded[1] >> 4) | ((decoded[8] & 0x0F) << 4)); + // Bit packing to get the step size prohibits translation on the byte. + else if(this->cmd == somfy_commands::StepDown) this->cmd = (somfy_commands)((decoded[1] >> 4) | ((decoded[8] & 0x08) << 4)); + } + } + if (this->valid) { + + // Check for valid command. + switch (this->cmd) { + //case somfy_commands::Unknown0: + case somfy_commands::My: + case somfy_commands::Up: + case somfy_commands::MyUp: + case somfy_commands::Down: + case somfy_commands::MyDown: + case somfy_commands::UpDown: + case somfy_commands::MyUpDown: + case somfy_commands::Prog: + case somfy_commands::Flag: + case somfy_commands::SunFlag: + case somfy_commands::Sensor: + break; + case somfy_commands::UnknownD: + case somfy_commands::RTWProto: + this->valid = false; + break; + case somfy_commands::StepUp: + case somfy_commands::StepDown: + // Decode the step size. + this->stepSize = ((decoded[8] & 0x07) << 4) | ((decoded[9] & 0xF0) >> 4); + break; + case somfy_commands::Toggle: + case somfy_commands::Favorite: + case somfy_commands::Stop: + break; + default: + this->valid = false; + break; + } + } + if(this->valid && this->encKey == 0) this->valid = false; + if (!this->valid) { + ESP_LOGW(TAG, "INVALID FRAME KEY:%d ADDR:%d CMD:%s RCODE:%d", + this->encKey, this->remoteAddress, + translateSomfyCommand(this->cmd).c_str(), this->rollingCode); + ESP_LOGD(TAG, " KEY 1 2 3 4 5 6 "); + ESP_LOGD(TAG, "--------------------------------"); + char enc_buf[64], dec_buf[64]; + int enc_pos = 0, dec_pos = 0; + enc_pos += snprintf(enc_buf + enc_pos, sizeof(enc_buf) - enc_pos, "ENC "); + dec_pos += snprintf(dec_buf + dec_pos, sizeof(dec_buf) - dec_pos, "DEC "); + for (byte i = 0; i < 10; i++) { + enc_pos += snprintf(enc_buf + enc_pos, sizeof(enc_buf) - enc_pos, "%3d ", frame[i]); + dec_pos += snprintf(dec_buf + dec_pos, sizeof(dec_buf) - dec_pos, "%3d ", decoded[i]); + } + ESP_LOGD(TAG, "%s", enc_buf); + ESP_LOGD(TAG, "%s", dec_buf); + } +} +byte somfy_frame_t::encode80Byte7(byte start, uint8_t repeat) { + while((repeat * 4) + start > 255) repeat -= 15; + return start + (repeat * 4); +} +void somfy_frame_t::encode80BitFrame(byte *frame, uint8_t repeat) { + switch(this->cmd) { + // Step up and down commands encode the step size into the last 3 bytes. + case somfy_commands::StepUp: + if(repeat == 0) frame[1] = (static_cast(somfy_commands::StepDown) << 4) | (frame[1] & 0x0F); + if(this->stepSize == 0) this->stepSize = 1; + frame[7] = 132; // For simplicity this appears to be constant. + frame[8] = ((this->stepSize & 0x70) >> 4) | 0x38; + frame[9] = ((this->stepSize & 0x0F) << 4); + frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]); + break; + case somfy_commands::StepDown: + if(repeat == 0) frame[1] = (static_cast(somfy_commands::StepDown) << 4) | (frame[1] & 0x0F); + if(this->stepSize == 0) this->stepSize = 1; + frame[7] = 132; // For simplicity this appears to be constant. + frame[8] = ((this->stepSize & 0x70) >> 4) | 0x30; + frame[9] = ((this->stepSize & 0x0F) << 4); + frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]); + break; + case somfy_commands::Favorite: + if(repeat == 0) frame[1] = (static_cast(somfy_commands::My) << 4) | (frame[1] & 0x0F); + frame[7] = repeat > 0 ? 132 : 196; + frame[8] = 44; + frame[9] = 0x90; + frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]); + break; + case somfy_commands::Stop: + if(repeat == 0) frame[1] = (static_cast(somfy_commands::My) << 4) | (frame[1] & 0x0F); + frame[7] = repeat > 0 ? 132 : 196; + frame[8] = 47; + frame[9] = 0xF0; + frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]); + break; + case somfy_commands::Toggle: + frame[0] = 164; + frame[1] |= 0xF0; + frame[7] = this->encode80Byte7(196, repeat); + frame[8] = 0; + frame[9] = 0x10; + frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]); + break; + case somfy_commands::Up: + frame[7] = this->encode80Byte7(196, repeat); + frame[8] = 32; + frame[9] = 0x00; + frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]); + break; + case somfy_commands::Down: + frame[7] = this->encode80Byte7(196, repeat); + frame[8] = 44; + frame[9] = 0x80; + frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]); + break; + case somfy_commands::Prog: + case somfy_commands::UpDown: + case somfy_commands::MyDown: + case somfy_commands::MyUp: + case somfy_commands::MyUpDown: + case somfy_commands::My: + frame[7] = this->encode80Byte7(196, repeat); + frame[8] = 0x00; + frame[9] = 0x10; + frame[9] |= this->calc80Checksum(frame[7], frame[8], frame[9]); + break; + + default: + break; + } +} +void somfy_frame_t::encodeFrame(byte *frame) { + const byte btn = static_cast(cmd); + this->valid = true; + frame[0] = this->encKey; // Encryption key. Doesn't matter much + frame[1] = (btn & 0x0F) << 4; // Which button did you press? The 4 LSB will be the checksum + frame[2] = this->rollingCode >> 8; // Rolling code (big endian) + frame[3] = this->rollingCode; // Rolling code + frame[4] = this->remoteAddress >> 16; // Remote address + frame[5] = this->remoteAddress >> 8; // Remote address + frame[6] = this->remoteAddress; // Remote address + frame[7] = 132; + frame[8] = 0; + frame[9] = 29; + // Ok so if this is an RTW things are a bit different. + if(this->proto == radio_proto::RTW) { + frame[1] = 0xF0; + switch(this->cmd) { + case somfy_commands::My: + frame[0] = 133; + break; + case somfy_commands::Up: + frame[0] = 134; + break; + case somfy_commands::MyUp: + frame[0] = 135; + break; + case somfy_commands::Down: + frame[0] = 136; + break; + case somfy_commands::MyDown: + frame[0] = 137; + break; + case somfy_commands::UpDown: + frame[0] = 138; + break; + case somfy_commands::MyUpDown: + frame[0] = 139; + break; + case somfy_commands::Prog: + frame[0] = 140; + break; + case somfy_commands::SunFlag: + frame[0] = 141; + break; + case somfy_commands::Flag: + frame[0] = 142; + break; + default: + break; + } + } + else if(this->proto == radio_proto::RTV) { + frame[1] = 0xF0; + switch(this->cmd) { + case somfy_commands::My: + frame[0] = 149; + break; + case somfy_commands::Up: + frame[0] = 150; + break; + case somfy_commands::MyUp: + frame[0] = 151; + break; + case somfy_commands::Down: + frame[0] = 152; + break; + case somfy_commands::MyDown: + frame[0] = 153; + break; + case somfy_commands::UpDown: + frame[0] = 154; + break; + case somfy_commands::MyUpDown: + frame[0] = 155; + break; + case somfy_commands::Prog: + frame[0] = 156; + break; + case somfy_commands::SunFlag: + frame[0] = 157; + break; + case somfy_commands::Flag: + frame[0] = 158; + break; + default: + break; + } + + } + else { + if(this->bitLength == 80) this->encode80BitFrame(&frame[0], this->repeats); + } + byte checksum = 0; + + for (byte i = 0; i < 7; i++) { + checksum = checksum ^ frame[i] ^ (frame[i] >> 4); + } + checksum &= 0b1111; // We keep the last 4 bits only + // Checksum integration + frame[1] |= checksum; + // Obfuscation: a XOR of all the bytes + for (byte i = 1; i < 7; i++) { + frame[i] ^= frame[i - 1]; + } +} +void somfy_frame_t::print() { + ESP_LOGD(TAG, "----------- Receiving -------------"); + ESP_LOGD(TAG, "RSSI:%d LQI:%d", this->rssi, this->lqi); + ESP_LOGD(TAG, "CMD:%s ADDR:%d RCODE:%d", + translateSomfyCommand(this->cmd).c_str(), this->remoteAddress, this->rollingCode); + ESP_LOGD(TAG, "KEY:%02X CS:%d", this->encKey, this->checksum); +} +bool somfy_frame_t::isSynonym(somfy_frame_t &frame) { return this->remoteAddress == frame.remoteAddress && this->cmd != frame.cmd && this->rollingCode == frame.rollingCode; } +bool somfy_frame_t::isRepeat(somfy_frame_t &frame) { return this->remoteAddress == frame.remoteAddress && this->cmd == frame.cmd && this->rollingCode == frame.rollingCode; } +void somfy_frame_t::copy(somfy_frame_t &frame) { + if(this->isRepeat(frame)) { + this->repeats++; + this->rssi = frame.rssi; + this->lqi = frame.lqi; + } + else { + this->synonym = this->isSynonym(frame); + this->valid = frame.valid; + if(!this->synonym) this->processed = frame.processed; + this->rssi = frame.rssi; + this->lqi = frame.lqi; + this->cmd = frame.cmd; + this->remoteAddress = frame.remoteAddress; + this->rollingCode = frame.rollingCode; + this->encKey = frame.encKey; + this->checksum = frame.checksum; + this->hwsync = frame.hwsync; + this->repeats = frame.repeats; + } +} diff --git a/src/SomfyFrame.h b/src/SomfyFrame.h new file mode 100644 index 0000000..ad2a249 --- /dev/null +++ b/src/SomfyFrame.h @@ -0,0 +1,70 @@ +#ifndef SOMFY_FRAME_H +#define SOMFY_FRAME_H +#include + +// The RTS frame codec and command names. Kept free of the radio, storage and +// network so it can be unit tested on the PC (pio test -e native). +enum class radio_proto : byte { // Ordinal byte 0-255 + RTS = 0x00, + RTW = 0x01, + RTV = 0x02, + GP_Relay = 0x08, + GP_Remote = 0x09 +}; +enum class somfy_commands : byte { + Unknown0 = 0x0, + My = 0x1, + Up = 0x2, + MyUp = 0x3, + Down = 0x4, + MyDown = 0x5, + UpDown = 0x6, + MyUpDown = 0x7, + Prog = 0x8, + SunFlag = 0x9, + Flag = 0xA, + StepDown = 0xB, + Toggle = 0xC, + UnknownD = 0xD, + Sensor = 0xE, + RTWProto = 0xF, // RTW Protocol + // Command extensions for 80 bit frames + StepUp = 0x8B, + Favorite = 0xC1, + Stop = 0xF1 +}; +String translateSomfyCommand(const somfy_commands cmd); +somfy_commands translateSomfyCommand(const String& string); + +struct somfy_rx_t; +struct somfy_frame_t { + bool valid = false; + bool processed = false; + bool synonym = false; + radio_proto proto = radio_proto::RTS; + int rssi = 0; + byte lqi = 0x0; + somfy_commands cmd; + uint32_t remoteAddress = 0; + uint16_t rollingCode = 0; + uint8_t encKey = 0xA7; + uint8_t checksum = 0; + uint8_t hwsync = 0; + uint8_t repeats = 0; + uint32_t await = 0; + uint8_t bitLength = 56; + uint16_t pulseCount = 0; + uint8_t stepSize = 0; + uint8_t rawCmd = 0; + void print(); + void encode80BitFrame(byte *frame, uint8_t repeat); + byte calc80Checksum(byte b0, byte b1, byte b2); + byte encode80Byte7(byte start, uint8_t repeat); + void encodeFrame(byte *frame); + void decodeFrame(byte* frame); + void decodeFrame(somfy_rx_t *rx); + bool isRepeat(somfy_frame_t &f); + bool isSynonym(somfy_frame_t &f); + void copy(somfy_frame_t &f); +}; +#endif From 3774aba45ccd2cd9e36c4fed76ec5115c7123191 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 13:20:39 +0200 Subject: [PATCH 17/53] test: cover the frame codec and command names Builds SomfyFrame.cpp in the native env, with a minimal String and no-op ESP_LOGx in the stubs, and tests: - a fixed 56-bit frame, byte for byte, so what goes on air is pinned - encode/decode round trips for 56-bit, 80-bit (repeats, Favorite, Stop, steps, Toggle), RTW and RTV frames - rejection of bad checksums, addresses, rolling codes, keys and 80-bit parity - repeat/synonym tracking in copy() - parsing and naming of commands The command-name round trip fails: names the firmware reports, such as "Step Up" and "My+Up", parse back to the wrong command. Co-Authored-By: Claude Opus 5.5 --- platformio.ini | 2 +- test/stubs/Arduino.h | 27 ++++ test/stubs/esp_log.h | 10 ++ test/test_frame/test_frame.cpp | 266 +++++++++++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 test/stubs/esp_log.h create mode 100644 test/test_frame/test_frame.cpp diff --git a/platformio.ini b/platformio.ini index a794091..04f0f12 100644 --- a/platformio.ini +++ b/platformio.ini @@ -101,7 +101,7 @@ lib_deps = platform = native test_framework = unity test_build_src = yes -build_src_filter = -<*> + +build_src_filter = -<*> + + build_flags = -std=gnu++17 -Isrc diff --git a/test/stubs/Arduino.h b/test/stubs/Arduino.h index 3755257..b9584dc 100644 --- a/test/stubs/Arduino.h +++ b/test/stubs/Arduino.h @@ -9,6 +9,10 @@ #include #include #include +#include +#include + +typedef uint8_t byte; // Fixed so anything formatted from it is deterministic. inline unsigned long millis() { return 0; } @@ -17,4 +21,27 @@ inline bool getLocalTime(struct tm *info, uint32_t ms = 5000) { time_t now = time(nullptr); return localtime_r(&now, info) != nullptr; } + +// The parts of Arduino's String the firmware code under test uses. +class String { + std::string _s; + public: + String() {} + String(const char *s) : _s(s ? s : "") {} + String(const std::string &s) : _s(s) {} + // Arduino formats integers in decimal. + template::value, int>::type = 0> + explicit String(T val) : _s(std::to_string(val)) {} + const char *c_str() const { return _s.c_str(); } + unsigned int length() const { return (unsigned int)_s.length(); } + bool equalsIgnoreCase(const String &other) const { + if(_s.length() != other._s.length()) return false; + for(size_t i = 0; i < _s.length(); i++) + if(tolower((unsigned char)_s[i]) != tolower((unsigned char)other._s[i])) return false; + return true; + } + bool startsWith(const String &prefix) const { return _s.compare(0, prefix._s.length(), prefix._s) == 0; } + friend String operator+(const String &a, const String &b) { return String(a._s + b._s); } + friend bool operator==(const String &a, const String &b) { return a._s == b._s; } +}; #endif diff --git a/test/stubs/esp_log.h b/test/stubs/esp_log.h new file mode 100644 index 0000000..681bfdf --- /dev/null +++ b/test/stubs/esp_log.h @@ -0,0 +1,10 @@ +// Logging stand-in for the native test env. The format arguments are never +// evaluated so tests stay quiet and logging can't affect what they check. +#ifndef esp_log_stub_h +#define esp_log_stub_h +#define ESP_LOGE(tag, ...) ((void)(tag)) +#define ESP_LOGW(tag, ...) ((void)(tag)) +#define ESP_LOGI(tag, ...) ((void)(tag)) +#define ESP_LOGD(tag, ...) ((void)(tag)) +#define ESP_LOGV(tag, ...) ((void)(tag)) +#endif diff --git a/test/test_frame/test_frame.cpp b/test/test_frame/test_frame.cpp new file mode 100644 index 0000000..66f00cf --- /dev/null +++ b/test/test_frame/test_frame.cpp @@ -0,0 +1,266 @@ +#include +#include "SomfyFrame.h" + +#define ASSERT_CMD(expected, actual) TEST_ASSERT_EQUAL_HEX8(static_cast(expected), static_cast(actual)) + +static const uint32_t ADDR = 0x123456; + +// A frame as SomfyRemote::sendCommand builds it. +static somfy_frame_t makeFrame(somfy_commands cmd, uint32_t address, uint16_t rollingCode, + uint8_t bitLength = 56, radio_proto proto = radio_proto::RTS) { + somfy_frame_t f; + f.cmd = cmd; + f.remoteAddress = address; + f.rollingCode = rollingCode; + f.bitLength = bitLength; + f.proto = proto; + f.encKey = 0xA0 | (rollingCode & 0x0F); + return f; +} +// What a receiver makes of the bytes, given it heard the same frame length. +static somfy_frame_t decode(const byte *payload, uint8_t bitLength) { + byte buf[10]; + memcpy(buf, payload, sizeof(buf)); + somfy_frame_t d; + d.bitLength = bitLength; + d.decodeFrame(buf); + return d; +} +static somfy_frame_t roundTrip(somfy_frame_t f) { + byte buf[10]; + f.encodeFrame(buf); + return decode(buf, f.bitLength); +} + +void setUp() {} +void tearDown() {} + +/********************************************************************* + * Encoding + ********************************************************************/ +static void test_encode_56bit_golden_frame() { + // Pins exactly what goes on air. If this changes, find out why before + // updating the bytes: motors reject frames that are encoded differently. + somfy_frame_t f = makeFrame(somfy_commands::Up, ADDR, 0x0102); + f.encKey = 0xA7; + byte buf[10]; + f.encodeFrame(buf); + const byte expected[10] = {0xA7, 0x8C, 0x8D, 0x8F, 0x9D, 0xA9, 0xFF, 0x84, 0x00, 0x1D}; + TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, buf, 10); + + somfy_frame_t d = decode(expected, 56); + TEST_ASSERT_TRUE(d.valid); + ASSERT_CMD(somfy_commands::Up, d.cmd); + TEST_ASSERT_EQUAL_HEX32(ADDR, d.remoteAddress); + TEST_ASSERT_EQUAL_HEX16(0x0102, d.rollingCode); + TEST_ASSERT_EQUAL_HEX8(0xA7, d.encKey); +} +static void test_round_trip_56bit_commands() { + const somfy_commands cmds[] = { + somfy_commands::My, somfy_commands::Up, somfy_commands::MyUp, somfy_commands::Down, + somfy_commands::MyDown, somfy_commands::UpDown, somfy_commands::MyUpDown, somfy_commands::Prog, + somfy_commands::SunFlag, somfy_commands::Flag, somfy_commands::Toggle, somfy_commands::Sensor + }; + for(somfy_commands cmd : cmds) { + somfy_frame_t f = makeFrame(cmd, ADDR, 0x1234); + somfy_frame_t d = roundTrip(f); + TEST_ASSERT_TRUE_MESSAGE(d.valid, translateSomfyCommand(cmd).c_str()); + ASSERT_CMD(cmd, d.cmd); + TEST_ASSERT_EQUAL_HEX32(ADDR, d.remoteAddress); + TEST_ASSERT_EQUAL_HEX16(0x1234, d.rollingCode); + TEST_ASSERT_EQUAL_HEX8(f.encKey, d.encKey); + TEST_ASSERT_EQUAL(radio_proto::RTS, d.proto); + } +} +static void test_round_trip_80bit_commands_and_repeats() { + const somfy_commands cmds[] = {somfy_commands::Up, somfy_commands::Down, somfy_commands::My}; + for(somfy_commands cmd : cmds) { + for(uint8_t repeats : {0, 5}) { + somfy_frame_t f = makeFrame(cmd, ADDR, 0x0042, 80); + f.repeats = repeats; + byte buf[10]; + f.encodeFrame(buf); + // The repeat counter is carried in byte 7. + TEST_ASSERT_EQUAL_UINT8(196 + repeats * 4, buf[7]); + somfy_frame_t d = decode(buf, 80); + TEST_ASSERT_TRUE_MESSAGE(d.valid, translateSomfyCommand(cmd).c_str()); + ASSERT_CMD(cmd, d.cmd); + TEST_ASSERT_EQUAL_HEX16(0x0042, d.rollingCode); + } + } +} +static void test_round_trip_80bit_favorite_and_stop() { + // Sent as My with the real command in an extension nibble. + for(somfy_commands cmd : {somfy_commands::Favorite, somfy_commands::Stop}) { + somfy_frame_t d = roundTrip(makeFrame(cmd, ADDR, 7, 80)); + TEST_ASSERT_TRUE(d.valid); + ASSERT_CMD(cmd, d.cmd); + } +} +static void test_round_trip_80bit_steps() { + for(somfy_commands cmd : {somfy_commands::StepUp, somfy_commands::StepDown}) { + for(uint8_t step : {1, 100}) { + somfy_frame_t f = makeFrame(cmd, ADDR, 7, 80); + f.stepSize = step; + somfy_frame_t d = roundTrip(f); + TEST_ASSERT_TRUE(d.valid); + ASSERT_CMD(cmd, d.cmd); + TEST_ASSERT_EQUAL_UINT8(step, d.stepSize); + } + } +} +static void test_round_trip_80bit_toggle() { + somfy_frame_t d = roundTrip(makeFrame(somfy_commands::Toggle, ADDR, 7, 80)); + TEST_ASSERT_TRUE(d.valid); + ASSERT_CMD(somfy_commands::Toggle, d.cmd); + TEST_ASSERT_EQUAL_UINT8(164, d.encKey); +} +static void test_round_trip_rtw_and_rtv() { + // These protocols carry the command in the key byte. + for(radio_proto proto : {radio_proto::RTW, radio_proto::RTV}) { + for(somfy_commands cmd : {somfy_commands::Up, somfy_commands::Down}) { + somfy_frame_t d = roundTrip(makeFrame(cmd, ADDR, 9, 56, proto)); + TEST_ASSERT_TRUE(d.valid); + ASSERT_CMD(cmd, d.cmd); + TEST_ASSERT_EQUAL(proto, d.proto); + } + } +} + +/********************************************************************* + * Rejected frames + ********************************************************************/ +static void test_rejects_bad_checksum() { + byte buf[10]; + makeFrame(somfy_commands::Up, ADDR, 0x1234).encodeFrame(buf); + // Byte 6 is the last obfuscated byte so the flip changes only its decoded value. + buf[6] ^= 0x01; + TEST_ASSERT_FALSE(decode(buf, 56).valid); +} +static void test_rejects_out_of_range_address() { + TEST_ASSERT_FALSE(roundTrip(makeFrame(somfy_commands::Up, 0, 0x1234)).valid); + TEST_ASSERT_FALSE(roundTrip(makeFrame(somfy_commands::Up, 0xFFFFFF, 0x1234)).valid); +} +static void test_rejects_zero_rolling_code_except_sensor() { + TEST_ASSERT_FALSE(roundTrip(makeFrame(somfy_commands::Up, ADDR, 0)).valid); + // Sensor frames carry flags in the rolling code so 0 is fine. + TEST_ASSERT_TRUE(roundTrip(makeFrame(somfy_commands::Sensor, ADDR, 0)).valid); +} +static void test_rejects_zero_key() { + somfy_frame_t f = makeFrame(somfy_commands::Up, ADDR, 0x1234); + f.encKey = 0; + TEST_ASSERT_FALSE(roundTrip(f).valid); +} +static void test_rejects_bad_80bit_parity() { + byte buf[10]; + makeFrame(somfy_commands::Up, ADDR, 0x1234, 80).encodeFrame(buf); + buf[9] ^= 0x01; + TEST_ASSERT_FALSE(decode(buf, 80).valid); +} + +/********************************************************************* + * Helpers + ********************************************************************/ +static void test_encode80Byte7_wraps() { + somfy_frame_t f; + TEST_ASSERT_EQUAL_UINT8(196, f.encode80Byte7(196, 0)); + TEST_ASSERT_EQUAL_UINT8(252, f.encode80Byte7(196, 14)); + // Past 255 the counter steps back by 15 repeats. + TEST_ASSERT_EQUAL_UINT8(196, f.encode80Byte7(196, 15)); + TEST_ASSERT_EQUAL_UINT8(216, f.encode80Byte7(196, 20)); +} +static void test_copy_counts_repeats_and_marks_synonyms() { + somfy_frame_t last = makeFrame(somfy_commands::Up, ADDR, 10); + last.processed = true; + + somfy_frame_t repeat = makeFrame(somfy_commands::Up, ADDR, 10); + last.copy(repeat); + TEST_ASSERT_EQUAL_UINT8(1, last.repeats); + + // Same press reported as another command: keep what was already processed. + somfy_frame_t synonym = makeFrame(somfy_commands::My, ADDR, 10); + last.copy(synonym); + TEST_ASSERT_TRUE(last.synonym); + TEST_ASSERT_TRUE(last.processed); + ASSERT_CMD(somfy_commands::My, last.cmd); + + // A new press replaces it. + somfy_frame_t next = makeFrame(somfy_commands::Down, ADDR, 11); + last.copy(next); + TEST_ASSERT_FALSE(last.synonym); + TEST_ASSERT_FALSE(last.processed); + TEST_ASSERT_EQUAL_UINT16(11, last.rollingCode); + TEST_ASSERT_EQUAL_UINT8(0, last.repeats); +} + +/********************************************************************* + * Command names + ********************************************************************/ +static void test_parse_command_names() { + // What the web UI and the Home Assistant integration send. + ASSERT_CMD(somfy_commands::Up, translateSomfyCommand(String("up"))); + ASSERT_CMD(somfy_commands::Down, translateSomfyCommand(String("DOWN"))); + ASSERT_CMD(somfy_commands::My, translateSomfyCommand(String("my"))); + ASSERT_CMD(somfy_commands::Toggle, translateSomfyCommand(String("toggle"))); + ASSERT_CMD(somfy_commands::Flag, translateSomfyCommand(String("flag"))); + ASSERT_CMD(somfy_commands::SunFlag, translateSomfyCommand(String("sunflag"))); + ASSERT_CMD(somfy_commands::StepUp, translateSomfyCommand(String("StepUp"))); + ASSERT_CMD(somfy_commands::StepDown, translateSomfyCommand(String("StepDown"))); + ASSERT_CMD(somfy_commands::Prog, translateSomfyCommand(String("Prog"))); + // Short forms. + ASSERT_CMD(somfy_commands::Favorite, translateSomfyCommand(String("fav"))); + ASSERT_CMD(somfy_commands::MyUpDown, translateSomfyCommand(String("mud"))); + // A single hex digit is the raw command. + ASSERT_CMD(somfy_commands::Down, translateSomfyCommand(String("4"))); + // Anything else falls back to My. + ASSERT_CMD(somfy_commands::My, translateSomfyCommand(String("xyz"))); + ASSERT_CMD(somfy_commands::My, translateSomfyCommand(String(""))); +} +static void test_command_display_names() { + TEST_ASSERT_EQUAL_STRING("Up", translateSomfyCommand(somfy_commands::Up).c_str()); + TEST_ASSERT_EQUAL_STRING("My+Up", translateSomfyCommand(somfy_commands::MyUp).c_str()); + TEST_ASSERT_EQUAL_STRING("Sun Flag", translateSomfyCommand(somfy_commands::SunFlag).c_str()); + TEST_ASSERT_EQUAL_STRING("Step Up", translateSomfyCommand(somfy_commands::StepUp).c_str()); + TEST_ASSERT_EQUAL_STRING("Favorite", translateSomfyCommand(somfy_commands::Favorite).c_str()); + TEST_ASSERT_EQUAL_STRING("Unknown(13)", translateSomfyCommand(somfy_commands::UnknownD).c_str()); +} +static void test_command_names_round_trip() { + // The names in shadeCommand events (and Home Assistant events) must parse + // back to the same command. + const somfy_commands cmds[] = { + somfy_commands::My, somfy_commands::Up, somfy_commands::MyUp, somfy_commands::Down, + somfy_commands::MyDown, somfy_commands::UpDown, somfy_commands::MyUpDown, somfy_commands::Prog, + somfy_commands::SunFlag, somfy_commands::Flag, somfy_commands::StepUp, somfy_commands::StepDown, + somfy_commands::Sensor, somfy_commands::Toggle, somfy_commands::Favorite, somfy_commands::Stop + }; + std::string failed; + for(somfy_commands cmd : cmds) { + String name = translateSomfyCommand(cmd); + if(translateSomfyCommand(name) != cmd) failed += std::string(failed.empty() ? "" : ", ") + name.c_str(); + } + TEST_ASSERT_EQUAL_STRING_MESSAGE("", failed.c_str(), "These names don't parse back to their command"); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_encode_56bit_golden_frame); + RUN_TEST(test_round_trip_56bit_commands); + RUN_TEST(test_round_trip_80bit_commands_and_repeats); + RUN_TEST(test_round_trip_80bit_favorite_and_stop); + RUN_TEST(test_round_trip_80bit_steps); + RUN_TEST(test_round_trip_80bit_toggle); + RUN_TEST(test_round_trip_rtw_and_rtv); + RUN_TEST(test_rejects_bad_checksum); + RUN_TEST(test_rejects_out_of_range_address); + RUN_TEST(test_rejects_zero_rolling_code_except_sensor); + RUN_TEST(test_rejects_zero_key); + RUN_TEST(test_rejects_bad_80bit_parity); + RUN_TEST(test_encode80Byte7_wraps); + RUN_TEST(test_copy_counts_repeats_and_marks_synonyms); + RUN_TEST(test_parse_command_names); + RUN_TEST(test_command_display_names); + RUN_TEST(test_command_names_round_trip); + return UNITY_END(); +} From 60419a37aabc28c72118520af677b03e1d5174a5 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 13:20:49 +0200 Subject: [PATCH 18/53] fix: parse the command names the firmware reports shadeCommand events (and the Home Assistant events built from them) name commands "Step Up", "Sun Flag", "My+Up" and so on, but parsing those names fell through to the prefix shortcuts: "Step Up" became SunFlag, "Sun Flag" became StepUp and "My+Up" became My. Match the reported names exactly before the shortcuts. Co-Authored-By: Claude Opus 5.5 --- src/SomfyFrame.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/SomfyFrame.cpp b/src/SomfyFrame.cpp index 9e58f7d..adf978f 100644 --- a/src/SomfyFrame.cpp +++ b/src/SomfyFrame.cpp @@ -21,6 +21,14 @@ somfy_commands translateSomfyCommand(const String& string) { else if (string.equalsIgnoreCase("Toggle")) return somfy_commands::Toggle; else if (string.equalsIgnoreCase("Favorite")) return somfy_commands::Favorite; else if (string.equalsIgnoreCase("Stop")) return somfy_commands::Stop; + // The names translateSomfyCommand(cmd) reports so they parse back. + else if (string.equalsIgnoreCase("Sun Flag")) return somfy_commands::SunFlag; + else if (string.equalsIgnoreCase("Step Up")) return somfy_commands::StepUp; + else if (string.equalsIgnoreCase("Step Down")) return somfy_commands::StepDown; + else if (string.equalsIgnoreCase("My+Up")) return somfy_commands::MyUp; + else if (string.equalsIgnoreCase("Up+Down")) return somfy_commands::UpDown; + else if (string.equalsIgnoreCase("My+Down")) return somfy_commands::MyDown; + else if (string.equalsIgnoreCase("My+Up+Down")) return somfy_commands::MyUpDown; else if (string.startsWith("fav") || string.startsWith("FAV")) return somfy_commands::Favorite; else if (string.startsWith("mud") || string.startsWith("MUD")) return somfy_commands::MyUpDown; else if (string.startsWith("md") || string.startsWith("MD")) return somfy_commands::MyDown; From d723c1024d3ea25ff4b38c7ba9103b44b68eeec0 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 13:29:30 +0200 Subject: [PATCH 19/53] refactor: move JsonFormatter into JsonFormatter.cpp JsonFormatter and BufferedJsonFormatter move verbatim out of WResp.cpp/WResp.h, which now include JsonFormatter.h. They don't need the web server, so on their own they can be unit tested on the PC; the native env builds the new file. AsyncJsonResp and JsonSockEvent stay in WResp. No behaviour change. Co-Authored-By: Claude Opus 5.5 --- platformio.ini | 2 +- src/JsonFormatter.cpp | 152 ++++++++++++++++++++++++++++++++++++++++++ src/JsonFormatter.h | 61 +++++++++++++++++ src/WResp.cpp | 151 ----------------------------------------- src/WResp.h | 55 +-------------- 5 files changed, 215 insertions(+), 206 deletions(-) create mode 100644 src/JsonFormatter.cpp create mode 100644 src/JsonFormatter.h diff --git a/platformio.ini b/platformio.ini index 04f0f12..432f0d9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -101,7 +101,7 @@ lib_deps = platform = native test_framework = unity test_build_src = yes -build_src_filter = -<*> + + +build_src_filter = -<*> + + + build_flags = -std=gnu++17 -Isrc diff --git a/src/JsonFormatter.cpp b/src/JsonFormatter.cpp new file mode 100644 index 0000000..714f64b --- /dev/null +++ b/src/JsonFormatter.cpp @@ -0,0 +1,152 @@ +#include "JsonFormatter.h" + +void BufferedJsonFormatter::setBuffer(char *b, size_t sz) { + this->buff = b; + this->buffSize = sz; + this->buff[0] = 0; + this->_nocomma = true; + this->_objects = 0; + this->_arrays = 0; +} +size_t BufferedJsonFormatter::length() const { + return strlen(this->buff); +} +void JsonFormatter::beginObject(const char *name) { + if(name && strlen(name) > 0) this->appendElem(name); + else if(!this->_nocomma) this->_safecat(","); + this->_safecat("{"); + this->_objects++; + this->_nocomma = true; +} +void JsonFormatter::endObject() { + //if(strlen(this->buff) + 1 > this->buffSize - 1) this->send(); + this->_safecat("}"); + this->_objects--; + this->_nocomma = false; +} +void JsonFormatter::beginArray(const char *name) { + if(name && strlen(name) > 0) this->appendElem(name); + else if(!this->_nocomma) this->_safecat(","); + this->_safecat("["); + this->_arrays++; + this->_nocomma = true; +} +void JsonFormatter::endArray() { + //if(strlen(this->buff) + 1 > this->buffSize - 1) this->send(); + this->_safecat("]"); + this->_arrays--; + this->_nocomma = false; +} + +void JsonFormatter::appendElem(const char *name) { + if(!this->_nocomma) this->_safecat(","); + if(name && strlen(name) > 0) { + this->_safecat(name, true); + this->_safecat(":"); + } + this->_nocomma = false; +} + +void JsonFormatter::addElem(const char *name, const char *val) { + if(!val) return; + this->appendElem(name); + this->_safecat(val, true); +} +void JsonFormatter::addElem(const char *val) { this->addElem(nullptr, val); } +void JsonFormatter::addElem(float fval) { sprintf(this->_numbuff, "%.4f", fval); this->_appendNumber(nullptr); } +void JsonFormatter::addElem(int8_t nval) { sprintf(this->_numbuff, "%d", nval); this->_appendNumber(nullptr); } +void JsonFormatter::addElem(uint8_t nval) { sprintf(this->_numbuff, "%u", nval); this->_appendNumber(nullptr); } +void JsonFormatter::addElem(int32_t nval) { sprintf(this->_numbuff, "%ld", (long)nval); this->_appendNumber(nullptr); } +void JsonFormatter::addElem(uint32_t nval) { sprintf(this->_numbuff, "%lu", (unsigned long)nval); this->_appendNumber(nullptr); } + +/* +void JsonFormatter::addElem(int16_t nval) { sprintf(this->_numbuff, "%d", nval); this->_appendNumber(nullptr); } +void JsonFormatter::addElem(uint16_t nval) { sprintf(this->_numbuff, "%u", nval); this->_appendNumber(nullptr); } +void JsonFormatter::addElem(int64_t lval) { sprintf(this->_numbuff, "%lld", (long long)lval); this->_appendNumber(nullptr); } +void JsonFormatter::addElem(uint64_t lval) { sprintf(this->_numbuff, "%llu", (unsigned long long)lval); this->_appendNumber(nullptr); } +*/ +void JsonFormatter::addElem(bool bval) { strcpy(this->_numbuff, bval ? "true" : "false"); this->_appendNumber(nullptr); } + +void JsonFormatter::addElem(const char *name, float fval) { sprintf(this->_numbuff, "%.4f", fval); this->_appendNumber(name); } +void JsonFormatter::addElem(const char *name, int8_t nval) { sprintf(this->_numbuff, "%d", nval); this->_appendNumber(name); } +void JsonFormatter::addElem(const char *name, uint8_t nval) { sprintf(this->_numbuff, "%u", nval); this->_appendNumber(name); } +void JsonFormatter::addElem(const char *name, int32_t nval) { sprintf(this->_numbuff, "%ld", (long)nval); this->_appendNumber(name); } +void JsonFormatter::addElem(const char *name, uint32_t nval) { sprintf(this->_numbuff, "%lu", (unsigned long)nval); this->_appendNumber(name); } + +/* +void JsonFormatter::addElem(const char *name, int16_t nval) { sprintf(this->_numbuff, "%d", nval); this->_appendNumber(name); } +void JsonFormatter::addElem(const char *name, uint16_t nval) { sprintf(this->_numbuff, "%u", nval); this->_appendNumber(name); } +void JsonFormatter::addElem(const char *name, int64_t lval) { sprintf(this->_numbuff, "%lld", (long long)lval); this->_appendNumber(name); } +*/ +void JsonFormatter::addElem(const char *name, uint64_t lval) { sprintf(this->_numbuff, "%llu", (unsigned long long)lval); this->_appendNumber(name); } + +void JsonFormatter::addElem(const char *name, bool bval) { strcpy(this->_numbuff, bval ? "true" : "false"); this->_appendNumber(name); } + +void JsonFormatter::_safecat(const char *val, bool escape) { + size_t len = (escape ? this->calcEscapedLength(val) : strlen(val)) + strlen(this->buff); + if(escape) len += 2; + if(len >= this->buffSize) { + return; + } + if(escape) strcat(this->buff, "\""); + if(escape) this->escapeString(val, &this->buff[strlen(this->buff)]); + else strcat(this->buff, val); + if(escape) strcat(this->buff, "\""); +} +void JsonFormatter::_appendNumber(const char *name) { this->appendElem(name); this->_safecat(this->_numbuff); } +uint32_t JsonFormatter::calcEscapedLength(const char *raw) { + uint32_t len = 0; + for(size_t i = strlen(raw); i > 0; i--) { + switch(raw[i]) { + case '"': + case '/': + case '\b': + case '\f': + case '\n': + case '\r': + case '\t': + case '\\': + len += 2; + break; + default: + len++; + break; + } + } + return len; +} +void JsonFormatter::escapeString(const char *raw, char *escaped) { + for(uint32_t i = 0; i < strlen(raw); i++) { + switch(raw[i]) { + case '"': + strcat(escaped, "\\\""); + break; + case '/': + strcat(escaped, "\\/"); + break; + case '\b': + strcat(escaped, "\\b"); + break; + case '\f': + strcat(escaped, "\\f"); + break; + case '\n': + strcat(escaped, "\\n"); + break; + case '\r': + strcat(escaped, "\\r"); + break; + case '\t': + strcat(escaped, "\\t"); + break; + case '\\': + strcat(escaped, "\\\\"); + break; + default: + size_t len = strlen(escaped); + escaped[len] = raw[i]; + escaped[len+1] = 0x00; + break; + } + } +} diff --git a/src/JsonFormatter.h b/src/JsonFormatter.h new file mode 100644 index 0000000..5afe9c4 --- /dev/null +++ b/src/JsonFormatter.h @@ -0,0 +1,61 @@ +#ifndef json_formatter_h +#define json_formatter_h +#include + +// Streams JSON into a fixed buffer. Free of the web server so it can be unit +// tested on the PC (pio test -e native). +class JsonFormatter { + protected: + char *buff; + size_t buffSize; + bool _headersSent = false; + uint8_t _objects = 0; + uint8_t _arrays = 0; + bool _nocomma = true; + char _numbuff[25] = {0}; + virtual void _safecat(const char *val, bool escape = false); + void _appendNumber(const char *name); + public: + void escapeString(const char *raw, char *escaped); + uint32_t calcEscapedLength(const char *raw); + void beginObject(const char *name = nullptr); + void endObject(); + void beginArray(const char *name = nullptr); + void endArray(); + void appendElem(const char *name = nullptr); + + void addElem(const char* val); + void addElem(float fval); + void addElem(int8_t nval); + void addElem(uint8_t nval); + /* + void addElem(int32_t nval); + void addElem(int16_t nval); + void addElem(uint16_t nval); + void addElem(unsigned int nval); + */ + void addElem(int32_t lval); + void addElem(uint32_t lval); + void addElem(bool bval); + + void addElem(const char* name, float fval); + void addElem(const char* name, int8_t nval); + void addElem(const char* name, uint8_t nval); + /* + void addElem(const char* name, int nval); + void addElem(const char* name, int16_t nval); + void addElem(const char* name, uint16_t nval); + void addElem(const char* name, unsigned int nval); + */ + void addElem(const char* name, int32_t lval); + void addElem(const char* name, uint32_t lval); + void addElem(const char* name, bool bval); + void addElem(const char *name, const char *val); + void addElem(const char* name, uint64_t lval); +}; +class BufferedJsonFormatter : public JsonFormatter { + public: + void setBuffer(char *b, size_t sz); + size_t length() const; +}; +#endif diff --git a/src/WResp.cpp b/src/WResp.cpp index c40a028..64fda3b 100644 --- a/src/WResp.cpp +++ b/src/WResp.cpp @@ -35,17 +35,6 @@ void JsonSockEvent::_safecat(const char *val, bool escape) { else strcat(this->buff, val); if(escape) strcat(this->buff, "\""); } -void BufferedJsonFormatter::setBuffer(char *b, size_t sz) { - this->buff = b; - this->buffSize = sz; - this->buff[0] = 0; - this->_nocomma = true; - this->_objects = 0; - this->_arrays = 0; -} -size_t BufferedJsonFormatter::length() const { - return strlen(this->buff); -} void AsyncJsonResp::beginResponse(AsyncWebServerRequest *request, char *buff, size_t buffSize) { this->_request = request; this->buff = buff; @@ -78,143 +67,3 @@ void AsyncJsonResp::_safecat(const char *val, bool escape) { else strcat(this->buff, val); if(escape) strcat(this->buff, "\""); } - -void JsonFormatter::beginObject(const char *name) { - if(name && strlen(name) > 0) this->appendElem(name); - else if(!this->_nocomma) this->_safecat(","); - this->_safecat("{"); - this->_objects++; - this->_nocomma = true; -} -void JsonFormatter::endObject() { - //if(strlen(this->buff) + 1 > this->buffSize - 1) this->send(); - this->_safecat("}"); - this->_objects--; - this->_nocomma = false; -} -void JsonFormatter::beginArray(const char *name) { - if(name && strlen(name) > 0) this->appendElem(name); - else if(!this->_nocomma) this->_safecat(","); - this->_safecat("["); - this->_arrays++; - this->_nocomma = true; -} -void JsonFormatter::endArray() { - //if(strlen(this->buff) + 1 > this->buffSize - 1) this->send(); - this->_safecat("]"); - this->_arrays--; - this->_nocomma = false; -} - -void JsonFormatter::appendElem(const char *name) { - if(!this->_nocomma) this->_safecat(","); - if(name && strlen(name) > 0) { - this->_safecat(name, true); - this->_safecat(":"); - } - this->_nocomma = false; -} - -void JsonFormatter::addElem(const char *name, const char *val) { - if(!val) return; - this->appendElem(name); - this->_safecat(val, true); -} -void JsonFormatter::addElem(const char *val) { this->addElem(nullptr, val); } -void JsonFormatter::addElem(float fval) { sprintf(this->_numbuff, "%.4f", fval); this->_appendNumber(nullptr); } -void JsonFormatter::addElem(int8_t nval) { sprintf(this->_numbuff, "%d", nval); this->_appendNumber(nullptr); } -void JsonFormatter::addElem(uint8_t nval) { sprintf(this->_numbuff, "%u", nval); this->_appendNumber(nullptr); } -void JsonFormatter::addElem(int32_t nval) { sprintf(this->_numbuff, "%ld", (long)nval); this->_appendNumber(nullptr); } -void JsonFormatter::addElem(uint32_t nval) { sprintf(this->_numbuff, "%lu", (unsigned long)nval); this->_appendNumber(nullptr); } - -/* -void JsonFormatter::addElem(int16_t nval) { sprintf(this->_numbuff, "%d", nval); this->_appendNumber(nullptr); } -void JsonFormatter::addElem(uint16_t nval) { sprintf(this->_numbuff, "%u", nval); this->_appendNumber(nullptr); } -void JsonFormatter::addElem(int64_t lval) { sprintf(this->_numbuff, "%lld", (long long)lval); this->_appendNumber(nullptr); } -void JsonFormatter::addElem(uint64_t lval) { sprintf(this->_numbuff, "%llu", (unsigned long long)lval); this->_appendNumber(nullptr); } -*/ -void JsonFormatter::addElem(bool bval) { strcpy(this->_numbuff, bval ? "true" : "false"); this->_appendNumber(nullptr); } - -void JsonFormatter::addElem(const char *name, float fval) { sprintf(this->_numbuff, "%.4f", fval); this->_appendNumber(name); } -void JsonFormatter::addElem(const char *name, int8_t nval) { sprintf(this->_numbuff, "%d", nval); this->_appendNumber(name); } -void JsonFormatter::addElem(const char *name, uint8_t nval) { sprintf(this->_numbuff, "%u", nval); this->_appendNumber(name); } -void JsonFormatter::addElem(const char *name, int32_t nval) { sprintf(this->_numbuff, "%ld", (long)nval); this->_appendNumber(name); } -void JsonFormatter::addElem(const char *name, uint32_t nval) { sprintf(this->_numbuff, "%lu", (unsigned long)nval); this->_appendNumber(name); } - -/* -void JsonFormatter::addElem(const char *name, int16_t nval) { sprintf(this->_numbuff, "%d", nval); this->_appendNumber(name); } -void JsonFormatter::addElem(const char *name, uint16_t nval) { sprintf(this->_numbuff, "%u", nval); this->_appendNumber(name); } -void JsonFormatter::addElem(const char *name, int64_t lval) { sprintf(this->_numbuff, "%lld", (long long)lval); this->_appendNumber(name); } -*/ -void JsonFormatter::addElem(const char *name, uint64_t lval) { sprintf(this->_numbuff, "%llu", (unsigned long long)lval); this->_appendNumber(name); } - -void JsonFormatter::addElem(const char *name, bool bval) { strcpy(this->_numbuff, bval ? "true" : "false"); this->_appendNumber(name); } - -void JsonFormatter::_safecat(const char *val, bool escape) { - size_t len = (escape ? this->calcEscapedLength(val) : strlen(val)) + strlen(this->buff); - if(escape) len += 2; - if(len >= this->buffSize) { - return; - } - if(escape) strcat(this->buff, "\""); - if(escape) this->escapeString(val, &this->buff[strlen(this->buff)]); - else strcat(this->buff, val); - if(escape) strcat(this->buff, "\""); -} -void JsonFormatter::_appendNumber(const char *name) { this->appendElem(name); this->_safecat(this->_numbuff); } -uint32_t JsonFormatter::calcEscapedLength(const char *raw) { - uint32_t len = 0; - for(size_t i = strlen(raw); i > 0; i--) { - switch(raw[i]) { - case '"': - case '/': - case '\b': - case '\f': - case '\n': - case '\r': - case '\t': - case '\\': - len += 2; - break; - default: - len++; - break; - } - } - return len; -} -void JsonFormatter::escapeString(const char *raw, char *escaped) { - for(uint32_t i = 0; i < strlen(raw); i++) { - switch(raw[i]) { - case '"': - strcat(escaped, "\\\""); - break; - case '/': - strcat(escaped, "\\/"); - break; - case '\b': - strcat(escaped, "\\b"); - break; - case '\f': - strcat(escaped, "\\f"); - break; - case '\n': - strcat(escaped, "\\n"); - break; - case '\r': - strcat(escaped, "\\r"); - break; - case '\t': - strcat(escaped, "\\t"); - break; - case '\\': - strcat(escaped, "\\\\"); - break; - default: - size_t len = strlen(escaped); - escaped[len] = raw[i]; - escaped[len+1] = 0x00; - break; - } - } -} diff --git a/src/WResp.h b/src/WResp.h index 17e6c8c..23de961 100644 --- a/src/WResp.h +++ b/src/WResp.h @@ -2,61 +2,8 @@ #include "Somfy.h" #ifndef wresp_h #define wresp_h +#include "JsonFormatter.h" -class JsonFormatter { - protected: - char *buff; - size_t buffSize; - bool _headersSent = false; - uint8_t _objects = 0; - uint8_t _arrays = 0; - bool _nocomma = true; - char _numbuff[25] = {0}; - virtual void _safecat(const char *val, bool escape = false); - void _appendNumber(const char *name); - public: - void escapeString(const char *raw, char *escaped); - uint32_t calcEscapedLength(const char *raw); - void beginObject(const char *name = nullptr); - void endObject(); - void beginArray(const char *name = nullptr); - void endArray(); - void appendElem(const char *name = nullptr); - - void addElem(const char* val); - void addElem(float fval); - void addElem(int8_t nval); - void addElem(uint8_t nval); - /* - void addElem(int32_t nval); - void addElem(int16_t nval); - void addElem(uint16_t nval); - void addElem(unsigned int nval); - */ - void addElem(int32_t lval); - void addElem(uint32_t lval); - void addElem(bool bval); - - void addElem(const char* name, float fval); - void addElem(const char* name, int8_t nval); - void addElem(const char* name, uint8_t nval); - /* - void addElem(const char* name, int nval); - void addElem(const char* name, int16_t nval); - void addElem(const char* name, uint16_t nval); - void addElem(const char* name, unsigned int nval); - */ - void addElem(const char* name, int32_t lval); - void addElem(const char* name, uint32_t lval); - void addElem(const char* name, bool bval); - void addElem(const char *name, const char *val); - void addElem(const char* name, uint64_t lval); -}; -class BufferedJsonFormatter : public JsonFormatter { - public: - void setBuffer(char *b, size_t sz); - size_t length() const; -}; class AsyncJsonResp : public JsonFormatter { protected: void _safecat(const char *val, bool escape = false) override; From 48676b05c3b1e29369f40fe7454ecff66d609ddf Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 13:30:14 +0200 Subject: [PATCH 20/53] test: cover the JSON formatter Tests BufferedJsonFormatter output: nesting and commas, number and bool values, skipped null strings and escaping. A canary after the buffer catches writes past its end. Three fail: control characters other than \b\f\n\r\t aren't escaped, calcEscapedLength skips the first character, and so a value starting with an escaped character can be written one byte past the buffer. Co-Authored-By: Claude Opus 5.5 --- test/test_json/test_json.cpp | 161 +++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 test/test_json/test_json.cpp diff --git a/test/test_json/test_json.cpp b/test/test_json/test_json.cpp new file mode 100644 index 0000000..4172b63 --- /dev/null +++ b/test/test_json/test_json.cpp @@ -0,0 +1,161 @@ +#include +#include "JsonFormatter.h" + +// The formatter only gets told about the first BUFF_SIZE bytes. The rest is a +// canary that must never be written. +#define STORAGE_SIZE 160 +#define BUFF_SIZE 128 +#define CANARY 'X' + +static char storage[STORAGE_SIZE]; +static BufferedJsonFormatter fmt; + +static void useBuffer(size_t size) { + memset(storage, CANARY, sizeof(storage)); + fmt.setBuffer(storage, size); +} +static void assertCanary(size_t from) { + for(size_t i = from; i < STORAGE_SIZE; i++) { + char msg[48]; + snprintf(msg, sizeof(msg), "wrote past the buffer at byte %u", (unsigned)i); + TEST_ASSERT_EQUAL_HEX8_MESSAGE(CANARY, storage[i], msg); + } +} +// The JSON written for a single top level string value. +static const char *escaped(const char *val) { + useBuffer(BUFF_SIZE); + fmt.addElem(val); + return storage; +} + +void setUp() { useBuffer(BUFF_SIZE); } +void tearDown() { assertCanary(BUFF_SIZE); } + +/********************************************************************* + * Structure + ********************************************************************/ +static void test_empty_object() { + fmt.beginObject(); + fmt.endObject(); + TEST_ASSERT_EQUAL_STRING("{}", storage); +} +static void test_nested_objects_and_arrays() { + fmt.beginObject(); + fmt.addElem("a", (uint8_t)1); + fmt.beginObject("b"); + fmt.addElem("c", true); + fmt.endObject(); + fmt.beginArray("d"); + fmt.addElem((uint8_t)1); + fmt.addElem((uint8_t)2); + fmt.beginObject(); + fmt.addElem("e", "x"); + fmt.endObject(); + fmt.endArray(); + fmt.endObject(); + TEST_ASSERT_EQUAL_STRING("{\"a\":1,\"b\":{\"c\":true},\"d\":[1,2,{\"e\":\"x\"}]}", storage); +} +static void test_array_of_objects() { + fmt.beginArray(); + fmt.beginObject(); + fmt.endObject(); + fmt.beginObject(); + fmt.endObject(); + fmt.endArray(); + TEST_ASSERT_EQUAL_STRING("[{},{}]", storage); +} +static void test_named_object_after_element() { + fmt.beginObject(); + fmt.addElem("a", (uint8_t)1); + fmt.beginObject("o"); + fmt.endObject(); + fmt.endObject(); + TEST_ASSERT_EQUAL_STRING("{\"a\":1,\"o\":{}}", storage); +} + +/********************************************************************* + * Values + ********************************************************************/ +static void test_number_and_bool_values() { + fmt.beginArray(); + fmt.addElem((int8_t)-5); + fmt.addElem((uint8_t)255); + fmt.addElem((int32_t)-100000); + fmt.addElem((uint32_t)4000000000u); + fmt.addElem(1.5f); + fmt.addElem(true); + fmt.addElem(false); + fmt.endArray(); + TEST_ASSERT_EQUAL_STRING("[-5,255,-100000,4000000000,1.5000,true,false]", storage); +} +static void test_named_uint64() { + fmt.beginObject(); + fmt.addElem("u", (uint64_t)18446744073709551615ULL); + fmt.endObject(); + TEST_ASSERT_EQUAL_STRING("{\"u\":18446744073709551615}", storage); +} +static void test_null_string_is_skipped() { + fmt.beginObject(); + fmt.addElem("a", (uint8_t)1); + fmt.addElem("n", (const char *)nullptr); + fmt.addElem("b", (uint8_t)2); + fmt.endObject(); + TEST_ASSERT_EQUAL_STRING("{\"a\":1,\"b\":2}", storage); +} + +/********************************************************************* + * Escaping + ********************************************************************/ +static void test_escapes_special_characters() { + TEST_ASSERT_EQUAL_STRING("\"plain\"", escaped("plain")); + TEST_ASSERT_EQUAL_STRING("\"\\\"\"", escaped("\"")); + TEST_ASSERT_EQUAL_STRING("\"\\\\\"", escaped("\\")); + TEST_ASSERT_EQUAL_STRING("\"\\/\"", escaped("/")); + TEST_ASSERT_EQUAL_STRING("\"\\n\\t\\r\\b\\f\"", escaped("\n\t\r\b\f")); + TEST_ASSERT_EQUAL_STRING("\"Living \\\"Room\\\"\"", escaped("Living \"Room\"")); +} +static void test_escapes_other_control_characters() { + // Raw control characters are not allowed in JSON strings. + TEST_ASSERT_EQUAL_STRING("\"a\\u0001b\"", escaped("a\x01" "b")); + TEST_ASSERT_EQUAL_STRING("\"\\u001f\"", escaped("\x1f")); +} +static void test_escaped_length() { + TEST_ASSERT_EQUAL_UINT32(0, fmt.calcEscapedLength("")); + TEST_ASSERT_EQUAL_UINT32(2, fmt.calcEscapedLength("ab")); + TEST_ASSERT_EQUAL_UINT32(4, fmt.calcEscapedLength("a\"b")); +} +static void test_escaped_length_counts_first_character() { + TEST_ASSERT_EQUAL_UINT32(4, fmt.calcEscapedLength("\"ab")); + TEST_ASSERT_EQUAL_UINT32(2, fmt.calcEscapedLength("/")); +} + +/********************************************************************* + * Buffer limits + ********************************************************************/ +static void test_value_needing_one_byte_too_many_is_refused() { + // "\/abcdefg" with its quotes is 11 characters, so with the terminator it + // needs 12 bytes and must not be written into 11. + useBuffer(11); + fmt.addElem("/abcdefg"); + TEST_ASSERT_TRUE(strlen(storage) < 11); + assertCanary(11); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_empty_object); + RUN_TEST(test_nested_objects_and_arrays); + RUN_TEST(test_array_of_objects); + RUN_TEST(test_named_object_after_element); + RUN_TEST(test_number_and_bool_values); + RUN_TEST(test_named_uint64); + RUN_TEST(test_null_string_is_skipped); + RUN_TEST(test_escapes_special_characters); + RUN_TEST(test_escapes_other_control_characters); + RUN_TEST(test_escaped_length); + RUN_TEST(test_escaped_length_counts_first_character); + RUN_TEST(test_value_needing_one_byte_too_many_is_refused); + return UNITY_END(); +} From b2294ced34a61b4173742e58beb7c0c21e9c27b0 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 13:30:42 +0200 Subject: [PATCH 21/53] fix: escape control characters and count JSON string lengths correctly calcEscapedLength looped from strlen(raw) down to 1, counting the terminator instead of the first character. A value starting with a character that needs escaping was counted one byte short, so the fit check could let it be written one byte past the buffer. Control characters other than \b\f\n\r\t were copied raw, which isn't valid JSON. Both now go through one escapeChar helper, which also escapes the remaining control characters as \u00XX. Co-Authored-By: Claude Opus 5.5 --- src/JsonFormatter.cpp | 79 +++++++++++++++---------------------------- src/JsonFormatter.h | 3 ++ 2 files changed, 31 insertions(+), 51 deletions(-) diff --git a/src/JsonFormatter.cpp b/src/JsonFormatter.cpp index 714f64b..d54d81c 100644 --- a/src/JsonFormatter.cpp +++ b/src/JsonFormatter.cpp @@ -94,59 +94,36 @@ void JsonFormatter::_safecat(const char *val, bool escape) { if(escape) strcat(this->buff, "\""); } void JsonFormatter::_appendNumber(const char *name) { this->appendElem(name); this->_safecat(this->_numbuff); } -uint32_t JsonFormatter::calcEscapedLength(const char *raw) { - uint32_t len = 0; - for(size_t i = strlen(raw); i > 0; i--) { - switch(raw[i]) { - case '"': - case '/': - case '\b': - case '\f': - case '\n': - case '\r': - case '\t': - case '\\': - len += 2; - break; - default: - len++; - break; - } +size_t JsonFormatter::escapeChar(char c, char *out) { + const char *esc = nullptr; + switch(c) { + case '"': esc = "\\\""; break; + case '/': esc = "\\/"; break; + case '\b': esc = "\\b"; break; + case '\f': esc = "\\f"; break; + case '\n': esc = "\\n"; break; + case '\r': esc = "\\r"; break; + case '\t': esc = "\\t"; break; + case '\\': esc = "\\\\"; break; + default: break; } + if(esc) { + strcpy(out, esc); + return 2; + } + // Any other control character is not allowed raw in a JSON string. + if((unsigned char)c < 0x20) return snprintf(out, 7, "\\u%04x", (unsigned char)c); + out[0] = c; + out[1] = 0x00; + return 1; +} +uint32_t JsonFormatter::calcEscapedLength(const char *raw) { + char tmp[7]; + uint32_t len = 0; + for(size_t i = 0; raw[i]; i++) len += escapeChar(raw[i], tmp); return len; } void JsonFormatter::escapeString(const char *raw, char *escaped) { - for(uint32_t i = 0; i < strlen(raw); i++) { - switch(raw[i]) { - case '"': - strcat(escaped, "\\\""); - break; - case '/': - strcat(escaped, "\\/"); - break; - case '\b': - strcat(escaped, "\\b"); - break; - case '\f': - strcat(escaped, "\\f"); - break; - case '\n': - strcat(escaped, "\\n"); - break; - case '\r': - strcat(escaped, "\\r"); - break; - case '\t': - strcat(escaped, "\\t"); - break; - case '\\': - strcat(escaped, "\\\\"); - break; - default: - size_t len = strlen(escaped); - escaped[len] = raw[i]; - escaped[len+1] = 0x00; - break; - } - } + char *out = escaped + strlen(escaped); + for(size_t i = 0; raw[i]; i++) out += escapeChar(raw[i], out); } diff --git a/src/JsonFormatter.h b/src/JsonFormatter.h index 5afe9c4..6554870 100644 --- a/src/JsonFormatter.h +++ b/src/JsonFormatter.h @@ -16,6 +16,9 @@ class JsonFormatter { virtual void _safecat(const char *val, bool escape = false); void _appendNumber(const char *name); public: + // Writes c as it must appear inside a JSON string (out needs 7 bytes) and + // returns its length. + static size_t escapeChar(char c, char *out); void escapeString(const char *raw, char *escaped); uint32_t calcEscapedLength(const char *raw); void beginObject(const char *name = nullptr); From 8881289b1d3e63a126f3aa614894bea88946d0a7 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 13:31:48 +0200 Subject: [PATCH 22/53] fix: never write past a JSON buffer or send a broken socket event When something didn't fit, the formatters skipped it silently, closing brackets included, and clients got malformed JSON. Two writes could also go past the buffer: - AsyncJsonResp flushed when full and then wrote the value without checking again, so a value bigger than the whole buffer overflowed. It is now streamed straight out instead. - JsonSockEvent::closeEvent added the closing ] when only one byte was left, putting the terminator one byte past the buffer. The formatters now set an overflowed() flag and log an error, and a socket event that overflowed is logged and not sent. Co-Authored-By: Claude Opus 5.5 --- src/JsonFormatter.cpp | 6 ++++++ src/JsonFormatter.h | 3 +++ src/WResp.cpp | 38 ++++++++++++++++++++++++++++++------ test/test_json/test_json.cpp | 20 +++++++++++++++++++ 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/JsonFormatter.cpp b/src/JsonFormatter.cpp index d54d81c..3a4c20d 100644 --- a/src/JsonFormatter.cpp +++ b/src/JsonFormatter.cpp @@ -1,10 +1,14 @@ #include "JsonFormatter.h" +#include "esp_log.h" + +static const char *TAG = "JSON"; void BufferedJsonFormatter::setBuffer(char *b, size_t sz) { this->buff = b; this->buffSize = sz; this->buff[0] = 0; this->_nocomma = true; + this->_overflow = false; this->_objects = 0; this->_arrays = 0; } @@ -86,6 +90,8 @@ void JsonFormatter::_safecat(const char *val, bool escape) { size_t len = (escape ? this->calcEscapedLength(val) : strlen(val)) + strlen(this->buff); if(escape) len += 2; if(len >= this->buffSize) { + if(!this->_overflow) ESP_LOGE(TAG, "JSON exceeded buffer size %u", (unsigned)this->buffSize); + this->_overflow = true; return; } if(escape) strcat(this->buff, "\""); diff --git a/src/JsonFormatter.h b/src/JsonFormatter.h index 6554870..54f8927 100644 --- a/src/JsonFormatter.h +++ b/src/JsonFormatter.h @@ -12,10 +12,13 @@ class JsonFormatter { uint8_t _objects = 0; uint8_t _arrays = 0; bool _nocomma = true; + // Something didn't fit and was left out so the JSON is incomplete. + bool _overflow = false; char _numbuff[25] = {0}; virtual void _safecat(const char *val, bool escape = false); void _appendNumber(const char *name); public: + bool overflowed() const { return _overflow; } // Writes c as it must appear inside a JSON string (out needs 7 bytes) and // returns its length. static size_t escapeChar(char c, char *out); diff --git a/src/WResp.cpp b/src/WResp.cpp index 64fda3b..1fb8aa4 100644 --- a/src/WResp.cpp +++ b/src/WResp.cpp @@ -8,18 +8,25 @@ void JsonSockEvent::beginEvent(AsyncWebSocket *server, const char *evt, char *bu this->buffSize = buffSize; this->_nocomma = true; this->_closed = false; + this->_overflow = false; snprintf(this->buff, buffSize, "42[%s,", evt); } void JsonSockEvent::closeEvent() { if(!this->_closed) { - if(strlen(this->buff) < buffSize) strcat(this->buff, "]"); - else this->buff[buffSize - 1] = ']'; + // The ] and the terminator both have to fit. + if(strlen(this->buff) + 1 < buffSize) strcat(this->buff, "]"); + else this->_overflow = true; } this->_nocomma = true; this->_closed = true; } void JsonSockEvent::endEvent(uint32_t clientId) { this->closeEvent(); + // Part of it is missing so clients would only fail to parse it. + if(this->_overflow) { + ESP_LOGE(TAG, "Socket event too large for %u bytes, not sent: %.32s", (unsigned)this->buffSize, this->buff); + return; + } if(clientId == 0) this->server->textAll(this->buff); else this->server->text(clientId, this->buff); } @@ -27,7 +34,7 @@ void JsonSockEvent::_safecat(const char *val, bool escape) { size_t len = (escape ? this->calcEscapedLength(val) : strlen(val)) + strlen(this->buff); if(escape) len += 2; if(len >= this->buffSize) { - ESP_LOGE(TAG, "Socket exceeded buffer size %d - %d: %s", this->buffSize, len, this->buff); + this->_overflow = true; return; } if(escape) strcat(this->buff, "\""); @@ -42,6 +49,7 @@ void AsyncJsonResp::beginResponse(AsyncWebServerRequest *request, char *buff, si this->buff[0] = 0x00; this->_nocomma = true; this->_headersSent = false; + this->_overflow = false; this->_stream = request->beginResponseStream("application/json"); } void AsyncJsonResp::endResponse() { @@ -57,10 +65,28 @@ void AsyncJsonResp::flush() { } } void AsyncJsonResp::_safecat(const char *val, bool escape) { - size_t len = (escape ? this->calcEscapedLength(val) : strlen(val)) + strlen(this->buff); - if(escape) len += 2; - if(len >= this->buffSize) { + const size_t vlen = escape ? this->calcEscapedLength(val) + 2 : strlen(val); + if(vlen + strlen(this->buff) >= this->buffSize) { this->flush(); + if(vlen + strlen(this->buff) >= this->buffSize) { + // Bigger than the whole buffer so stream it straight out. + if(!this->_stream) { + this->_overflow = true; + return; + } + if(!escape) { + this->_stream->print(val); + return; + } + char esc[7]; + this->_stream->print("\""); + for(size_t i = 0; val[i]; i++) { + escapeChar(val[i], esc); + this->_stream->print(esc); + } + this->_stream->print("\""); + return; + } } if(escape) strcat(this->buff, "\""); if(escape) this->escapeString(val, &this->buff[strlen(this->buff)]); diff --git a/test/test_json/test_json.cpp b/test/test_json/test_json.cpp index 4172b63..4b21266 100644 --- a/test/test_json/test_json.cpp +++ b/test/test_json/test_json.cpp @@ -140,6 +140,24 @@ static void test_value_needing_one_byte_too_many_is_refused() { TEST_ASSERT_TRUE(strlen(storage) < 11); assertCanary(11); } +static void test_overflow_is_flagged() { + useBuffer(16); + fmt.beginObject(); + fmt.addElem("name", "fits"); + TEST_ASSERT_FALSE(fmt.overflowed()); + fmt.addElem("long", "this value does not fit"); + TEST_ASSERT_TRUE(fmt.overflowed()); + // What was already written (here up to the comma) stays intact and terminated. + TEST_ASSERT_EQUAL_STRING("{\"name\":\"fits\",", storage); + assertCanary(16); +} +static void test_setBuffer_clears_overflow() { + useBuffer(4); + fmt.addElem("too long"); + TEST_ASSERT_TRUE(fmt.overflowed()); + useBuffer(BUFF_SIZE); + TEST_ASSERT_FALSE(fmt.overflowed()); +} int main(int argc, char **argv) { (void)argc; @@ -157,5 +175,7 @@ int main(int argc, char **argv) { RUN_TEST(test_escaped_length); RUN_TEST(test_escaped_length_counts_first_character); RUN_TEST(test_value_needing_one_byte_too_many_is_refused); + RUN_TEST(test_overflow_is_flagged); + RUN_TEST(test_setBuffer_clears_overflow); return UNITY_END(); } From 3a8736f4d452601f573e83a8bf866c4e654d0262 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 13:37:48 +0200 Subject: [PATCH 23/53] refactor: share the shade travel maths in ShadeMotion.cpp checkMovement worked out the position four times, nearly copy-pasted, for lift down, lift up, tilt down and tilt up. All four now call travelPosition(), which keeps the same arithmetic, including how the elapsed time wraps with millis(). Tilt up with no tilt time still stops the tilt direction in the caller. transformPosition and transformCommand keep their behaviour through the new displayPosition() and flipCommand() helpers. The native env builds ShadeMotion.cpp so all of this can be unit tested on the PC. No behaviour change. Co-Authored-By: Claude Opus 5.5 --- platformio.ini | 2 +- src/ShadeMotion.cpp | 31 ++++++++++ src/ShadeMotion.h | 15 +++++ src/Somfy.cpp | 135 +++----------------------------------------- src/SomfyFrame.cpp | 18 ++++++ src/SomfyFrame.h | 2 + 6 files changed, 76 insertions(+), 127 deletions(-) create mode 100644 src/ShadeMotion.cpp create mode 100644 src/ShadeMotion.h diff --git a/platformio.ini b/platformio.ini index 432f0d9..1890425 100644 --- a/platformio.ini +++ b/platformio.ini @@ -101,7 +101,7 @@ lib_deps = platform = native test_framework = unity test_build_src = yes -build_src_filter = -<*> + + + +build_src_filter = -<*> + + + + build_flags = -std=gnu++17 -Isrc diff --git a/src/ShadeMotion.cpp b/src/ShadeMotion.cpp new file mode 100644 index 0000000..39c9acb --- /dev/null +++ b/src/ShadeMotion.cpp @@ -0,0 +1,31 @@ +#include +#include "ShadeMotion.h" + +float travelPosition(float startPos, int8_t dir, uint32_t elapsedMs, int32_t travelMs) { + // Work in time travelled from the end we are moving away from: the start + // position is turned into the time it would take to get there, the elapsed + // time is added and the total is turned back into a position. The addition + // wraps like the uint32 millis() so it keeps working across a rollover. + if(dir > 0) { + if(travelMs == 0) return 100.0f; + int32_t ms = (int32_t)floor((startPos / 100) * travelMs); + ms += elapsedMs; + ms = std::min(travelMs, ms); + if(ms >= travelMs) return 100.0f; + return std::min(std::max(0.0f, (float)ms / (float)travelMs), 1.0f) * 100; + } + if(dir < 0) { + if(travelMs == 0) return 0.0f; + int32_t ms = travelMs - (int32_t)floor((startPos / 100) * travelMs); + ms += elapsedMs; + ms = std::min(travelMs, ms); + if(ms >= travelMs) return 0.0f; + float pos = (1.0f - std::min(std::max(0.0f, (float)ms / (float)travelMs), 1.0f)) * 100; + return pos <= 0.0f ? 0.0f : pos; + } + return startPos; +} +int8_t displayPosition(float pos, bool flip) { + if(pos < 0) return -1; + return static_cast(flip && pos >= 0.00f ? floor(100.0f - pos) : floor(pos)); +} diff --git a/src/ShadeMotion.h b/src/ShadeMotion.h new file mode 100644 index 0000000..903d794 --- /dev/null +++ b/src/ShadeMotion.h @@ -0,0 +1,15 @@ +#ifndef SHADE_MOTION_H +#define SHADE_MOTION_H +#include + +// Shade position maths. Kept free of the shade state, radio and network so it +// can be unit tested on the PC (pio test -e native). + +// Where a shade (or its tilt) is after travelling elapsedMs from startPos +// (0-100). dir > 0 moves toward 100 and dir < 0 toward 0. travelMs is the +// time a full travel takes in that direction, 0 meaning it gets there at once. +float travelPosition(float startPos, int8_t dir, uint32_t elapsedMs, int32_t travelMs); +// A position as reported to clients: -1 when unset, otherwise the whole +// percentage, counted from the other end when flipped. +int8_t displayPosition(float pos, bool flip); +#endif diff --git a/src/Somfy.cpp b/src/Somfy.cpp index 06c33d7..181596f 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -11,6 +11,7 @@ #include "Utils.h" #include "ConfigSettings.h" #include "Somfy.h" +#include "ShadeMotion.h" #include "Sockets.h" #include "MQTT.h" #include "ConfigFile.h" @@ -822,41 +823,7 @@ void SomfyShade::checkMovement() { } if(!tilt_first && this->direction > 0) { - if(downTime == 0) { - this->p_currentPos(100.0); - //this->p_direction(0); - } - else { - // The shade is moving down so we need to calculate its position through the down position. - // 10000ms from 0 to 100 - // The starting posion is a float value from 0-1 that indicates how much the shade is open. So - // if we take the starting position * the total down time then this will tell us how many ms it - // has moved in the down position. - int32_t msFrom0 = (int32_t)floor((this->startPos/100) * downTime); - - // So if the start position is .1 it is 10% closed so we have a 1000ms (1sec) of time to account for - // before we add any more time. - msFrom0 += (curTime - this->moveStart); - // Now we should have the total number of ms that the shade moved from the top. But just so we - // don't have any rounding errors make sure that it is not greater than the max down time. - msFrom0 = min(downTime, msFrom0); - if(msFrom0 >= downTime) { - this->p_currentPos(100.0f); - //this->p_direction(0); - } - else { - // So now we know how much time has elapsed from the 0 position to down. The current position should be - // a ratio of how much time has travelled over the total time to go 100%. - - // We should now have the number of ms it will take to reach the shade fully close. - this->p_currentPos((min(max((float)0.0, (float)msFrom0 / (float)downTime), (float)1.0)) * 100); - // If the current position is >= 1 then we are at the bottom of the shade. - if(this->currentPos >= 100) { - this->p_currentPos(100.0); - //this->p_direction(0); - } - } - } + this->p_currentPos(travelPosition(this->startPos, 1, (uint32_t)(curTime - this->moveStart), downTime)); if(this->currentPos >= this->target) { this->p_currentPos(this->target); //if(this->settingMyPos) Serial.printf("IsAtTarget: %d %f=%f\n", this->isAtTarget(), this->currentPos, this->target); @@ -880,34 +847,8 @@ void SomfyShade::checkMovement() { } } else if(!tilt_first && this->direction < 0) { - if(upTime == 0) { - this->p_currentPos(0); - //this->p_direction(0); - } - else { - // The shade is moving up so we need to calculate its position through the up position. Shades - // often move slower in the up position so since we are using a relative position the up time - // can be calculated. - // 10000ms from 100 to 0; - int32_t msFrom100 = upTime - (int32_t)floor((this->startPos/100) * upTime); - msFrom100 += (curTime - this->moveStart); - msFrom100 = min(upTime, msFrom100); - if(msFrom100 >= upTime) { - this->p_currentPos(0.0f); - //this->p_direction(0); - } - else { - float fpos = ((float)1.0 - min(max((float)0.0, (float)msFrom100 / (float)upTime), (float)1.0)) * 100; - // We should now have the number of ms it will take to reach the shade fully open. - // If we are at the top of the shade then set the movement to 0. - if(fpos <= 0.0) { - this->p_currentPos(0.0f); - //this->p_direction(0); - } - else - this->p_currentPos(fpos); - } - } + // Shades often move slower up so this uses its own travel time. + this->p_currentPos(travelPosition(this->startPos, -1, (uint32_t)(curTime - this->moveStart), upTime)); if(this->currentPos <= this->target) { this->p_currentPos(this->target); //if(this->settingMyPos) Serial.printf("IsAtTarget: %d %f=%f\n", this->isAtTarget(), this->currentPos, this->target); @@ -933,24 +874,7 @@ void SomfyShade::checkMovement() { } if(this->tiltDirection > 0) { if(tilt_first) this->moveStart = curTime; - int32_t msFrom0 = (int32_t)floor((this->startTiltPos/100) * tiltTime); - msFrom0 += (curTime - this->tiltStart); - msFrom0 = min(tiltTime, msFrom0); - if(msFrom0 >= tiltTime) { - this->p_currentTiltPos(100.0f); - //this->p_tiltDirection(0); - //Serial.printf("Setting tiltDirection to 0 (not enough time) %.4f %.4f\n", msFrom0, tiltTime); - } - else { - float fpos = (min(max((float)0.0, (float)msFrom0 / (float)tiltTime), (float)1.0)) * 100; - - if(fpos > 100.0f) { - this->p_currentTiltPos(100.0f); - //this->p_tiltDirection(0); - //Serial.println("Setting tiltDirection to 0 (100%)"); - } - else this->p_currentTiltPos(fpos); - } + this->p_currentTiltPos(travelPosition(this->startTiltPos, 1, (uint32_t)(curTime - this->tiltStart), tiltTime)); if(tilt_first) { if(this->currentTiltPos >= 100.0f) { this->p_currentTiltPos(100.0f); @@ -982,26 +906,8 @@ void SomfyShade::checkMovement() { } else if(this->tiltDirection < 0) { if(tilt_first) this->moveStart = curTime; - if(tiltTime == 0) { - this->p_tiltDirection(0); - this->p_currentTiltPos(0.0f); - } - else { - int32_t msFrom100 = tiltTime - (int32_t)floor((this->startTiltPos/100) * tiltTime); - msFrom100 += (curTime - this->tiltStart); - msFrom100 = min(tiltTime, msFrom100); - if(msFrom100 >= tiltTime) { - this->p_currentTiltPos(0.0f); - //this->p_tiltDirection(0); - } - float fpos = ((float)1.0 - min(max((float)0.0, (float)msFrom100 / (float)tiltTime), (float)1.0)) * 100; - // If we are at the top of the shade then set the movement to 0. - if(fpos <= 0.0f) { - this->p_currentTiltPos(0.0f); - //this->p_tiltDirection(0); - } - else this->p_currentTiltPos(fpos); - } + if(tiltTime == 0) this->p_tiltDirection(0); + this->p_currentTiltPos(travelPosition(this->startTiltPos, -1, (uint32_t)(curTime - this->tiltStart), tiltTime)); if(tilt_first) { if(this->currentTiltPos <= 0.0f) { this->p_currentTiltPos(0.0f); @@ -1743,10 +1649,7 @@ void SomfyGroup::emitState(uint8_t num, const char *evt) { */ this->publish(); } -int8_t SomfyShade::transformPosition(float fpos) { - if(fpos < 0) return -1; - return static_cast(this->flipPosition && fpos >= 0.00f ? floor(100.0f - fpos) : floor(fpos)); -} +int8_t SomfyShade::transformPosition(float fpos) { return displayPosition(fpos, this->flipPosition); } bool SomfyShade::isIdle() { return this->isAtTarget() && this->direction == 0 && this->tiltDirection == 0; } @@ -3596,27 +3499,7 @@ SomfyGroup *SomfyShadeController::addGroup() { } return group; } -somfy_commands SomfyRemote::transformCommand(somfy_commands cmd) { - if(this->flipCommands) { - switch(cmd) { - case somfy_commands::Up: - return somfy_commands::Down; - case somfy_commands::MyUp: - return somfy_commands::MyDown; - case somfy_commands::Down: - return somfy_commands::Up; - case somfy_commands::MyDown: - return somfy_commands::MyUp; - case somfy_commands::StepUp: - return somfy_commands::StepDown; - case somfy_commands::StepDown: - return somfy_commands::StepUp; - default: - break; - } - } - return cmd; -} +somfy_commands SomfyRemote::transformCommand(somfy_commands cmd) { return this->flipCommands ? flipCommand(cmd) : cmd; } bool SomfyRemote::sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repeat) { uint8_t flags = (this->flags >> 4) & 0x0F; if(isWindy > 0) flags |= 0x01; diff --git a/src/SomfyFrame.cpp b/src/SomfyFrame.cpp index adf978f..629fe74 100644 --- a/src/SomfyFrame.cpp +++ b/src/SomfyFrame.cpp @@ -85,6 +85,24 @@ String translateSomfyCommand(const somfy_commands cmd) { return "Unknown(" + String((uint8_t)cmd) + ")"; } } +somfy_commands flipCommand(somfy_commands cmd) { + switch(cmd) { + case somfy_commands::Up: + return somfy_commands::Down; + case somfy_commands::MyUp: + return somfy_commands::MyDown; + case somfy_commands::Down: + return somfy_commands::Up; + case somfy_commands::MyDown: + return somfy_commands::MyUp; + case somfy_commands::StepUp: + return somfy_commands::StepDown; + case somfy_commands::StepDown: + return somfy_commands::StepUp; + default: + return cmd; + } +} byte somfy_frame_t::calc80Checksum(byte b0, byte b1, byte b2) { byte cs80 = 0; cs80 = (((b0 & 0xF0) >> 4) ^ ((b1 & 0xF0) >> 4)); diff --git a/src/SomfyFrame.h b/src/SomfyFrame.h index ad2a249..011de09 100644 --- a/src/SomfyFrame.h +++ b/src/SomfyFrame.h @@ -35,6 +35,8 @@ enum class somfy_commands : byte { }; String translateSomfyCommand(const somfy_commands cmd); somfy_commands translateSomfyCommand(const String& string); +// The command with up and down swapped, for motors wired the other way round. +somfy_commands flipCommand(somfy_commands cmd); struct somfy_rx_t; struct somfy_frame_t { From 1d1f685fcef10f10b2f433146507f824d2188983 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 13:38:23 +0200 Subject: [PATCH 24/53] test: cover shade travel and position/command flipping Tests travelPosition (part-way positions both ways, separate up and down times, stopping at the ends, zero travel time, steady progress and staying within 0-100), displayPosition with and without flipping, and flipCommand. Co-Authored-By: Claude Opus 5.5 --- test/test_motion/test_motion.cpp | 130 +++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 test/test_motion/test_motion.cpp diff --git a/test/test_motion/test_motion.cpp b/test/test_motion/test_motion.cpp new file mode 100644 index 0000000..d17c8f2 --- /dev/null +++ b/test/test_motion/test_motion.cpp @@ -0,0 +1,130 @@ +#include +#include "ShadeMotion.h" +#include "SomfyFrame.h" + +#define ASSERT_POS(expected, actual) TEST_ASSERT_FLOAT_WITHIN(0.01f, (expected), (actual)) +#define ASSERT_CMD(expected, actual) TEST_ASSERT_EQUAL_HEX8(static_cast(expected), static_cast(actual)) + +static const int8_t DOWN = 1; +static const int8_t UP = -1; + +void setUp() {} +void tearDown() {} + +/********************************************************************* + * travelPosition + ********************************************************************/ +static void test_down_part_way() { + ASSERT_POS(50.0f, travelPosition(0, DOWN, 5000, 10000)); + // From 30% the first 3s of travel are already done. + ASSERT_POS(50.0f, travelPosition(30, DOWN, 2000, 10000)); +} +static void test_up_part_way() { + ASSERT_POS(75.0f, travelPosition(100, UP, 2500, 10000)); + ASSERT_POS(25.0f, travelPosition(50, UP, 5000, 20000)); +} +static void test_no_time_elapsed_stays_put() { + // The start is counted in whole milliseconds so allow for that. + TEST_ASSERT_FLOAT_WITHIN(0.02f, 50.5f, travelPosition(50.5f, DOWN, 0, 10000)); + TEST_ASSERT_FLOAT_WITHIN(0.02f, 50.5f, travelPosition(50.5f, UP, 0, 10000)); +} +static void test_stops_at_the_ends() { + ASSERT_POS(100.0f, travelPosition(0, DOWN, 10000, 10000)); + ASSERT_POS(100.0f, travelPosition(0, DOWN, 20000, 10000)); + ASSERT_POS(0.0f, travelPosition(100, UP, 10000, 10000)); + ASSERT_POS(0.0f, travelPosition(100, UP, 20000, 10000)); +} +static void test_starting_at_the_end() { + ASSERT_POS(100.0f, travelPosition(100, DOWN, 0, 10000)); + ASSERT_POS(100.0f, travelPosition(100, DOWN, 3000, 10000)); + ASSERT_POS(0.0f, travelPosition(0, UP, 0, 10000)); + ASSERT_POS(0.0f, travelPosition(0, UP, 3000, 10000)); +} +static void test_always_within_range() { + const float starts[] = {0.0f, 12.5f, 50.0f, 99.9f, 100.0f}; + for(float start : starts) { + for(uint32_t elapsed = 0; elapsed <= 30000; elapsed += 250) { + for(int8_t dir : {DOWN, UP}) { + float pos = travelPosition(start, dir, elapsed, 10000); + TEST_ASSERT_TRUE(pos >= 0.0f && pos <= 100.0f); + } + } + } +} +static void test_up_and_down_use_their_own_times() { + // 2s of a 10s travel down covers 20%, 2s of a 20s travel up only 10%. + ASSERT_POS(20.0f, travelPosition(0, DOWN, 2000, 10000)); + ASSERT_POS(90.0f, travelPosition(100, UP, 2000, 20000)); +} +static void test_zero_travel_time_is_instant() { + ASSERT_POS(100.0f, travelPosition(0, DOWN, 0, 0)); + ASSERT_POS(100.0f, travelPosition(42, DOWN, 0, 0)); + ASSERT_POS(0.0f, travelPosition(100, UP, 0, 0)); + ASSERT_POS(0.0f, travelPosition(42, UP, 0, 0)); +} +static void test_moves_steadily() { + // Each step of time moves the shade further, never back. + float last = 0.0f; + for(uint32_t elapsed = 0; elapsed <= 10000; elapsed += 100) { + float pos = travelPosition(0, DOWN, elapsed, 10000); + TEST_ASSERT_TRUE(pos >= last); + last = pos; + } + last = 100.0f; + for(uint32_t elapsed = 0; elapsed <= 10000; elapsed += 100) { + float pos = travelPosition(100, UP, elapsed, 10000); + TEST_ASSERT_TRUE(pos <= last); + last = pos; + } +} + +/********************************************************************* + * displayPosition + ********************************************************************/ +static void test_displayPosition() { + TEST_ASSERT_EQUAL_INT8(-1, displayPosition(-1, false)); + TEST_ASSERT_EQUAL_INT8(-1, displayPosition(-1, true)); + TEST_ASSERT_EQUAL_INT8(37, displayPosition(37.9f, false)); + TEST_ASSERT_EQUAL_INT8(62, displayPosition(37.9f, true)); + TEST_ASSERT_EQUAL_INT8(100, displayPosition(0, true)); + TEST_ASSERT_EQUAL_INT8(0, displayPosition(100, true)); + TEST_ASSERT_EQUAL_INT8(100, displayPosition(100, false)); +} + +/********************************************************************* + * flipCommand + ********************************************************************/ +static void test_flipCommand_swaps_directions() { + ASSERT_CMD(somfy_commands::Down, flipCommand(somfy_commands::Up)); + ASSERT_CMD(somfy_commands::Up, flipCommand(somfy_commands::Down)); + ASSERT_CMD(somfy_commands::MyDown, flipCommand(somfy_commands::MyUp)); + ASSERT_CMD(somfy_commands::MyUp, flipCommand(somfy_commands::MyDown)); + ASSERT_CMD(somfy_commands::StepDown, flipCommand(somfy_commands::StepUp)); + ASSERT_CMD(somfy_commands::StepUp, flipCommand(somfy_commands::StepDown)); +} +static void test_flipCommand_keeps_the_rest() { + const somfy_commands same[] = { + somfy_commands::My, somfy_commands::Prog, somfy_commands::Toggle, + somfy_commands::UpDown, somfy_commands::MyUpDown, somfy_commands::Favorite + }; + for(somfy_commands cmd : same) ASSERT_CMD(cmd, flipCommand(cmd)); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_down_part_way); + RUN_TEST(test_up_part_way); + RUN_TEST(test_no_time_elapsed_stays_put); + RUN_TEST(test_stops_at_the_ends); + RUN_TEST(test_starting_at_the_end); + RUN_TEST(test_always_within_range); + RUN_TEST(test_up_and_down_use_their_own_times); + RUN_TEST(test_zero_travel_time_is_instant); + RUN_TEST(test_moves_steadily); + RUN_TEST(test_displayPosition); + RUN_TEST(test_flipCommand_swaps_directions); + RUN_TEST(test_flipCommand_keeps_the_rest); + return UNITY_END(); +} From 05c3d309a620d9d24235e4158f4fb2612895cb3a Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 20:35:42 +0200 Subject: [PATCH 25/53] fix: send wifiStrength only for real signal changes At -70 dBm the RSSI moves a dBm or two constantly, so the >1 dBm check every 1.5s sent wifiStrength to every client about 270 times an hour. That is extra traffic on an already lossy link and a state write in Home Assistant for each one. Broadcast on a channel change, or for a change of at least 3 dBm no more than every 10s. A newly connected client still gets the current value straight away. Co-Authored-By: Claude Opus 5.5 --- src/ESPNetwork.cpp | 7 +++++-- src/ESPNetwork.h | 5 +++++ src/Utils.cpp | 5 +++++ src/Utils.h | 4 ++++ test/test_utils/test_utils.cpp | 25 +++++++++++++++++++++++++ 5 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/ESPNetwork.cpp b/src/ESPNetwork.cpp index 98d2c5d..9a8cbd5 100644 --- a/src/ESPNetwork.cpp +++ b/src/ESPNetwork.cpp @@ -181,8 +181,9 @@ bool ESPNetwork::changeAP(const uint8_t *bssid, const int32_t channel) { } void ESPNetwork::emitSockets() { this->emitHeap(); - if(this->needsBroadcast || - (this->connType == conn_types_t::wifi && (abs(abs(WiFi.RSSI()) - abs(this->lastRSSI)) > 1 || WiFi.channel() != this->lastChannel))) { + if(this->needsBroadcast || + (this->connType == conn_types_t::wifi && rssiNeedsEmit(WiFi.RSSI(), this->lastRSSI, WiFi.channel(), this->lastChannel, + millis() - this->lastRSSIEmit, RSSI_EMIT_MIN_DBM, RSSI_EMIT_MIN_MS))) { this->emitSockets(255); sockEmit.loop(); this->needsBroadcast = false; @@ -212,6 +213,7 @@ void ESPNetwork::emitSockets(uint8_t num) { sockEmit.endEmit(num); this->lastRSSI = WiFi.RSSI(); this->lastChannel = WiFi.channel(); + this->lastRSSIEmit = millis(); } else { JsonSockEvent *json = sockEmit.beginEmit("wifiStrength"); @@ -231,6 +233,7 @@ void ESPNetwork::emitSockets(uint8_t num) { sockEmit.endEmit(num); this->lastRSSI = -100; this->lastChannel = -1; + this->lastRSSIEmit = millis(); } } this->emitHeap(num); diff --git a/src/ESPNetwork.h b/src/ESPNetwork.h index a77921c..f1594d0 100644 --- a/src/ESPNetwork.h +++ b/src/ESPNetwork.h @@ -8,10 +8,15 @@ #define CONNECT_TIMEOUT 20000 #define SSID_SCAN_INTERVAL 600000 +// A weak signal moves a dBm or two all the time, so only broadcast real +// changes and not more often than this. +#define RSSI_EMIT_MIN_DBM 3 +#define RSSI_EMIT_MIN_MS 10000 class ESPNetwork { protected: unsigned long lastEmit = 0; unsigned long lastMDNS = 0; + unsigned long lastRSSIEmit = 0; int lastRSSI = 0; int lastChannel = 0; int linkSpeed = 0; diff --git a/src/Utils.cpp b/src/Utils.cpp index 74af2ea..68a9043 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -130,3 +130,8 @@ int Timestamp::tzOffset() { time(&now); return Timestamp::calcTZOffset(&now); } +bool rssiNeedsEmit(int rssi, int lastRssi, int channel, int lastChannel, uint32_t sinceLastMs, int minDelta, uint32_t minMs) { + if(channel != lastChannel) return true; + int delta = rssi > lastRssi ? rssi - lastRssi : lastRssi - rssi; + return delta >= minDelta && sinceLastMs >= minMs; +} diff --git a/src/Utils.h b/src/Utils.h index 51a29b2..5088bbc 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -56,6 +56,10 @@ struct rebootDelay_t { else if(str[0] == 'f' || str[0] == 'F' || str[0] == '0') return false; return def; } +// Whether a wifiStrength broadcast is worth sending: always on a channel change, +// otherwise only for an RSSI change of at least minDelta dBm and not more often +// than every minMs. +bool rssiNeedsEmit(int rssi, int lastRssi, int channel, int lastChannel, uint32_t sinceLastMs, int minDelta, uint32_t minMs); class Timestamp { char _timeBuffer[128]; diff --git a/test/test_utils/test_utils.cpp b/test/test_utils/test_utils.cpp index 0a14039..ebde1d5 100644 --- a/test/test_utils/test_utils.cpp +++ b/test/test_utils/test_utils.cpp @@ -168,6 +168,26 @@ static void test_parseUTCTime_ignores_local_zone() { TEST_ASSERT_EQUAL_INT64((int64_t)SAMPLE_UTC, (int64_t)Timestamp::parseUTCTime("2024-03-05T07:08:09Z")); } +/********************************************************************* + * rssiNeedsEmit + ********************************************************************/ +static void test_rssiNeedsEmit_channel_change_always_emits() { + TEST_ASSERT_TRUE(rssiNeedsEmit(-70, -70, 6, 11, 0, 3, 10000)); +} +static void test_rssiNeedsEmit_small_change_is_ignored() { + TEST_ASSERT_FALSE(rssiNeedsEmit(-72, -70, 6, 6, 60000, 3, 10000)); +} +static void test_rssiNeedsEmit_real_change_after_interval_emits() { + TEST_ASSERT_TRUE(rssiNeedsEmit(-73, -70, 6, 6, 10000, 3, 10000)); +} +static void test_rssiNeedsEmit_real_change_too_soon_waits() { + TEST_ASSERT_FALSE(rssiNeedsEmit(-73, -70, 6, 6, 5000, 3, 10000)); +} +static void test_rssiNeedsEmit_either_direction() { + TEST_ASSERT_TRUE(rssiNeedsEmit(-67, -70, 6, 6, 10000, 3, 10000)); + TEST_ASSERT_TRUE(rssiNeedsEmit(-73, -70, 6, 6, 10000, 3, 10000)); +} + int main(int argc, char **argv) { (void)argc; (void)argv; @@ -193,5 +213,10 @@ int main(int argc, char **argv) { RUN_TEST(test_calcTZOffset_half_hour_west); RUN_TEST(test_parseUTCTime_in_utc); RUN_TEST(test_parseUTCTime_ignores_local_zone); + RUN_TEST(test_rssiNeedsEmit_channel_change_always_emits); + RUN_TEST(test_rssiNeedsEmit_small_change_is_ignored); + RUN_TEST(test_rssiNeedsEmit_real_change_after_interval_emits); + RUN_TEST(test_rssiNeedsEmit_real_change_too_soon_waits); + RUN_TEST(test_rssiNeedsEmit_either_direction); return UNITY_END(); } From dd47c0490564450f1d7fb9336feaf60111af5790 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 20:35:43 +0200 Subject: [PATCH 26/53] feat: log timings for slow work, socket clients and boot crashes Add Timing.h and use it to log what holds up the main loop, how long a new socket client's setup takes, slow socket sends, and how long each web request took and from which client. Socket connects and disconnects are logged with how long the client stayed, and late pings as warnings. Routine sends and pings are not logged, as each line costs several ms on a 115200 baud console. Report a stored crash once with the crashed and running build IDs, then erase it, since it was reported on every boot and its backtrace only decodes against the build that crashed. Mount the file system once. Co-Authored-By: Claude Opus 5.5 --- src/Sockets.cpp | 36 +++++++++++++++++++++++++++-- src/SomfyController.ino | 49 +++++++++++++++++++++++++++------------- src/Timing.h | 50 +++++++++++++++++++++++++++++++++++++++++ src/WResp.cpp | 11 +++++++++ src/WResp.h | 1 + src/Web.cpp | 28 +++++++++++++++++++++++ 6 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 src/Timing.h diff --git a/src/Sockets.cpp b/src/Sockets.cpp index ba4babe..0fb61c7 100644 --- a/src/Sockets.cpp +++ b/src/Sockets.cpp @@ -7,6 +7,7 @@ #include "Somfy.h" #include "ESPNetwork.h" #include "GitOTA.h" +#include "Timing.h" static const char *TAG = "Sockets"; @@ -21,6 +22,11 @@ AsyncWebSocket ws("/"); #define MAX_WS_CLIENTS 5 static uint32_t clientMap[MAX_WS_CLIENTS] = {0,0,0,0,0}; +// When each slot connected and last saw a ping, so the logs show how long a +// client stayed and how far apart its pings arrived. +static uint32_t connectedAt[MAX_WS_CLIENTS] = {0,0,0,0,0}; +static uint32_t lastPing[MAX_WS_CLIENTS] = {0,0,0,0,0}; +#define PING_LATE_MS 25000 static uint8_t mapClientId(uint32_t asyncId) { for(uint8_t i = 0; i < MAX_WS_CLIENTS; i++) @@ -131,13 +137,22 @@ void SocketEmitter::initClients() { if(asyncId != 0 && ws.hasClient(asyncId)) { ESP_LOGD(TAG, "Initializing Socket Client %u (asyncId=%lu)", slot, asyncId); esp_task_wdt_reset(); + ElapsedTimer total, step; settings.emitSockets(slot); + logElapsed(TAG, "Socket init settings", step.us()); if(!ws.hasClient(asyncId)) { this->newClients[i] = 255; continue; } + step.restart(); somfy.emitState(slot); + logElapsed(TAG, "Socket init somfy state", step.us()); if(!ws.hasClient(asyncId)) { this->newClients[i] = 255; continue; } + step.restart(); git.emitUpdateCheck(slot); + logElapsed(TAG, "Socket init update check", step.us()); if(!ws.hasClient(asyncId)) { this->newClients[i] = 255; continue; } + step.restart(); net.emitSockets(slot); + logElapsed(TAG, "Socket init network", step.us()); + logElapsed(TAG, "Socket init total", total.us()); esp_task_wdt_reset(); } this->newClients[i] = 255; @@ -176,7 +191,9 @@ void SocketEmitter::wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client return; } IPAddress ip = client->remoteIP(); - ESP_LOGD(TAG, "Socket [%lu] Connected from %d.%d.%d.%d (slot %u)", asyncId, ip[0], ip[1], ip[2], ip[3], slot); + ESP_LOGI(TAG, "Socket [%lu] Connected from %d.%d.%d.%d (slot %u)", asyncId, ip[0], ip[1], ip[2], ip[3], slot); + connectedAt[slot] = millis(); + lastPing[slot] = 0; client->text("Connected"); client->setCloseClientOnQueueFull(false); sockEmit.delayInit(slot); @@ -185,8 +202,9 @@ void SocketEmitter::wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client case WS_EVT_DISCONNECT: { uint8_t slot = mapClientId(asyncId); - ESP_LOGD(TAG, "Socket [%lu] Disconnected (slot %u)", asyncId, slot); if(slot != 255) { + ESP_LOGI(TAG, "Socket [%lu] Disconnected (slot %u) after %lums, last ping %lums ago", asyncId, slot, + (unsigned long)(millis() - connectedAt[slot]), lastPing[slot] ? (unsigned long)(millis() - lastPing[slot]) : 0UL); for(uint8_t i = 0; i < SOCK_MAX_ROOMS; i++) sockEmit.rooms[i].leave(slot); } @@ -218,6 +236,20 @@ void SocketEmitter::wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client case WS_EVT_ERROR: ESP_LOGE(TAG, "Socket [%lu] Error", asyncId); break; + case WS_EVT_PING: + { + // Home Assistant pings every 20s and drops the socket when the pong is + // late, so only a late ping is worth a line; it points at a stalled + // network task or a lossy link. + uint8_t slot = mapClientId(asyncId); + if(slot != 255) { + uint32_t now = millis(); + if(lastPing[slot] && now - lastPing[slot] > PING_LATE_MS) + ESP_LOGW(TAG, "Socket [%lu] late ping, %lums since the last", asyncId, (unsigned long)(now - lastPing[slot])); + lastPing[slot] = now; + } + } + break; case WS_EVT_PONG: break; } diff --git a/src/SomfyController.ino b/src/SomfyController.ino index 976bb44..25205c3 100644 --- a/src/SomfyController.ino +++ b/src/SomfyController.ino @@ -11,8 +11,18 @@ #include "MQTT.h" #include "GitOTA.h" #include "esp_core_dump.h" +#include "esp_idf_version.h" +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) +#include "esp_app_desc.h" +#define getRunningElfSha esp_app_get_elf_sha256 +#else +#include "esp_ota_ops.h" +#define getRunningElfSha esp_ota_get_app_elf_sha256 +#endif +#include "Timing.h" static const char *TAG = "Main"; +#define LOOP_SLOW_MS 100 ConfigSettings settings; Web webServer; @@ -65,21 +75,28 @@ void setup() { ESP_LOGW(TAG, " MTVAL: 0x%08x RA: 0x%08x SP: 0x%08x", summary.ex_info.mtval, summary.ex_info.ra, summary.ex_info.sp); #endif + // The backtrace only decodes against the firmware.elf of the build that + // crashed, so say whether that is the one running now. + const char *crashedSha = (const char *)summary.app_elf_sha256; + char runningSha[APP_ELF_SHA256_SZ] = {0}; + getRunningElfSha(runningSha, sizeof(runningSha)); + if(strcmp(crashedSha, runningSha) == 0) + ESP_LOGW(TAG, " Build: %s (running %s, same build)", crashedSha, runningSha); + else + ESP_LOGW(TAG, " Build: %s (running %s, different build, decode the backtrace with the matching firmware.elf)", crashedSha, runningSha); + // Erase it so the next boot only reports a new crash. + if(esp_core_dump_image_erase() != ESP_OK) ESP_LOGE(TAG, "Could not erase the coredump"); } ESP_LOGI(TAG, "Mounting File System..."); - - if (LittleFS.begin()) { ESP_LOGI(TAG, "Total: %d bytes", LittleFS.totalBytes()); ESP_LOGI(TAG, "Used: %d bytes", LittleFS.usedBytes()); ESP_LOGI(TAG, "Free: %d bytes", LittleFS.totalBytes() - LittleFS.usedBytes()); listDir("/", 3); + ESP_LOGI(TAG, "File system mounted successfully"); } else { ESP_LOGE(TAG, "LittleFS mount failed!"); } - - if(LittleFS.begin()) ESP_LOGI(TAG, "File system mounted successfully"); - else ESP_LOGE(TAG, "Error mounting file system"); settings.begin(); if(WiFi.status() == WL_CONNECTED) WiFi.disconnect(true); delay(10); @@ -108,30 +125,30 @@ void loop() { ESP.restart(); return; } - uint32_t timing = millis(); - + // Each step only logs when it holds up the loop for LOOP_SLOW_MS or more. + ElapsedTimer timing; net.loop(); - if(millis() - timing > 100) ESP_LOGD(TAG, "Timing Net: %ldms", millis() - timing); - timing = millis(); + logIfSlow(TAG, "Loop net", timing.us(), LOOP_SLOW_MS); + timing.restart(); esp_task_wdt_reset(); somfy.loop(); - if(millis() - timing > 100) ESP_LOGD(TAG, "Timing Somfy: %ldms", millis() - timing); - timing = millis(); + logIfSlow(TAG, "Loop somfy", timing.us(), LOOP_SLOW_MS); esp_task_wdt_reset(); if(net.connected() || net.softAPOpened) { if(!rebootDelay.reboot && net.connected() && !net.softAPOpened) { + timing.restart(); git.loop(); + logIfSlow(TAG, "Loop git", timing.us(), LOOP_SLOW_MS); esp_task_wdt_reset(); } + timing.restart(); webServer.loop(); + logIfSlow(TAG, "Loop web server", timing.us(), LOOP_SLOW_MS); esp_task_wdt_reset(); - if(millis() - timing > 100) ESP_LOGD(TAG, "Timing WebServer: %ldms", millis() - timing); - esp_task_wdt_reset(); - timing = millis(); + timing.restart(); sockEmit.loop(); - if(millis() - timing > 100) ESP_LOGD(TAG, "Timing Socket: %ldms", millis() - timing); + logIfSlow(TAG, "Loop sockets", timing.us(), LOOP_SLOW_MS); esp_task_wdt_reset(); - timing = millis(); } if(rebootDelay.reboot && millis() > rebootDelay.rebootTime) { net.end(); diff --git a/src/Timing.h b/src/Timing.h new file mode 100644 index 0000000..160b8fc --- /dev/null +++ b/src/Timing.h @@ -0,0 +1,50 @@ +#ifndef timing_h +#define timing_h +#include +#include "esp_log.h" + +// Timings shorter than this are not logged. Every line costs several ms on a +// 115200 baud console so raise it (e.g. -DTIMING_LOG_MIN_MS=50) to see only +// the calls worth looking at. +#ifndef TIMING_LOG_MIN_MS +#define TIMING_LOG_MIN_MS 0 +#endif +// Anything that takes at least this long is logged as a warning so the slow +// calls stand out from the routine timings. +#ifndef TIMING_SLOW_MS +#define TIMING_SLOW_MS 1000 +#endif + +// Measures elapsed time in microseconds. micros() wraps every ~71 minutes but +// the unsigned subtraction keeps any span shorter than that correct. +class ElapsedTimer { + uint32_t _start; + public: + ElapsedTimer() : _start(micros()) {} + void restart() { _start = micros(); } + uint32_t us() const { return micros() - _start; } +}; + +// Logs how long something took as milliseconds with microsecond precision. +static inline void logElapsed(const char *tag, const char *what, uint32_t us) { + const unsigned long ms = us / 1000, frac = us % 1000; + if(ms < TIMING_LOG_MIN_MS) return; + if(ms >= TIMING_SLOW_MS) ESP_LOGW(tag, "%s took %lu.%03lums (slow)", what, ms, frac); + else ESP_LOGI(tag, "%s took %lu.%03lums", what, ms, frac); +} +// For calls that run constantly, like loop(), where logging every pass would +// flood the console. Only logs once the time reaches minMs. +static inline void logIfSlow(const char *tag, const char *what, uint32_t us, uint32_t minMs) { + if(us >= minMs * 1000) logElapsed(tag, what, us); +} + +// Logs the time the enclosing scope took when it ends. +class ScopeTimer { + const char *_tag; + const char *_what; + ElapsedTimer _timer; + public: + ScopeTimer(const char *tag, const char *what) : _tag(tag), _what(what) {} + ~ScopeTimer() { logElapsed(_tag, _what, _timer.us()); } +}; +#endif diff --git a/src/WResp.cpp b/src/WResp.cpp index 1fb8aa4..1d4dcde 100644 --- a/src/WResp.cpp +++ b/src/WResp.cpp @@ -1,9 +1,12 @@ #include "WResp.h" #include "esp_log.h" +#include "Timing.h" static const char *TAG = "WResp"; +#define EMIT_SLOW_MS 20 void JsonSockEvent::beginEvent(AsyncWebSocket *server, const char *evt, char *buff, size_t buffSize) { this->server = server; + this->_evt = evt; this->buff = buff; this->buffSize = buffSize; this->_nocomma = true; @@ -27,8 +30,16 @@ void JsonSockEvent::endEvent(uint32_t clientId) { ESP_LOGE(TAG, "Socket event too large for %u bytes, not sent: %.32s", (unsigned)this->buffSize, this->buff); return; } + ElapsedTimer timer; if(clientId == 0) this->server->textAll(this->buff); else this->server->text(clientId, this->buff); + // Sends happen every few seconds, so only the slow ones are worth a line. + const uint32_t us = timer.us(); + if(us < EMIT_SLOW_MS * 1000) return; + char what[80]; + if(clientId == 0) snprintf(what, sizeof(what), "Socket emit %s (%u bytes) to all", this->_evt, (unsigned)strlen(this->buff)); + else snprintf(what, sizeof(what), "Socket emit %s (%u bytes) to %lu", this->_evt, (unsigned)strlen(this->buff), (unsigned long)clientId); + logElapsed(TAG, what, us); } void JsonSockEvent::_safecat(const char *val, bool escape) { size_t len = (escape ? this->calcEscapedLength(val) : strlen(val)) + strlen(this->buff); diff --git a/src/WResp.h b/src/WResp.h index 23de961..ad5890f 100644 --- a/src/WResp.h +++ b/src/WResp.h @@ -17,6 +17,7 @@ class AsyncJsonResp : public JsonFormatter { class JsonSockEvent : public JsonFormatter { protected: bool _closed = false; + const char *_evt = ""; void _safecat(const char *val, bool escape = false) override; public: AsyncWebSocket *server = nullptr; diff --git a/src/Web.cpp b/src/Web.cpp index 604c2dc..89b4965 100644 --- a/src/Web.cpp +++ b/src/Web.cpp @@ -14,6 +14,7 @@ #include "MQTT.h" #include "GitOTA.h" #include "ESPNetwork.h" +#include "Timing.h" #include #include #include @@ -1245,8 +1246,35 @@ void Web::handleNotFound(AsyncWebServerRequest *request) { request->send(404, _encoding_text, g_async_content); } +// Logs how long a request takes to handle and, once the connection closes, how +// long the whole exchange took. The total includes chunked bodies like +// /controller that are built while they are sent, after the handler returns. +// The server name must be a literal as the disconnect handler keeps it. +static void timeRequest(AsyncWebServerRequest *request, ArMiddlewareNext next, const char *server) { + ElapsedTimer timer; + // Taken now as the client may already be gone when the disconnect runs. + IPAddress ip = request->client() ? request->client()->remoteIP() : IPAddress(); + // Uploads set their own disconnect handler while the body arrives, before + // this runs, and replacing it would stop the reboot after an update. + if(!request->multipart()) { + request->onDisconnect([request, timer, server, ip]() { + char what[112]; + snprintf(what, sizeof(what), "%s %s %s from %u.%u.%u.%u total", server, request->methodToString(), request->url().c_str(), + ip[0], ip[1], ip[2], ip[3]); + logElapsed(TAG, what, timer.us()); + }); + } + next(); + char what[112]; + snprintf(what, sizeof(what), "%s %s %s from %u.%u.%u.%u handler", server, request->methodToString(), request->url().c_str(), + ip[0], ip[1], ip[2], ip[3]); + logElapsed(TAG, what, timer.us()); +} + void Web::begin() { ESP_LOGI(TAG, "Creating Web MicroServices..."); + asyncApiServer.addMiddleware([](AsyncWebServerRequest *r, ArMiddlewareNext next) { timeRequest(r, next, "api"); }); + asyncServer.addMiddleware([](AsyncWebServerRequest *r, ArMiddlewareNext next) { timeRequest(r, next, "web"); }); // Async API Server (port 8081) DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*"); DefaultHeaders::Instance().addHeader("Access-Control-Allow-Methods", "PUT,POST,GET,OPTIONS"); From 6b19d24eb4bc65a052202078c776137c39f9ee9a Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 20:50:41 +0200 Subject: [PATCH 27/53] fix: print each log line whole The Arduino core's log_printf() has its UART lock commented out and writes with ets_printf(), so lines logged at the same time from async_tcp and loop() came out interleaved character by character. It also formats short lines into one static buffer shared by every task. Wrap log_printf at link time and hold a mutex around the core's log_printfv(). It never blocks before the scheduler runs, in an ISR or in a critical section, and waits at most 50ms before printing anyway. Co-Authored-By: Claude Opus 5.5 --- platformio.ini | 2 ++ src/LogLock.cpp | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/LogLock.cpp diff --git a/platformio.ini b/platformio.ini index 1890425..428ba61 100644 --- a/platformio.ini +++ b/platformio.ini @@ -37,6 +37,8 @@ build_flags = -DCONFIG_ESP_COREDUMP_CHECKSUM_CRC32=1 -DCONFIG_ESP_TASK_WDT_PANIC=1 -DCONFIG_ESP_COREDUMP_DECODE_INFO=1 + ; Route log_printf through src/LogLock.cpp so lines from both cores don't interleave. + -Wl,--wrap=log_printf monitor_speed = 115200 monitor_filters = time diff --git a/src/LogLock.cpp b/src/LogLock.cpp new file mode 100644 index 0000000..2c06388 --- /dev/null +++ b/src/LogLock.cpp @@ -0,0 +1,36 @@ +// Prints each log line whole. +// +// The Arduino core's log_printf() writes with ets_printf() and has its UART +// lock commented out, so lines logged at the same time from async_tcp (core 0) +// and loop() (core 1) come out interleaved character by character. It also +// formats short lines into one static buffer shared by every task. +// +// platformio.ini links with -Wl,--wrap=log_printf so every ESP_LOGx/log_x call +// lands here and takes this lock around the core's log_printfv(). +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +// Defined in the core's esp32-hal-uart.c but not declared in a header. +extern "C" int log_printfv(const char *format, va_list arg); + +// Created during static initialisation, before anything can race for it. A +// constructor that logs before this runs simply goes unlocked. +static SemaphoreHandle_t logMutex = xSemaphoreCreateRecursiveMutex(); +// Never wait longer than this for the lock; the line is printed anyway, so a +// task stuck mid-log can only garble output, not hang another task. +#define LOG_LOCK_WAIT_MS 50 + +extern "C" int __wrap_log_printf(const char *format, ...) { + // No blocking before the scheduler runs, in an ISR or in a critical section. + const bool canLock = logMutex && xTaskGetSchedulerState() == taskSCHEDULER_RUNNING && xPortCanYield(); + const bool locked = canLock && xSemaphoreTakeRecursive(logMutex, pdMS_TO_TICKS(LOG_LOCK_WAIT_MS)) == pdTRUE; + va_list arg; + va_start(arg, format); + int len = log_printfv(format, arg); + va_end(arg); + if(locked) xSemaphoreGiveRecursive(logMutex); + return len; +} From f1eac31957c9bc77b82d6346f4ff1d5147db66a9 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 20:50:42 +0200 Subject: [PATCH 28/53] fix: report the real flipPosition for linked shades serializeShadeRef sent flipCommands as flipPosition, so group linked shades and the /shadeCommand reply showed the wrong setting. Co-Authored-By: Claude Opus 5.5 --- src/Web.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Web.cpp b/src/Web.cpp index 89b4965..4b2ad79 100644 --- a/src/Web.cpp +++ b/src/Web.cpp @@ -308,7 +308,7 @@ static void serializeShadeRef(SomfyShade *shade, JsonFormatter &json) { json.addElem("paired", shade->paired); json.addElem("shadeType", static_cast(shade->shadeType)); json.addElem("flipCommands", shade->flipCommands); - json.addElem("flipPosition", shade->flipCommands); + json.addElem("flipPosition", shade->flipPosition); json.addElem("bitLength", shade->bitLength); json.addElem("proto", static_cast(shade->proto)); json.addElem("flags", shade->flags); From bd7ed17a9757058e892edc5945d56349ec68fc30 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 20:50:46 +0200 Subject: [PATCH 29/53] feat: log AP signal levels and roam for a 6 dB gain The roaming scan found two APs for the SSID every 10 minutes but the log never showed their signal, so there was no way to tell whether the device at -70 dBm had a better AP to move to. Log each AP on the roaming scans, show roams at info level, and switch for a 6 dB gain instead of 10. Co-Authored-By: Claude Opus 5.5 --- src/ESPNetwork.cpp | 17 +++++++++++++---- src/ESPNetwork.h | 2 ++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/ESPNetwork.cpp b/src/ESPNetwork.cpp index 9a8cbd5..3abdc90 100644 --- a/src/ESPNetwork.cpp +++ b/src/ESPNetwork.cpp @@ -111,11 +111,11 @@ void ESPNetwork::loop() { if(this->getStrongestAP(settings.WIFI.ssid, bssid, &channel)) { if(!WiFi.BSSID() || memcmp(bssid, WiFi.BSSID(), sizeof(bssid)) != 0) { if(!this->connected()) { - ESP_LOGD(TAG, "Connecting to AP %02X:%02X:%02X:%02X:%02X:%02X CH: %d", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5], channel); + ESP_LOGI(TAG, "Connecting to AP %02X:%02X:%02X:%02X:%02X:%02X CH: %d", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5], channel); this->connectWiFi(bssid, channel); } else { - ESP_LOGD(TAG, "Found stronger AP %02X:%02X:%02X:%02X:%02X:%02X CH: %d", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5], channel); + ESP_LOGI(TAG, "Found stronger AP %02X:%02X:%02X:%02X:%02X:%02X CH: %d", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5], channel); this->changeAP(bssid, channel); } } @@ -538,8 +538,9 @@ uint32_t ESPNetwork::getChipId() { return chipId; } bool ESPNetwork::getStrongestAP(const char *ssid, uint8_t *bssid, int32_t *channel) { - // The new AP must be at least 10dbm greater. - int32_t strength = this->connected() ? WiFi.RSSI() + 10 : -127; + // The new AP must be at least ROAM_MIN_GAIN_DB stronger. + const bool connected = this->connected(); + int32_t strength = connected ? WiFi.RSSI() + ROAM_MIN_GAIN_DB : -127; int32_t chan = -1; memset(bssid, 0x00, 6); esp_task_wdt_delete(NULL); @@ -548,6 +549,14 @@ bool ESPNetwork::getStrongestAP(const char *ssid, uint8_t *bssid, int32_t *chann esp_task_wdt_add(NULL); for(int16_t i = 0; i < n; i++) { if(WiFi.SSID(i).compareTo(ssid) == 0) { + // Only on the roaming scans, so the log shows whether a better AP is + // around without filling up while we scan to reconnect. + if(connected) { + const uint8_t *b = WiFi.BSSID(i); + const uint8_t *cur = WiFi.BSSID(); + ESP_LOGI(TAG, "AP %02X:%02X:%02X:%02X:%02X:%02X ch %ld %ld dBm%s", b[0], b[1], b[2], b[3], b[4], b[5], + (long)WiFi.channel(i), (long)WiFi.RSSI(i), (cur && memcmp(b, cur, 6) == 0) ? " (connected)" : ""); + } if(WiFi.RSSI(i) > strength) { strength = WiFi.RSSI(i); memcpy(bssid, WiFi.BSSID(i), 6); diff --git a/src/ESPNetwork.h b/src/ESPNetwork.h index f1594d0..08cd562 100644 --- a/src/ESPNetwork.h +++ b/src/ESPNetwork.h @@ -12,6 +12,8 @@ // changes and not more often than this. #define RSSI_EMIT_MIN_DBM 3 #define RSSI_EMIT_MIN_MS 10000 +// A roaming scan only moves to another AP this much stronger. +#define ROAM_MIN_GAIN_DB 6 class ESPNetwork { protected: unsigned long lastEmit = 0; From 66d264e4b556d5cd7c4c89423563c1901dd95c18 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 20:50:47 +0200 Subject: [PATCH 30/53] fix: broadcast memStatus every 30s It went out whenever free heap moved by 1.5 KB and 7s had passed, which with normal traffic was about 240 times an hour. Send it every 30s instead. It is also the one event always sent, so 30s keeps Home Assistant well inside its 60s receive timeout. Per-client sends and the web UI's room are unchanged. Co-Authored-By: Claude Opus 5.5 --- src/ESPNetwork.cpp | 31 ++++++------------------------- src/ESPNetwork.h | 3 +++ 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/src/ESPNetwork.cpp b/src/ESPNetwork.cpp index 3abdc90..ccd6b99 100644 --- a/src/ESPNetwork.cpp +++ b/src/ESPNetwork.cpp @@ -32,8 +32,6 @@ extern SomfyShadeController somfy; static unsigned long _lastHeapEmit = 0; static bool _apScanning = false; -static uint32_t _lastMaxHeap = 0; -static uint32_t _lastHeap = 0; int connectRetries = 0; void ESPNetwork::end() { SSDP.end(); @@ -673,20 +671,12 @@ void ESPNetwork::networkEvent(WiFiEvent_t event) { } } void ESPNetwork::emitHeap(uint8_t num) { - bool bEmit = false; - bool bTimeEmit = millis() - _lastHeapEmit > 15000; - bool bRoomEmit = false; - bool bValEmit = false; - if(num != 255 || this->needsBroadcast) bEmit = true; - if(millis() - _lastHeapEmit > 15000) bTimeEmit = true; uint32_t freeHeap = ESP.getFreeHeap(); uint32_t maxHeap = ESP.getMaxAllocHeap(); uint32_t minHeap = ESP.getMinFreeHeap(); - if(abs((int)(freeHeap - _lastHeap)) > 1500) bValEmit = true; - if(abs((int)(maxHeap - _lastMaxHeap)) > 1500) bValEmit = true; - bRoomEmit = sockEmit.activeClients(0) > 0; - if(bValEmit) bTimeEmit = millis() - _lastHeapEmit > 7000; - if(bEmit || bTimeEmit || bRoomEmit || bValEmit) { + const bool bBroadcast = num == 255 && millis() - _lastHeapEmit >= HEAP_EMIT_MS; + const bool bRoomEmit = sockEmit.activeClients(0) > 0; + if(num != 255 || bBroadcast || bRoomEmit) { JsonSockEvent *json = sockEmit.beginEmit("memStatus"); json->beginObject(); json->addElem("max", maxHeap); @@ -695,20 +685,11 @@ void ESPNetwork::emitHeap(uint8_t num) { json->addElem("total", ESP.getHeapSize()); json->addElem("uptime", (uint64_t)millis()); json->endObject(); - if(num == 255 && bTimeEmit && bValEmit) { + if(bBroadcast) { sockEmit.endEmit(num); _lastHeapEmit = millis(); - _lastHeap = freeHeap; - _lastMaxHeap = maxHeap; - //Serial.printf("BROAD HEAP: Emit:%d TimeEmit:%d ValEmit:%d\n", bEmit, bTimeEmit, bValEmit); - } - else if(num != 255) { - sockEmit.endEmit(num); - //Serial.printf("TARGET HEAP %d: Emit:%d TimeEmit:%d ValEmit:%d\n", num, bEmit, bTimeEmit, bValEmit); - } - else if(bRoomEmit) { - sockEmit.endEmitRoom(0); - //Serial.printf("ROOM HEAP: Emit:%d TimeEmit:%d ValEmit:%d\n", bEmit, bTimeEmit, bValEmit); } + else if(num != 255) sockEmit.endEmit(num); + else sockEmit.endEmitRoom(0); } } diff --git a/src/ESPNetwork.h b/src/ESPNetwork.h index 08cd562..bb0bdc6 100644 --- a/src/ESPNetwork.h +++ b/src/ESPNetwork.h @@ -12,6 +12,9 @@ // changes and not more often than this. #define RSSI_EMIT_MIN_DBM 3 #define RSSI_EMIT_MIN_MS 10000 +// How often memStatus is broadcast. It is also the one event that is always +// sent, so it must stay well under Home Assistant's 60s receive timeout. +#define HEAP_EMIT_MS 30000 // A roaming scan only moves to another AP this much stronger. #define ROAM_MIN_GAIN_DB 6 class ESPNetwork { From 0610dc4369c5fe9df92b0199e52792b0b6fc90f7 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 20:50:49 +0200 Subject: [PATCH 31/53] feat: log where /discovery spends its time The /discovery handler takes about 200ms on the async_tcp task on every call with no obvious cause. When it takes 50ms or more, log how long the header, memory stats, rooms, shades, groups and sending took. Co-Authored-By: Claude Opus 5.5 --- src/Web.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Web.cpp b/src/Web.cpp index 4b2ad79..f8e9414 100644 --- a/src/Web.cpp +++ b/src/Web.cpp @@ -32,6 +32,8 @@ extern ESPNetwork net; //#define WEB_MAX_RESPONSE 34768 #define WEB_MAX_RESPONSE 4096 static char g_async_content[WEB_MAX_RESPONSE]; +// /discovery logs where its time went when it takes at least this long. +#define DISCOVERY_SLOW_MS 50 // General responses @@ -684,6 +686,10 @@ void Web::handleDiscovery(AsyncWebServerRequest *request) { char connType[10] = "Unknown"; if(net.connType == conn_types_t::ethernet) strcpy(connType, "Ethernet"); else if(net.connType == conn_types_t::wifi) strcpy(connType, "Wifi"); + // This handler takes about 200ms on the async_tcp task for no obvious + // reason, so time each part to find out where it goes. + ElapsedTimer total, step; + uint32_t usHead, usMem, usRooms, usShades, usGroups, usSend; AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); @@ -697,6 +703,7 @@ void Web::handleDiscovery(AsyncWebServerRequest *request) { resp.addElem("chipModel", settings.chipModel); resp.addElem("connType", connType); resp.addElem("checkForUpdate", settings.checkForUpdate); + usHead = step.us(); step.restart(); resp.beginObject("memory"); resp.addElem("max", ESP.getMaxAllocHeap()); resp.addElem("free", ESP.getFreeHeap()); @@ -704,18 +711,27 @@ void Web::handleDiscovery(AsyncWebServerRequest *request) { resp.addElem("total", ESP.getHeapSize()); resp.addElem("uptime", (uint64_t)millis()); resp.endObject(); + usMem = step.us(); step.restart(); resp.beginArray("rooms"); serializeRooms(resp); resp.endArray(); + usRooms = step.us(); step.restart(); resp.beginArray("shades"); serializeShades(resp); resp.endArray(); + usShades = step.us(); step.restart(); resp.beginArray("groups"); serializeGroups(resp); resp.endArray(); resp.endObject(); + usGroups = step.us(); step.restart(); resp.endResponse(); + usSend = step.us(); net.needsBroadcast = true; + if(total.us() >= DISCOVERY_SLOW_MS * 1000) { + ESP_LOGW(TAG, "discovery: head %.1f mem %.1f rooms %.1f shades %.1f groups %.1f send %.1f ms", + usHead / 1000.0f, usMem / 1000.0f, usRooms / 1000.0f, usShades / 1000.0f, usGroups / 1000.0f, usSend / 1000.0f); + } } else request->send(500, _encoding_text, "Invalid http method"); From b6ed24f5bb21a5df5b67aae0754b4dd18879dd55 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 22:23:11 +0200 Subject: [PATCH 32/53] fix: hook the log lock into log_printfv The Arduino core already links with --wrap=log_printf for the ESP Insights hook in libesp_diagnostics, so defining __wrap_log_printf failed to link with a duplicate definition. That hook prints through log_printfv, so wrap that instead and take the lock there. Co-Authored-By: Claude Opus 5.5 --- platformio.ini | 5 +++-- src/LogLock.cpp | 18 ++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/platformio.ini b/platformio.ini index 428ba61..1b3eeee 100644 --- a/platformio.ini +++ b/platformio.ini @@ -37,8 +37,9 @@ build_flags = -DCONFIG_ESP_COREDUMP_CHECKSUM_CRC32=1 -DCONFIG_ESP_TASK_WDT_PANIC=1 -DCONFIG_ESP_COREDUMP_DECODE_INFO=1 - ; Route log_printf through src/LogLock.cpp so lines from both cores don't interleave. - -Wl,--wrap=log_printf + ; Route log_printfv through src/LogLock.cpp so lines from both cores don't + ; interleave. The core already wraps log_printf for ESP Insights. + -Wl,--wrap=log_printfv monitor_speed = 115200 monitor_filters = time diff --git a/src/LogLock.cpp b/src/LogLock.cpp index 2c06388..7f3198d 100644 --- a/src/LogLock.cpp +++ b/src/LogLock.cpp @@ -1,20 +1,21 @@ // Prints each log line whole. // -// The Arduino core's log_printf() writes with ets_printf() and has its UART +// The Arduino core's log_printfv() writes with ets_printf() and has its UART // lock commented out, so lines logged at the same time from async_tcp (core 0) // and loop() (core 1) come out interleaved character by character. It also // formats short lines into one static buffer shared by every task. // -// platformio.ini links with -Wl,--wrap=log_printf so every ESP_LOGx/log_x call -// lands here and takes this lock around the core's log_printfv(). +// The core already links with --wrap=log_printf, which sends every +// ESP_LOGx/log_x call to the ESP Insights hook in libesp_diagnostics, and that +// hook prints through log_printfv(). platformio.ini adds +// -Wl,--wrap=log_printfv so that call lands here and takes this lock. #include #include #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" -// Defined in the core's esp32-hal-uart.c but not declared in a header. -extern "C" int log_printfv(const char *format, va_list arg); +extern "C" int __real_log_printfv(const char *format, va_list arg); // Created during static initialisation, before anything can race for it. A // constructor that logs before this runs simply goes unlocked. @@ -23,14 +24,11 @@ static SemaphoreHandle_t logMutex = xSemaphoreCreateRecursiveMutex(); // task stuck mid-log can only garble output, not hang another task. #define LOG_LOCK_WAIT_MS 50 -extern "C" int __wrap_log_printf(const char *format, ...) { +extern "C" int __wrap_log_printfv(const char *format, va_list arg) { // No blocking before the scheduler runs, in an ISR or in a critical section. const bool canLock = logMutex && xTaskGetSchedulerState() == taskSCHEDULER_RUNNING && xPortCanYield(); const bool locked = canLock && xSemaphoreTakeRecursive(logMutex, pdMS_TO_TICKS(LOG_LOCK_WAIT_MS)) == pdTRUE; - va_list arg; - va_start(arg, format); - int len = log_printfv(format, arg); - va_end(arg); + int len = __real_log_printfv(format, arg); if(locked) xSemaphoreGiveRecursive(logMutex); return len; } From 55b1db4508af419cbe1d21f15120c338f174f3cb Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 11 Oct 2026 22:23:11 +0200 Subject: [PATCH 33/53] fix: stop warning about late pings Home Assistant's aiohttp client only pings after 20s without a message from the device, so with memStatus every 30s pings arrive every 30-60s and every one was logged as late. The gap depends on our own traffic, not the link, so log pings at debug level and keep the time of the last one for the disconnect line. Co-Authored-By: Claude Opus 5.5 --- src/Sockets.cpp | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Sockets.cpp b/src/Sockets.cpp index 0fb61c7..fdaa276 100644 --- a/src/Sockets.cpp +++ b/src/Sockets.cpp @@ -26,8 +26,6 @@ static uint32_t clientMap[MAX_WS_CLIENTS] = {0,0,0,0,0}; // client stayed and how far apart its pings arrived. static uint32_t connectedAt[MAX_WS_CLIENTS] = {0,0,0,0,0}; static uint32_t lastPing[MAX_WS_CLIENTS] = {0,0,0,0,0}; -#define PING_LATE_MS 25000 - static uint8_t mapClientId(uint32_t asyncId) { for(uint8_t i = 0; i < MAX_WS_CLIENTS; i++) if(clientMap[i] == asyncId) return i; @@ -238,14 +236,13 @@ void SocketEmitter::wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client break; case WS_EVT_PING: { - // Home Assistant pings every 20s and drops the socket when the pong is - // late, so only a late ping is worth a line; it points at a stalled - // network task or a lossy link. + // Home Assistant only pings after 20s without a message from us, so + // the gap depends on our own traffic and says nothing about the link. + // It is kept for the disconnect line. uint8_t slot = mapClientId(asyncId); if(slot != 255) { uint32_t now = millis(); - if(lastPing[slot] && now - lastPing[slot] > PING_LATE_MS) - ESP_LOGW(TAG, "Socket [%lu] late ping, %lums since the last", asyncId, (unsigned long)(now - lastPing[slot])); + if(lastPing[slot]) ESP_LOGD(TAG, "Socket [%lu] ping, %lums since the last", asyncId, (unsigned long)(now - lastPing[slot])); lastPing[slot] = now; } } From a8bb3779579eb1cd60f2c5e6e64ada083a4a780f Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 16:04:13 +0200 Subject: [PATCH 34/53] fix: track the JSON length instead of recounting it Every append found the end of the buffer with strlen(), up to four times per append and four appends per field, so building a response cost the square of its size. /discovery fills a 4 KB buffer and spent about 195 ms of its ~210 ms there (shades 126 ms, groups 69 ms), all on the async_tcp task that serves every socket and web request. Keep the length in the formatter and write every append at the end through one helper. The web response and socket event subclasses and ControllerChunker use the tracked length too. Output is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/JsonFormatter.cpp | 29 +++++++++++++++++++-------- src/JsonFormatter.h | 5 +++++ src/WResp.cpp | 36 +++++++++++++++------------------ src/Web.cpp | 10 ++++----- test/test_json/test_json.cpp | 39 ++++++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 33 deletions(-) diff --git a/src/JsonFormatter.cpp b/src/JsonFormatter.cpp index 3a4c20d..87b7a20 100644 --- a/src/JsonFormatter.cpp +++ b/src/JsonFormatter.cpp @@ -7,13 +7,14 @@ void BufferedJsonFormatter::setBuffer(char *b, size_t sz) { this->buff = b; this->buffSize = sz; this->buff[0] = 0; + this->_len = 0; this->_nocomma = true; this->_overflow = false; this->_objects = 0; this->_arrays = 0; } size_t BufferedJsonFormatter::length() const { - return strlen(this->buff); + return this->_len; } void JsonFormatter::beginObject(const char *name) { if(name && strlen(name) > 0) this->appendElem(name); @@ -87,17 +88,29 @@ void JsonFormatter::addElem(const char *name, uint64_t lval) { sprintf(this->_nu void JsonFormatter::addElem(const char *name, bool bval) { strcpy(this->_numbuff, bval ? "true" : "false"); this->_appendNumber(name); } void JsonFormatter::_safecat(const char *val, bool escape) { - size_t len = (escape ? this->calcEscapedLength(val) : strlen(val)) + strlen(this->buff); - if(escape) len += 2; - if(len >= this->buffSize) { + const size_t vlen = escape ? this->calcEscapedLength(val) + 2 : strlen(val); + if(this->_len + vlen >= this->buffSize) { if(!this->_overflow) ESP_LOGE(TAG, "JSON exceeded buffer size %u", (unsigned)this->buffSize); this->_overflow = true; return; } - if(escape) strcat(this->buff, "\""); - if(escape) this->escapeString(val, &this->buff[strlen(this->buff)]); - else strcat(this->buff, val); - if(escape) strcat(this->buff, "\""); + this->_appendAt(val, escape, vlen); +} +void JsonFormatter::_appendAt(const char *val, bool escape, size_t vlen) { + char *p = this->buff + this->_len; + if(escape) { + *p++ = '"'; + *p = 0; + this->escapeString(val, p); + p += vlen - 2; + *p++ = '"'; + } + else { + memcpy(p, val, vlen); + p += vlen; + } + *p = 0; + this->_len += vlen; } void JsonFormatter::_appendNumber(const char *name) { this->appendElem(name); this->_safecat(this->_numbuff); } size_t JsonFormatter::escapeChar(char c, char *out) { diff --git a/src/JsonFormatter.h b/src/JsonFormatter.h index 54f8927..3f93025 100644 --- a/src/JsonFormatter.h +++ b/src/JsonFormatter.h @@ -8,6 +8,8 @@ class JsonFormatter { protected: char *buff; size_t buffSize; + // What is in buff, kept so appending never has to strlen() the buffer. + size_t _len = 0; bool _headersSent = false; uint8_t _objects = 0; uint8_t _arrays = 0; @@ -16,6 +18,9 @@ class JsonFormatter { bool _overflow = false; char _numbuff[25] = {0}; virtual void _safecat(const char *val, bool escape = false); + // Appends val at buff + _len. vlen is what it takes, including the quotes + // when escaped; the caller has made sure it fits. + void _appendAt(const char *val, bool escape, size_t vlen); void _appendNumber(const char *name); public: bool overflowed() const { return _overflow; } diff --git a/src/WResp.cpp b/src/WResp.cpp index 1d4dcde..7d00372 100644 --- a/src/WResp.cpp +++ b/src/WResp.cpp @@ -12,12 +12,13 @@ void JsonSockEvent::beginEvent(AsyncWebSocket *server, const char *evt, char *bu this->_nocomma = true; this->_closed = false; this->_overflow = false; - snprintf(this->buff, buffSize, "42[%s,", evt); + const int n = snprintf(this->buff, buffSize, "42[%s,", evt); + this->_len = n < 0 ? 0 : ((size_t)n < buffSize ? (size_t)n : buffSize - 1); } void JsonSockEvent::closeEvent() { if(!this->_closed) { // The ] and the terminator both have to fit. - if(strlen(this->buff) + 1 < buffSize) strcat(this->buff, "]"); + if(this->_len + 1 < buffSize) this->_appendAt("]", false, 1); else this->_overflow = true; } this->_nocomma = true; @@ -37,49 +38,47 @@ void JsonSockEvent::endEvent(uint32_t clientId) { const uint32_t us = timer.us(); if(us < EMIT_SLOW_MS * 1000) return; char what[80]; - if(clientId == 0) snprintf(what, sizeof(what), "Socket emit %s (%u bytes) to all", this->_evt, (unsigned)strlen(this->buff)); - else snprintf(what, sizeof(what), "Socket emit %s (%u bytes) to %lu", this->_evt, (unsigned)strlen(this->buff), (unsigned long)clientId); + if(clientId == 0) snprintf(what, sizeof(what), "Socket emit %s (%u bytes) to all", this->_evt, (unsigned)this->_len); + else snprintf(what, sizeof(what), "Socket emit %s (%u bytes) to %lu", this->_evt, (unsigned)this->_len, (unsigned long)clientId); logElapsed(TAG, what, us); } void JsonSockEvent::_safecat(const char *val, bool escape) { - size_t len = (escape ? this->calcEscapedLength(val) : strlen(val)) + strlen(this->buff); - if(escape) len += 2; - if(len >= this->buffSize) { + const size_t vlen = escape ? this->calcEscapedLength(val) + 2 : strlen(val); + if(this->_len + vlen >= this->buffSize) { this->_overflow = true; return; } - if(escape) strcat(this->buff, "\""); - if(escape) this->escapeString(val, &this->buff[strlen(this->buff)]); - else strcat(this->buff, val); - if(escape) strcat(this->buff, "\""); + this->_appendAt(val, escape, vlen); } void AsyncJsonResp::beginResponse(AsyncWebServerRequest *request, char *buff, size_t buffSize) { this->_request = request; this->buff = buff; this->buffSize = buffSize; this->buff[0] = 0x00; + this->_len = 0; this->_nocomma = true; this->_headersSent = false; this->_overflow = false; this->_stream = request->beginResponseStream("application/json"); } void AsyncJsonResp::endResponse() { - if(strlen(this->buff)) this->flush(); + if(this->_len) this->flush(); if(this->_request && this->_stream) { this->_request->send(this->_stream); } } void AsyncJsonResp::flush() { - if(this->_stream && strlen(this->buff) > 0) { - this->_stream->print(this->buff); + if(this->_stream && this->_len > 0) { + this->_stream->write((const uint8_t *)this->buff, this->_len); this->buff[0] = 0x00; + this->_len = 0; } } void AsyncJsonResp::_safecat(const char *val, bool escape) { const size_t vlen = escape ? this->calcEscapedLength(val) + 2 : strlen(val); - if(vlen + strlen(this->buff) >= this->buffSize) { + if(vlen + this->_len >= this->buffSize) { this->flush(); - if(vlen + strlen(this->buff) >= this->buffSize) { + if(vlen + this->_len >= this->buffSize) { // Bigger than the whole buffer so stream it straight out. if(!this->_stream) { this->_overflow = true; @@ -99,8 +98,5 @@ void AsyncJsonResp::_safecat(const char *val, bool escape) { return; } } - if(escape) strcat(this->buff, "\""); - if(escape) this->escapeString(val, &this->buff[strlen(this->buff)]); - else strcat(this->buff, val); - if(escape) strcat(this->buff, "\""); + this->_appendAt(val, escape, vlen); } diff --git a/src/Web.cpp b/src/Web.cpp index f8e9414..4cc1276 100644 --- a/src/Web.cpp +++ b/src/Web.cpp @@ -555,7 +555,7 @@ private: serializeGitVersion(fmt); fmt.endObject(); fmt.beginArray("rooms"); - unitLen = strlen(unit); + unitLen = fmt.length(); section = S_ROOMS; idx = 0; firstInArray = true; @@ -577,7 +577,7 @@ private: fmt.beginObject(); serializeRoom(&somfy.rooms[idx], fmt); fmt.endObject(); - unitLen = pos + strlen(unit + pos); + unitLen = pos + fmt.length(); idx++; firstInArray = false; return; @@ -599,7 +599,7 @@ private: fmt.beginObject(); serializeShade(&somfy.shades[idx], fmt); fmt.endObject(); - unitLen = pos + strlen(unit + pos); + unitLen = pos + fmt.length(); idx++; firstInArray = false; return; @@ -622,7 +622,7 @@ private: fmt.beginObject(); serializeGroupRef(g, fmt); fmt.beginArray("linkedShades"); - unitLen = pos + strlen(unit + pos); + unitLen = pos + fmt.length(); lsIdx = 0; firstInArray = false; firstInLSArray = true; @@ -650,7 +650,7 @@ private: fmt.beginObject(); serializeShadeRef(lshade, fmt); fmt.endObject(); - unitLen = pos + strlen(unit + pos); + unitLen = pos + fmt.length(); lsIdx++; firstInLSArray = false; return; diff --git a/test/test_json/test_json.cpp b/test/test_json/test_json.cpp index 4b21266..1dfa190 100644 --- a/test/test_json/test_json.cpp +++ b/test/test_json/test_json.cpp @@ -159,6 +159,42 @@ static void test_setBuffer_clears_overflow() { TEST_ASSERT_FALSE(fmt.overflowed()); } +/********************************************************************* + * Tracked length + ********************************************************************/ +static void test_length_matches_what_was_written() { + fmt.beginObject(); + fmt.addElem("name", "Living \"Room\"\n"); + fmt.addElem("pos", 41.5f); + fmt.beginArray("ids"); + fmt.addElem((uint8_t)1); + fmt.addElem((int32_t)-2); + fmt.endArray(); + fmt.addElem("on", true); + fmt.endObject(); + TEST_ASSERT_EQUAL_UINT32(strlen(storage), fmt.length()); + TEST_ASSERT_EQUAL_STRING("{\"name\":\"Living \\\"Room\\\"\\n\",\"pos\":41.5000,\"ids\":[1,-2],\"on\":true}", storage); +} +static void test_refused_append_leaves_length_alone() { + useBuffer(16); + fmt.beginObject(); + fmt.addElem("name", "fits"); + const size_t before = fmt.length(); + fmt.addElem("x", "this value does not fit"); + TEST_ASSERT_TRUE(fmt.overflowed()); + // The comma and key still fit; the value was refused whole. + TEST_ASSERT_EQUAL_UINT32(strlen(storage), fmt.length()); + TEST_ASSERT_TRUE(fmt.length() >= before); + TEST_ASSERT_TRUE(fmt.length() < 16); +} +static void test_setBuffer_resets_length() { + fmt.addElem("something"); + TEST_ASSERT_TRUE(fmt.length() > 0); + useBuffer(BUFF_SIZE); + TEST_ASSERT_EQUAL_UINT32(0, fmt.length()); + TEST_ASSERT_EQUAL_STRING("", storage); +} + int main(int argc, char **argv) { (void)argc; (void)argv; @@ -177,5 +213,8 @@ int main(int argc, char **argv) { RUN_TEST(test_value_needing_one_byte_too_many_is_refused); RUN_TEST(test_overflow_is_flagged); RUN_TEST(test_setBuffer_clears_overflow); + RUN_TEST(test_length_matches_what_was_written); + RUN_TEST(test_refused_append_leaves_length_alone); + RUN_TEST(test_setBuffer_resets_length); return UNITY_END(); } From 6cec411fa857dedeeb2d7c892d3e72e651254c09 Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 17:57:33 +0200 Subject: [PATCH 35/53] fix: stop the log lock lifting loop() above the radio task The log lock was a mutex, so when async_tcp waited on it loop() inherited async_tcp's priority and could preempt the radio task mid-frame. Use a binary semaphore, which has no priority inheritance, track the holder so a nested log doesn't wait on itself, and cap the wait at 20ms. Co-Authored-By: Claude Opus 5.5 --- src/LogLock.cpp | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/LogLock.cpp b/src/LogLock.cpp index 7f3198d..381c58d 100644 --- a/src/LogLock.cpp +++ b/src/LogLock.cpp @@ -9,6 +9,10 @@ // ESP_LOGx/log_x call to the ESP Insights hook in libesp_diagnostics, and that // hook prints through log_printfv(). platformio.ini adds // -Wl,--wrap=log_printfv so that call lands here and takes this lock. +// +// The lock is a binary semaphore, not a mutex. A mutex passes its waiter's +// priority to the holder, so async_tcp waiting on loop() would lift loop() +// above the radio task and let it break into a frame on the air. #include #include #include "freertos/FreeRTOS.h" @@ -17,18 +21,31 @@ extern "C" int __real_log_printfv(const char *format, va_list arg); +static SemaphoreHandle_t createLogLock() { + SemaphoreHandle_t sem = xSemaphoreCreateBinary(); + if(sem) xSemaphoreGive(sem); + return sem; +} // Created during static initialisation, before anything can race for it. A // constructor that logs before this runs simply goes unlocked. -static SemaphoreHandle_t logMutex = xSemaphoreCreateRecursiveMutex(); +static SemaphoreHandle_t logLock = createLogLock(); +// The task holding the lock, so a log from inside a log doesn't wait on itself. +static volatile TaskHandle_t logOwner = nullptr; // Never wait longer than this for the lock; the line is printed anyway, so a -// task stuck mid-log can only garble output, not hang another task. -#define LOG_LOCK_WAIT_MS 50 +// task stuck mid-log can only garble output, not hang another task. A long +// line takes about 20ms at 115200 baud. +#define LOG_LOCK_WAIT_MS 20 extern "C" int __wrap_log_printfv(const char *format, va_list arg) { // No blocking before the scheduler runs, in an ISR or in a critical section. - const bool canLock = logMutex && xTaskGetSchedulerState() == taskSCHEDULER_RUNNING && xPortCanYield(); - const bool locked = canLock && xSemaphoreTakeRecursive(logMutex, pdMS_TO_TICKS(LOG_LOCK_WAIT_MS)) == pdTRUE; + const bool canLock = logLock && xTaskGetSchedulerState() == taskSCHEDULER_RUNNING && xPortCanYield() && + logOwner != xTaskGetCurrentTaskHandle(); + const bool locked = canLock && xSemaphoreTake(logLock, pdMS_TO_TICKS(LOG_LOCK_WAIT_MS)) == pdTRUE; + if(locked) logOwner = xTaskGetCurrentTaskHandle(); int len = __real_log_printfv(format, arg); - if(locked) xSemaphoreGiveRecursive(logMutex); + if(locked) { + logOwner = nullptr; + xSemaphoreGive(logLock); + } return len; } From 77d3e18cce7227b2e1b40bed4a5bf746c18fe213 Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 17:57:47 +0200 Subject: [PATCH 36/53] fix: log only timings of 100ms or more by default Every HTTP request logged two timing lines and every socket connect five, all from the async_tcp task, at several ms each on a 115200 baud console. Raise the default TIMING_LOG_MIN_MS to 100 and drop the unused ScopeTimer. Co-Authored-By: Claude Opus 5.5 --- src/Timing.h | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/Timing.h b/src/Timing.h index 160b8fc..1367ec9 100644 --- a/src/Timing.h +++ b/src/Timing.h @@ -4,10 +4,11 @@ #include "esp_log.h" // Timings shorter than this are not logged. Every line costs several ms on a -// 115200 baud console so raise it (e.g. -DTIMING_LOG_MIN_MS=50) to see only -// the calls worth looking at. +// 115200 baud console, and per-request timings would add dozens of lines to +// every page load on the task that serves the sockets. Build with +// -DTIMING_LOG_MIN_MS=0 to see every timing. #ifndef TIMING_LOG_MIN_MS -#define TIMING_LOG_MIN_MS 0 +#define TIMING_LOG_MIN_MS 100 #endif // Anything that takes at least this long is logged as a warning so the slow // calls stand out from the routine timings. @@ -37,14 +38,4 @@ static inline void logElapsed(const char *tag, const char *what, uint32_t us) { static inline void logIfSlow(const char *tag, const char *what, uint32_t us, uint32_t minMs) { if(us >= minMs * 1000) logElapsed(tag, what, us); } - -// Logs the time the enclosing scope took when it ends. -class ScopeTimer { - const char *_tag; - const char *_what; - ElapsedTimer _timer; - public: - ScopeTimer(const char *tag, const char *what) : _tag(tag), _what(what) {} - ~ScopeTimer() { logElapsed(_tag, _what, _timer.us()); } -}; #endif From 1cc55e07f09b77e7ad731a76cc8b704f95e54bbe Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 17:58:12 +0200 Subject: [PATCH 37/53] fix: keep the core dump in flash after reporting it Erasing the dump on the first boot after a crash lost it on headless devices where nobody saw the serial summary. Leave it until the next crash replaces it and use the reset reason to tell a fresh crash (WARN) from one reported on an earlier boot (INFO). Co-Authored-By: Claude Opus 5.5 --- src/SomfyController.ino | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/SomfyController.ino b/src/SomfyController.ino index 25205c3..4b5411d 100644 --- a/src/SomfyController.ino +++ b/src/SomfyController.ino @@ -11,6 +11,7 @@ #include "MQTT.h" #include "GitOTA.h" #include "esp_core_dump.h" +#include "esp_system.h" #include "esp_idf_version.h" #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) #include "esp_app_desc.h" @@ -59,20 +60,28 @@ void setup() { ESP_LOGI(TAG, "Startup/Boot...."); esp_core_dump_summary_t summary; if (esp_core_dump_get_summary(&summary) == ESP_OK) { - ESP_LOGW(TAG, "*** Previous crash coredump found ***"); - ESP_LOGW(TAG, " Task: %s", summary.exc_task); - ESP_LOGW(TAG, " PC: 0x%08x", summary.exc_pc); + // The dump stays in flash until the next crash replaces it, so it can still + // be read later with espcoredump.py. Only shout about it on the boot right + // after the crash. + const esp_reset_reason_t reason = esp_reset_reason(); + const bool newCrash = reason == ESP_RST_PANIC || reason == ESP_RST_TASK_WDT || + reason == ESP_RST_INT_WDT || reason == ESP_RST_WDT; +#define CRASH_LOG(fmt, ...) do { if(newCrash) ESP_LOGW(TAG, fmt, ##__VA_ARGS__); else ESP_LOGI(TAG, fmt, ##__VA_ARGS__); } while(0) + if(newCrash) ESP_LOGW(TAG, "*** Crash coredump from the last boot ***"); + else ESP_LOGI(TAG, "Stored coredump from an earlier crash"); + CRASH_LOG(" Task: %s", summary.exc_task); + CRASH_LOG(" PC: 0x%08x", summary.exc_pc); #ifdef CONFIG_IDF_TARGET_ARCH_XTENSA - ESP_LOGW(TAG, " Cause: %d", summary.ex_info.exc_cause); + CRASH_LOG(" Cause: %d", summary.ex_info.exc_cause); char bt_buf[256] = {0}; int pos = 0; for (int i = 0; i < summary.exc_bt_info.depth; i++) { pos += snprintf(bt_buf + pos, sizeof(bt_buf) - pos, " 0x%08x", summary.exc_bt_info.bt[i]); } - ESP_LOGW(TAG, " Backtrace:%s", bt_buf); + CRASH_LOG(" Backtrace:%s", bt_buf); #elif CONFIG_IDF_TARGET_ARCH_RISCV - ESP_LOGW(TAG, " Cause: %d", summary.ex_info.mcause); - ESP_LOGW(TAG, " MTVAL: 0x%08x RA: 0x%08x SP: 0x%08x", + CRASH_LOG(" Cause: %d", summary.ex_info.mcause); + CRASH_LOG(" MTVAL: 0x%08x RA: 0x%08x SP: 0x%08x", summary.ex_info.mtval, summary.ex_info.ra, summary.ex_info.sp); #endif // The backtrace only decodes against the firmware.elf of the build that @@ -81,11 +90,10 @@ void setup() { char runningSha[APP_ELF_SHA256_SZ] = {0}; getRunningElfSha(runningSha, sizeof(runningSha)); if(strcmp(crashedSha, runningSha) == 0) - ESP_LOGW(TAG, " Build: %s (running %s, same build)", crashedSha, runningSha); + CRASH_LOG(" Build: %s (running %s, same build)", crashedSha, runningSha); else - ESP_LOGW(TAG, " Build: %s (running %s, different build, decode the backtrace with the matching firmware.elf)", crashedSha, runningSha); - // Erase it so the next boot only reports a new crash. - if(esp_core_dump_image_erase() != ESP_OK) ESP_LOGE(TAG, "Could not erase the coredump"); + CRASH_LOG(" Build: %s (running %s, different build, decode the backtrace with the matching firmware.elf)", crashedSha, runningSha); +#undef CRASH_LOG } ESP_LOGI(TAG, "Mounting File System..."); if (LittleFS.begin()) { From 03eb3b3d99e5466d9ccf2ab23a358ab779f809eb Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 17:59:10 +0200 Subject: [PATCH 38/53] fix: let a priority stop overtake a delayed radio job The radio task took a delayed job (the My after a shade reaches its target) off the queue and slept until it was due, so a stop queued at the front waited for the sleep plus the whole delayed job. Wait on a task notification instead and send front jobs while the delayed one is held. A held job still counts as queued for isQueued(), so a stop for the same remote goes behind it and the rolling codes stay in order. Co-Authored-By: Claude Opus 5.5 --- src/Somfy.cpp | 80 +++++++++++++++++++++++++++++++++++---------------- src/Somfy.h | 5 ++++ 2 files changed, 60 insertions(+), 25 deletions(-) diff --git a/src/Somfy.cpp b/src/Somfy.cpp index 181596f..c8f7a8c 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -69,8 +69,8 @@ static std::atomic txSuspendMask{0}; // Last sequence number handed to a queued job. Only touched under TxQueueLock. static uint32_t txSeq = 0; // Sequence number of the last job queued at the back that the radio task took -// (or a flush dropped). Those jobs go out in order so any later one is still -// waiting in the queue. +// (a delayed one only once it is due) or a flush dropped. Those jobs go out +// in order so any later one is still waiting in the queue. static std::atomic txBackTaken{0}; // The radio task and a flush both move txBackTaken. Never let it go backwards. static void advanceBackTaken(uint32_t seq) { @@ -4766,7 +4766,11 @@ bool Transceiver::queueTx(somfy_tx_job_t &job) { if(++txSeq == 0) ++txSeq; // 0 means no job. job.seq = txSeq; // We are the only producer while holding the lock so the space check holds. - if(job.front) xQueueSendToFront(txJobQueue, &job, 0); + if(job.front) { + xQueueSendToFront(txJobQueue, &job, 0); + // It may overtake a delayed job the radio task is waiting on. + xTaskNotifyGive(txTaskHandle); + } else xQueueSendToBack(txJobQueue, &job, 0); return true; } @@ -4789,6 +4793,8 @@ void Transceiver::flushTx() { somfy_tx_job_t job; const uint32_t now = millis(); while(xQueueReceive(txJobQueue, &job, 0) == pdTRUE) postTxEvent(tx_event_t::dropped, job, now); + // Drop a delayed job the radio task is waiting on now rather than when it is due. + if(txTaskHandle) xTaskNotifyGive(txTaskHandle); } void Transceiver::suspendTx(uint8_t reason) { { @@ -4821,39 +4827,63 @@ bool Transceiver::transmitJob(somfy_tx_job_t &job) { this->endTransmit(); return complete; } +void Transceiver::runJob(somfy_tx_job_t &job) { + if(job.raw) { + // Don't step on a remote that is transmitting right now. + for(uint32_t waited = 0; rxInProgress() && waited < TX_RX_IDLE_WAIT; waited += 5) + vTaskDelay(pdMS_TO_TICKS(5)); + } + { + RadioLock lock; + // The queue may have been flushed while we waited. + if(txSuspendMask || job.gen != txGen) { + postTxEvent(tx_event_t::dropped, job, millis()); + return; + } + postTxEvent(tx_event_t::started, job, millis()); + const bool complete = this->transmitJob(job); + postTxEvent(complete ? tx_event_t::finished : tx_event_t::aborted, job, millis()); + } + // Give the motors a full frame beat between separate transmissions. + vTaskDelay(pdMS_TO_TICKS(TX_QUEUE_DELAY)); +} +void Transceiver::waitUntilDue(const somfy_tx_job_t &job) { + for(;;) { + if(txSuspendMask || job.gen != txGen) return; + const int32_t wait = (int32_t)(job.notBefore - millis()); + if(wait <= 0) return; + // A stop only goes to the front when its remote has nothing waiting, and + // this job still counts as waiting, so any front job is free to go first. + somfy_tx_job_t next; + bool haveNext = false; + { + TxQueueLock lock; + if(xQueuePeek(txJobQueue, &next, 0) == pdTRUE && next.front) + haveNext = xQueueReceive(txJobQueue, &next, 0) == pdTRUE; + } + if(haveNext) { + this->runJob(next); + continue; + } + // queueTx() and flushTx() wake us for a new front job or a flush. + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(wait)); + } +} void Transceiver::txTask(void *arg) { Transceiver *trans = static_cast(arg); somfy_tx_job_t job; for(;;) { if(xQueueReceive(txJobQueue, &job, portMAX_DELAY) != pdTRUE) continue; + if(job.notBefore != 0) trans->waitUntilDue(job); + // Only now is a delayed job off the queue as far as isQueued() is + // concerned, so a stop for its remote queues behind it while it waits. if(!job.front) advanceBackTaken(job.seq); // Flushed after we took it off the queue. if(txSuspendMask || job.gen != txGen) { postTxEvent(tx_event_t::dropped, job, millis()); continue; } - if(job.notBefore != 0) { - int32_t wait = (int32_t)(job.notBefore - millis()); - if(wait > 0) vTaskDelay(pdMS_TO_TICKS(wait)); - } - if(job.raw) { - // Don't step on a remote that is transmitting right now. - for(uint32_t waited = 0; rxInProgress() && waited < TX_RX_IDLE_WAIT; waited += 5) - vTaskDelay(pdMS_TO_TICKS(5)); - } - { - RadioLock lock; - // The queue may have been flushed while we waited. - if(txSuspendMask || job.gen != txGen) { - postTxEvent(tx_event_t::dropped, job, millis()); - continue; - } - postTxEvent(tx_event_t::started, job, millis()); - const bool complete = trans->transmitJob(job); - postTxEvent(complete ? tx_event_t::finished : tx_event_t::aborted, job, millis()); - } - // Give the motors a full frame beat between separate transmissions. - vTaskDelay(pdMS_TO_TICKS(TX_QUEUE_DELAY)); + trans->runJob(job); } } void Transceiver::startTxTask() { diff --git a/src/Somfy.h b/src/Somfy.h index b680b0b..b0299c3 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -494,6 +494,11 @@ class Transceiver { somfy_frame_t frame; void startTxTask(); bool transmitJob(somfy_tx_job_t &job); + // Sends a job taken off the queue and leaves a frame beat after it. + void runJob(somfy_tx_job_t &job); + // Holds a delayed job until it is due, sending stops queued at the front + // in the meantime. + void waitUntilDue(const somfy_tx_job_t &job); public: transceiver_config_t config; bool printBuffer = false; From ab0eba2035f934e5e67b5ebc527c0a4302c86f88 Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 17:59:39 +0200 Subject: [PATCH 39/53] fix: leave out /controller items that overflow instead of breaking the JSON The formatter drops a value that doesn't fit but keeps appending the commas and braces after it, so an oversized shade or group streamed invalid JSON. Check overflowed() after each unit, log the item and skip it, and keep producing until a unit or the end so a skip never ends the stream early. Co-Authored-By: Claude Opus 5.5 --- src/Web.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/Web.cpp b/src/Web.cpp index 4cc1276..c93ae66 100644 --- a/src/Web.cpp +++ b/src/Web.cpp @@ -533,8 +533,23 @@ private: fmt.setBuffer(unit + offset, sizeof(unit) - offset); } + // An item too big for the unit buffer is left out so the JSON stays valid. + // Skipping one produces nothing, so go on to the next until something is + // produced or the document is done. void produceNext() { consumed = 0; + do produceUnit(); while(unitLen == 0 && section != S_DONE); + } + + // True when the unit just formatted didn't fit. It is logged and skipped. + bool overflowed(const char *what, uint8_t id) { + if(!fmt.overflowed()) return false; + ESP_LOGE(TAG, "/controller: %s %u doesn't fit in %u bytes, leaving it out", what, id, (unsigned)sizeof(unit)); + unitLen = 0; + return true; + } + + void produceUnit() { unitLen = 0; switch(section) { case S_HEADER: { @@ -555,6 +570,8 @@ private: serializeGitVersion(fmt); fmt.endObject(); fmt.beginArray("rooms"); + // Only fixed size values so this can't happen short of a bug. + if(fmt.overflowed()) ESP_LOGE(TAG, "/controller: header doesn't fit in %u bytes", (unsigned)sizeof(unit)); unitLen = fmt.length(); section = S_ROOMS; idx = 0; @@ -577,6 +594,7 @@ private: fmt.beginObject(); serializeRoom(&somfy.rooms[idx], fmt); fmt.endObject(); + if(overflowed("room", somfy.rooms[idx].roomId)) { idx++; return; } unitLen = pos + fmt.length(); idx++; firstInArray = false; @@ -599,6 +617,7 @@ private: fmt.beginObject(); serializeShade(&somfy.shades[idx], fmt); fmt.endObject(); + if(overflowed("shade", somfy.shades[idx].getShadeId())) { idx++; return; } unitLen = pos + fmt.length(); idx++; firstInArray = false; @@ -622,6 +641,7 @@ private: fmt.beginObject(); serializeGroupRef(g, fmt); fmt.beginArray("linkedShades"); + if(overflowed("group", g->getGroupId())) { idx++; return; } unitLen = pos + fmt.length(); lsIdx = 0; firstInArray = false; @@ -650,6 +670,7 @@ private: fmt.beginObject(); serializeShadeRef(lshade, fmt); fmt.endObject(); + if(overflowed("linked shade", lshade->getShadeId())) { lsIdx++; return; } unitLen = pos + fmt.length(); lsIdx++; firstInLSArray = false; From a49fa5d95380dda904b3b28752549562de9382f9 Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 17:59:48 +0200 Subject: [PATCH 40/53] fix: don't hold position updates back while the radio is full processQueue stopped draining web commands whenever the radio queue was near full, including set_positions, which never transmits. Look at the command at the head and only hold it back if it needs the radio, keeping the commands in order. Co-Authored-By: Claude Opus 5.5 --- src/Web.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Web.cpp b/src/Web.cpp index c93ae66..4803d27 100644 --- a/src/Web.cpp +++ b/src/Web.cpp @@ -134,14 +134,19 @@ bool Web::queueCommand(const web_command_t &cmd) { } return true; } +// Only setting positions never goes on air. +static bool usesRadio(web_cmd_t type) { return type != web_cmd_t::set_positions; } void Web::processQueue() { if(!webCmdQueue) return; web_command_t cmd; // 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) { + // Commands run in order, so only the one at the head can be held back. + while(xQueuePeek(webCmdQueue, &cmd, 0) == pdTRUE) { + if(usesRadio(cmd.type) && !somfy.transceiver.txSuspended() && !somfy.transceiver.txHasRoom()) break; + // loop() is the only consumer so this is the command we peeked. + xQueueReceive(webCmdQueue, &cmd, 0); bool sent = true; switch(cmd.type) { case web_cmd_t::shade_command: { From 08d2cd7a0cbedb7b10742b2452ced98df6fd7eb3 Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 18:00:51 +0200 Subject: [PATCH 41/53] fix: read the rolling code from NVS only until it has been written Splitting getNextRollingCode into peek and commit opened the ShadeCodes namespace twice per command. Once a code has been written for an address lastRollingCode matches NVS, so peek uses it and only commit opens NVS. Changing the address drops back to reading NVS. Co-Authored-By: Claude Opus 5.5 --- src/Somfy.cpp | 8 ++++++-- src/Somfy.h | 3 +++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Somfy.cpp b/src/Somfy.cpp index c8f7a8c..50b89b9 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -3144,7 +3144,7 @@ bool SomfyRemote::toJSON(JsonObject &obj) { return true; } */ -void SomfyRemote::setRemoteAddress(uint32_t address) { this->m_remoteAddress = address; snprintf(this->m_remotePrefId, sizeof(this->m_remotePrefId), "_%lu", (unsigned long)this->m_remoteAddress); } +void SomfyRemote::setRemoteAddress(uint32_t address) { this->m_remoteAddress = address; this->m_rollingCodeSynced = false; snprintf(this->m_remotePrefId, sizeof(this->m_remotePrefId), "_%lu", (unsigned long)this->m_remoteAddress); } uint32_t SomfyRemote::getRemoteAddress() { return this->m_remoteAddress; } void SomfyShadeController::processFrame(somfy_frame_t &frame, bool internal) { for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { @@ -3681,8 +3681,10 @@ bool SomfyShadeController::deleteGroup(uint8_t groupId) { bool SomfyShadeController::loadShadesFile(const char *filename) { return ShadeConfigFile::load(this, filename); } // The next rolling code is only read here. It is consumed by commitRollingCode -// once the frame using it has been queued. +// once the frame using it has been queued. NVS is only read until a code for +// this address has been written, after that lastRollingCode holds the same. uint16_t SomfyRemote::peekNextRollingCode() { + if(this->m_rollingCodeSynced) return (uint16_t)(this->lastRollingCode + 1); if(!pref.begin("ShadeCodes")) { ESP_LOGE(TAG, "peekNextRollingCode() pref.begin(ShadeCodes) FAILED"); } @@ -3697,6 +3699,7 @@ void SomfyRemote::commitRollingCode(uint16_t code) { pref.putUShort(this->m_remotePrefId, code); pref.end(); this->p_lastRollingCode(code); + this->m_rollingCodeSynced = true; } uint16_t SomfyRemote::p_lastRollingCode(uint16_t code) { uint16_t old = this->lastRollingCode; @@ -3709,6 +3712,7 @@ uint16_t SomfyRemote::setRollingCode(uint16_t code) { pref.putUShort(this->m_remotePrefId, code); pref.end(); this->lastRollingCode = code; + this->m_rollingCodeSynced = true; ESP_LOGD(TAG, "Setting Last Rolling code %d", this->lastRollingCode); } return code; diff --git a/src/Somfy.h b/src/Somfy.h index b0299c3..9bdcbf3 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -198,6 +198,9 @@ class SomfyRemote { protected: char m_remotePrefId[11] = ""; uint32_t m_remoteAddress = 0; + // lastRollingCode is known to match NVS for this address, so the next code + // can be worked out without opening NVS. + bool m_rollingCodeSynced = false; public: radio_proto proto = radio_proto::RTS; uint8_t gpioFlags = 0; From b4d480caa0df1d7487ecbfefc7a1fbff225509c9 Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 18:00:52 +0200 Subject: [PATCH 42/53] fix: send memStatus once per network broadcast emitSockets() sent memStatus and then emitSockets(255) sent it again, so room 0 got two identical events in one pass after every /discovery. Only the per-client path sends it from emitSockets(num). Co-Authored-By: Claude Opus 5.5 --- src/ESPNetwork.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ESPNetwork.cpp b/src/ESPNetwork.cpp index ccd6b99..7ae33b0 100644 --- a/src/ESPNetwork.cpp +++ b/src/ESPNetwork.cpp @@ -234,7 +234,8 @@ void ESPNetwork::emitSockets(uint8_t num) { this->lastRSSIEmit = millis(); } } - this->emitHeap(num); + // A broadcast comes from emitSockets(), which has already sent memStatus. + if(num != 255) this->emitHeap(num); } void ESPNetwork::setConnected(conn_types_t connType) { safe_wdt_reset(); From 2ee7af51abc04cfd68d630b27ae7e993ad93ec6b Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 18:01:31 +0200 Subject: [PATCH 43/53] refactor: share shade remote matching in SomfyShade::findRemote markTxPending copied the shade and linked-remote address matching from SomfyShade::processFrame. Both now use findRemote() so they can't drift. Co-Authored-By: Claude Opus 5.5 --- src/Somfy.cpp | 28 +++++++++++----------------- src/Somfy.h | 2 ++ 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/Somfy.cpp b/src/Somfy.cpp index 50b89b9..6d63286 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -1767,22 +1767,21 @@ void SomfyShade::processWaitingFrame() { } } } +SomfyRemote *SomfyShade::findRemote(uint32_t address) { + if(this->getRemoteAddress() == address) return this; + for(uint8_t i = 0; i < SOMFY_MAX_LINKED_REMOTES; i++) { + if(this->linkedRemotes[i].getRemoteAddress() == address) return &this->linkedRemotes[i]; + } + return nullptr; +} void SomfyShade::processFrame(somfy_frame_t &frame, bool internal) { // The reason why we are processing all frames here is so // any linked remotes that may happen to be on the same ESPSomfy RTS // device can trigger the appropriate actions. if(this->shadeId == 255) return; - bool hasRemote = this->getRemoteAddress() == frame.remoteAddress; - if(!hasRemote) { - for(uint8_t i = 0; i < SOMFY_MAX_LINKED_REMOTES; i++) { - if(this->linkedRemotes[i].getRemoteAddress() == frame.remoteAddress) { - if(frame.cmd != somfy_commands::Sensor) this->linkedRemotes[i].setRollingCode(frame.rollingCode); - hasRemote = true; - break; - } - } - } - if(!hasRemote) return; + SomfyRemote *remote = this->findRemote(frame.remoteAddress); + if(!remote) return; + if(remote != this && frame.cmd != somfy_commands::Sensor) remote->setRollingCode(frame.rollingCode); const uint64_t curTime = millis(); this->lastFrame.copy(frame); int8_t dir = 0; @@ -3152,14 +3151,9 @@ void SomfyShadeController::processFrame(somfy_frame_t &frame, bool internal) { } } void SomfyShadeController::markTxPending(uint32_t remoteAddress, uint32_t seq) { - // Same matching as SomfyShade::processFrame for shade and linked remotes. for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { SomfyShade *shade = &this->shades[i]; - if(shade->getShadeId() == 255) continue; - bool hasRemote = shade->getRemoteAddress() == remoteAddress; - for(uint8_t j = 0; !hasRemote && j < SOMFY_MAX_LINKED_REMOTES; j++) - hasRemote = shade->linkedRemotes[j].getRemoteAddress() == remoteAddress; - if(hasRemote) shade->markTxPending(seq); + if(shade->getShadeId() != 255 && shade->findRemote(remoteAddress)) shade->markTxPending(seq); } // Group commands move their member shades through processInternalCommand. for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { diff --git a/src/Somfy.h b/src/Somfy.h index 9bdcbf3..65887a8 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -319,6 +319,8 @@ class SomfyShade : public SomfyRemote { bool isInGroup(); void checkMovement(); void processFrame(somfy_frame_t &frame, bool internal = false); + // The shade itself or the linked remote with this address, else nullptr. + SomfyRemote *findRemote(uint32_t address); void processInternalCommand(somfy_commands cmd, uint8_t repeat = 1); void markTxPending(uint32_t seq); void processTxEvent(const somfy_tx_event_t &evt); From e1c76bc2afb5c1f847df0968c9b812bae6179ba4 Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 18:29:59 +0200 Subject: [PATCH 44/53] fix: compare the coredump build SHA without reading past the array app_elf_sha256 has no terminator (IDF 4.4 fills all 17 bytes), so strcmp never matched the running build and %s printed whatever followed it. Copy it into a terminated buffer first. Co-Authored-By: Claude Opus 5.5 --- src/SomfyController.ino | 6 +++++- test/test_utils/test_utils.cpp | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/SomfyController.ino b/src/SomfyController.ino index 4b5411d..29af53e 100644 --- a/src/SomfyController.ino +++ b/src/SomfyController.ino @@ -86,7 +86,11 @@ void setup() { #endif // The backtrace only decodes against the firmware.elf of the build that // crashed, so say whether that is the one running now. - const char *crashedSha = (const char *)summary.app_elf_sha256; + // app_elf_sha256 isn't always terminated, so copy it before comparing or + // printing it. + char crashedSha[APP_ELF_SHA256_SZ]; + static_assert(sizeof(crashedSha) <= sizeof(summary.app_elf_sha256), "copy would read past app_elf_sha256"); + SETCHARPROP(crashedSha, (const char *)summary.app_elf_sha256, sizeof(crashedSha)); char runningSha[APP_ELF_SHA256_SZ] = {0}; getRunningElfSha(runningSha, sizeof(runningSha)); if(strcmp(crashedSha, runningSha) == 0) diff --git a/test/test_utils/test_utils.cpp b/test/test_utils/test_utils.cpp index ebde1d5..5d17801 100644 --- a/test/test_utils/test_utils.cpp +++ b/test/test_utils/test_utils.cpp @@ -79,6 +79,15 @@ static void test_setcharprop_truncates_long_value() { SETCHARPROP(prop, "abcdefgh", sizeof(prop)); TEST_ASSERT_EQUAL_STRING("abcd", prop); } +static void test_setcharprop_unterminated_source() { + // A coredump's app_elf_sha256 can be full of hex digits with no terminator. + // The copy must stop inside the array and come out as a comparable string. + const char crashed[17] = {'0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f','9'}; + char sha[sizeof(crashed)]; + SETCHARPROP(sha, crashed, sizeof(sha)); + TEST_ASSERT_EQUAL_STRING("0123456789abcdef", sha); + TEST_ASSERT_EQUAL_INT(0, strcmp(sha, "0123456789abcdef")); +} /********************************************************************* * sortArray / sortArrayReverse @@ -202,6 +211,7 @@ int main(int argc, char **argv) { RUN_TEST(test_toBoolean_unknown_uses_default); RUN_TEST(test_setcharprop_copies_short_value); RUN_TEST(test_setcharprop_truncates_long_value); + RUN_TEST(test_setcharprop_unterminated_source); RUN_TEST(test_sortArray_ascending); RUN_TEST(test_sortArrayReverse_descending); RUN_TEST(test_sortArray_custom_comparator); From 7e9687c0235d5eb34503645e9573a3a15e38cc25 Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 18:31:39 +0200 Subject: [PATCH 45/53] fix: keep a repeater frame until the radio queue takes it The frame was popped before queueTx(), so whenever the radio queue was busy it was lost. Peek it instead, only remove it once it is queued (or TX is suspended), and don't try while the queue has no room. The repeater buffer moves to TxBuffer.h/.cpp so it can be unit tested. Co-Authored-By: Claude Opus 5.5 --- platformio.ini | 2 +- src/Somfy.cpp | 64 +++------------ src/Somfy.h | 29 +------ src/TxBuffer.cpp | 55 +++++++++++++ src/TxBuffer.h | 43 ++++++++++ test/test_txbuffer/test_txbuffer.cpp | 114 +++++++++++++++++++++++++++ 6 files changed, 227 insertions(+), 80 deletions(-) create mode 100644 src/TxBuffer.cpp create mode 100644 src/TxBuffer.h create mode 100644 test/test_txbuffer/test_txbuffer.cpp diff --git a/platformio.ini b/platformio.ini index 1b3eeee..a11a081 100644 --- a/platformio.ini +++ b/platformio.ini @@ -104,7 +104,7 @@ lib_deps = platform = native test_framework = unity test_build_src = yes -build_src_filter = -<*> + + + + +build_src_filter = -<*> + + + + + build_flags = -std=gnu++17 -Isrc diff --git a/src/Somfy.cpp b/src/Somfy.cpp index 6d63286..b5ba1b3 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -3805,52 +3805,6 @@ static int16_t bitMin = SYMBOL * TOLERANCE_MIN; static somfy_rx_t somfy_rx; static somfy_rx_queue_t rx_queue; static somfy_tx_queue_t tx_queue; -bool somfy_tx_queue_t::pop(somfy_tx_t *tx) { - // Read the oldest index. - for(int8_t i = MAX_TX_BUFFER - 1; i >= 0; i--) { - if(this->index[i] < MAX_TX_BUFFER) { - uint8_t ndx = this->index[i]; - memcpy(tx, &this->items[ndx], sizeof(somfy_tx_t)); - this->items[ndx].clear(); - if(this->length > 0) this->length--; - this->index[i] = 255; - return true; - } - } - return false; -} -void somfy_tx_queue_t::push(somfy_rx_t *rx) { this->push(rx->cpt_synchro_hw, rx->payload, rx->bit_length); } -void somfy_tx_queue_t::push(uint8_t hwsync, uint8_t *payload, uint8_t bit_length) { - if(this->length >= MAX_TX_BUFFER) { - // We have overflowed the buffer simply empty the last item - // in this instance we will simply throw it away. - uint8_t ndx = this->index[MAX_TX_BUFFER - 1]; - if(ndx < MAX_TX_BUFFER) this->items[ndx].clear(); - this->index[MAX_TX_BUFFER - 1] = 255; - this->length--; - } - uint8_t first = 0; - // Place this record in the first empty slot. There will - // be one since we cleared a space above should there - // be an overflow. - for(uint8_t i = 0; i < MAX_TX_BUFFER; i++) { - if(this->items[i].bit_length == 0) { - first = i; - this->items[i].bit_length = bit_length; - this->items[i].hwsync = hwsync; - memcpy(&this->items[i].payload, payload, sizeof(this->items[i].payload)); - break; - } - } - // Move the index so that it is the at position 0. The oldest item will fall off. - for(uint8_t i = MAX_TX_BUFFER - 1; i > 0; i--) { - this->index[i] = this->index[i - 1]; - } - this->length++; - // When popping from the queue we always pull from the end - this->index[0] = first; - this->delay_time = millis() + TX_QUEUE_DELAY; // We do not want to process this frame until a full frame beat has passed. -} void somfy_rx_queue_t::init() { ESP_LOGD(TAG, "Initializing RX Queue"); for (uint8_t i = 0; i < MAX_RX_BUFFER; i++) @@ -4715,7 +4669,9 @@ void Transceiver::loop() { else if (this->receive(&rx)) { for(uint8_t i = 0; i < SOMFY_MAX_REPEATERS; i++) { if(somfy.repeaters[i] == frame.remoteAddress) { - tx_queue.push(&rx); + tx_queue.push(rx.cpt_synchro_hw, rx.payload, rx.bit_length); + // We do not want to process this frame until a full frame beat has passed. + tx_queue.delay_time = millis() + TX_QUEUE_DELAY; ESP_LOGD(TAG, "Queued repeater frame..."); break; } @@ -4725,11 +4681,14 @@ void Transceiver::loop() { else { 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) { - // Repeater frames are handed to the radio task like any other transmission. + // Repeater frames are handed to the radio task like any other transmission. + // One stays here until the radio has room for it rather than being lost + // while the radio is busy. While TX is suspended nothing goes out anyway. + if(tx_queue.length > 0 && millis() > tx_queue.delay_time && somfy_rx.cpt_synchro_hw == 0 && + (this->txSuspended() || this->txHasRoom())) { somfy_tx_job_t job; job.raw = true; - tx_queue.pop(&job.tx); + tx_queue.peek(&job.tx); { somfy_tx_t &tx = job.tx; char frame_buf[128]; @@ -4740,7 +4699,10 @@ void Transceiver::loop() { snprintf(frame_buf + fpos, sizeof(frame_buf) - fpos, "]"); ESP_LOGD(TAG, "%s", frame_buf); } - this->queueTx(job); + if(this->queueTx(job) || this->txSuspended()) { + somfy_tx_t sent; + tx_queue.pop(&sent); + } tx_queue.delay_time = millis() + TX_QUEUE_DELAY; } } diff --git a/src/Somfy.h b/src/Somfy.h index 65887a8..39cf4de 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -22,6 +22,7 @@ #include "SomfyFrame.h" +#include "TxBuffer.h" enum class group_types : byte { channel = 0x00 }; @@ -54,7 +55,6 @@ enum class tilt_types : byte { #define MAX_TIMINGS 300 #define MAX_RX_BUFFER 3 -#define MAX_TX_BUFFER 5 typedef enum { waiting_synchro = 0, @@ -95,33 +95,6 @@ struct somfy_rx_queue_t { void push(somfy_rx_t *rx); bool pop(somfy_rx_t *rx); }; -struct somfy_tx_t { - void clear() { - this->hwsync = 0; - this->bit_length = 0; - memset(this->payload, 0x00, sizeof(this->payload)); - } - uint8_t hwsync = 0; - uint8_t bit_length = 0; - uint8_t payload[10] = {}; -}; -struct somfy_tx_queue_t { - somfy_tx_queue_t() { this->clear(); } - void clear() { - for (uint8_t i = 0; i < MAX_TX_BUFFER; i++) { - this->index[i] = 255; - this->items[i].clear(); - } - this->length = 0; - } - unsigned long delay_time = 0; - uint8_t length = 0; - uint8_t index[MAX_TX_BUFFER] = {255}; - somfy_tx_t items[MAX_TX_BUFFER]; - bool pop(somfy_tx_t *tx); - void push(somfy_rx_t *rx); // Used for repeats - void push(uint8_t hwsync, byte *payload, uint8_t bit_length); -}; enum class somfy_flags_t : byte { SunFlag = 0x01, diff --git a/src/TxBuffer.cpp b/src/TxBuffer.cpp new file mode 100644 index 0000000..8d19362 --- /dev/null +++ b/src/TxBuffer.cpp @@ -0,0 +1,55 @@ +#include "TxBuffer.h" + +int8_t somfy_tx_queue_t::oldest() const { + // The newest index is at position 0 so the oldest is the last one in use. + for(int8_t i = MAX_TX_BUFFER - 1; i >= 0; i--) { + if(this->index[i] < MAX_TX_BUFFER) return i; + } + return -1; +} +bool somfy_tx_queue_t::peek(somfy_tx_t *tx) const { + const int8_t i = this->oldest(); + if(i < 0) return false; + memcpy(tx, &this->items[this->index[i]], sizeof(somfy_tx_t)); + return true; +} +bool somfy_tx_queue_t::pop(somfy_tx_t *tx) { + const int8_t i = this->oldest(); + if(i < 0) return false; + uint8_t ndx = this->index[i]; + memcpy(tx, &this->items[ndx], sizeof(somfy_tx_t)); + this->items[ndx].clear(); + if(this->length > 0) this->length--; + this->index[i] = 255; + return true; +} +void somfy_tx_queue_t::push(uint8_t hwsync, uint8_t *payload, uint8_t bit_length) { + if(this->length >= MAX_TX_BUFFER) { + // We have overflowed the buffer simply empty the last item + // in this instance we will simply throw it away. + uint8_t ndx = this->index[MAX_TX_BUFFER - 1]; + if(ndx < MAX_TX_BUFFER) this->items[ndx].clear(); + this->index[MAX_TX_BUFFER - 1] = 255; + this->length--; + } + uint8_t first = 0; + // Place this record in the first empty slot. There will + // be one since we cleared a space above should there + // be an overflow. + for(uint8_t i = 0; i < MAX_TX_BUFFER; i++) { + if(this->items[i].bit_length == 0) { + first = i; + this->items[i].bit_length = bit_length; + this->items[i].hwsync = hwsync; + memcpy(&this->items[i].payload, payload, sizeof(this->items[i].payload)); + break; + } + } + // Move the index so that it is the at position 0. The oldest item will fall off. + for(uint8_t i = MAX_TX_BUFFER - 1; i > 0; i--) { + this->index[i] = this->index[i - 1]; + } + this->length++; + // When popping from the queue we always pull from the end + this->index[0] = first; +} diff --git a/src/TxBuffer.h b/src/TxBuffer.h new file mode 100644 index 0000000..940f995 --- /dev/null +++ b/src/TxBuffer.h @@ -0,0 +1,43 @@ +#ifndef TX_BUFFER_H +#define TX_BUFFER_H +#include + +// Repeater frames waiting to be handed to the radio task. Kept free of the +// radio and timers so it can be unit tested on the PC (pio test -e native). + +#define MAX_TX_BUFFER 5 + +struct somfy_tx_t { + void clear() { + this->hwsync = 0; + this->bit_length = 0; + memset(this->payload, 0x00, sizeof(this->payload)); + } + uint8_t hwsync = 0; + uint8_t bit_length = 0; + uint8_t payload[10] = {}; +}; +struct somfy_tx_queue_t { + somfy_tx_queue_t() { this->clear(); } + void clear() { + for (uint8_t i = 0; i < MAX_TX_BUFFER; i++) { + this->index[i] = 255; + this->items[i].clear(); + } + this->length = 0; + } + unsigned long delay_time = 0; // Set by the caller, not before this millis(). + uint8_t length = 0; + uint8_t index[MAX_TX_BUFFER] = {255}; + somfy_tx_t items[MAX_TX_BUFFER]; + // The oldest frame without removing it, so it can stay queued until the radio + // takes it. + bool peek(somfy_tx_t *tx) const; + bool pop(somfy_tx_t *tx); + // When full the oldest frame is thrown away. + void push(uint8_t hwsync, byte *payload, uint8_t bit_length); + private: + // Position in index of the oldest frame, -1 when empty. + int8_t oldest() const; +}; +#endif diff --git a/test/test_txbuffer/test_txbuffer.cpp b/test/test_txbuffer/test_txbuffer.cpp new file mode 100644 index 0000000..c47ad41 --- /dev/null +++ b/test/test_txbuffer/test_txbuffer.cpp @@ -0,0 +1,114 @@ +#include +#include "TxBuffer.h" + +void setUp() {} +void tearDown() {} + +// Pushes a frame whose payload is all `tag` so frames can be told apart. +static void pushTagged(somfy_tx_queue_t &q, uint8_t tag, uint8_t bitLength = 56) { + uint8_t payload[10]; + memset(payload, tag, sizeof(payload)); + q.push(bitLength == 56 ? 2 : 12, payload, bitLength); +} +static void assertTagged(const somfy_tx_t &tx, uint8_t tag) { + uint8_t expected[10]; + memset(expected, tag, sizeof(expected)); + TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, tx.payload, sizeof(expected)); +} + +static void test_push_then_pop_returns_the_frame() { + somfy_tx_queue_t q; + pushTagged(q, 0x11, 80); + TEST_ASSERT_EQUAL_UINT8(1, q.length); + somfy_tx_t tx; + TEST_ASSERT_TRUE(q.pop(&tx)); + TEST_ASSERT_EQUAL_UINT8(12, tx.hwsync); + TEST_ASSERT_EQUAL_UINT8(80, tx.bit_length); + assertTagged(tx, 0x11); + TEST_ASSERT_EQUAL_UINT8(0, q.length); +} +static void test_frames_come_out_oldest_first() { + somfy_tx_queue_t q; + pushTagged(q, 1); + pushTagged(q, 2); + pushTagged(q, 3); + somfy_tx_t tx; + for(uint8_t tag = 1; tag <= 3; tag++) { + TEST_ASSERT_TRUE(q.pop(&tx)); + assertTagged(tx, tag); + } + TEST_ASSERT_FALSE(q.pop(&tx)); +} +static void test_peek_leaves_the_frame_queued() { + somfy_tx_queue_t q; + pushTagged(q, 1); + pushTagged(q, 2); + somfy_tx_t tx; + TEST_ASSERT_TRUE(q.peek(&tx)); + assertTagged(tx, 1); + TEST_ASSERT_TRUE(q.peek(&tx)); + assertTagged(tx, 1); + TEST_ASSERT_EQUAL_UINT8(2, q.length); + TEST_ASSERT_TRUE(q.pop(&tx)); + assertTagged(tx, 1); + TEST_ASSERT_TRUE(q.peek(&tx)); + assertTagged(tx, 2); +} +static void test_empty_buffer_has_nothing() { + somfy_tx_queue_t q; + somfy_tx_t tx; + TEST_ASSERT_FALSE(q.peek(&tx)); + TEST_ASSERT_FALSE(q.pop(&tx)); + TEST_ASSERT_EQUAL_UINT8(0, q.length); +} +static void test_overflow_drops_the_oldest() { + somfy_tx_queue_t q; + for(uint8_t tag = 1; tag <= MAX_TX_BUFFER + 2; tag++) pushTagged(q, tag); + TEST_ASSERT_EQUAL_UINT8(MAX_TX_BUFFER, q.length); + somfy_tx_t tx; + // The first two fell off. + for(uint8_t tag = 3; tag <= MAX_TX_BUFFER + 2; tag++) { + TEST_ASSERT_TRUE(q.pop(&tx)); + assertTagged(tx, tag); + } + TEST_ASSERT_FALSE(q.pop(&tx)); +} +static void test_slots_are_reused_after_pop() { + somfy_tx_queue_t q; + somfy_tx_t tx; + // Keep two frames in flight so freed slots are filled again many times over. + for(uint8_t tag = 1; tag <= 4 * MAX_TX_BUFFER; tag++) { + pushTagged(q, tag); + if(tag >= 2) { + TEST_ASSERT_TRUE(q.pop(&tx)); + assertTagged(tx, tag - 1); + TEST_ASSERT_EQUAL_UINT8(1, q.length); + } + } +} +static void test_clear_empties_it() { + somfy_tx_queue_t q; + pushTagged(q, 1); + pushTagged(q, 2); + q.clear(); + TEST_ASSERT_EQUAL_UINT8(0, q.length); + somfy_tx_t tx; + TEST_ASSERT_FALSE(q.peek(&tx)); + pushTagged(q, 3); + TEST_ASSERT_TRUE(q.pop(&tx)); + assertTagged(tx, 3); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_push_then_pop_returns_the_frame); + RUN_TEST(test_frames_come_out_oldest_first); + RUN_TEST(test_peek_leaves_the_frame_queued); + RUN_TEST(test_empty_buffer_has_nothing); + RUN_TEST(test_overflow_drops_the_oldest); + RUN_TEST(test_slots_are_reused_after_pop); + RUN_TEST(test_clear_empties_it); + return UNITY_END(); +} From a212d562cb0420ac78136c480e5feeb1803609f1 Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 18:32:12 +0200 Subject: [PATCH 46/53] fix: send frequency scan events from loop() only beginFrequencyScan, endFrequencyScan and apply() run on async_tcp from the web handlers and emitted into the socket event buffer loop() also writes, so an event could be mixed up or dropped. They now only flag the change and Transceiver::loop() sends it. Co-Authored-By: Claude Opus 5.5 --- src/Somfy.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Somfy.cpp b/src/Somfy.cpp index b5ba1b3..a22c5af 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -4055,6 +4055,10 @@ float markFreq = 433.0f; int markRSSI = -100; uint32_t lastScan = 0; static uint32_t scanStart = 0; +// Scan state changes made off loop() (the web handlers and apply() run on +// async_tcp) are only flagged here. The socket event buffer is shared, so +// loop() sends the event. +static std::atomic scanEmitPending{false}; // 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 @@ -4076,7 +4080,7 @@ void Transceiver::beginFrequencyScan() { ELECHOUSE_cc1101.setMHZ(currFreq); ESP_LOGD(TAG, "Begin frequency scan on Pin #%d", this->config.RXPin); attachInterrupt(interruptPin, handleReceive, CHANGE); - this->emitFrequencyScan(); + scanEmitPending = true; } } void Transceiver::processFrequencyScan(bool received) { @@ -4114,7 +4118,7 @@ void Transceiver::endFrequencyScan() { if(interruptPin > 0) detachInterrupt(interruptPin); interruptPin = 0; this->config.apply(); - this->emitFrequencyScan(); + scanEmitPending = true; } // Even if something else already ended the scan, nothing may keep TX suspended. this->resumeTx(TX_SUSPEND_SCAN); @@ -4528,7 +4532,7 @@ void transceiver_config_t::apply() { somfy.transceiver.disableReceive(); if(wasScanning) { somfy.transceiver.resumeTx(TX_SUSPEND_SCAN); - somfy.transceiver.emitFrequencyScan(); + scanEmitPending = true; } bit_length = this->type; if(this->enabled) { @@ -4648,6 +4652,7 @@ void Transceiver::loop() { // Line the simulated shades up with what actually went on air. somfy.processTxEvent(evt); } + if(scanEmitPending.exchange(false)) this->emitFrequencyScan(); if (noiseDetected && rxmode != 3 && this->config.noiseDetection) { if (millis() - noiseStart > 100) { gpio_intr_enable((gpio_num_t)interruptPin); From 0ba5260d1d83b016a75cba69f9e36c774df96921 Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 18:33:19 +0200 Subject: [PATCH 47/53] refactor: pin which radio job holds a shade's simulated movement Review flagged the outer My/Favorite losing to the nested Up/Down it queues. Every such nested send is from a simMy shade, where the Up/Down is what starts the motor, so the behaviour stays. The hold moves into TxHold in ShadeMotion with a comment explaining the order and unit tests pinning it. Co-Authored-By: Claude Opus 5.5 --- src/ShadeMotion.cpp | 10 +++++ src/ShadeMotion.h | 19 ++++++++ src/Somfy.cpp | 25 +++++------ src/Somfy.h | 6 +-- test/test_motion/test_motion.cpp | 76 ++++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 18 deletions(-) diff --git a/src/ShadeMotion.cpp b/src/ShadeMotion.cpp index 39c9acb..da5328b 100644 --- a/src/ShadeMotion.cpp +++ b/src/ShadeMotion.cpp @@ -29,3 +29,13 @@ int8_t displayPosition(float pos, bool flip) { if(pos < 0) return -1; return static_cast(flip && pos >= 0.00f ? floor(100.0f - pos) : floor(pos)); } +void TxHold::mark(uint32_t jobSeq, bool moving, uint32_t now) { + if(jobSeq == 0 || this->seq != 0 || moving) return; + this->seq = jobSeq; + this->since = now; +} +bool TxHold::release(uint32_t jobSeq) { + if(jobSeq == 0 || jobSeq != this->seq) return false; + this->seq = 0; + return true; +} diff --git a/src/ShadeMotion.h b/src/ShadeMotion.h index 903d794..d45dc0e 100644 --- a/src/ShadeMotion.h +++ b/src/ShadeMotion.h @@ -12,4 +12,23 @@ float travelPosition(float startPos, int8_t dir, uint32_t elapsedMs, int32_t tra // A position as reported to clients: -1 when unset, otherwise the whole // percentage, counted from the other end when flipped. int8_t displayPosition(float pos, bool flip); + +// The radio job that set an idle shade moving. Its simulated movement is held +// until the job goes on air, since that is when the motor starts. +struct TxHold { + uint32_t seq = 0; // 0 when nothing is held. + uint32_t since = 0; // millis() when the hold started. + // Holds for job seq unless the shade was already moving (its motor carries on + // with the earlier command) or a job is already held. The first job held is + // the one that starts the motor. A command sent while processing another + // registers before it: for a simMy shade My/Favorite queues its Up/Down from + // moveToMyPosition() first, and that Up/Down is what starts a motor with no + // favorite of its own, so the outer My/Favorite is ignored on purpose. + void mark(uint32_t jobSeq, bool moving, uint32_t now); + // Ends the hold when jobSeq is the held job. True when it was. + bool release(uint32_t jobSeq); + bool held() const { return this->seq != 0; } + bool timedOut(uint32_t now, uint32_t timeoutMs) const { return now - this->since >= timeoutMs; } + void clear() { this->seq = 0; } +}; #endif diff --git a/src/Somfy.cpp b/src/Somfy.cpp index a22c5af..451ce0b 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -762,20 +762,20 @@ void SomfyShade::checkMovement() { uint8_t currPos = floor(this->currentPos); uint8_t currTiltPos = floor(this->currentTiltPos); if(this->direction != 0) this->lastMovement = this->direction; - if(this->txPendingSeq != 0 && this->direction == 0 && this->tiltDirection == 0) { + if(this->txHold.held() && this->direction == 0 && this->tiltDirection == 0) { // The command didn't set the shade moving so there is nothing to hold. - this->txPendingSeq = 0; + this->txHold.clear(); } - if(this->txPendingSeq != 0) { + if(this->txHold.held()) { // The command that set us moving hasn't gone on air yet so the motor hasn't // started. Hold the simulated position until it does. - if((uint32_t)curTime - this->txPendingSince < TX_PENDING_TIMEOUT) { + if(!this->txHold.timedOut((uint32_t)curTime, TX_PENDING_TIMEOUT)) { this->moveStart = this->tiltStart = curTime; if(currDir != this->direction || currTiltDir != this->tiltDirection) this->emitState(); return; } ESP_LOGW(TAG, "[Shade %u] Timed out waiting for the radio, moving anyway", this->shadeId); - this->txPendingSeq = 0; + this->txHold.clear(); } if (sunFlag) { if (isSunny && !isWindy) { // It is sunny and there is no wind so we should be extended @@ -2335,20 +2335,15 @@ void SomfyShade::setTiltMovement(int8_t dir) { } } void SomfyShade::markTxPending(uint32_t seq) { - // The motor starts on the first of several queued commands to go on air. - if(this->txPendingSeq != 0) return; // This runs before the caller sets the new target so direction still says - // whether the shade was moving. A moving motor carries on with its earlier - // command so its simulation must not be held. A reversal is simulated as - // soon as it is queued even though the motor turns once it is on air. - if(this->direction != 0 || this->tiltDirection != 0) return; - this->txPendingSeq = seq; - this->txPendingSince = millis(); + // whether the shade was moving. A reversal is simulated as soon as it is + // queued even though the motor turns once it is on air. See TxHold::mark + // for which of several queued commands is held. + this->txHold.mark(seq, this->direction != 0 || this->tiltDirection != 0, millis()); } void SomfyShade::processTxEvent(const somfy_tx_event_t &evt) { if(evt.seq == 0) return; - if(this->txPendingSeq == evt.seq) { - this->txPendingSeq = 0; + if(this->txHold.release(evt.seq)) { if(evt.type == tx_event_t::started) { // The motor starts now so the simulated movement does too. this->moveStart = this->tiltStart = evt.at; diff --git a/src/Somfy.h b/src/Somfy.h index 39cf4de..edd948b 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -23,6 +23,7 @@ #include "SomfyFrame.h" #include "TxBuffer.h" +#include "ShadeMotion.h" enum class group_types : byte { channel = 0x00 }; @@ -240,9 +241,8 @@ class SomfyShade : public SomfyRemote { bool settingTiltPos = false; uint32_t awaitMy = 0; // Radio job that set the shade moving. The simulated position is held - // until it goes on air. 0 when nothing is waiting. - uint32_t txPendingSeq = 0; - uint32_t txPendingSince = 0; + // until it goes on air. + TxHold txHold; // Stop sent when the simulated target was reached. If it never goes on // air the motor runs on to its end limit. 0 once it is on air. uint32_t txStopSeq = 0; diff --git a/test/test_motion/test_motion.cpp b/test/test_motion/test_motion.cpp index d17c8f2..92f4822 100644 --- a/test/test_motion/test_motion.cpp +++ b/test/test_motion/test_motion.cpp @@ -110,6 +110,74 @@ static void test_flipCommand_keeps_the_rest() { for(somfy_commands cmd : same) ASSERT_CMD(cmd, flipCommand(cmd)); } +/********************************************************************* + * TxHold + ********************************************************************/ +static void test_txhold_idle_shade_is_held() { + TxHold hold; + hold.mark(7, false, 1000); + TEST_ASSERT_TRUE(hold.held()); + TEST_ASSERT_EQUAL_UINT32(7, hold.seq); + TEST_ASSERT_EQUAL_UINT32(1000, hold.since); +} +static void test_txhold_moving_shade_is_not_held() { + TxHold hold; + hold.mark(7, true, 1000); + TEST_ASSERT_FALSE(hold.held()); +} +static void test_txhold_ignores_seq_zero() { + TxHold hold; + hold.mark(0, false, 1000); + TEST_ASSERT_FALSE(hold.held()); + hold.mark(7, false, 1000); + TEST_ASSERT_FALSE(hold.release(0)); + TEST_ASSERT_TRUE(hold.held()); +} +static void test_txhold_first_job_wins() { + // simMy Favorite: the Up/Down queued from inside processFrame (N+1) is + // marked before the outer Favorite (N), and the hold stays on the Up/Down. + TxHold hold; + hold.mark(11, false, 1000); + hold.mark(10, false, 1005); + TEST_ASSERT_EQUAL_UINT32(11, hold.seq); + TEST_ASSERT_EQUAL_UINT32(1000, hold.since); + TEST_ASSERT_FALSE(hold.release(10)); + TEST_ASSERT_TRUE(hold.held()); + TEST_ASSERT_TRUE(hold.release(11)); + TEST_ASSERT_FALSE(hold.held()); +} +static void test_txhold_release_only_matches_the_held_job() { + TxHold hold; + hold.mark(5, false, 0); + TEST_ASSERT_FALSE(hold.release(4)); + TEST_ASSERT_FALSE(hold.release(6)); + TEST_ASSERT_TRUE(hold.held()); + TEST_ASSERT_TRUE(hold.release(5)); + TEST_ASSERT_FALSE(hold.release(5)); +} +static void test_txhold_new_hold_after_release() { + TxHold hold; + hold.mark(5, false, 0); + hold.release(5); + hold.mark(6, false, 200); + TEST_ASSERT_EQUAL_UINT32(6, hold.seq); + TEST_ASSERT_EQUAL_UINT32(200, hold.since); +} +static void test_txhold_times_out() { + TxHold hold; + hold.mark(5, false, 1000); + TEST_ASSERT_FALSE(hold.timedOut(1000, 30000)); + TEST_ASSERT_FALSE(hold.timedOut(30999, 30000)); + TEST_ASSERT_TRUE(hold.timedOut(31000, 30000)); +} +static void test_txhold_times_out_across_millis_wrap() { + TxHold hold; + hold.mark(5, false, 0xFFFFF000u); + TEST_ASSERT_FALSE(hold.timedOut(0x00001000u, 30000)); // 8192ms later + TEST_ASSERT_FALSE(hold.timedOut(0x00006000u, 30000)); // 28672ms later + TEST_ASSERT_TRUE(hold.timedOut(0x00007000u, 30000)); // 32768ms later +} + int main(int argc, char **argv) { (void)argc; (void)argv; @@ -126,5 +194,13 @@ int main(int argc, char **argv) { RUN_TEST(test_displayPosition); RUN_TEST(test_flipCommand_swaps_directions); RUN_TEST(test_flipCommand_keeps_the_rest); + RUN_TEST(test_txhold_idle_shade_is_held); + RUN_TEST(test_txhold_moving_shade_is_not_held); + RUN_TEST(test_txhold_ignores_seq_zero); + RUN_TEST(test_txhold_first_job_wins); + RUN_TEST(test_txhold_release_only_matches_the_held_job); + RUN_TEST(test_txhold_new_hold_after_release); + RUN_TEST(test_txhold_times_out); + RUN_TEST(test_txhold_times_out_across_millis_wrap); return UNITY_END(); } From c4472ba8e83ef30b08b511f778afe8ea80a7afea Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 18:33:59 +0200 Subject: [PATCH 48/53] fix: set the radio up for a new config before anything transmits on it setConfig() swapped the config in and released the radio lock, and apply() only ran later from save(), so the radio task could send queued jobs with the new TX pin and settings on a CC1101 that wasn't set up for them. setConfig() now swaps, saves and applies under one lock and save() goes. Co-Authored-By: Claude Opus 5.5 --- src/ConfigFile.cpp | 1 - src/Somfy.cpp | 16 ++++++++-------- src/Somfy.h | 2 +- src/Web.cpp | 1 - 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/ConfigFile.cpp b/src/ConfigFile.cpp index cd56e9e..b012400 100644 --- a/src/ConfigFile.cpp +++ b/src/ConfigFile.cpp @@ -612,7 +612,6 @@ bool ShadeConfigFile::restoreFile(SomfyShadeController *s, const char *filename, transceiver_config_t cfg = s->transceiver.config; this->readTransRecord(cfg); s->transceiver.setConfig(cfg); - s->transceiver.save(); } if(opts.settings || opts.network) settings.save(); if(opts.settings) settings.NTP.save(); diff --git a/src/Somfy.cpp b/src/Somfy.cpp index 451ce0b..5deb257 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -4254,12 +4254,17 @@ bool Transceiver::fromJSON(JsonObject& obj) { return true; } void Transceiver::setConfig(const transceiver_config_t &cfg) { - // Swap the whole config in once any transmission in flight has stopped so the - // radio task never sees a half updated one. Queued jobs are kept and go out - // with the new settings. + // Swap the whole config in, save it and set the radio up with it once any + // transmission in flight has stopped, all under one lock, so the radio task + // never sees a half updated config or transmits before the radio is set up + // for the new one. Queued jobs are kept and go out with the new settings. + // Saving first means a radio that locks up while applying comes back with + // the new config after the reboot. RadioLock lock(portMAX_DELAY, true); if(this->config.enabled && this->config.TXPin != cfg.TXPin) pinMode(this->config.TXPin, INPUT); this->config = cfg; + this->config.save(); + this->config.apply(); } bool Transceiver::usesPin(uint8_t pin) { if(this->config.enabled) { @@ -4273,11 +4278,6 @@ bool Transceiver::usesPin(uint8_t pin) { } return false; } -bool Transceiver::save() { - this->config.save(); - this->config.apply(); - return true; -} bool Transceiver::end() { // Nothing may transmit (and re-enable receive) after this. It is not // resumed since the only caller is OTA, which reboots when the upload diff --git a/src/Somfy.h b/src/Somfy.h index edd948b..e67d215 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -482,8 +482,8 @@ class Transceiver { bool printBuffer = false; //bool toJSON(JsonObject& obj); bool fromJSON(JsonObject& obj); + // Swaps in, saves and applies the config. void setConfig(const transceiver_config_t &cfg); - bool save(); bool begin(); void loop(); bool end(); diff --git a/src/Web.cpp b/src/Web.cpp index 4803d27..2da4208 100644 --- a/src/Web.cpp +++ b/src/Web.cpp @@ -2411,7 +2411,6 @@ void Web::begin() { } JsonObject obj = json.as(); somfy.transceiver.fromJSON(obj); - somfy.transceiver.save(); AsyncJsonResp resp; resp.beginResponse(request, g_async_content, sizeof(g_async_content)); resp.beginObject(); From e47381b19e5e545402e157bd99dd27b32f42e397 Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 18:34:45 +0200 Subject: [PATCH 49/53] fix: only let wifiStrength broadcasts move the RSSI baseline The emit to a newly connected client also stored the RSSI and time, which restarted the 10s hold-off and hid a real change from the clients already connected. The baseline moves into RssiBroadcast, which ignores per-client emits, with unit tests. Co-Authored-By: Claude Opus 5.5 --- src/ESPNetwork.cpp | 12 +++------ src/ESPNetwork.h | 5 ++-- src/Utils.cpp | 9 +++++++ src/Utils.h | 9 +++++++ test/test_utils/test_utils.cpp | 48 ++++++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 11 deletions(-) diff --git a/src/ESPNetwork.cpp b/src/ESPNetwork.cpp index 7ae33b0..d25abde 100644 --- a/src/ESPNetwork.cpp +++ b/src/ESPNetwork.cpp @@ -180,8 +180,8 @@ bool ESPNetwork::changeAP(const uint8_t *bssid, const int32_t channel) { void ESPNetwork::emitSockets() { this->emitHeap(); if(this->needsBroadcast || - (this->connType == conn_types_t::wifi && rssiNeedsEmit(WiFi.RSSI(), this->lastRSSI, WiFi.channel(), this->lastChannel, - millis() - this->lastRSSIEmit, RSSI_EMIT_MIN_DBM, RSSI_EMIT_MIN_MS))) { + (this->connType == conn_types_t::wifi && + this->rssiBroadcast.due(WiFi.RSSI(), WiFi.channel(), millis(), RSSI_EMIT_MIN_DBM, RSSI_EMIT_MIN_MS))) { this->emitSockets(255); sockEmit.loop(); this->needsBroadcast = false; @@ -209,9 +209,7 @@ void ESPNetwork::emitSockets(uint8_t num) { json->addElem("channel", (int32_t)this->channel); json->endObject(); sockEmit.endEmit(num); - this->lastRSSI = WiFi.RSSI(); - this->lastChannel = WiFi.channel(); - this->lastRSSIEmit = millis(); + this->rssiBroadcast.sent(num == 255, WiFi.RSSI(), WiFi.channel(), millis()); } else { JsonSockEvent *json = sockEmit.beginEmit("wifiStrength"); @@ -229,9 +227,7 @@ void ESPNetwork::emitSockets(uint8_t num) { json->addElem("fullduplex", false); json->endObject(); sockEmit.endEmit(num); - this->lastRSSI = -100; - this->lastChannel = -1; - this->lastRSSIEmit = millis(); + this->rssiBroadcast.sent(num == 255, -100, -1, millis()); } } // A broadcast comes from emitSockets(), which has already sent memStatus. diff --git a/src/ESPNetwork.h b/src/ESPNetwork.h index bb0bdc6..e90d0ed 100644 --- a/src/ESPNetwork.h +++ b/src/ESPNetwork.h @@ -1,5 +1,6 @@ #include #include +#include "Utils.h" #ifndef ESPNetwork_h #define ESPNetwork_h @@ -21,9 +22,7 @@ class ESPNetwork { protected: unsigned long lastEmit = 0; unsigned long lastMDNS = 0; - unsigned long lastRSSIEmit = 0; - int lastRSSI = 0; - int lastChannel = 0; + RssiBroadcast rssiBroadcast; int linkSpeed = 0; bool _connecting = false; public: diff --git a/src/Utils.cpp b/src/Utils.cpp index 68a9043..becb358 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -135,3 +135,12 @@ bool rssiNeedsEmit(int rssi, int lastRssi, int channel, int lastChannel, uint32_ int delta = rssi > lastRssi ? rssi - lastRssi : lastRssi - rssi; return delta >= minDelta && sinceLastMs >= minMs; } +bool RssiBroadcast::due(int rssi, int channel, uint32_t now, int minDelta, uint32_t minMs) const { + return rssiNeedsEmit(rssi, this->rssi, channel, this->channel, now - this->at, minDelta, minMs); +} +void RssiBroadcast::sent(bool broadcast, int rssi, int channel, uint32_t now) { + if(!broadcast) return; + this->rssi = rssi; + this->channel = channel; + this->at = now; +} diff --git a/src/Utils.h b/src/Utils.h index 5088bbc..ebe5597 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -60,6 +60,15 @@ struct rebootDelay_t { // otherwise only for an RSSI change of at least minDelta dBm and not more often // than every minMs. bool rssiNeedsEmit(int rssi, int lastRssi, int channel, int lastChannel, uint32_t sinceLastMs, int minDelta, uint32_t minMs); +// What the last wifiStrength broadcast told every client. Only broadcasts +// move it: an emit to one new client tells the others nothing. +struct RssiBroadcast { + int rssi = 0; + int channel = 0; + uint32_t at = 0; // millis() of the last broadcast. + bool due(int rssi, int channel, uint32_t now, int minDelta, uint32_t minMs) const; + void sent(bool broadcast, int rssi, int channel, uint32_t now); +}; class Timestamp { char _timeBuffer[128]; diff --git a/test/test_utils/test_utils.cpp b/test/test_utils/test_utils.cpp index 5d17801..7d483bd 100644 --- a/test/test_utils/test_utils.cpp +++ b/test/test_utils/test_utils.cpp @@ -197,6 +197,49 @@ static void test_rssiNeedsEmit_either_direction() { TEST_ASSERT_TRUE(rssiNeedsEmit(-73, -70, 6, 6, 10000, 3, 10000)); } +/********************************************************************* + * RssiBroadcast + ********************************************************************/ +static RssiBroadcast broadcastAt(int rssi, int channel, uint32_t at) { + RssiBroadcast b; + b.sent(true, rssi, channel, at); + return b; +} +static void test_rssiBroadcast_client_emit_keeps_baseline() { + // A new client gets the current RSSI right as it drops 5 dBm. The clients + // already connected must still get the change. + RssiBroadcast b = broadcastAt(-60, 6, 0); + b.sent(false, -65, 6, 20000); + TEST_ASSERT_EQUAL_INT(-60, b.rssi); + TEST_ASSERT_EQUAL_UINT32(0, b.at); + TEST_ASSERT_TRUE(b.due(-65, 6, 20000, 3, 10000)); +} +static void test_rssiBroadcast_broadcast_moves_baseline() { + RssiBroadcast b = broadcastAt(-60, 6, 0); + b.sent(true, -65, 6, 20000); + TEST_ASSERT_EQUAL_INT(-65, b.rssi); + TEST_ASSERT_EQUAL_UINT32(20000, b.at); + // Measured from the new baseline and too soon after it. + TEST_ASSERT_FALSE(b.due(-66, 6, 40000, 3, 10000)); + TEST_ASSERT_FALSE(b.due(-70, 6, 25000, 3, 10000)); + TEST_ASSERT_TRUE(b.due(-70, 6, 30000, 3, 10000)); +} +static void test_rssiBroadcast_channel_change_is_due() { + RssiBroadcast b = broadcastAt(-60, 6, 1000); + TEST_ASSERT_TRUE(b.due(-60, 11, 1000, 3, 10000)); +} +static void test_rssiBroadcast_client_emit_on_disconnect_keeps_baseline() { + RssiBroadcast b = broadcastAt(-60, 6, 0); + b.sent(false, -100, -1, 5000); + TEST_ASSERT_EQUAL_INT(6, b.channel); + TEST_ASSERT_FALSE(b.due(-60, 6, 5000, 3, 10000)); +} +static void test_rssiBroadcast_hold_off_across_millis_wrap() { + RssiBroadcast b = broadcastAt(-60, 6, 0xFFFFF000u); + TEST_ASSERT_FALSE(b.due(-70, 6, 0x00001000u, 3, 10000)); // 8192ms later + TEST_ASSERT_TRUE(b.due(-70, 6, 0x00002000u, 3, 10000)); // 12288ms later +} + int main(int argc, char **argv) { (void)argc; (void)argv; @@ -228,5 +271,10 @@ int main(int argc, char **argv) { RUN_TEST(test_rssiNeedsEmit_real_change_after_interval_emits); RUN_TEST(test_rssiNeedsEmit_real_change_too_soon_waits); RUN_TEST(test_rssiNeedsEmit_either_direction); + RUN_TEST(test_rssiBroadcast_client_emit_keeps_baseline); + RUN_TEST(test_rssiBroadcast_broadcast_moves_baseline); + RUN_TEST(test_rssiBroadcast_channel_change_is_due); + RUN_TEST(test_rssiBroadcast_client_emit_on_disconnect_keeps_baseline); + RUN_TEST(test_rssiBroadcast_hold_off_across_millis_wrap); return UNITY_END(); } From 9988a2bec8fd61e77231744e25c603c7058f809e Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 18:35:42 +0200 Subject: [PATCH 50/53] fix: don't wait on a log lock holder that can't run The log lock is a binary semaphore, so when the radio task preempted loop() mid-log every log from async_tcp waited the full 20ms for a holder that couldn't let go until the radio job ended. Waiting now stops as soon as the holder is preempted on another core. The rule lives in LogLockPolicy.h with unit tests. Co-Authored-By: Claude Opus 5.5 --- src/LogLock.cpp | 36 ++++++++++++++++++++++++++-- src/LogLockPolicy.h | 21 +++++++++++++++++ test/test_logging/test_logging.cpp | 38 ++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 src/LogLockPolicy.h create mode 100644 test/test_logging/test_logging.cpp diff --git a/src/LogLock.cpp b/src/LogLock.cpp index 381c58d..8c68d63 100644 --- a/src/LogLock.cpp +++ b/src/LogLock.cpp @@ -12,12 +12,17 @@ // // The lock is a binary semaphore, not a mutex. A mutex passes its waiter's // priority to the holder, so async_tcp waiting on loop() would lift loop() -// above the radio task and let it break into a frame on the air. +// above the radio task and let it break into a frame on the air. Without that +// boost a holder preempted on the other core (loop() while the radio task sends +// a whole job) can't let go, so a waiter only waits while the holder can make +// progress (see LogLockPolicy.h). #include #include +#include "esp_idf_version.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" +#include "LogLockPolicy.h" extern "C" int __real_log_printfv(const char *format, va_list arg); @@ -36,11 +41,38 @@ static volatile TaskHandle_t logOwner = nullptr; // line takes about 20ms at 115200 baud. #define LOG_LOCK_WAIT_MS 20 +#if ESP_IDF_VERSION_MAJOR >= 5 +#define taskOnCore(core) xTaskGetCurrentTaskHandleForCore(core) +#define taskCore(task) xTaskGetCoreID(task) +#else +#define taskOnCore(core) xTaskGetCurrentTaskHandleForCPU(core) +#define taskCore(task) xTaskGetAffinity(task) +#endif +static LogHolder logHolder(TaskHandle_t owner) { + if(!owner) return LogHolder::unknown; + for(BaseType_t core = 0; core < portNUM_PROCESSORS; core++) + if(taskOnCore(core) == owner) return LogHolder::running; + // A task pinned elsewhere only runs once whatever preempted it there is done. + const BaseType_t core = taskCore(owner); + if(core == tskNO_AFFINITY || core == xPortGetCoreID()) return LogHolder::preemptedHere; + return LogHolder::preemptedElsewhere; +} +// Takes the lock, waiting in one tick slices for as long as the holder can let +// go of it. False when the line has to be printed without it. +static bool takeLogLock() { + uint32_t waitedMs = 0; + while(xSemaphoreTake(logLock, 1) != pdTRUE) { + waitedMs += portTICK_PERIOD_MS; + if(!keepWaitingForLogLock(logHolder(logOwner), waitedMs, LOG_LOCK_WAIT_MS)) return false; + } + return true; +} + extern "C" int __wrap_log_printfv(const char *format, va_list arg) { // No blocking before the scheduler runs, in an ISR or in a critical section. const bool canLock = logLock && xTaskGetSchedulerState() == taskSCHEDULER_RUNNING && xPortCanYield() && logOwner != xTaskGetCurrentTaskHandle(); - const bool locked = canLock && xSemaphoreTake(logLock, pdMS_TO_TICKS(LOG_LOCK_WAIT_MS)) == pdTRUE; + const bool locked = canLock && takeLogLock(); if(locked) logOwner = xTaskGetCurrentTaskHandle(); int len = __real_log_printfv(format, arg); if(locked) { diff --git a/src/LogLockPolicy.h b/src/LogLockPolicy.h new file mode 100644 index 0000000..a7d783a --- /dev/null +++ b/src/LogLockPolicy.h @@ -0,0 +1,21 @@ +#ifndef LOG_LOCK_POLICY_H +#define LOG_LOCK_POLICY_H +#include + +// When a log call stops waiting for the log lock (src/LogLock.cpp). Kept free +// of FreeRTOS so it can be unit tested on the PC (pio test -e native). + +// What the task holding the log lock is doing. +enum class LogHolder { + unknown, // Just took the lock and hasn't recorded itself yet. + running, // Running on a core, so it lets go within a line. + preemptedHere, // Waiting for this core, so blocking lets it finish. + preemptedElsewhere // Preempted on another core (the radio task during a + // whole transmission), so waiting gets nowhere. +}; +// Whether to keep waiting after waitedMs. Never past maxMs. +inline bool keepWaitingForLogLock(LogHolder holder, uint32_t waitedMs, uint32_t maxMs) { + if(waitedMs >= maxMs) return false; + return holder != LogHolder::preemptedElsewhere; +} +#endif diff --git a/test/test_logging/test_logging.cpp b/test/test_logging/test_logging.cpp new file mode 100644 index 0000000..3cfda43 --- /dev/null +++ b/test/test_logging/test_logging.cpp @@ -0,0 +1,38 @@ +#include +#include "LogLockPolicy.h" + +void setUp() {} +void tearDown() {} + +/********************************************************************* + * keepWaitingForLogLock + ********************************************************************/ +static void assertWaitsUntilMax(LogHolder holder) { + TEST_ASSERT_TRUE(keepWaitingForLogLock(holder, 0, 20)); + TEST_ASSERT_TRUE(keepWaitingForLogLock(holder, 19, 20)); + TEST_ASSERT_FALSE(keepWaitingForLogLock(holder, 20, 20)); + TEST_ASSERT_FALSE(keepWaitingForLogLock(holder, 1000, 20)); +} +static void test_logLock_waits_for_a_running_holder() { assertWaitsUntilMax(LogHolder::running); } +static void test_logLock_waits_for_a_holder_just_taking_it() { assertWaitsUntilMax(LogHolder::unknown); } +static void test_logLock_waits_for_a_holder_preempted_here() { assertWaitsUntilMax(LogHolder::preemptedHere); } +static void test_logLock_gives_up_on_a_holder_preempted_elsewhere() { + // loop() preempted by the radio task can't let go until the job is done. + TEST_ASSERT_FALSE(keepWaitingForLogLock(LogHolder::preemptedElsewhere, 0, 20)); + TEST_ASSERT_FALSE(keepWaitingForLogLock(LogHolder::preemptedElsewhere, 1, 20)); +} +static void test_logLock_no_wait_allowed() { + TEST_ASSERT_FALSE(keepWaitingForLogLock(LogHolder::running, 0, 0)); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_logLock_waits_for_a_running_holder); + RUN_TEST(test_logLock_waits_for_a_holder_just_taking_it); + RUN_TEST(test_logLock_waits_for_a_holder_preempted_here); + RUN_TEST(test_logLock_gives_up_on_a_holder_preempted_elsewhere); + RUN_TEST(test_logLock_no_wait_allowed); + return UNITY_END(); +} From 6a3769406f9b9ddd7d334f284f5950eb8da323c0 Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 18:36:09 +0200 Subject: [PATCH 51/53] fix: skip formatting request timings that won't be logged timeRequest built both 112-byte descriptions (with a String copy of the URL) on async_tcp for every request, and logElapsed threw them away for anything under 100ms. Check worthLogging() first. Co-Authored-By: Claude Opus 5.5 --- src/Timing.h | 5 ++++- src/Web.cpp | 8 ++++++-- test/stubs/Arduino.h | 1 + test/test_logging/test_logging.cpp | 17 +++++++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/Timing.h b/src/Timing.h index 1367ec9..2e46f5b 100644 --- a/src/Timing.h +++ b/src/Timing.h @@ -26,10 +26,13 @@ class ElapsedTimer { uint32_t us() const { return micros() - _start; } }; +// Whether logElapsed() would print a timing this long. Check it before +// building the description so the common, fast case costs nothing. +static inline bool worthLogging(uint32_t us) { return us / 1000 >= TIMING_LOG_MIN_MS; } // Logs how long something took as milliseconds with microsecond precision. static inline void logElapsed(const char *tag, const char *what, uint32_t us) { + if(!worthLogging(us)) return; const unsigned long ms = us / 1000, frac = us % 1000; - if(ms < TIMING_LOG_MIN_MS) return; if(ms >= TIMING_SLOW_MS) ESP_LOGW(tag, "%s took %lu.%03lums (slow)", what, ms, frac); else ESP_LOGI(tag, "%s took %lu.%03lums", what, ms, frac); } diff --git a/src/Web.cpp b/src/Web.cpp index 2da4208..faff038 100644 --- a/src/Web.cpp +++ b/src/Web.cpp @@ -1300,17 +1300,21 @@ static void timeRequest(AsyncWebServerRequest *request, ArMiddlewareNext next, c // this runs, and replacing it would stop the reboot after an update. if(!request->multipart()) { request->onDisconnect([request, timer, server, ip]() { + const uint32_t us = timer.us(); + if(!worthLogging(us)) return; char what[112]; snprintf(what, sizeof(what), "%s %s %s from %u.%u.%u.%u total", server, request->methodToString(), request->url().c_str(), ip[0], ip[1], ip[2], ip[3]); - logElapsed(TAG, what, timer.us()); + logElapsed(TAG, what, us); }); } next(); + const uint32_t us = timer.us(); + if(!worthLogging(us)) return; char what[112]; snprintf(what, sizeof(what), "%s %s %s from %u.%u.%u.%u handler", server, request->methodToString(), request->url().c_str(), ip[0], ip[1], ip[2], ip[3]); - logElapsed(TAG, what, timer.us()); + logElapsed(TAG, what, us); } void Web::begin() { diff --git a/test/stubs/Arduino.h b/test/stubs/Arduino.h index b9584dc..f38a317 100644 --- a/test/stubs/Arduino.h +++ b/test/stubs/Arduino.h @@ -16,6 +16,7 @@ typedef uint8_t byte; // Fixed so anything formatted from it is deterministic. inline unsigned long millis() { return 0; } +inline unsigned long micros() { return 0; } inline bool getLocalTime(struct tm *info, uint32_t ms = 5000) { (void)ms; time_t now = time(nullptr); diff --git a/test/test_logging/test_logging.cpp b/test/test_logging/test_logging.cpp index 3cfda43..120fe0e 100644 --- a/test/test_logging/test_logging.cpp +++ b/test/test_logging/test_logging.cpp @@ -1,5 +1,6 @@ #include #include "LogLockPolicy.h" +#include "Timing.h" void setUp() {} void tearDown() {} @@ -25,6 +26,20 @@ static void test_logLock_no_wait_allowed() { TEST_ASSERT_FALSE(keepWaitingForLogLock(LogHolder::running, 0, 0)); } +/********************************************************************* + * worthLogging + ********************************************************************/ +static void test_worthLogging_fast_timings_are_skipped() { + TEST_ASSERT_FALSE(worthLogging(0)); + TEST_ASSERT_FALSE(worthLogging(999)); + TEST_ASSERT_FALSE(worthLogging(TIMING_LOG_MIN_MS * 1000 - 1)); +} +static void test_worthLogging_from_the_threshold() { + TEST_ASSERT_TRUE(worthLogging(TIMING_LOG_MIN_MS * 1000)); + TEST_ASSERT_TRUE(worthLogging(TIMING_SLOW_MS * 1000)); + TEST_ASSERT_TRUE(worthLogging(UINT32_MAX)); +} + int main(int argc, char **argv) { (void)argc; (void)argv; @@ -34,5 +49,7 @@ int main(int argc, char **argv) { RUN_TEST(test_logLock_waits_for_a_holder_preempted_here); RUN_TEST(test_logLock_gives_up_on_a_holder_preempted_elsewhere); RUN_TEST(test_logLock_no_wait_allowed); + RUN_TEST(test_worthLogging_fast_timings_are_skipped); + RUN_TEST(test_worthLogging_from_the_threshold); return UNITY_END(); } From 1962778912b7e1d0fc78a573b0e5f0b8030da1f6 Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 20:26:33 +0200 Subject: [PATCH 52/53] test: cover the radio queue rules, shade TX state, rolling codes and web command hold Move the pieces of the radio TX path that don't need FreeRTOS or NVS out of Somfy.cpp and Web.cpp so they build in the native test env: the TX job and event types and job encoding (TxBuffer), the queue rules (TxQueuePolicy.h), a shade's pending radio jobs (ShadeTxState), the next rolling code (RollingCode.h) and the web command queue types (WebCommand.h). Co-Authored-By: Claude Opus 5.5 --- src/RollingCode.h | 16 ++ src/ShadeMotion.cpp | 56 +++++ src/ShadeMotion.h | 55 +++++ src/Somfy.cpp | 158 +++++-------- src/Somfy.h | 48 +--- src/TxBuffer.cpp | 23 ++ src/TxBuffer.h | 52 ++++- src/TxQueuePolicy.h | 31 +++ src/Web.cpp | 7 +- src/Web.h | 33 +-- src/WebCommand.h | 51 +++++ test/test_rollingcode/test_rollingcode.cpp | 59 +++++ test/test_shadetx/test_shadetx.cpp | 245 +++++++++++++++++++++ test/test_txqueue/test_txqueue.cpp | 216 ++++++++++++++++++ test/test_webcmd/test_webcmd.cpp | 48 ++++ 15 files changed, 912 insertions(+), 186 deletions(-) create mode 100644 src/RollingCode.h create mode 100644 src/TxQueuePolicy.h create mode 100644 src/WebCommand.h create mode 100644 test/test_rollingcode/test_rollingcode.cpp create mode 100644 test/test_shadetx/test_shadetx.cpp create mode 100644 test/test_txqueue/test_txqueue.cpp create mode 100644 test/test_webcmd/test_webcmd.cpp diff --git a/src/RollingCode.h b/src/RollingCode.h new file mode 100644 index 0000000..67fba39 --- /dev/null +++ b/src/RollingCode.h @@ -0,0 +1,16 @@ +#ifndef ROLLING_CODE_H +#define ROLLING_CODE_H +#include + +// Working out a remote's next rolling code (src/Somfy.cpp). Kept free of NVS +// so it can be unit tested on the PC (pio test -e native). + +// The code the next frame uses. It is only read here and consumed once the +// frame using it has been queued. synced says last matches what is stored for +// this address, so the store is only read through loadStored until a code has +// been written. +template +inline uint16_t nextRollingCode(bool synced, uint16_t last, LoadStored loadStored) { + return (uint16_t)((synced ? last : loadStored()) + 1); +} +#endif diff --git a/src/ShadeMotion.cpp b/src/ShadeMotion.cpp index da5328b..b1b2e9c 100644 --- a/src/ShadeMotion.cpp +++ b/src/ShadeMotion.cpp @@ -29,6 +29,7 @@ int8_t displayPosition(float pos, bool flip) { if(pos < 0) return -1; return static_cast(flip && pos >= 0.00f ? floor(100.0f - pos) : floor(pos)); } +float limitTarget(int8_t dir) { return dir > 0 ? 100.0f : 0.0f; } void TxHold::mark(uint32_t jobSeq, bool moving, uint32_t now) { if(jobSeq == 0 || this->seq != 0 || moving) return; this->seq = jobSeq; @@ -39,3 +40,58 @@ bool TxHold::release(uint32_t jobSeq) { this->seq = 0; return true; } +TxHoldCheck TxHold::check(bool moving, uint32_t now, uint32_t timeoutMs) { + if(!this->held()) return TxHoldCheck::none; + if(!moving) { + // The command didn't set the shade moving so there is nothing to hold. + this->clear(); + return TxHoldCheck::none; + } + if(!this->timedOut(now, timeoutMs)) return TxHoldCheck::holding; + this->clear(); + return TxHoldCheck::expired; +} +void ShadeTxState::stopSent(uint32_t seq, uint32_t now, int8_t dir, bool tilt) { + this->stopSeq = seq; + this->stopAt = now; + this->stopDir = dir; + this->stopTilt = tilt; +} +void ShadeTxState::setMySent(uint32_t seq, float oldPos, float oldTiltPos) { + this->mySeq = seq; + this->myOldPos = oldPos; + this->myOldTiltPos = oldTiltPos; +} +ShadeTxActions ShadeTxState::onEvent(const somfy_tx_event_t &evt, bool moving) { + ShadeTxActions act; + if(evt.seq == 0) return act; + if(this->hold.release(evt.seq)) { + if(evt.type == tx_event_t::started) { + act.startMovement = true; + act.startAt = evt.at; + } + else act.cancelMovement = true; + } + if(this->mySeq == evt.seq && evt.type != tx_event_t::started) { + this->mySeq = 0; + // The motor only stores the favorite when it gets the whole long press. + if(evt.type == tx_event_t::finished) act.commitMy = true; + else { + act.restoreMy = true; + act.myOldPos = this->myOldPos; + act.myOldTiltPos = this->myOldTiltPos; + } + } + if(this->stopSeq == evt.seq) { + this->stopSeq = 0; + // Unless something has moved the shade since, the motor never got the stop + // and is still running. + if(evt.type == tx_event_t::dropped && !moving) { + act.runToLimit = true; + act.limitDir = this->stopDir; + act.limitTilt = this->stopTilt; + act.limitSince = this->stopAt; + } + } + return act; +} diff --git a/src/ShadeMotion.h b/src/ShadeMotion.h index d45dc0e..dff2c14 100644 --- a/src/ShadeMotion.h +++ b/src/ShadeMotion.h @@ -1,6 +1,7 @@ #ifndef SHADE_MOTION_H #define SHADE_MOTION_H #include +#include "TxBuffer.h" // Shade position maths. Kept free of the shade state, radio and network so it // can be unit tested on the PC (pio test -e native). @@ -12,7 +13,15 @@ float travelPosition(float startPos, int8_t dir, uint32_t elapsedMs, int32_t tra // A position as reported to clients: -1 when unset, otherwise the whole // percentage, counted from the other end when flipped. int8_t displayPosition(float pos, bool flip); +// Where a shade (or its tilt) moving in dir ends up when nothing stops it. +float limitTarget(int8_t dir); +// What a held shade does on this pass of checkMovement(). +enum class TxHoldCheck { + none, // Nothing is held. + holding, // Still waiting for the job to go on air. + expired // Waited too long, so the hold was dropped and the shade moves anyway. +}; // The radio job that set an idle shade moving. Its simulated movement is held // until the job goes on air, since that is when the motor starts. struct TxHold { @@ -30,5 +39,51 @@ struct TxHold { bool held() const { return this->seq != 0; } bool timedOut(uint32_t now, uint32_t timeoutMs) const { return now - this->since >= timeoutMs; } void clear() { this->seq = 0; } + // Drops a hold on a shade the command didn't set moving, or one that has + // waited timeoutMs for the radio. + TxHoldCheck check(bool moving, uint32_t now, uint32_t timeoutMs); +}; +// What a shade has to do about a radio event. More than one can apply to the +// same event. +struct ShadeTxActions { + // The held job went on air, so the motor starts at startAt. + bool startMovement = false; + uint32_t startAt = 0; + // The held job never went on air, so the motor hasn't moved. + bool cancelMovement = false; + // The favorite went on air in full and is committed, or it didn't and the + // previous one is put back. + bool commitMy = false; + bool restoreMy = false; + float myOldPos = -1.0f; + float myOldTiltPos = -1.0f; + // The stop never went on air, so the motor runs on to its end limit from + // where and when it should have stopped. + bool runToLimit = false; + int8_t limitDir = 0; + bool limitTilt = false; + uint32_t limitSince = 0; + bool any() const { return startMovement || cancelMovement || commitMy || restoreMy || runToLimit; } +}; +// The radio jobs a shade waits on to find out what its motor really did. +struct ShadeTxState { + // Radio job that set the shade moving. The simulated position is held + // until it goes on air. + TxHold hold; + // Stop sent when the simulated target was reached. If it never goes on + // air the motor runs on to its end limit. 0 once an event for it arrived. + uint32_t stopSeq = 0; + uint32_t stopAt = 0; + int8_t stopDir = 0; + bool stopTilt = false; + // Command telling the motor to store the favorite. The favorite is only + // committed once all of it is on air and restored if it never is. + uint32_t mySeq = 0; + float myOldPos = -1.0f; + float myOldTiltPos = -1.0f; + void stopSent(uint32_t seq, uint32_t now, int8_t dir, bool tilt); + void setMySent(uint32_t seq, float oldPos, float oldTiltPos); + // What to do about evt. moving is whether the shade is moving now. + ShadeTxActions onEvent(const somfy_tx_event_t &evt, bool moving); }; #endif diff --git a/src/Somfy.cpp b/src/Somfy.cpp index 5deb257..e7437ab 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -12,6 +12,8 @@ #include "ConfigSettings.h" #include "Somfy.h" #include "ShadeMotion.h" +#include "TxQueuePolicy.h" +#include "RollingCode.h" #include "Sockets.h" #include "MQTT.h" #include "ConfigFile.h" @@ -39,10 +41,6 @@ uint8_t rxmode = 0; // Indicates whether the radio is in receive mode. Just to #define SETMY_REPEATS 35 #define TILT_REPEATS 15 -// The radio task bit-bangs a whole job on loop()'s core at a higher priority -// so loop() only runs between jobs. A frame takes about 140ms so this keeps a -// job around 7s, well inside the 15s task watchdog loop() is subscribed to. -#define TX_MAX_REPEATS 50 #define TX_QUEUE_DELAY 100 #define TX_TASK_PRIORITY 5 #define TX_TASK_STACK 4096 @@ -75,7 +73,7 @@ static std::atomic txBackTaken{0}; // The radio task and a flush both move txBackTaken. Never let it go backwards. static void advanceBackTaken(uint32_t seq) { uint32_t cur = txBackTaken; - while((int32_t)(seq - cur) > 0 && !txBackTaken.compare_exchange_weak(cur, seq)) {} + while(txSeqAfter(seq, cur) && !txBackTaken.compare_exchange_weak(cur, seq)) {} } // How long a shade's simulated movement waits for its command to go on air // before it starts anyway in case the event was lost. @@ -107,20 +105,6 @@ static void postTxEvent(tx_event_t type, const somfy_tx_job_t &job, uint32_t at) evt.at = at; if(xQueueSend(txEventQueue, &evt, 0) != pdTRUE) ESP_LOGW(TAG, "Radio TX event queue full"); } -// Encodes the first frame of a transmission into the job. Encoding here keeps -// its side effects on the frame (valid flag, default step size) visible to the -// caller and the radio task only re-encodes the 80-bit repeat frames. -static void encodeTxJob(somfy_tx_job_t &job, somfy_frame_t &frame, uint8_t repeat) { - frame.encodeFrame(job.tx.payload); - job.tx.hwsync = frame.bitLength == 56 ? 2 : 12; - job.tx.bit_length = frame.bitLength; - job.frame = frame; - if(repeat > TX_MAX_REPEATS) { - ESP_LOGW(TAG, "Limiting %u repeats to %u", repeat, TX_MAX_REPEATS); - repeat = TX_MAX_REPEATS; - } - job.repeats = repeat; -} // Commands that can set a shade moving. static bool startsMovement(somfy_commands cmd) { switch(cmd) { @@ -762,20 +746,18 @@ void SomfyShade::checkMovement() { uint8_t currPos = floor(this->currentPos); uint8_t currTiltPos = floor(this->currentTiltPos); if(this->direction != 0) this->lastMovement = this->direction; - if(this->txHold.held() && this->direction == 0 && this->tiltDirection == 0) { - // The command didn't set the shade moving so there is nothing to hold. - this->txHold.clear(); - } - if(this->txHold.held()) { - // The command that set us moving hasn't gone on air yet so the motor hasn't - // started. Hold the simulated position until it does. - if(!this->txHold.timedOut((uint32_t)curTime, TX_PENDING_TIMEOUT)) { + switch(this->tx.hold.check(this->direction != 0 || this->tiltDirection != 0, (uint32_t)curTime, TX_PENDING_TIMEOUT)) { + case TxHoldCheck::holding: + // The command that set us moving hasn't gone on air yet so the motor + // hasn't started. Hold the simulated position until it does. this->moveStart = this->tiltStart = curTime; if(currDir != this->direction || currTiltDir != this->tiltDirection) this->emitState(); return; - } - ESP_LOGW(TAG, "[Shade %u] Timed out waiting for the radio, moving anyway", this->shadeId); - this->txHold.clear(); + case TxHoldCheck::expired: + ESP_LOGW(TAG, "[Shade %u] Timed out waiting for the radio, moving anyway", this->shadeId); + break; + case TxHoldCheck::none: + break; } if (sunFlag) { if (isSunny && !isWindy) { // It is sunny and there is no wind so we should be extended @@ -2339,54 +2321,40 @@ void SomfyShade::markTxPending(uint32_t seq) { // whether the shade was moving. A reversal is simulated as soon as it is // queued even though the motor turns once it is on air. See TxHold::mark // for which of several queued commands is held. - this->txHold.mark(seq, this->direction != 0 || this->tiltDirection != 0, millis()); + this->tx.hold.mark(seq, this->direction != 0 || this->tiltDirection != 0, millis()); } void SomfyShade::processTxEvent(const somfy_tx_event_t &evt) { - if(evt.seq == 0) return; - if(this->txHold.release(evt.seq)) { - if(evt.type == tx_event_t::started) { - // The motor starts now so the simulated movement does too. - this->moveStart = this->tiltStart = evt.at; - } - else { - // The motor never got the command so it hasn't moved. - ESP_LOGW(TAG, "[Shade %u] Command dropped by the radio, staying at %.2f%%", this->shadeId, this->currentPos); - this->settingPos = this->settingTiltPos = this->settingMyPos = false; - this->p_target(this->currentPos); - this->p_tiltTarget(this->currentTiltPos); - this->emitState(); - } + const ShadeTxActions act = this->tx.onEvent(evt, this->direction != 0 || this->tiltDirection != 0); + if(act.startMovement) { + // The motor starts now so the simulated movement does too. + this->moveStart = this->tiltStart = act.startAt; } - if(this->txMySeq == evt.seq && evt.type != tx_event_t::started) { - this->txMySeq = 0; - if(evt.type == tx_event_t::finished) this->commitMyPosition(); - else { - // The motor only stores the favorite when it gets the whole long press. - ESP_LOGW(TAG, "[Shade %u] Favorite not sent in full, keeping the previous one", this->shadeId); - this->p_myPos(this->txMyOldPos); - this->p_myTiltPos(this->txMyOldTiltPos); - this->emitState(); - } + if(act.cancelMovement) { + // The motor never got the command so it hasn't moved. + ESP_LOGW(TAG, "[Shade %u] Command dropped by the radio, staying at %.2f%%", this->shadeId, this->currentPos); + this->settingPos = this->settingTiltPos = this->settingMyPos = false; + this->p_target(this->currentPos); + this->p_tiltTarget(this->currentTiltPos); + this->emitState(); } - if(this->txStopSeq == evt.seq) { - this->txStopSeq = 0; - // Unless something has moved the shade since, the motor never got the stop - // and is still running. - if(evt.type == tx_event_t::dropped && this->direction == 0 && this->tiltDirection == 0) { - ESP_LOGW(TAG, "[Shade %u] Stop dropped by the radio, running on to the limit", this->shadeId); - this->runToLimit(this->txStopDir, this->txStopTilt, this->txStopAt); - this->emitState(); - } + if(act.commitMy) this->commitMyPosition(); + if(act.restoreMy) { + ESP_LOGW(TAG, "[Shade %u] Favorite not sent in full, keeping the previous one", this->shadeId); + this->p_myPos(act.myOldPos); + this->p_myTiltPos(act.myOldTiltPos); + this->emitState(); + } + if(act.runToLimit) { + ESP_LOGW(TAG, "[Shade %u] Stop dropped by the radio, running on to the limit", this->shadeId); + this->runToLimit(act.limitDir, act.limitTilt, act.limitSince); + this->emitState(); } } bool SomfyShade::sendStop(int8_t dir, bool tilt) { const uint32_t now = millis(); // Stops jump the radio queue so the motor stops as close to the target as it can. if(SomfyRemote::sendCommandAfter(somfy_commands::My, this->repeats, 0, 0, true)) { - this->txStopSeq = this->lastTxSeq; - this->txStopAt = now; - this->txStopDir = dir; - this->txStopTilt = tilt; + this->tx.stopSent(this->lastTxSeq, now, dir, tilt); return true; } ESP_LOGW(TAG, "[Shade %u] Stop not sent, running on to the limit", this->shadeId); @@ -2397,11 +2365,9 @@ bool SomfyShade::sendSetMy(uint16_t delayMs, float oldPos, float oldTiltPos) { // The new favorite is already in myPos/myTiltPos. It is committed once the // whole long press is on air, see processTxEvent. if(SomfyRemote::sendCommandAfter(somfy_commands::My, SETMY_REPEATS, 0, delayMs)) { - this->txMySeq = this->lastTxSeq; - this->txMyOldPos = oldPos; - this->txMyOldTiltPos = oldTiltPos; + this->tx.setMySent(this->lastTxSeq, oldPos, oldTiltPos); // Without the radio (GPIO remotes) there is nothing to wait for. - if(this->txMySeq == 0) this->commitMyPosition(); + if(this->lastTxSeq == 0) this->commitMyPosition(); return true; } // The motor never learned it so keep the favorite it has. @@ -2416,13 +2382,13 @@ void SomfyShade::runToLimit(int8_t dir, bool tilt, uint32_t since) { this->startTiltPos = this->currentTiltPos; this->tiltStart = since; this->settingTiltPos = false; - this->p_tiltTarget(dir > 0 ? 100.0f : 0.0f); + this->p_tiltTarget(limitTarget(dir)); } else { this->startPos = this->currentPos; this->moveStart = since; this->settingPos = false; - this->p_target(dir > 0 ? 100.0f : 0.0f); + this->p_target(limitTarget(dir)); } // The favorite can't be set where the shade won't stop. this->settingMyPos = false; @@ -3551,10 +3517,7 @@ bool SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t s ESP_LOGD(TAG, "CMD:%s ADDR:%d RCODE:%d REPEAT:%d", translateSomfyCommand(frame.cmd).c_str(), frame.remoteAddress, frame.rollingCode, repeat); - // A stop jumps the queue unless an earlier frame of this remote is still - // waiting. Overtaking it would put the rolling codes out of order and the - // motor would reject the older one. - const bool front = priority && !somfy.transceiver.isQueued(this->lastBackTxSeq); + const bool front = stopGoesToFront(priority, somfy.transceiver.isQueued(this->lastBackTxSeq)); // Only simulate the command if it is actually going on air. if(!somfy.sendFrame(frame, repeat, delayMs, priority, front, &seq)) return false; if(!front) this->lastBackTxSeq = seq; @@ -3582,15 +3545,11 @@ bool SomfyRemote::repeatFrame(uint8_t repeat) { this->triggerGPIOs(this->lastFrame); return true; } - // The first frame is encoded with the current repeat counter and the radio - // task counts up from there for each repeat frame. somfy_tx_job_t job; - encodeTxJob(job, this->lastFrame, repeat); - job.firstRepeat = this->lastFrame.repeats + 2; - // Only advance the counter for frames that will go on air. + encodeRepeatJob(job, this->lastFrame, repeat); if(!somfy.transceiver.queueTx(job)) return false; this->lastBackTxSeq = job.seq; - this->lastFrame.repeats += job.repeats + 1; + noteRepeatQueued(this->lastFrame, job); //somfy.processFrame(this->lastFrame, true); return true; } @@ -3669,17 +3628,17 @@ bool SomfyShadeController::deleteGroup(uint8_t groupId) { } bool SomfyShadeController::loadShadesFile(const char *filename) { return ShadeConfigFile::load(this, filename); } -// The next rolling code is only read here. It is consumed by commitRollingCode -// once the frame using it has been queued. NVS is only read until a code for -// this address has been written, after that lastRollingCode holds the same. +// The next rolling code is consumed by commitRollingCode once the frame using +// it has been queued. After that lastRollingCode holds what NVS does. uint16_t SomfyRemote::peekNextRollingCode() { - if(this->m_rollingCodeSynced) return (uint16_t)(this->lastRollingCode + 1); - if(!pref.begin("ShadeCodes")) { - ESP_LOGE(TAG, "peekNextRollingCode() pref.begin(ShadeCodes) FAILED"); - } - uint16_t code = pref.getUShort(this->m_remotePrefId, 0); - pref.end(); - return (uint16_t)(code + 1); + return nextRollingCode(this->m_rollingCodeSynced, this->lastRollingCode, [this]() { + if(!pref.begin("ShadeCodes")) { + ESP_LOGE(TAG, "peekNextRollingCode() pref.begin(ShadeCodes) FAILED"); + } + uint16_t code = pref.getUShort(this->m_remotePrefId, 0); + pref.end(); + return code; + }); } void SomfyRemote::commitRollingCode(uint16_t code) { if(!pref.begin("ShadeCodes")) { @@ -4717,13 +4676,12 @@ bool Transceiver::queueTx(somfy_tx_job_t &job) { ESP_LOGW(TAG, "Radio TX suspended, dropping transmission"); return false; } - // Normal jobs leave the reserved slots free for stops. - if(uxQueueSpacesAvailable(txJobQueue) <= (job.priority ? 0 : TX_RESERVED_SLOTS)) { + if(!txAdmits(uxQueueSpacesAvailable(txJobQueue), job.priority)) { ESP_LOGE(TAG, "Radio TX queue full, dropping transmission"); return false; } job.gen = txGen; - if(++txSeq == 0) ++txSeq; // 0 means no job. + txSeq = nextTxSeq(txSeq); job.seq = txSeq; // We are the only producer while holding the lock so the space check holds. if(job.front) { @@ -4736,13 +4694,13 @@ bool Transceiver::queueTx(somfy_tx_job_t &job) { } bool Transceiver::isQueued(uint32_t seq) { // Only meaningful for jobs queued at the back. - return seq != 0 && (int32_t)(seq - txBackTaken) > 0; + return txSeqQueued(seq, txBackTaken); } 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; + return txAdmits(uxQueueSpacesAvailable(txJobQueue), false); } void Transceiver::flushTx() { TxQueueLock lock; @@ -4810,7 +4768,7 @@ void Transceiver::runJob(somfy_tx_job_t &job) { void Transceiver::waitUntilDue(const somfy_tx_job_t &job) { for(;;) { if(txSuspendMask || job.gen != txGen) return; - const int32_t wait = (int32_t)(job.notBefore - millis()); + const int32_t wait = txDueInMs(job.notBefore, millis()); if(wait <= 0) return; // A stop only goes to the front when its remote has nothing waiting, and // this job still counts as waiting, so any front job is free to go first. diff --git a/src/Somfy.h b/src/Somfy.h index e67d215..d7ead1a 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -115,41 +115,9 @@ struct somfy_relay_t { uint8_t sync = 0; byte frame[10] = {0}; }; -#define TX_JOB_QUEUE_SIZE 32 -// Slots only priority jobs (stops) may use so a stop always fits in the queue. -#define TX_RESERVED_SLOTS 4 // Reasons the radio must not transmit. TX resumes once every reason is cleared. #define TX_SUSPEND_SCAN 0x01 #define TX_SUSPEND_OTA 0x02 -// A transmission handed off to the radio task. The first frame is encoded by -// the producer into tx. The frame is a copy so the radio task never touches the -// remote's lastFrame and is only used to re-encode 80-bit repeat frames. -struct somfy_tx_job_t { - somfy_tx_t tx; // Encoded first frame with its hw sync count and bit length. - 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; // Repeater frame. Waits for the receiver to be idle. - bool priority = false; // May use the reserved slots. - bool front = false; // Goes to the front of the queue. Only for priority jobs. - uint32_t gen = 0; // Flush generation when queued. Stale jobs are dropped. - uint32_t seq = 0; // Assigned when queued so the job's events can be matched up. - uint32_t notBefore = 0; // millis() before which the job must not go on air (0 = none). -}; -enum class tx_event_t : uint8_t { - started, // The job is going on air. - finished, // Every frame of the job went on air. - aborted, // The job was cut short by a flush or a radio reconfiguration. - dropped // The job was flushed and will never go on air. -}; -// Reported by the radio task for every queued non-repeater job. -struct somfy_tx_event_t { - tx_event_t type = tx_event_t::started; - somfy_commands cmd = somfy_commands::My; - uint32_t remoteAddress = 0; - uint32_t seq = 0; - uint32_t at = 0; // millis() when the job started or was dropped. -}; class SomfyRoom { public: @@ -240,20 +208,8 @@ class SomfyShade : public SomfyRemote { bool settingPos = false; bool settingTiltPos = false; uint32_t awaitMy = 0; - // Radio job that set the shade moving. The simulated position is held - // until it goes on air. - TxHold txHold; - // Stop sent when the simulated target was reached. If it never goes on - // air the motor runs on to its end limit. 0 once it is on air. - uint32_t txStopSeq = 0; - uint32_t txStopAt = 0; - int8_t txStopDir = 0; - bool txStopTilt = false; - // Command telling the motor to store the favorite. The favorite is only - // committed once all of it is on air and restored if it never is. - uint32_t txMySeq = 0; - float txMyOldPos = -1.0f; - float txMyOldTiltPos = -1.0f; + // Radio jobs whose outcome decides what the motor really did. + ShadeTxState tx; bool sendStop(int8_t dir, bool tilt); bool sendSetMy(uint16_t delayMs, float oldPos, float oldTiltPos); void runToLimit(int8_t dir, bool tilt, uint32_t since); diff --git a/src/TxBuffer.cpp b/src/TxBuffer.cpp index 8d19362..46784e1 100644 --- a/src/TxBuffer.cpp +++ b/src/TxBuffer.cpp @@ -1,4 +1,8 @@ #include "TxBuffer.h" +#include "esp_log.h" + +// Logged as part of the radio code it came from. +static const char *TAG = "Somfy"; int8_t somfy_tx_queue_t::oldest() const { // The newest index is at position 0 so the oldest is the last one in use. @@ -53,3 +57,22 @@ void somfy_tx_queue_t::push(uint8_t hwsync, uint8_t *payload, uint8_t bit_length // When popping from the queue we always pull from the end this->index[0] = first; } +void encodeTxJob(somfy_tx_job_t &job, somfy_frame_t &frame, uint8_t repeat) { + frame.encodeFrame(job.tx.payload); + job.tx.hwsync = frame.bitLength == 56 ? 2 : 12; + job.tx.bit_length = frame.bitLength; + job.frame = frame; + if(repeat > TX_MAX_REPEATS) { + ESP_LOGW(TAG, "Limiting %u repeats to %u", repeat, TX_MAX_REPEATS); + repeat = TX_MAX_REPEATS; + } + job.repeats = repeat; +} +void encodeRepeatJob(somfy_tx_job_t &job, somfy_frame_t &frame, uint8_t repeat) { + encodeTxJob(job, frame, repeat); + job.firstRepeat = frame.repeats + 2; +} +void noteRepeatQueued(somfy_frame_t &frame, const somfy_tx_job_t &job) { + // Only advance the counter for frames that will go on air. + frame.repeats += job.repeats + 1; +} diff --git a/src/TxBuffer.h b/src/TxBuffer.h index 940f995..9289d95 100644 --- a/src/TxBuffer.h +++ b/src/TxBuffer.h @@ -1,11 +1,20 @@ #ifndef TX_BUFFER_H #define TX_BUFFER_H #include +#include "SomfyFrame.h" -// Repeater frames waiting to be handed to the radio task. Kept free of the -// radio and timers so it can be unit tested on the PC (pio test -e native). +// Repeater frames waiting to be handed to the radio task, and the jobs and +// events passed to and from it. Kept free of the radio and timers so it can be +// unit tested on the PC (pio test -e native). #define MAX_TX_BUFFER 5 +#define TX_JOB_QUEUE_SIZE 32 +// Slots only priority jobs (stops) may use so a stop always fits in the queue. +#define TX_RESERVED_SLOTS 4 +// The radio task bit-bangs a whole job on loop()'s core at a higher priority +// so loop() only runs between jobs. A frame takes about 140ms so this keeps a +// job around 7s, well inside the 15s task watchdog loop() is subscribed to. +#define TX_MAX_REPEATS 50 struct somfy_tx_t { void clear() { @@ -40,4 +49,43 @@ struct somfy_tx_queue_t { // Position in index of the oldest frame, -1 when empty. int8_t oldest() const; }; +// A transmission handed off to the radio task. The first frame is encoded by +// the producer into tx. The frame is a copy so the radio task never touches the +// remote's lastFrame and is only used to re-encode 80-bit repeat frames. +struct somfy_tx_job_t { + somfy_tx_t tx; // Encoded first frame with its hw sync count and bit length. + 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; // Repeater frame. Waits for the receiver to be idle. + bool priority = false; // May use the reserved slots. + bool front = false; // Goes to the front of the queue. Only for priority jobs. + uint32_t gen = 0; // Flush generation when queued. Stale jobs are dropped. + uint32_t seq = 0; // Assigned when queued so the job's events can be matched up. + uint32_t notBefore = 0; // millis() before which the job must not go on air (0 = none). +}; +enum class tx_event_t : uint8_t { + started, // The job is going on air. + finished, // Every frame of the job went on air. + aborted, // The job was cut short by a flush or a radio reconfiguration. + dropped // The job was flushed and will never go on air. +}; +// Reported by the radio task for every queued non-repeater job. +struct somfy_tx_event_t { + tx_event_t type = tx_event_t::started; + somfy_commands cmd = somfy_commands::My; + uint32_t remoteAddress = 0; + uint32_t seq = 0; + uint32_t at = 0; // millis() when the job started or was dropped. +}; +// Encodes the first frame of a transmission into the job. Encoding here keeps +// its side effects on the frame (valid flag, default step size) visible to the +// caller and the radio task only re-encodes the 80-bit repeat frames. More +// than TX_MAX_REPEATS repeats are cut down to it. +void encodeTxJob(somfy_tx_job_t &job, somfy_frame_t &frame, uint8_t repeat); +// Encodes a repeat of the frame last sent. The first frame carries the +// frame's current repeat counter and the radio task counts up from there. +void encodeRepeatJob(somfy_tx_job_t &job, somfy_frame_t &frame, uint8_t repeat); +// Advances the frame's repeat counter past a repeat job the radio took. +void noteRepeatQueued(somfy_frame_t &frame, const somfy_tx_job_t &job); #endif diff --git a/src/TxQueuePolicy.h b/src/TxQueuePolicy.h new file mode 100644 index 0000000..2f31963 --- /dev/null +++ b/src/TxQueuePolicy.h @@ -0,0 +1,31 @@ +#ifndef TX_QUEUE_POLICY_H +#define TX_QUEUE_POLICY_H +#include +#include "TxBuffer.h" + +// The rules the radio TX queue follows (src/Somfy.cpp). Kept free of FreeRTOS +// so they can be unit tested on the PC (pio test -e native). + +// Whether a job fits with spaces slots free. Normal jobs leave the reserved +// slots free for stops. +inline bool txAdmits(uint32_t spaces, bool priority) { + return spaces > (priority ? 0u : (uint32_t)TX_RESERVED_SLOTS); +} +// The sequence number after seq. 0 means no job so it is skipped on wrap. +inline uint32_t nextTxSeq(uint32_t seq) { + if(++seq == 0) ++seq; + return seq; +} +// Whether job seq was queued after job cur, allowing for wrap. +inline bool txSeqAfter(uint32_t seq, uint32_t cur) { return (int32_t)(seq - cur) > 0; } +// Whether a job queued at the back is still waiting, given taken is the last +// back job the radio task took or a flush dropped. Those go out in order. +inline bool txSeqQueued(uint32_t seq, uint32_t taken) { return seq != 0 && txSeqAfter(seq, taken); } +// A stop jumps the queue unless an earlier frame of its remote is still +// waiting. Overtaking it would put the rolling codes out of order and the +// motor would reject the older one. +inline bool stopGoesToFront(bool priority, bool lastBackQueued) { return priority && !lastBackQueued; } +// Milliseconds until a delayed job is due, 0 or less once it is. Works +// across a millis() wrap. +inline int32_t txDueInMs(uint32_t notBefore, uint32_t now) { return (int32_t)(notBefore - now); } +#endif diff --git a/src/Web.cpp b/src/Web.cpp index faff038..5e23e76 100644 --- a/src/Web.cpp +++ b/src/Web.cpp @@ -134,17 +134,12 @@ bool Web::queueCommand(const web_command_t &cmd) { } return true; } -// Only setting positions never goes on air. -static bool usesRadio(web_cmd_t type) { return type != web_cmd_t::set_positions; } void Web::processQueue() { if(!webCmdQueue) return; web_command_t cmd; - // 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. // Commands run in order, so only the one at the head can be held back. while(xQueuePeek(webCmdQueue, &cmd, 0) == pdTRUE) { - if(usesRadio(cmd.type) && !somfy.transceiver.txSuspended() && !somfy.transceiver.txHasRoom()) break; + if(holdWebCommand(cmd.type, somfy.transceiver.txSuspended(), somfy.transceiver.txHasRoom())) break; // loop() is the only consumer so this is the command we peeked. xQueueReceive(webCmdQueue, &cmd, 0); bool sent = true; diff --git a/src/Web.h b/src/Web.h index cba5111..473c471 100644 --- a/src/Web.h +++ b/src/Web.h @@ -3,41 +3,10 @@ #include #include #include "Somfy.h" +#include "WebCommand.h" #ifndef webserver_h #define webserver_h -// 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 - group_command, // group sendCommand - tilt_command, // moveToTiltTarget or sendTiltCommand - shade_repeat, // shade sendCommand/repeatFrame - group_repeat, // group sendCommand/repeatFrame - 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 { - web_cmd_t type; - uint8_t shadeId; - uint8_t groupId; - uint8_t target; // 0-100 or 255 (none) - somfy_commands command; - int8_t repeat; - uint8_t stepSize; - int8_t position; // for setPositions - int8_t tiltPosition; // for setPositions/tilt - int8_t sunny; // for sensor - int8_t windy; // for sensor - bool wasPaired; // for unpair, restored if the Prog can't be sent -}; - class Web { public: bool uploadSuccess = false; diff --git a/src/WebCommand.h b/src/WebCommand.h new file mode 100644 index 0000000..0928791 --- /dev/null +++ b/src/WebCommand.h @@ -0,0 +1,51 @@ +#ifndef WEB_COMMAND_H +#define WEB_COMMAND_H +#include +#include "SomfyFrame.h" + +// Commands the web servers hand to the main loop (src/Web.cpp). Kept free of +// the web server and FreeRTOS so it can be unit tested on the PC +// (pio test -e native). + +// 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 + group_command, // group sendCommand + tilt_command, // moveToTiltTarget or sendTiltCommand + shade_repeat, // shade sendCommand/repeatFrame + group_repeat, // group sendCommand/repeatFrame + 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 { + web_cmd_t type; + uint8_t shadeId; + uint8_t groupId; + uint8_t target; // 0-100 or 255 (none) + somfy_commands command; + int8_t repeat; + uint8_t stepSize; + int8_t position; // for setPositions + int8_t tiltPosition; // for setPositions/tilt + int8_t sunny; // for sensor + int8_t windy; // for sensor + bool wasPaired; // for unpair, restored if the Prog can't be sent +}; + +// Only setting positions never goes on air. +inline bool webCommandUsesRadio(web_cmd_t type) { return type != web_cmd_t::set_positions; } +// Whether the command at the head of the queue waits for the radio. The +// client was already told it was accepted so it stays queued until the radio +// has room rather than have the radio drop it. While TX is suspended nothing +// gets through anyway so nothing is held back then. +inline bool holdWebCommand(web_cmd_t type, bool txSuspended, bool txHasRoom) { + return webCommandUsesRadio(type) && !txSuspended && !txHasRoom; +} +#endif diff --git a/test/test_rollingcode/test_rollingcode.cpp b/test/test_rollingcode/test_rollingcode.cpp new file mode 100644 index 0000000..8de99aa --- /dev/null +++ b/test/test_rollingcode/test_rollingcode.cpp @@ -0,0 +1,59 @@ +#include +#include "RollingCode.h" + +// Stands in for NVS and counts how often it is read. +struct FakeStore { + uint16_t code = 0; + int reads = 0; + uint16_t operator()() { this->reads++; return this->code; } +}; +// Passes the store by reference so the test sees the read count. +static uint16_t next(bool synced, uint16_t last, FakeStore &store) { + return nextRollingCode(synced, last, [&store]() { return store(); }); +} + +void setUp() {} +void tearDown() {} + +static void test_unsynced_reads_the_store() { + FakeStore store; + store.code = 41; + TEST_ASSERT_EQUAL_UINT16(42, next(false, 7, store)); + TEST_ASSERT_EQUAL_INT(1, store.reads); +} +static void test_synced_never_reads_the_store() { + FakeStore store; + store.code = 41; + TEST_ASSERT_EQUAL_UINT16(8, next(true, 7, store)); + TEST_ASSERT_EQUAL_INT(0, store.reads); +} +static void test_reading_does_not_use_up_a_code() { + // A command the radio refuses must leave the remote on the same code. + FakeStore store; + store.code = 41; + TEST_ASSERT_EQUAL_UINT16(42, next(false, 0, store)); + TEST_ASSERT_EQUAL_UINT16(42, next(false, 0, store)); + TEST_ASSERT_EQUAL_UINT16(8, next(true, 7, store)); + TEST_ASSERT_EQUAL_UINT16(8, next(true, 7, store)); +} +static void test_wraps_to_zero_after_0xFFFF() { + // Current behaviour, kept on purpose for now: the code after 0xFFFF is 0. + // Our own decoder rejects a zero rolling code (see + // test_rejects_zero_rolling_code_except_sensor in test_frame), so a remote + // that gets this far sends one frame other receivers may ignore. + FakeStore store; + store.code = 0xFFFF; + TEST_ASSERT_EQUAL_UINT16(0, next(false, 0, store)); + TEST_ASSERT_EQUAL_UINT16(0, next(true, 0xFFFF, store)); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_unsynced_reads_the_store); + RUN_TEST(test_synced_never_reads_the_store); + RUN_TEST(test_reading_does_not_use_up_a_code); + RUN_TEST(test_wraps_to_zero_after_0xFFFF); + return UNITY_END(); +} diff --git a/test/test_shadetx/test_shadetx.cpp b/test/test_shadetx/test_shadetx.cpp new file mode 100644 index 0000000..f227457 --- /dev/null +++ b/test/test_shadetx/test_shadetx.cpp @@ -0,0 +1,245 @@ +#include +#include "ShadeMotion.h" + +#define ASSERT_POS(expected, actual) TEST_ASSERT_FLOAT_WITHIN(0.01f, (expected), (actual)) + +static const int8_t DOWN = 1; +static const int8_t UP = -1; +static const bool IDLE = false; +static const bool MOVING = true; + +static somfy_tx_event_t evt(tx_event_t type, uint32_t seq, uint32_t at = 0) { + somfy_tx_event_t e; + e.type = type; + e.seq = seq; + e.at = at; + return e; +} +// A shade whose idle motor was set moving by radio job seq. +static ShadeTxState heldBy(uint32_t seq) { + ShadeTxState tx; + tx.hold.mark(seq, false, 0); + return tx; +} + +void setUp() {} +void tearDown() {} + +/********************************************************************* + * The job that set the shade moving + ********************************************************************/ +static void test_held_job_started_starts_the_movement() { + ShadeTxState tx = heldBy(7); + ShadeTxActions act = tx.onEvent(evt(tx_event_t::started, 7, 1234), MOVING); + TEST_ASSERT_TRUE(act.startMovement); + TEST_ASSERT_EQUAL_UINT32(1234, act.startAt); + TEST_ASSERT_FALSE(act.cancelMovement); + TEST_ASSERT_FALSE(tx.hold.held()); +} +static void test_held_job_finished_after_start_does_nothing() { + ShadeTxState tx = heldBy(7); + tx.onEvent(evt(tx_event_t::started, 7, 1234), MOVING); + ShadeTxActions act = tx.onEvent(evt(tx_event_t::finished, 7, 2000), MOVING); + TEST_ASSERT_FALSE(act.any()); +} +static void test_held_job_dropped_cancels_the_movement() { + ShadeTxState tx = heldBy(7); + ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 7), MOVING); + TEST_ASSERT_TRUE(act.cancelMovement); + TEST_ASSERT_FALSE(act.startMovement); + TEST_ASSERT_FALSE(tx.hold.held()); +} +static void test_held_job_aborted_before_start_cancels_the_movement() { + ShadeTxState tx = heldBy(7); + ShadeTxActions act = tx.onEvent(evt(tx_event_t::aborted, 7), MOVING); + TEST_ASSERT_TRUE(act.cancelMovement); +} + +/********************************************************************* + * The favorite (long press My) + ********************************************************************/ +static void test_favorite_started_waits_for_the_end() { + ShadeTxState tx; + tx.setMySent(9, 40.0f, 10.0f); + ShadeTxActions act = tx.onEvent(evt(tx_event_t::started, 9), IDLE); + TEST_ASSERT_FALSE(act.any()); + TEST_ASSERT_EQUAL_UINT32(9, tx.mySeq); +} +static void test_favorite_finished_is_committed() { + ShadeTxState tx; + tx.setMySent(9, 40.0f, 10.0f); + tx.onEvent(evt(tx_event_t::started, 9), IDLE); + ShadeTxActions act = tx.onEvent(evt(tx_event_t::finished, 9), IDLE); + TEST_ASSERT_TRUE(act.commitMy); + TEST_ASSERT_FALSE(act.restoreMy); + TEST_ASSERT_EQUAL_UINT32(0, tx.mySeq); +} +static void test_favorite_aborted_restores_the_previous_one() { + // The motor only stores the favorite when it gets the whole long press. + ShadeTxState tx; + tx.setMySent(9, 40.0f, 10.0f); + tx.onEvent(evt(tx_event_t::started, 9), IDLE); + ShadeTxActions act = tx.onEvent(evt(tx_event_t::aborted, 9), IDLE); + TEST_ASSERT_TRUE(act.restoreMy); + TEST_ASSERT_FALSE(act.commitMy); + ASSERT_POS(40.0f, act.myOldPos); + ASSERT_POS(10.0f, act.myOldTiltPos); + TEST_ASSERT_EQUAL_UINT32(0, tx.mySeq); +} +static void test_favorite_dropped_restores_the_previous_one() { + ShadeTxState tx; + tx.setMySent(9, -1.0f, -1.0f); + ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 9), IDLE); + TEST_ASSERT_TRUE(act.restoreMy); + ASSERT_POS(-1.0f, act.myOldPos); + ASSERT_POS(-1.0f, act.myOldTiltPos); +} + +/********************************************************************* + * The stop sent at the target + ********************************************************************/ +static void test_stop_dropped_runs_on_to_the_limit() { + ShadeTxState tx; + tx.stopSent(12, 5000, DOWN, false); + ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 12, 5300), IDLE); + TEST_ASSERT_TRUE(act.runToLimit); + TEST_ASSERT_EQUAL_INT8(DOWN, act.limitDir); + TEST_ASSERT_FALSE(act.limitTilt); + // From when the motor should have stopped, not when the drop was reported. + TEST_ASSERT_EQUAL_UINT32(5000, act.limitSince); + TEST_ASSERT_EQUAL_UINT32(0, tx.stopSeq); +} +static void test_tilt_stop_dropped_runs_the_tilt_on() { + ShadeTxState tx; + tx.stopSent(12, 5000, UP, true); + ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 12), IDLE); + TEST_ASSERT_TRUE(act.runToLimit); + TEST_ASSERT_EQUAL_INT8(UP, act.limitDir); + TEST_ASSERT_TRUE(act.limitTilt); +} +static void test_stop_dropped_after_moving_again_does_nothing() { + // Something set the shade moving since, so that command decides where the + // motor goes. + ShadeTxState tx; + tx.stopSent(12, 5000, DOWN, false); + ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 12), MOVING); + TEST_ASSERT_FALSE(act.any()); + TEST_ASSERT_EQUAL_UINT32(0, tx.stopSeq); +} +static void test_stop_on_air_does_nothing() { + ShadeTxState tx; + tx.stopSent(12, 5000, DOWN, false); + TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::started, 12), IDLE).any()); + TEST_ASSERT_EQUAL_UINT32(0, tx.stopSeq); + // Nothing is waiting on it any more. + TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::dropped, 12), IDLE).any()); +} +static void test_stop_aborted_does_not_run_on() { + // Part of the stop went on air, which is enough for the motor. + ShadeTxState tx; + tx.stopSent(12, 5000, DOWN, false); + TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::aborted, 12), IDLE).any()); +} + +/********************************************************************* + * Edge cases + ********************************************************************/ +static void test_one_event_for_the_held_job_and_the_favorite() { + // The delayed My that stores the favorite also sets an idle shade moving, + // so the same job is held and awaited as the favorite. + ShadeTxState tx = heldBy(9); + tx.setMySent(9, 40.0f, 10.0f); + ShadeTxActions act = tx.onEvent(evt(tx_event_t::dropped, 9), MOVING); + TEST_ASSERT_TRUE(act.cancelMovement); + TEST_ASSERT_TRUE(act.restoreMy); + TEST_ASSERT_FALSE(tx.hold.held()); + TEST_ASSERT_EQUAL_UINT32(0, tx.mySeq); +} +static void test_seq_zero_is_ignored() { + ShadeTxState tx; + tx.setMySent(0, 40.0f, 10.0f); + tx.stopSent(0, 5000, DOWN, false); + TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::dropped, 0), IDLE).any()); + TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::finished, 0), IDLE).any()); +} +static void test_other_jobs_are_ignored() { + ShadeTxState tx = heldBy(7); + tx.setMySent(9, 40.0f, 10.0f); + tx.stopSent(12, 5000, DOWN, false); + TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::dropped, 8), IDLE).any()); + TEST_ASSERT_FALSE(tx.onEvent(evt(tx_event_t::finished, 13), IDLE).any()); + TEST_ASSERT_EQUAL_UINT32(7, tx.hold.seq); + TEST_ASSERT_EQUAL_UINT32(9, tx.mySeq); + TEST_ASSERT_EQUAL_UINT32(12, tx.stopSeq); +} + +/********************************************************************* + * limitTarget + ********************************************************************/ +static void test_limitTarget() { + ASSERT_POS(100.0f, limitTarget(DOWN)); + ASSERT_POS(0.0f, limitTarget(UP)); +} + +/********************************************************************* + * TxHold::check + ********************************************************************/ +static void test_check_nothing_held() { + TxHold hold; + TEST_ASSERT_TRUE(hold.check(true, 1000, 30000) == TxHoldCheck::none); +} +static void test_check_idle_shade_drops_the_hold() { + // The command didn't set the shade moving so there is nothing to hold. + TxHold hold; + hold.mark(5, false, 1000); + TEST_ASSERT_TRUE(hold.check(false, 1000, 30000) == TxHoldCheck::none); + TEST_ASSERT_FALSE(hold.held()); +} +static void test_check_holds_a_moving_shade() { + TxHold hold; + hold.mark(5, false, 1000); + TEST_ASSERT_TRUE(hold.check(true, 30999, 30000) == TxHoldCheck::holding); + TEST_ASSERT_TRUE(hold.held()); +} +static void test_check_expires() { + TxHold hold; + hold.mark(5, false, 1000); + TEST_ASSERT_TRUE(hold.check(true, 31000, 30000) == TxHoldCheck::expired); + TEST_ASSERT_FALSE(hold.held()); + TEST_ASSERT_TRUE(hold.check(true, 31001, 30000) == TxHoldCheck::none); +} +static void test_check_across_millis_wrap() { + TxHold hold; + hold.mark(5, false, 0xFFFFF000u); + TEST_ASSERT_TRUE(hold.check(true, 0x00006000u, 30000) == TxHoldCheck::holding); + TEST_ASSERT_TRUE(hold.check(true, 0x00007000u, 30000) == TxHoldCheck::expired); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_held_job_started_starts_the_movement); + RUN_TEST(test_held_job_finished_after_start_does_nothing); + RUN_TEST(test_held_job_dropped_cancels_the_movement); + RUN_TEST(test_held_job_aborted_before_start_cancels_the_movement); + RUN_TEST(test_favorite_started_waits_for_the_end); + RUN_TEST(test_favorite_finished_is_committed); + RUN_TEST(test_favorite_aborted_restores_the_previous_one); + RUN_TEST(test_favorite_dropped_restores_the_previous_one); + RUN_TEST(test_stop_dropped_runs_on_to_the_limit); + RUN_TEST(test_tilt_stop_dropped_runs_the_tilt_on); + RUN_TEST(test_stop_dropped_after_moving_again_does_nothing); + RUN_TEST(test_stop_on_air_does_nothing); + RUN_TEST(test_stop_aborted_does_not_run_on); + RUN_TEST(test_one_event_for_the_held_job_and_the_favorite); + RUN_TEST(test_seq_zero_is_ignored); + RUN_TEST(test_other_jobs_are_ignored); + RUN_TEST(test_limitTarget); + RUN_TEST(test_check_nothing_held); + RUN_TEST(test_check_idle_shade_drops_the_hold); + RUN_TEST(test_check_holds_a_moving_shade); + RUN_TEST(test_check_expires); + RUN_TEST(test_check_across_millis_wrap); + return UNITY_END(); +} diff --git a/test/test_txqueue/test_txqueue.cpp b/test/test_txqueue/test_txqueue.cpp new file mode 100644 index 0000000..667bf04 --- /dev/null +++ b/test/test_txqueue/test_txqueue.cpp @@ -0,0 +1,216 @@ +#include +#include "TxQueuePolicy.h" +#include "TxBuffer.h" + +static const uint32_t ADDR = 0x123456; + +// A frame as SomfyRemote::sendCommandAfter builds it. +static somfy_frame_t makeFrame(somfy_commands cmd, uint8_t bitLength = 56) { + somfy_frame_t f; + f.cmd = cmd; + f.remoteAddress = ADDR; + f.rollingCode = 0x0102; + f.encKey = 0xA2; + f.bitLength = bitLength; + f.valid = true; + return f; +} + +void setUp() {} +void tearDown() {} + +/********************************************************************* + * txAdmits + ********************************************************************/ +static void test_normal_job_leaves_the_reserved_slots() { + TEST_ASSERT_FALSE(txAdmits(0, false)); + TEST_ASSERT_FALSE(txAdmits(TX_RESERVED_SLOTS, false)); + TEST_ASSERT_TRUE(txAdmits(TX_RESERVED_SLOTS + 1, false)); + TEST_ASSERT_TRUE(txAdmits(TX_JOB_QUEUE_SIZE, false)); +} +static void test_priority_job_uses_the_reserved_slots() { + TEST_ASSERT_FALSE(txAdmits(0, true)); + TEST_ASSERT_TRUE(txAdmits(1, true)); + TEST_ASSERT_TRUE(txAdmits(TX_RESERVED_SLOTS, true)); +} + +/********************************************************************* + * Sequence numbers + ********************************************************************/ +static void test_nextTxSeq_counts_up() { + TEST_ASSERT_EQUAL_UINT32(1, nextTxSeq(0)); + TEST_ASSERT_EQUAL_UINT32(2, nextTxSeq(1)); +} +static void test_nextTxSeq_skips_zero_on_wrap() { + TEST_ASSERT_EQUAL_UINT32(1, nextTxSeq(0xFFFFFFFFu)); +} +static void test_txSeqQueued_zero_is_never_queued() { + TEST_ASSERT_FALSE(txSeqQueued(0, 0)); + TEST_ASSERT_FALSE(txSeqQueued(0, 10)); + TEST_ASSERT_FALSE(txSeqQueued(0, 0xFFFFFFF0u)); +} +static void test_txSeqQueued_after_taken() { + TEST_ASSERT_FALSE(txSeqQueued(9, 10)); + TEST_ASSERT_FALSE(txSeqQueued(10, 10)); + TEST_ASSERT_TRUE(txSeqQueued(11, 10)); +} +static void test_txSeqQueued_across_wrap() { + // Job 5 was numbered after the counter wrapped, so it is still waiting. + TEST_ASSERT_TRUE(txSeqQueued(5, 0xFFFFFFF0u)); + TEST_ASSERT_TRUE(txSeqQueued(0xFFFFFFFFu, 0xFFFFFFF0u)); + TEST_ASSERT_FALSE(txSeqQueued(0xFFFFFFF0u, 5)); +} +static void test_txSeqAfter_never_moves_back() { + // A flush marks every job up to 20 as taken. The radio task then reports + // job 15, which it took off the queue before the flush. That must not move + // the mark back or jobs 16-20 would look queued again. + uint32_t taken = 20; + if(txSeqAfter(15, taken)) taken = 15; + TEST_ASSERT_EQUAL_UINT32(20, taken); + if(txSeqAfter(21, taken)) taken = 21; + TEST_ASSERT_EQUAL_UINT32(21, taken); + TEST_ASSERT_FALSE(txSeqAfter(21, 21)); +} + +/********************************************************************* + * stopGoesToFront + ********************************************************************/ +static void test_stop_goes_to_front_when_nothing_waits() { + TEST_ASSERT_TRUE(stopGoesToFront(true, false)); +} +static void test_stop_queues_behind_its_remote() { + // Overtaking the remote's waiting frame would send the rolling codes out of + // order. + TEST_ASSERT_FALSE(stopGoesToFront(true, true)); +} +static void test_normal_job_never_goes_to_front() { + TEST_ASSERT_FALSE(stopGoesToFront(false, false)); + TEST_ASSERT_FALSE(stopGoesToFront(false, true)); +} + +/********************************************************************* + * txDueInMs + ********************************************************************/ +static void test_txDueInMs() { + TEST_ASSERT_EQUAL_INT32(500, txDueInMs(1500, 1000)); + TEST_ASSERT_EQUAL_INT32(0, txDueInMs(1000, 1000)); + TEST_ASSERT_EQUAL_INT32(-200, txDueInMs(1000, 1200)); +} +static void test_txDueInMs_across_millis_wrap() { + // Queued 0x100ms before the wrap with a 0x200ms delay. + const uint32_t notBefore = 0xFFFFFF00u + 0x200u; + TEST_ASSERT_EQUAL_INT32(0x200, txDueInMs(notBefore, 0xFFFFFF00u)); + TEST_ASSERT_EQUAL_INT32(0x100, txDueInMs(notBefore, 0)); + TEST_ASSERT_EQUAL_INT32(-0x10, txDueInMs(notBefore, 0x110u)); +} + +/********************************************************************* + * encodeTxJob + ********************************************************************/ +static void test_encode_56bit_job() { + somfy_frame_t f = makeFrame(somfy_commands::Up); + somfy_tx_job_t job; + encodeTxJob(job, f, 3); + byte expected[10] = {}; + somfy_frame_t copy = makeFrame(somfy_commands::Up); + copy.encodeFrame(expected); + TEST_ASSERT_EQUAL_UINT8(2, job.tx.hwsync); + TEST_ASSERT_EQUAL_UINT8(56, job.tx.bit_length); + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, job.tx.payload, sizeof(expected)); + TEST_ASSERT_EQUAL_UINT8(3, job.repeats); +} +static void test_encode_80bit_job() { + somfy_frame_t f = makeFrame(somfy_commands::Down, 80); + somfy_tx_job_t job; + encodeTxJob(job, f, 1); + byte expected[10] = {}; + somfy_frame_t copy = makeFrame(somfy_commands::Down, 80); + copy.encodeFrame(expected); + TEST_ASSERT_EQUAL_UINT8(12, job.tx.hwsync); + TEST_ASSERT_EQUAL_UINT8(80, job.tx.bit_length); + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, job.tx.payload, sizeof(expected)); +} +static void test_encode_job_copies_the_frame() { + somfy_frame_t f = makeFrame(somfy_commands::My); + somfy_tx_job_t job; + encodeTxJob(job, f, 1); + TEST_ASSERT_EQUAL_UINT32(ADDR, job.frame.remoteAddress); + TEST_ASSERT_EQUAL_UINT16(0x0102, job.frame.rollingCode); + // The radio task uses its own copy, so later changes to the remote's frame + // don't reach it. + f.rollingCode = 0x0200; + TEST_ASSERT_EQUAL_UINT16(0x0102, job.frame.rollingCode); +} +static void test_encode_job_limits_repeats() { + somfy_frame_t f = makeFrame(somfy_commands::My); + somfy_tx_job_t job; + encodeTxJob(job, f, TX_MAX_REPEATS); + TEST_ASSERT_EQUAL_UINT8(TX_MAX_REPEATS, job.repeats); + encodeTxJob(job, f, TX_MAX_REPEATS + 1); + TEST_ASSERT_EQUAL_UINT8(TX_MAX_REPEATS, job.repeats); + encodeTxJob(job, f, 255); + TEST_ASSERT_EQUAL_UINT8(TX_MAX_REPEATS, job.repeats); + encodeTxJob(job, f, 0); + TEST_ASSERT_EQUAL_UINT8(0, job.repeats); +} + +/********************************************************************* + * Repeat jobs + ********************************************************************/ +static void test_repeat_job_counts_on_from_the_frame() { + somfy_frame_t f = makeFrame(somfy_commands::Up, 80); + f.repeats = 4; + somfy_tx_job_t job; + encodeRepeatJob(job, f, 2); + TEST_ASSERT_EQUAL_UINT8(6, job.firstRepeat); + TEST_ASSERT_EQUAL_UINT8(2, job.repeats); + // Nothing moves until the radio takes the job. + TEST_ASSERT_EQUAL_UINT8(4, f.repeats); +} +static void test_repeat_queued_advances_the_counter() { + somfy_frame_t f = makeFrame(somfy_commands::Up, 80); + f.repeats = 4; + somfy_tx_job_t job; + encodeRepeatJob(job, f, 2); + noteRepeatQueued(f, job); + // The first frame plus two repeats went out. + TEST_ASSERT_EQUAL_UINT8(7, f.repeats); + // The next repeat job picks up where this one stops. + somfy_tx_job_t next; + encodeRepeatJob(next, f, 1); + TEST_ASSERT_EQUAL_UINT8(9, next.firstRepeat); +} +static void test_repeat_queued_uses_the_limited_repeats() { + somfy_frame_t f = makeFrame(somfy_commands::Up); + somfy_tx_job_t job; + encodeRepeatJob(job, f, 200); + noteRepeatQueued(f, job); + TEST_ASSERT_EQUAL_UINT8(TX_MAX_REPEATS + 1, f.repeats); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_normal_job_leaves_the_reserved_slots); + RUN_TEST(test_priority_job_uses_the_reserved_slots); + RUN_TEST(test_nextTxSeq_counts_up); + RUN_TEST(test_nextTxSeq_skips_zero_on_wrap); + RUN_TEST(test_txSeqQueued_zero_is_never_queued); + RUN_TEST(test_txSeqQueued_after_taken); + RUN_TEST(test_txSeqQueued_across_wrap); + RUN_TEST(test_txSeqAfter_never_moves_back); + RUN_TEST(test_stop_goes_to_front_when_nothing_waits); + RUN_TEST(test_stop_queues_behind_its_remote); + RUN_TEST(test_normal_job_never_goes_to_front); + RUN_TEST(test_txDueInMs); + RUN_TEST(test_txDueInMs_across_millis_wrap); + RUN_TEST(test_encode_56bit_job); + RUN_TEST(test_encode_80bit_job); + RUN_TEST(test_encode_job_copies_the_frame); + RUN_TEST(test_encode_job_limits_repeats); + RUN_TEST(test_repeat_job_counts_on_from_the_frame); + RUN_TEST(test_repeat_queued_advances_the_counter); + RUN_TEST(test_repeat_queued_uses_the_limited_repeats); + return UNITY_END(); +} diff --git a/test/test_webcmd/test_webcmd.cpp b/test/test_webcmd/test_webcmd.cpp new file mode 100644 index 0000000..c6a7514 --- /dev/null +++ b/test/test_webcmd/test_webcmd.cpp @@ -0,0 +1,48 @@ +#include +#include "WebCommand.h" + +static const web_cmd_t RADIO_COMMANDS[] = { + web_cmd_t::shade_command, web_cmd_t::group_command, web_cmd_t::tilt_command, + web_cmd_t::shade_repeat, web_cmd_t::group_repeat, web_cmd_t::shade_sensor, + web_cmd_t::group_sensor, web_cmd_t::set_my_position, web_cmd_t::unpair_shade +}; + +void setUp() {} +void tearDown() {} + +static void test_only_set_positions_stays_off_the_radio() { + int offRadio = 0; + for(uint8_t t = 0; t <= static_cast(web_cmd_t::unpair_shade); t++) { + if(!webCommandUsesRadio(static_cast(t))) offRadio++; + } + TEST_ASSERT_EQUAL_INT(1, offRadio); + TEST_ASSERT_FALSE(webCommandUsesRadio(web_cmd_t::set_positions)); +} +static void test_set_positions_is_never_held() { + TEST_ASSERT_FALSE(holdWebCommand(web_cmd_t::set_positions, false, false)); + TEST_ASSERT_FALSE(holdWebCommand(web_cmd_t::set_positions, true, false)); +} +static void test_radio_command_waits_for_room() { + for(web_cmd_t t : RADIO_COMMANDS) { + TEST_ASSERT_TRUE(holdWebCommand(t, false, false)); + TEST_ASSERT_FALSE(holdWebCommand(t, false, true)); + } +} +static void test_nothing_is_held_while_suspended() { + // Nothing goes on air during a frequency scan or OTA anyway. + for(web_cmd_t t : RADIO_COMMANDS) { + TEST_ASSERT_FALSE(holdWebCommand(t, true, false)); + TEST_ASSERT_FALSE(holdWebCommand(t, true, true)); + } +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_only_set_positions_stays_off_the_radio); + RUN_TEST(test_set_positions_is_never_held); + RUN_TEST(test_radio_command_waits_for_room); + RUN_TEST(test_nothing_is_held_while_suspended); + return UNITY_END(); +} From 4135f21ee7bacc70e177a92d68e6e206d0bee18a Mon Sep 17 00:00:00 2001 From: cjkas Date: Mon, 12 Oct 2026 20:36:32 +0200 Subject: [PATCH 53/53] refactor: let one RadioScheduler own the radio job queue The order radio jobs go out in was spread across callers, file statics and the radio task: each remote tracked its last back job so a stop could decide whether it may jump the queue, and the task kept a separate count of the back jobs it had taken. RadioScheduler now owns the waiting jobs, their tickets, the stop-goes-first rule, delayed jobs, flush and suspend, and the outcome of every job. It is plain C++, with the time passed in and the caller locking, so all of it is covered by native tests. Transceiver keeps the FreeRTOS task, the locks and the CC1101. sendCommandAfter returns the job's ticket instead of leaving it in lastTxSeq. Two stops for the same remote now go out in the order they were sent; queued to the front they used to come out reversed, putting the rolling codes out of order. Adds GLOSSARY.md for the radio terms. Co-Authored-By: Claude Opus 5.5 --- GLOSSARY.md | 32 ++ platformio.ini | 2 +- src/RadioScheduler.cpp | 130 ++++++ src/RadioScheduler.h | 70 +++ src/Somfy.cpp | 223 ++++------ src/Somfy.h | 28 +- src/TxBuffer.h | 6 +- src/TxQueuePolicy.h | 31 -- .../test_radioscheduler.cpp | 417 ++++++++++++++++++ test/test_txjob/test_txjob.cpp | 117 +++++ test/test_txqueue/test_txqueue.cpp | 216 --------- 11 files changed, 862 insertions(+), 410 deletions(-) create mode 100644 GLOSSARY.md create mode 100644 src/RadioScheduler.cpp create mode 100644 src/RadioScheduler.h delete mode 100644 src/TxQueuePolicy.h create mode 100644 test/test_radioscheduler/test_radioscheduler.cpp create mode 100644 test/test_txjob/test_txjob.cpp delete mode 100644 test/test_txqueue/test_txqueue.cpp diff --git a/GLOSSARY.md b/GLOSSARY.md new file mode 100644 index 0000000..cf91174 --- /dev/null +++ b/GLOSSARY.md @@ -0,0 +1,32 @@ +# Glossary + +Domain terms used in the code, commits and design discussions. + +## Radio + +**Radio job** +One transmission handed to the radio task: an encoded first frame plus a number of repeat frames. A job is either *our own* (a command or sensor frame from one of our remotes, with outcome events) or a *repeater frame* (a frame received from someone else's remote and sent again). A repeater frame reports no events, and since it carries none of our rolling codes it never holds back a stop. + +**RadioScheduler** +The module that owns the radio jobs waiting for, or on, the air. It decides their order, when a delayed job is due, which jobs a flush or suspend drops, and the outcome of every job. Producers hand it jobs and get a ticket back. The radio task asks it for the next job and reports back how the job went. It knows nothing about FreeRTOS, the CC1101 or shades. + +**Ticket** +What submitting a radio job returns: the job's sequence number, 0 when the radio refused it. Every event about the job carries it. A command that went out over GPIO, not the radio, has ticket 0. + +**Back job / front job** +A job normally joins the back of the queue. A *priority* job (a stop) goes to the front, unless an earlier job from the same remote is still waiting. Overtaking that job would put the remote's rolling codes out of order, and the motor would reject the older frame. + +**Delayed job** +A job that must not go on air before a given time (for example the long press that stores a favorite). It holds up the jobs behind it, but front jobs may overtake it while it waits. + +**Flush** +Drops every waiting job (each gets a *dropped* event) and stops the job on air after its current frame (it gets *aborted*). + +**Suspend** +Stops the radio taking jobs for a reason (frequency scan, OTA). Suspending flushes; while suspended, new jobs are refused. + +**Job outcome** +Every job we submit gets exactly one *started* event, unless it is dropped before going on air. It then ends with exactly one of *finished* (every frame went on air), *aborted* (cut short) or *dropped* (never went on air). + +**Rolling code** +The per-remote counter in every Somfy frame. A motor ignores any frame whose code is not newer than the last one it accepted, so a remote's frames must go on air in the order their codes were assigned. diff --git a/platformio.ini b/platformio.ini index a11a081..e422212 100644 --- a/platformio.ini +++ b/platformio.ini @@ -104,7 +104,7 @@ lib_deps = platform = native test_framework = unity test_build_src = yes -build_src_filter = -<*> + + + + + +build_src_filter = -<*> + + + + + + build_flags = -std=gnu++17 -Isrc diff --git a/src/RadioScheduler.cpp b/src/RadioScheduler.cpp new file mode 100644 index 0000000..654bb72 --- /dev/null +++ b/src/RadioScheduler.cpp @@ -0,0 +1,130 @@ +#include "RadioScheduler.h" +#include "esp_log.h" + +// Logged as part of the radio code it came from. +static const char *TAG = "Somfy"; + +#define TX_EVENT_QUEUE_SIZE (TX_JOB_QUEUE_SIZE * 3) + +// Whether a job fits with spaces slots free. Normal jobs leave the reserved +// slots free for stops. +static bool admits(uint32_t spaces, bool priority) { + return spaces > (priority ? 0u : (uint32_t)TX_RESERVED_SLOTS); +} +uint32_t RadioScheduler::submit(somfy_tx_job_t &job, bool priority, uint16_t delayMs, uint32_t now) { + job.seq = 0; + if(this->suspendMask) { + ESP_LOGW(TAG, "Radio TX suspended, dropping transmission"); + return 0; + } + if(!admits(TX_JOB_QUEUE_SIZE - this->count, priority)) { + ESP_LOGE(TAG, "Radio TX queue full, dropping transmission"); + return 0; + } + // 0 means no job so it is skipped on wrap. + if(++this->lastSeq == 0) ++this->lastSeq; + job.seq = this->lastSeq; + job.priority = priority; + job.front = priority && !job.raw && !this->waitingFor(job.frame.remoteAddress); + job.notBefore = 0; + if(delayMs > 0) { + job.notBefore = now + delayMs; + if(job.notBefore == 0) job.notBefore = 1; // 0 means not delayed. + } + // Front jobs go out in the order they came, ahead of the back. + uint8_t at = this->count; + if(job.front) { + at = 0; + while(at < this->count && this->jobs[at].front) at++; + } + for(uint8_t i = this->count; i > at; i--) this->jobs[i] = this->jobs[i - 1]; + this->jobs[at] = job; + this->count++; + return job.seq; +} +bool RadioScheduler::hasRoom(bool priority) const { + return !this->suspendMask && admits(TX_JOB_QUEUE_SIZE - this->count, priority); +} +bool RadioScheduler::waitingFor(uint32_t remoteAddress) const { + for(uint8_t i = 0; i < this->count; i++) { + if(!this->jobs[i].raw && this->jobs[i].frame.remoteAddress == remoteAddress) return true; + } + return false; +} +void RadioScheduler::remove(uint8_t i) { + for(; i + 1 < this->count; i++) this->jobs[i] = this->jobs[i + 1]; + this->count--; +} +bool RadioScheduler::next(uint32_t now, somfy_tx_job_t &job, uint32_t &waitMs) { + waitMs = RADIO_WAIT_FOREVER; + if(this->count == 0) return false; + // Jobs go out in order, so a delayed job holds up the ones behind it. A + // front job queued meanwhile is ahead of it. + const somfy_tx_job_t &head = this->jobs[0]; + if(head.notBefore != 0) { + // Works across a millis() wrap. + const int32_t due = (int32_t)(head.notBefore - now); + if(due > 0) { + waitMs = (uint32_t)due; + return false; + } + } + job = head; + this->remove(0); + this->taken = job.seq; + this->takenFlushed = false; + this->takenJob = job; + return true; +} +bool RadioScheduler::start(uint32_t ticket, uint32_t now) { + if(ticket == 0 || ticket != this->taken) return false; + if(this->takenFlushed) { + // Flushed after the radio task took it. + this->post(tx_event_t::dropped, this->takenJob, now); + this->taken = 0; + return false; + } + this->post(tx_event_t::started, this->takenJob, now); + return true; +} +bool RadioScheduler::keepSending(uint32_t ticket) const { + return ticket != 0 && ticket == this->taken && !this->takenFlushed; +} +void RadioScheduler::ended(uint32_t ticket, bool complete, uint32_t now) { + if(ticket == 0 || ticket != this->taken) return; + this->post(complete ? tx_event_t::finished : tx_event_t::aborted, this->takenJob, now); + this->taken = 0; +} +void RadioScheduler::flush(uint32_t now) { + // Every discarded job is reported as dropped. + for(uint8_t i = 0; i < this->count; i++) this->post(tx_event_t::dropped, this->jobs[i], now); + this->count = 0; + if(this->taken) this->takenFlushed = true; +} +void RadioScheduler::suspend(uint8_t reason, uint32_t now) { + this->suspendMask |= reason; + this->flush(now); +} +void RadioScheduler::resume(uint8_t reason) { this->suspendMask &= ~reason; } +void RadioScheduler::post(tx_event_t type, const somfy_tx_job_t &job, uint32_t at) { + // Repeater frames don't belong to any of our remotes. + if(job.raw) return; + if(this->eventCount >= TX_EVENT_QUEUE_SIZE) { + ESP_LOGW(TAG, "Radio TX event queue full"); + return; + } + somfy_tx_event_t &evt = this->events[(this->eventHead + this->eventCount) % TX_EVENT_QUEUE_SIZE]; + evt.type = type; + evt.cmd = job.frame.cmd; + evt.remoteAddress = job.frame.remoteAddress; + evt.seq = job.seq; + evt.at = at; + this->eventCount++; +} +bool RadioScheduler::popEvent(somfy_tx_event_t &evt) { + if(this->eventCount == 0) return false; + evt = this->events[this->eventHead]; + this->eventHead = (uint8_t)((this->eventHead + 1) % TX_EVENT_QUEUE_SIZE); + this->eventCount--; + return true; +} diff --git a/src/RadioScheduler.h b/src/RadioScheduler.h new file mode 100644 index 0000000..8ce7231 --- /dev/null +++ b/src/RadioScheduler.h @@ -0,0 +1,70 @@ +#ifndef RADIO_SCHEDULER_H +#define RADIO_SCHEDULER_H +#include +#include "TxBuffer.h" + +// Reasons the radio must not transmit. TX resumes once every reason is cleared. +#define TX_SUSPEND_SCAN 0x01 +#define TX_SUSPEND_OTA 0x02 +// No job is waiting so the radio task sleeps until it is woken. +#define RADIO_WAIT_FOREVER 0xFFFFFFFFu + +// The radio jobs waiting for, or on, the air (see GLOSSARY.md). It decides +// their order, when a delayed job is due, what a flush or suspend drops, and +// reports the outcome of every job. Kept free of FreeRTOS and the radio so it +// can be unit tested on the PC (pio test -e native). +// +// Not thread safe. The caller serializes every call and passes the time in. +class RadioScheduler { + public: + // Queues a job. Its ticket comes back and is also set in job.seq, 0 when + // the queue is full or suspended. A priority job (a stop) may use the + // reserved slots and goes ahead of every job queued at the back, unless a + // job of its remote is already waiting: overtaking that would put the + // rolling codes out of order. A delayed job doesn't go on air before + // delayMs from now and holds up the jobs queued behind it. + uint32_t submit(somfy_tx_job_t &job, bool priority, uint16_t delayMs, uint32_t now); + // Whether a job would be taken right now. + bool hasRoom(bool priority) const; + // For the radio task: takes the job to send next. False when there is + // none due, waitMs then says how long until there is (RADIO_WAIT_FOREVER + // when nothing waits). A job taken goes on air through start() and + // ended(). + bool next(uint32_t now, somfy_tx_job_t &job, uint32_t &waitMs); + // The job taken is about to go on air. False when it was flushed since, + // it is then dropped and must not be sent. + bool start(uint32_t ticket, uint32_t now); + // Whether the job on air should send its next frame. A flush cuts it + // short. + bool keepSending(uint32_t ticket) const; + // The job on air stopped, after every frame when complete. + void ended(uint32_t ticket, bool complete, uint32_t now); + // Drops every waiting job and cuts short the one on air. + void flush(uint32_t now); + // Refuses new jobs until every reason (TX_SUSPEND_*) is resumed. Flushes. + void suspend(uint8_t reason, uint32_t now); + void resume(uint8_t reason); + bool suspended() const { return this->suspendMask != 0; } + // Takes the oldest job outcome not yet handed out. Every job submitted + // that isn't a repeater frame is started and then finished or aborted, or + // dropped. + bool popEvent(somfy_tx_event_t &evt); + private: + somfy_tx_job_t jobs[TX_JOB_QUEUE_SIZE]; + uint8_t count = 0; + uint32_t lastSeq = 0; + uint8_t suspendMask = 0; + // The job taken by next() and not yet ended (0 for none), kept for its + // events. + uint32_t taken = 0; + somfy_tx_job_t takenJob; + bool takenFlushed = false; + somfy_tx_event_t events[TX_JOB_QUEUE_SIZE * 3]; + uint8_t eventHead = 0; + uint8_t eventCount = 0; + // Whether a job of the remote is still waiting. + bool waitingFor(uint32_t remoteAddress) const; + void remove(uint8_t i); + void post(tx_event_t type, const somfy_tx_job_t &job, uint32_t at); +}; +#endif diff --git a/src/Somfy.cpp b/src/Somfy.cpp index e7437ab..89a8c2d 100644 --- a/src/Somfy.cpp +++ b/src/Somfy.cpp @@ -12,7 +12,6 @@ #include "ConfigSettings.h" #include "Somfy.h" #include "ShadeMotion.h" -#include "TxQueuePolicy.h" #include "RollingCode.h" #include "Sockets.h" #include "MQTT.h" @@ -49,32 +48,13 @@ uint8_t rxmode = 0; // Indicates whether the radio is in receive mode. Just to // 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; -// Makes queueing a job and flushing the queue atomic with respect to each other -// so a job queueTx() reported as queued can't be silently lost to a flush. +// Serializes every call into the RadioScheduler. The radio task, the main loop +// and the async web server task all use it. static SemaphoreHandle_t txQueueMutex = nullptr; -static QueueHandle_t txJobQueue = nullptr; -static QueueHandle_t txEventQueue = nullptr; static TaskHandle_t txTaskHandle = nullptr; -// Bumped by flushTx(). A job from an older generation that the radio task -// already took off the queue is dropped, and one in flight stops after its -// current frame. -static std::atomic txGen{0}; // Number of tasks waiting for the radio to reconfigure it. A job in flight // stops after its current frame so they never wait for a whole transmission. static std::atomic txPreemptWaiters{0}; -// TX_SUSPEND_* reasons the radio must not transmit for (frequency scan, OTA). -static std::atomic txSuspendMask{0}; -// Last sequence number handed to a queued job. Only touched under TxQueueLock. -static uint32_t txSeq = 0; -// Sequence number of the last job queued at the back that the radio task took -// (a delayed one only once it is due) or a flush dropped. Those jobs go out -// in order so any later one is still waiting in the queue. -static std::atomic txBackTaken{0}; -// The radio task and a flush both move txBackTaken. Never let it go backwards. -static void advanceBackTaken(uint32_t seq) { - uint32_t cur = txBackTaken; - while(txSeqAfter(seq, cur) && !txBackTaken.compare_exchange_weak(cur, seq)) {} -} // How long a shade's simulated movement waits for its command to go on air // before it starts anyway in case the event was lost. #define TX_PENDING_TIMEOUT 30000 @@ -94,17 +74,6 @@ struct TxQueueLock { TxQueueLock() { if(txQueueMutex) xSemaphoreTake(txQueueMutex, portMAX_DELAY); } ~TxQueueLock() { if(txQueueMutex) xSemaphoreGive(txQueueMutex); } }; -static void postTxEvent(tx_event_t type, const somfy_tx_job_t &job, uint32_t at) { - // Repeater frames don't belong to any of our remotes. - if(job.raw || !txEventQueue) return; - somfy_tx_event_t evt; - evt.type = type; - evt.cmd = job.frame.cmd; - evt.remoteAddress = job.frame.remoteAddress; - evt.seq = job.seq; - evt.at = at; - if(xQueueSend(txEventQueue, &evt, 0) != pdTRUE) ESP_LOGW(TAG, "Radio TX event queue full"); -} // Commands that can set a shade moving. static bool startsMovement(somfy_commands cmd) { switch(cmd) { @@ -2353,8 +2322,8 @@ void SomfyShade::processTxEvent(const somfy_tx_event_t &evt) { bool SomfyShade::sendStop(int8_t dir, bool tilt) { const uint32_t now = millis(); // Stops jump the radio queue so the motor stops as close to the target as it can. - if(SomfyRemote::sendCommandAfter(somfy_commands::My, this->repeats, 0, 0, true)) { - this->tx.stopSent(this->lastTxSeq, now, dir, tilt); + if(const send_result_t sent = SomfyRemote::sendCommandAfter(somfy_commands::My, this->repeats, 0, 0, true)) { + this->tx.stopSent(sent.ticket, now, dir, tilt); return true; } ESP_LOGW(TAG, "[Shade %u] Stop not sent, running on to the limit", this->shadeId); @@ -2364,10 +2333,10 @@ bool SomfyShade::sendStop(int8_t dir, bool tilt) { bool SomfyShade::sendSetMy(uint16_t delayMs, float oldPos, float oldTiltPos) { // The new favorite is already in myPos/myTiltPos. It is committed once the // whole long press is on air, see processTxEvent. - if(SomfyRemote::sendCommandAfter(somfy_commands::My, SETMY_REPEATS, 0, delayMs)) { - this->tx.setMySent(this->lastTxSeq, oldPos, oldTiltPos); + if(const send_result_t sent = SomfyRemote::sendCommandAfter(somfy_commands::My, SETMY_REPEATS, 0, delayMs)) { + this->tx.setMySent(sent.ticket, oldPos, oldTiltPos); // Without the radio (GPIO remotes) there is nothing to wait for. - if(this->lastTxSeq == 0) this->commitMyPosition(); + if(sent.ticket == 0) this->commitMyPosition(); return true; } // The motor never learned it so keep the favorite it has. @@ -3481,8 +3450,8 @@ bool SomfyRemote::sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repe return true; } bool SomfyRemote::sendCommand(somfy_commands cmd) { return this->sendCommand(cmd, this->repeats); } -bool SomfyRemote::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { return this->sendCommandAfter(cmd, repeat, stepSize, 0); } -bool SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority) { +bool SomfyRemote::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { return this->sendCommandAfter(cmd, repeat, stepSize, 0).sent; } +send_result_t SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority) { // Build the frame aside and only take the rolling code once the frame is // queued so a dropped command leaves the remote untouched. somfy_frame_t frame = this->lastFrame; @@ -3501,7 +3470,7 @@ bool SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t s // We have to set the processed to clear this if we are sending // another command. frame.processed = false; - uint32_t seq = 0; + send_result_t result; if(this->proto == radio_proto::GP_Relay) { ESP_LOGD(TAG, "CMD:%s ADDR:%d RCODE:%d SETTING GPIO", translateSomfyCommand(frame.cmd).c_str(), @@ -3517,19 +3486,18 @@ bool SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t s ESP_LOGD(TAG, "CMD:%s ADDR:%d RCODE:%d REPEAT:%d", translateSomfyCommand(frame.cmd).c_str(), frame.remoteAddress, frame.rollingCode, repeat); - const bool front = stopGoesToFront(priority, somfy.transceiver.isQueued(this->lastBackTxSeq)); // Only simulate the command if it is actually going on air. - if(!somfy.sendFrame(frame, repeat, delayMs, priority, front, &seq)) return false; - if(!front) this->lastBackTxSeq = seq; + result.ticket = somfy.sendFrame(frame, repeat, delayMs, priority); + if(result.ticket == 0) return result; } + result.sent = true; this->commitRollingCode(frame.rollingCode); this->lastFrame = frame; - this->lastTxSeq = seq; somfy.processFrame(this->lastFrame, true); // Idle shades (and group members) this sets moving wait for the frame to go // on air. Stops are sent ahead of everything else so they don't need to. - if(seq != 0 && !priority && startsMovement(frame.cmd)) somfy.markTxPending(frame.remoteAddress, seq); - return true; + if(result.ticket != 0 && !priority && startsMovement(frame.cmd)) somfy.markTxPending(frame.remoteAddress, result.ticket); + return result; } bool SomfyRemote::isLastCommand(somfy_commands cmd) { if(this->lastFrame.cmd != cmd || this->lastFrame.rollingCode != this->lastRollingCode) { @@ -3547,22 +3515,16 @@ bool SomfyRemote::repeatFrame(uint8_t repeat) { } somfy_tx_job_t job; encodeRepeatJob(job, this->lastFrame, repeat); - if(!somfy.transceiver.queueTx(job)) return false; - this->lastBackTxSeq = job.seq; + if(!somfy.transceiver.submitTx(job)) return false; noteRepeatQueued(this->lastFrame, job); //somfy.processFrame(this->lastFrame, true); return true; } -bool SomfyShadeController::sendFrame(somfy_frame_t &frame, uint8_t repeat, uint16_t delayMs, bool priority, bool front, uint32_t *seq) { +uint32_t SomfyShadeController::sendFrame(somfy_frame_t &frame, uint8_t repeat, uint16_t delayMs, bool priority) { somfy_tx_job_t job; encodeTxJob(job, frame, repeat); job.firstRepeat = 1; - job.priority = priority; - job.front = priority && front; - if(delayMs > 0) job.notBefore = millis() + delayMs; - if(!this->transceiver.queueTx(job)) return false; - if(seq) *seq = job.seq; - return true; + return this->transceiver.submitTx(job, priority, delayMs); } bool SomfyShadeController::deleteShade(uint8_t shadeId) { for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { @@ -4598,7 +4560,11 @@ bool Transceiver::begin() { void Transceiver::loop() { somfy_rx_t rx; somfy_tx_event_t evt; - while(txEventQueue && xQueueReceive(txEventQueue, &evt, 0) == pdTRUE) { + for(;;) { + { + TxQueueLock lock; + if(!this->sched.popEvent(evt)) break; + } if(evt.type == tx_event_t::dropped) ESP_LOGW(TAG, "Radio TX dropped %s for %d", translateSomfyCommand(evt.cmd).c_str(), evt.remoteAddress); else if(evt.type == tx_event_t::aborted) @@ -4658,7 +4624,7 @@ void Transceiver::loop() { snprintf(frame_buf + fpos, sizeof(frame_buf) - fpos, "]"); ESP_LOGD(TAG, "%s", frame_buf); } - if(this->queueTx(job) || this->txSuspended()) { + if(this->submitTx(job) || this->txSuspended()) { somfy_tx_t sent; tx_queue.pop(&sent); } @@ -4666,63 +4632,50 @@ void Transceiver::loop() { } } } -bool Transceiver::queueTx(somfy_tx_job_t &job) { - if(!txJobQueue || !txTaskHandle) { +uint32_t Transceiver::submitTx(somfy_tx_job_t &job, bool priority, uint16_t delayMs) { + if(!txTaskHandle) { ESP_LOGE(TAG, "Radio task not started, dropping transmission"); - return false; + return 0; } + uint32_t ticket; + { + TxQueueLock lock; + ticket = this->sched.submit(job, priority, delayMs, millis()); + } + // The radio task may be idle, or waiting on a delayed job this one is ahead of. + if(ticket) xTaskNotifyGive(txTaskHandle); + return ticket; +} +bool Transceiver::txSuspended() { TxQueueLock lock; - if(txSuspendMask) { - ESP_LOGW(TAG, "Radio TX suspended, dropping transmission"); - return false; - } - if(!txAdmits(uxQueueSpacesAvailable(txJobQueue), job.priority)) { - ESP_LOGE(TAG, "Radio TX queue full, dropping transmission"); - return false; - } - job.gen = txGen; - txSeq = nextTxSeq(txSeq); - job.seq = txSeq; - // We are the only producer while holding the lock so the space check holds. - if(job.front) { - xQueueSendToFront(txJobQueue, &job, 0); - // It may overtake a delayed job the radio task is waiting on. - xTaskNotifyGive(txTaskHandle); - } - else xQueueSendToBack(txJobQueue, &job, 0); - return true; + return this->sched.suspended(); } -bool Transceiver::isQueued(uint32_t seq) { - // Only meaningful for jobs queued at the back. - return txSeqQueued(seq, txBackTaken); -} -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 txAdmits(uxQueueSpacesAvailable(txJobQueue), false); + // Without a radio task submitTx() fails straight away so there is no point waiting. + if(!txTaskHandle) return true; + TxQueueLock lock; + return this->sched.hasRoom(false); } void Transceiver::flushTx() { - TxQueueLock lock; - ++txGen; - advanceBackTaken(txSeq); - if(!txJobQueue) return; - // Drain rather than reset so every discarded job is reported as dropped. - somfy_tx_job_t job; - const uint32_t now = millis(); - while(xQueueReceive(txJobQueue, &job, 0) == pdTRUE) postTxEvent(tx_event_t::dropped, job, now); - // Drop a delayed job the radio task is waiting on now rather than when it is due. + { + TxQueueLock lock; + this->sched.flush(millis()); + } + // Stop waiting on a delayed job that is gone. if(txTaskHandle) xTaskNotifyGive(txTaskHandle); } void Transceiver::suspendTx(uint8_t reason) { { - // Set under the queue lock so nothing is queued between this and the flush. + // Suspends and flushes in one go so nothing is queued in between. TxQueueLock lock; - txSuspendMask |= reason; + this->sched.suspend(reason, millis()); } - this->flushTx(); + if(txTaskHandle) xTaskNotifyGive(txTaskHandle); +} +void Transceiver::resumeTx(uint8_t reason) { + TxQueueLock lock; + this->sched.resume(reason); } -void Transceiver::resumeTx(uint8_t reason) { txSuspendMask &= ~(uint32_t)reason; } // cpt_synchro_hw is updated by the RX interrupt and read here from the radio task. static bool rxInProgress() { return *(volatile uint8_t *)&somfy_rx.cpt_synchro_hw != 0; } bool Transceiver::transmitJob(somfy_tx_job_t &job) { @@ -4731,9 +4684,14 @@ bool Transceiver::transmitJob(somfy_tx_job_t &job) { this->beginTransmit(); this->sendFrame(tx.payload, tx.hwsync, tx.bit_length); for(uint8_t i = 0; i < job.repeats; i++) { + bool keep; + { + TxQueueLock lock; + keep = this->sched.keepSending(job.seq); + } // Stop early when flushed or when someone is waiting to reconfigure the // radio so they get it within a frame. - if(job.gen != txGen || txPreemptWaiters > 0) { + if(!keep || txPreemptWaiters > 0) { complete = false; break; } @@ -4753,66 +4711,37 @@ void Transceiver::runJob(somfy_tx_job_t &job) { } { RadioLock lock; - // The queue may have been flushed while we waited. - if(txSuspendMask || job.gen != txGen) { - postTxEvent(tx_event_t::dropped, job, millis()); - return; + bool start; + { + // The queue may have been flushed while we waited. + TxQueueLock qlock; + start = this->sched.start(job.seq, millis()); } - postTxEvent(tx_event_t::started, job, millis()); + if(!start) return; const bool complete = this->transmitJob(job); - postTxEvent(complete ? tx_event_t::finished : tx_event_t::aborted, job, millis()); + TxQueueLock qlock; + this->sched.ended(job.seq, complete, millis()); } // Give the motors a full frame beat between separate transmissions. vTaskDelay(pdMS_TO_TICKS(TX_QUEUE_DELAY)); } -void Transceiver::waitUntilDue(const somfy_tx_job_t &job) { - for(;;) { - if(txSuspendMask || job.gen != txGen) return; - const int32_t wait = txDueInMs(job.notBefore, millis()); - if(wait <= 0) return; - // A stop only goes to the front when its remote has nothing waiting, and - // this job still counts as waiting, so any front job is free to go first. - somfy_tx_job_t next; - bool haveNext = false; - { - TxQueueLock lock; - if(xQueuePeek(txJobQueue, &next, 0) == pdTRUE && next.front) - haveNext = xQueueReceive(txJobQueue, &next, 0) == pdTRUE; - } - if(haveNext) { - this->runJob(next); - continue; - } - // queueTx() and flushTx() wake us for a new front job or a flush. - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(wait)); - } -} void Transceiver::txTask(void *arg) { Transceiver *trans = static_cast(arg); somfy_tx_job_t job; for(;;) { - if(xQueueReceive(txJobQueue, &job, portMAX_DELAY) != pdTRUE) continue; - if(job.notBefore != 0) trans->waitUntilDue(job); - // Only now is a delayed job off the queue as far as isQueued() is - // concerned, so a stop for its remote queues behind it while it waits. - if(!job.front) advanceBackTaken(job.seq); - // Flushed after we took it off the queue. - if(txSuspendMask || job.gen != txGen) { - postTxEvent(tx_event_t::dropped, job, millis()); - continue; + uint32_t waitMs; + bool haveJob; + { + TxQueueLock lock; + haveJob = trans->sched.next(millis(), job, waitMs); } - trans->runJob(job); + if(haveJob) trans->runJob(job); + // submitTx() and flushTx() wake us for a new job or a flush. + else ulTaskNotifyTake(pdTRUE, waitMs == RADIO_WAIT_FOREVER ? portMAX_DELAY : pdMS_TO_TICKS(waitMs) + 1); } } void Transceiver::startTxTask() { if(txTaskHandle) return; - if(!txJobQueue) txJobQueue = xQueueCreate(TX_JOB_QUEUE_SIZE, sizeof(somfy_tx_job_t)); - // Every job reports when it starts and ends, plus room for the jobs a flush drains. - if(!txEventQueue) txEventQueue = xQueueCreate(TX_JOB_QUEUE_SIZE * 3, sizeof(somfy_tx_event_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 diff --git a/src/Somfy.h b/src/Somfy.h index d7ead1a..bf3e6a5 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -23,6 +23,7 @@ #include "SomfyFrame.h" #include "TxBuffer.h" +#include "RadioScheduler.h" #include "ShadeMotion.h" enum class group_types : byte { channel = 0x00 @@ -115,9 +116,6 @@ struct somfy_relay_t { uint8_t sync = 0; byte frame[10] = {0}; }; -// Reasons the radio must not transmit. TX resumes once every reason is cleared. -#define TX_SUSPEND_SCAN 0x01 -#define TX_SUSPEND_OTA 0x02 class SomfyRoom { public: @@ -133,6 +131,13 @@ class SomfyRoom { void unpublish(); }; +// What sending a command did. ticket is the radio job carrying it, 0 when the +// command went out over GPIO instead. +struct send_result_t { + bool sent = false; + uint32_t ticket = 0; + explicit operator bool() const { return this->sent; } +}; class SomfyRemote { // These sizes for the data have been // confirmed. The address is actually 24bits @@ -154,8 +159,6 @@ class SomfyRemote { somfy_frame_t lastFrame; bool flipCommands = false; uint16_t lastRollingCode = 0; - uint32_t lastTxSeq = 0; // Radio job of the last command sent, 0 if it didn't use the radio. - uint32_t lastBackTxSeq = 0; // Last radio job queued at the back of the queue. uint8_t flags = 0; uint8_t bitLength = 0; uint8_t repeats = 1; @@ -176,7 +179,7 @@ class SomfyRemote { // command. Nothing about the remote or shade state has changed in that case. virtual bool sendCommand(somfy_commands cmd); virtual bool sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0); - bool sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority = false); + send_result_t sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority = false); bool sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repeat); bool repeatFrame(uint8_t repeat); virtual uint16_t p_lastRollingCode(uint16_t code); @@ -426,13 +429,12 @@ class Transceiver { static void txTask(void *arg); bool _received = false; somfy_frame_t frame; + // Only touched under the TX queue lock. + RadioScheduler sched; void startTxTask(); bool transmitJob(somfy_tx_job_t &job); // Sends a job taken off the queue and leaves a frame beat after it. void runJob(somfy_tx_job_t &job); - // Holds a delayed job until it is due, sending stops queued at the front - // in the meantime. - void waitUntilDue(const somfy_tx_job_t &job); public: transceiver_config_t config; bool printBuffer = false; @@ -449,11 +451,12 @@ class Transceiver { void disableReceive(); somfy_frame_t& lastFrame(); void sendFrame(byte *frame, uint8_t sync, uint8_t bitLength = 56); - bool queueTx(somfy_tx_job_t &job); + // Hands a job to the radio task, see RadioScheduler::submit(). Returns its + // ticket, 0 when the radio can't take it. Safe from any task. + uint32_t submitTx(somfy_tx_job_t &job, bool priority = false, uint16_t delayMs = 0); void flushTx(); void suspendTx(uint8_t reason); void resumeTx(uint8_t reason); - bool isQueued(uint32_t seq); bool txSuspended(); bool txHasRoom(); void beginTransmit(); @@ -511,7 +514,8 @@ class SomfyShadeController { SomfyGroup * getGroupById(uint8_t groupId); SomfyShade * findShadeByRemoteAddress(uint32_t address); SomfyGroup * findGroupByRemoteAddress(uint32_t address); - bool sendFrame(somfy_frame_t &frame, uint8_t repeats = 0, uint16_t delayMs = 0, bool priority = false, bool front = false, uint32_t *seq = nullptr); + // Returns the radio job's ticket, 0 when the radio can't take it. + uint32_t sendFrame(somfy_frame_t &frame, uint8_t repeats = 0, uint16_t delayMs = 0, bool priority = false); void processFrame(somfy_frame_t &frame, bool internal = false); void markTxPending(uint32_t remoteAddress, uint32_t seq); void processTxEvent(const somfy_tx_event_t &evt); diff --git a/src/TxBuffer.h b/src/TxBuffer.h index 9289d95..9f4cc74 100644 --- a/src/TxBuffer.h +++ b/src/TxBuffer.h @@ -58,10 +58,10 @@ struct somfy_tx_job_t { 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; // Repeater frame. Waits for the receiver to be idle. + // Set by RadioScheduler::submit(). bool priority = false; // May use the reserved slots. - bool front = false; // Goes to the front of the queue. Only for priority jobs. - uint32_t gen = 0; // Flush generation when queued. Stale jobs are dropped. - uint32_t seq = 0; // Assigned when queued so the job's events can be matched up. + bool front = false; // Queued ahead of the back. Only for priority jobs. + uint32_t seq = 0; // Ticket the job's events are matched up by. uint32_t notBefore = 0; // millis() before which the job must not go on air (0 = none). }; enum class tx_event_t : uint8_t { diff --git a/src/TxQueuePolicy.h b/src/TxQueuePolicy.h deleted file mode 100644 index 2f31963..0000000 --- a/src/TxQueuePolicy.h +++ /dev/null @@ -1,31 +0,0 @@ -#ifndef TX_QUEUE_POLICY_H -#define TX_QUEUE_POLICY_H -#include -#include "TxBuffer.h" - -// The rules the radio TX queue follows (src/Somfy.cpp). Kept free of FreeRTOS -// so they can be unit tested on the PC (pio test -e native). - -// Whether a job fits with spaces slots free. Normal jobs leave the reserved -// slots free for stops. -inline bool txAdmits(uint32_t spaces, bool priority) { - return spaces > (priority ? 0u : (uint32_t)TX_RESERVED_SLOTS); -} -// The sequence number after seq. 0 means no job so it is skipped on wrap. -inline uint32_t nextTxSeq(uint32_t seq) { - if(++seq == 0) ++seq; - return seq; -} -// Whether job seq was queued after job cur, allowing for wrap. -inline bool txSeqAfter(uint32_t seq, uint32_t cur) { return (int32_t)(seq - cur) > 0; } -// Whether a job queued at the back is still waiting, given taken is the last -// back job the radio task took or a flush dropped. Those go out in order. -inline bool txSeqQueued(uint32_t seq, uint32_t taken) { return seq != 0 && txSeqAfter(seq, taken); } -// A stop jumps the queue unless an earlier frame of its remote is still -// waiting. Overtaking it would put the rolling codes out of order and the -// motor would reject the older one. -inline bool stopGoesToFront(bool priority, bool lastBackQueued) { return priority && !lastBackQueued; } -// Milliseconds until a delayed job is due, 0 or less once it is. Works -// across a millis() wrap. -inline int32_t txDueInMs(uint32_t notBefore, uint32_t now) { return (int32_t)(notBefore - now); } -#endif diff --git a/test/test_radioscheduler/test_radioscheduler.cpp b/test/test_radioscheduler/test_radioscheduler.cpp new file mode 100644 index 0000000..9f29e36 --- /dev/null +++ b/test/test_radioscheduler/test_radioscheduler.cpp @@ -0,0 +1,417 @@ +#include +#include "RadioScheduler.h" + +static const uint32_t REMOTE_A = 0x111111; +static const uint32_t REMOTE_B = 0x222222; + +void setUp() {} +void tearDown() {} + +static somfy_tx_job_t makeJob(uint32_t remoteAddress, somfy_commands cmd = somfy_commands::Up) { + somfy_tx_job_t job; + job.frame.remoteAddress = remoteAddress; + job.frame.cmd = cmd; + job.repeats = 2; + return job; +} +static uint32_t submit(RadioScheduler &s, uint32_t remoteAddress, uint32_t now = 0, bool priority = false, uint16_t delayMs = 0) { + somfy_tx_job_t job = makeJob(remoteAddress, priority ? somfy_commands::My : somfy_commands::Up); + return s.submit(job, priority, delayMs, now); +} +// Takes the next job and sends all of it, as the radio task does. Returns its +// ticket, 0 when none is due. +static uint32_t runNext(RadioScheduler &s, uint32_t now = 0) { + somfy_tx_job_t job; + uint32_t waitMs; + if(!s.next(now, job, waitMs)) return 0; + if(!s.start(job.seq, now)) return job.seq; + s.ended(job.seq, true, now); + return job.seq; +} +static void assertEvent(RadioScheduler &s, tx_event_t type, uint32_t ticket) { + somfy_tx_event_t evt; + TEST_ASSERT_TRUE_MESSAGE(s.popEvent(evt), "expected another event"); + TEST_ASSERT_EQUAL_UINT8((uint8_t)type, (uint8_t)evt.type); + TEST_ASSERT_EQUAL_UINT32(ticket, evt.seq); +} +static void assertNoEvents(RadioScheduler &s) { + somfy_tx_event_t evt; + TEST_ASSERT_FALSE(s.popEvent(evt)); +} +static void discardEvents(RadioScheduler &s) { + somfy_tx_event_t evt; + while(s.popEvent(evt)) {} +} + +/********************************************************************* + * Order + ********************************************************************/ +static void test_jobs_go_out_in_the_order_they_came() { + RadioScheduler s; + const uint32_t a = submit(s, REMOTE_A); + const uint32_t b = submit(s, REMOTE_B); + const uint32_t c = submit(s, REMOTE_A); + TEST_ASSERT_NOT_EQUAL(0, a); + TEST_ASSERT_EQUAL_UINT32(a, runNext(s)); + TEST_ASSERT_EQUAL_UINT32(b, runNext(s)); + TEST_ASSERT_EQUAL_UINT32(c, runNext(s)); + TEST_ASSERT_EQUAL_UINT32(0, runNext(s)); +} +static void test_tickets_are_distinct_and_set_in_the_job() { + RadioScheduler s; + somfy_tx_job_t job = makeJob(REMOTE_A); + const uint32_t a = s.submit(job, false, 0, 0); + TEST_ASSERT_EQUAL_UINT32(a, job.seq); + TEST_ASSERT_NOT_EQUAL(a, submit(s, REMOTE_A)); +} +static void test_nothing_waiting_sleeps_until_woken() { + RadioScheduler s; + somfy_tx_job_t job; + uint32_t waitMs = 0; + TEST_ASSERT_FALSE(s.next(0, job, waitMs)); + TEST_ASSERT_EQUAL_UINT32(RADIO_WAIT_FOREVER, waitMs); +} + +/********************************************************************* + * Stops (priority jobs) + ********************************************************************/ +static void test_stop_goes_ahead_of_other_remotes() { + RadioScheduler s; + const uint32_t b1 = submit(s, REMOTE_B); + const uint32_t b2 = submit(s, REMOTE_B); + const uint32_t stop = submit(s, REMOTE_A, 0, true); + TEST_ASSERT_EQUAL_UINT32(stop, runNext(s)); + TEST_ASSERT_EQUAL_UINT32(b1, runNext(s)); + TEST_ASSERT_EQUAL_UINT32(b2, runNext(s)); +} +static void test_stop_queues_behind_its_own_remote() { + // Overtaking the remote's waiting frame would send the rolling codes out of + // order and the motor would reject the older one. + RadioScheduler s; + const uint32_t up = submit(s, REMOTE_A); + const uint32_t b = submit(s, REMOTE_B); + const uint32_t stop = submit(s, REMOTE_A, 0, true); + TEST_ASSERT_EQUAL_UINT32(up, runNext(s)); + TEST_ASSERT_EQUAL_UINT32(b, runNext(s)); + TEST_ASSERT_EQUAL_UINT32(stop, runNext(s)); +} +static void test_stop_goes_ahead_once_its_remote_is_on_air() { + RadioScheduler s; + const uint32_t up = submit(s, REMOTE_A); + const uint32_t b = submit(s, REMOTE_B); + somfy_tx_job_t job; + uint32_t waitMs; + TEST_ASSERT_TRUE(s.next(0, job, waitMs)); + TEST_ASSERT_EQUAL_UINT32(up, job.seq); + // The remote's frame is already going out so the stop can't overtake it. + const uint32_t stop = submit(s, REMOTE_A, 0, true); + s.start(up, 0); + s.ended(up, true, 0); + TEST_ASSERT_EQUAL_UINT32(stop, runNext(s)); + TEST_ASSERT_EQUAL_UINT32(b, runNext(s)); +} +static void test_stops_go_out_in_the_order_they_came() { + RadioScheduler s; + const uint32_t back = submit(s, REMOTE_B); + const uint32_t stopA = submit(s, REMOTE_A, 0, true); + const uint32_t stopC = submit(s, 0x333333, 0, true); + TEST_ASSERT_EQUAL_UINT32(stopA, runNext(s)); + TEST_ASSERT_EQUAL_UINT32(stopC, runNext(s)); + TEST_ASSERT_EQUAL_UINT32(back, runNext(s)); +} +static void test_second_stop_of_a_remote_stays_behind_the_first() { + RadioScheduler s; + const uint32_t back = submit(s, REMOTE_B); + const uint32_t first = submit(s, REMOTE_A, 0, true); + const uint32_t second = submit(s, REMOTE_A, 0, true); + TEST_ASSERT_EQUAL_UINT32(first, runNext(s)); + TEST_ASSERT_EQUAL_UINT32(back, runNext(s)); + TEST_ASSERT_EQUAL_UINT32(second, runNext(s)); +} + +/********************************************************************* + * Room + ********************************************************************/ +static void test_normal_jobs_leave_the_reserved_slots() { + RadioScheduler s; + for(uint8_t i = 0; i < TX_JOB_QUEUE_SIZE - TX_RESERVED_SLOTS; i++) + TEST_ASSERT_NOT_EQUAL(0, submit(s, REMOTE_B)); + TEST_ASSERT_FALSE(s.hasRoom(false)); + TEST_ASSERT_TRUE(s.hasRoom(true)); + TEST_ASSERT_EQUAL_UINT32(0, submit(s, REMOTE_B)); +} +static void test_stops_use_the_reserved_slots() { + RadioScheduler s; + for(uint8_t i = 0; i < TX_JOB_QUEUE_SIZE - TX_RESERVED_SLOTS; i++) submit(s, REMOTE_B); + for(uint8_t i = 0; i < TX_RESERVED_SLOTS; i++) + TEST_ASSERT_NOT_EQUAL(0, submit(s, REMOTE_A, 0, true)); + TEST_ASSERT_FALSE(s.hasRoom(true)); + TEST_ASSERT_EQUAL_UINT32(0, submit(s, REMOTE_A, 0, true)); +} +static void test_a_refused_job_has_no_ticket() { + RadioScheduler s; + for(uint8_t i = 0; i < TX_JOB_QUEUE_SIZE - TX_RESERVED_SLOTS; i++) submit(s, REMOTE_B); + somfy_tx_job_t job = makeJob(REMOTE_A); + job.seq = 99; + TEST_ASSERT_EQUAL_UINT32(0, s.submit(job, false, 0, 0)); + TEST_ASSERT_EQUAL_UINT32(0, job.seq); +} +static void test_taking_a_job_frees_its_slot() { + RadioScheduler s; + for(uint8_t i = 0; i < TX_JOB_QUEUE_SIZE - TX_RESERVED_SLOTS; i++) submit(s, REMOTE_B); + runNext(s); + TEST_ASSERT_TRUE(s.hasRoom(false)); +} + +/********************************************************************* + * Delayed jobs + ********************************************************************/ +static void test_delayed_job_waits_until_due() { + RadioScheduler s; + const uint32_t a = submit(s, REMOTE_A, 1000, false, 500); + somfy_tx_job_t job; + uint32_t waitMs; + TEST_ASSERT_FALSE(s.next(1000, job, waitMs)); + TEST_ASSERT_EQUAL_UINT32(500, waitMs); + TEST_ASSERT_FALSE(s.next(1400, job, waitMs)); + TEST_ASSERT_EQUAL_UINT32(100, waitMs); + TEST_ASSERT_TRUE(s.next(1500, job, waitMs)); + TEST_ASSERT_EQUAL_UINT32(a, job.seq); +} +static void test_delayed_job_holds_up_the_jobs_behind_it() { + RadioScheduler s; + const uint32_t a = submit(s, REMOTE_A, 0, false, 500); + const uint32_t b = submit(s, REMOTE_B); + TEST_ASSERT_EQUAL_UINT32(0, runNext(s, 100)); + TEST_ASSERT_EQUAL_UINT32(a, runNext(s, 500)); + TEST_ASSERT_EQUAL_UINT32(b, runNext(s, 500)); +} +static void test_stop_overtakes_a_delayed_job_of_another_remote() { + RadioScheduler s; + const uint32_t a = submit(s, REMOTE_A, 0, false, 500); + const uint32_t stop = submit(s, REMOTE_B, 100, true); + TEST_ASSERT_EQUAL_UINT32(stop, runNext(s, 100)); + TEST_ASSERT_EQUAL_UINT32(0, runNext(s, 200)); + TEST_ASSERT_EQUAL_UINT32(a, runNext(s, 500)); +} +static void test_stop_waits_behind_its_remotes_delayed_job() { + // The favorite's long press holds a rolling code, so the stop can't go first. + RadioScheduler s; + const uint32_t setMy = submit(s, REMOTE_A, 0, false, 500); + const uint32_t stop = submit(s, REMOTE_A, 100, true); + TEST_ASSERT_EQUAL_UINT32(0, runNext(s, 100)); + TEST_ASSERT_EQUAL_UINT32(setMy, runNext(s, 500)); + TEST_ASSERT_EQUAL_UINT32(stop, runNext(s, 500)); +} +static void test_delay_across_millis_wrap() { + RadioScheduler s; + const uint32_t a = submit(s, REMOTE_A, 0xFFFFFF00u, false, 0x200); + somfy_tx_job_t job; + uint32_t waitMs; + TEST_ASSERT_FALSE(s.next(0xFFFFFF00u, job, waitMs)); + TEST_ASSERT_EQUAL_UINT32(0x200, waitMs); + TEST_ASSERT_FALSE(s.next(0x50, job, waitMs)); + TEST_ASSERT_EQUAL_UINT32(0xB0, waitMs); + TEST_ASSERT_TRUE(s.next(0x100, job, waitMs)); + TEST_ASSERT_EQUAL_UINT32(a, job.seq); +} + +/********************************************************************* + * Outcomes + ********************************************************************/ +static void test_job_reports_started_then_finished() { + RadioScheduler s; + const uint32_t a = submit(s, REMOTE_A); + somfy_tx_job_t job; + uint32_t waitMs; + s.next(10, job, waitMs); + TEST_ASSERT_TRUE(s.start(a, 20)); + TEST_ASSERT_TRUE(s.keepSending(a)); + s.ended(a, true, 300); + somfy_tx_event_t evt; + TEST_ASSERT_TRUE(s.popEvent(evt)); + TEST_ASSERT_EQUAL_UINT8((uint8_t)tx_event_t::started, (uint8_t)evt.type); + TEST_ASSERT_EQUAL_UINT32(a, evt.seq); + TEST_ASSERT_EQUAL_UINT32(20, evt.at); + TEST_ASSERT_EQUAL_UINT32(REMOTE_A, evt.remoteAddress); + TEST_ASSERT_EQUAL_UINT8((uint8_t)somfy_commands::Up, (uint8_t)evt.cmd); + TEST_ASSERT_TRUE(s.popEvent(evt)); + TEST_ASSERT_EQUAL_UINT8((uint8_t)tx_event_t::finished, (uint8_t)evt.type); + TEST_ASSERT_EQUAL_UINT32(300, evt.at); + assertNoEvents(s); +} +static void test_cut_short_job_reports_aborted() { + RadioScheduler s; + const uint32_t a = submit(s, REMOTE_A); + somfy_tx_job_t job; + uint32_t waitMs; + s.next(0, job, waitMs); + s.start(a, 0); + s.ended(a, false, 0); + assertEvent(s, tx_event_t::started, a); + assertEvent(s, tx_event_t::aborted, a); + assertNoEvents(s); +} +static void test_flush_drops_every_waiting_job_once() { + RadioScheduler s; + const uint32_t a = submit(s, REMOTE_A); + const uint32_t stop = submit(s, REMOTE_B, 0, true); + const uint32_t delayed = submit(s, REMOTE_A, 0, false, 5000); + s.flush(0); + assertEvent(s, tx_event_t::dropped, stop); + assertEvent(s, tx_event_t::dropped, a); + assertEvent(s, tx_event_t::dropped, delayed); + assertNoEvents(s); + TEST_ASSERT_EQUAL_UINT32(0, runNext(s, 5000)); +} +static void test_flush_cuts_short_the_job_on_air() { + RadioScheduler s; + const uint32_t a = submit(s, REMOTE_A); + somfy_tx_job_t job; + uint32_t waitMs; + s.next(0, job, waitMs); + s.start(a, 0); + s.flush(0); + TEST_ASSERT_FALSE(s.keepSending(a)); + s.ended(a, false, 0); + assertEvent(s, tx_event_t::started, a); + assertEvent(s, tx_event_t::aborted, a); + assertNoEvents(s); +} +static void test_job_flushed_before_it_starts_is_dropped() { + // The radio task took it off the queue, then a flush came before it got the + // radio. + RadioScheduler s; + const uint32_t a = submit(s, REMOTE_A); + somfy_tx_job_t job; + uint32_t waitMs; + s.next(0, job, waitMs); + s.flush(0); + TEST_ASSERT_FALSE(s.start(a, 0)); + assertEvent(s, tx_event_t::dropped, a); + assertNoEvents(s); +} +static void test_every_job_gets_one_outcome_across_a_flush() { + RadioScheduler s; + uint32_t tickets[6]; + for(uint8_t i = 0; i < 3; i++) tickets[i] = submit(s, REMOTE_A + i); + somfy_tx_job_t job; + uint32_t waitMs; + s.next(0, job, waitMs); + s.start(job.seq, 0); + s.flush(0); + // Jobs queued after the flush are unaffected by it. + for(uint8_t i = 3; i < 6; i++) tickets[i] = submit(s, REMOTE_B + i); + s.ended(job.seq, s.keepSending(job.seq), 0); + while(runNext(s)) {} + uint8_t outcomes[6] = {}; + somfy_tx_event_t evt; + while(s.popEvent(evt)) { + if(evt.type == tx_event_t::started) continue; + for(uint8_t i = 0; i < 6; i++) + if(tickets[i] == evt.seq) outcomes[i]++; + } + for(uint8_t i = 0; i < 6; i++) TEST_ASSERT_EQUAL_UINT8(1, outcomes[i]); +} +static void test_unknown_ticket_is_ignored() { + RadioScheduler s; + TEST_ASSERT_FALSE(s.keepSending(0)); + TEST_ASSERT_FALSE(s.keepSending(42)); + TEST_ASSERT_FALSE(s.start(42, 0)); + s.ended(42, true, 0); + assertNoEvents(s); +} + +/********************************************************************* + * Suspend + ********************************************************************/ +static void test_suspend_flushes_and_refuses_jobs() { + RadioScheduler s; + const uint32_t a = submit(s, REMOTE_A); + s.suspend(TX_SUSPEND_SCAN, 0); + TEST_ASSERT_TRUE(s.suspended()); + assertEvent(s, tx_event_t::dropped, a); + TEST_ASSERT_FALSE(s.hasRoom(true)); + TEST_ASSERT_EQUAL_UINT32(0, submit(s, REMOTE_A)); + TEST_ASSERT_EQUAL_UINT32(0, submit(s, REMOTE_A, 0, true)); + assertNoEvents(s); +} +static void test_resume_needs_every_reason_cleared() { + RadioScheduler s; + s.suspend(TX_SUSPEND_SCAN, 0); + s.suspend(TX_SUSPEND_OTA, 0); + s.resume(TX_SUSPEND_SCAN); + TEST_ASSERT_TRUE(s.suspended()); + TEST_ASSERT_EQUAL_UINT32(0, submit(s, REMOTE_A)); + s.resume(TX_SUSPEND_OTA); + TEST_ASSERT_FALSE(s.suspended()); + TEST_ASSERT_NOT_EQUAL(0, submit(s, REMOTE_A)); +} + +/********************************************************************* + * Repeater frames + ********************************************************************/ +static void test_repeater_frames_report_nothing() { + RadioScheduler s; + somfy_tx_job_t job = makeJob(REMOTE_A); + job.raw = true; + const uint32_t r = s.submit(job, false, 0, 0); + TEST_ASSERT_NOT_EQUAL(0, r); + TEST_ASSERT_EQUAL_UINT32(r, runNext(s)); + submit(s, REMOTE_B); + job = makeJob(REMOTE_A); + job.raw = true; + s.submit(job, false, 0, 0); + s.flush(0); + somfy_tx_event_t evt; + TEST_ASSERT_TRUE(s.popEvent(evt)); + TEST_ASSERT_EQUAL_UINT32(REMOTE_B, evt.remoteAddress); + assertNoEvents(s); +} +static void test_repeater_frame_doesnt_hold_back_a_stop() { + // It isn't one of our remotes so it has no rolling code of ours to keep in + // order. + RadioScheduler s; + somfy_tx_job_t job = makeJob(REMOTE_A); + job.raw = true; + const uint32_t r = s.submit(job, false, 0, 0); + const uint32_t stop = submit(s, REMOTE_A, 0, true); + TEST_ASSERT_EQUAL_UINT32(stop, runNext(s)); + TEST_ASSERT_EQUAL_UINT32(r, runNext(s)); + discardEvents(s); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_jobs_go_out_in_the_order_they_came); + RUN_TEST(test_tickets_are_distinct_and_set_in_the_job); + RUN_TEST(test_nothing_waiting_sleeps_until_woken); + RUN_TEST(test_stop_goes_ahead_of_other_remotes); + RUN_TEST(test_stop_queues_behind_its_own_remote); + RUN_TEST(test_stop_goes_ahead_once_its_remote_is_on_air); + RUN_TEST(test_stops_go_out_in_the_order_they_came); + RUN_TEST(test_second_stop_of_a_remote_stays_behind_the_first); + RUN_TEST(test_normal_jobs_leave_the_reserved_slots); + RUN_TEST(test_stops_use_the_reserved_slots); + RUN_TEST(test_a_refused_job_has_no_ticket); + RUN_TEST(test_taking_a_job_frees_its_slot); + RUN_TEST(test_delayed_job_waits_until_due); + RUN_TEST(test_delayed_job_holds_up_the_jobs_behind_it); + RUN_TEST(test_stop_overtakes_a_delayed_job_of_another_remote); + RUN_TEST(test_stop_waits_behind_its_remotes_delayed_job); + RUN_TEST(test_delay_across_millis_wrap); + RUN_TEST(test_job_reports_started_then_finished); + RUN_TEST(test_cut_short_job_reports_aborted); + RUN_TEST(test_flush_drops_every_waiting_job_once); + RUN_TEST(test_flush_cuts_short_the_job_on_air); + RUN_TEST(test_job_flushed_before_it_starts_is_dropped); + RUN_TEST(test_every_job_gets_one_outcome_across_a_flush); + RUN_TEST(test_unknown_ticket_is_ignored); + RUN_TEST(test_suspend_flushes_and_refuses_jobs); + RUN_TEST(test_resume_needs_every_reason_cleared); + RUN_TEST(test_repeater_frames_report_nothing); + RUN_TEST(test_repeater_frame_doesnt_hold_back_a_stop); + return UNITY_END(); +} diff --git a/test/test_txjob/test_txjob.cpp b/test/test_txjob/test_txjob.cpp new file mode 100644 index 0000000..e800ce1 --- /dev/null +++ b/test/test_txjob/test_txjob.cpp @@ -0,0 +1,117 @@ +#include +#include "TxBuffer.h" + +static const uint32_t ADDR = 0x123456; + +// A frame as SomfyRemote::sendCommandAfter builds it. +static somfy_frame_t makeFrame(somfy_commands cmd, uint8_t bitLength = 56) { + somfy_frame_t f; + f.cmd = cmd; + f.remoteAddress = ADDR; + f.rollingCode = 0x0102; + f.encKey = 0xA2; + f.bitLength = bitLength; + f.valid = true; + return f; +} + +void setUp() {} +void tearDown() {} + +/********************************************************************* + * encodeTxJob + ********************************************************************/ +static void test_encode_56bit_job() { + somfy_frame_t f = makeFrame(somfy_commands::Up); + somfy_tx_job_t job; + encodeTxJob(job, f, 3); + byte expected[10] = {}; + somfy_frame_t copy = makeFrame(somfy_commands::Up); + copy.encodeFrame(expected); + TEST_ASSERT_EQUAL_UINT8(2, job.tx.hwsync); + TEST_ASSERT_EQUAL_UINT8(56, job.tx.bit_length); + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, job.tx.payload, sizeof(expected)); + TEST_ASSERT_EQUAL_UINT8(3, job.repeats); +} +static void test_encode_80bit_job() { + somfy_frame_t f = makeFrame(somfy_commands::Down, 80); + somfy_tx_job_t job; + encodeTxJob(job, f, 1); + byte expected[10] = {}; + somfy_frame_t copy = makeFrame(somfy_commands::Down, 80); + copy.encodeFrame(expected); + TEST_ASSERT_EQUAL_UINT8(12, job.tx.hwsync); + TEST_ASSERT_EQUAL_UINT8(80, job.tx.bit_length); + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, job.tx.payload, sizeof(expected)); +} +static void test_encode_job_copies_the_frame() { + somfy_frame_t f = makeFrame(somfy_commands::My); + somfy_tx_job_t job; + encodeTxJob(job, f, 1); + TEST_ASSERT_EQUAL_UINT32(ADDR, job.frame.remoteAddress); + TEST_ASSERT_EQUAL_UINT16(0x0102, job.frame.rollingCode); + // The radio task uses its own copy, so later changes to the remote's frame + // don't reach it. + f.rollingCode = 0x0200; + TEST_ASSERT_EQUAL_UINT16(0x0102, job.frame.rollingCode); +} +static void test_encode_job_limits_repeats() { + somfy_frame_t f = makeFrame(somfy_commands::My); + somfy_tx_job_t job; + encodeTxJob(job, f, TX_MAX_REPEATS); + TEST_ASSERT_EQUAL_UINT8(TX_MAX_REPEATS, job.repeats); + encodeTxJob(job, f, TX_MAX_REPEATS + 1); + TEST_ASSERT_EQUAL_UINT8(TX_MAX_REPEATS, job.repeats); + encodeTxJob(job, f, 255); + TEST_ASSERT_EQUAL_UINT8(TX_MAX_REPEATS, job.repeats); + encodeTxJob(job, f, 0); + TEST_ASSERT_EQUAL_UINT8(0, job.repeats); +} + +/********************************************************************* + * Repeat jobs + ********************************************************************/ +static void test_repeat_job_counts_on_from_the_frame() { + somfy_frame_t f = makeFrame(somfy_commands::Up, 80); + f.repeats = 4; + somfy_tx_job_t job; + encodeRepeatJob(job, f, 2); + TEST_ASSERT_EQUAL_UINT8(6, job.firstRepeat); + TEST_ASSERT_EQUAL_UINT8(2, job.repeats); + // Nothing moves until the radio takes the job. + TEST_ASSERT_EQUAL_UINT8(4, f.repeats); +} +static void test_repeat_queued_advances_the_counter() { + somfy_frame_t f = makeFrame(somfy_commands::Up, 80); + f.repeats = 4; + somfy_tx_job_t job; + encodeRepeatJob(job, f, 2); + noteRepeatQueued(f, job); + // The first frame plus two repeats went out. + TEST_ASSERT_EQUAL_UINT8(7, f.repeats); + // The next repeat job picks up where this one stops. + somfy_tx_job_t next; + encodeRepeatJob(next, f, 1); + TEST_ASSERT_EQUAL_UINT8(9, next.firstRepeat); +} +static void test_repeat_queued_uses_the_limited_repeats() { + somfy_frame_t f = makeFrame(somfy_commands::Up); + somfy_tx_job_t job; + encodeRepeatJob(job, f, 200); + noteRepeatQueued(f, job); + TEST_ASSERT_EQUAL_UINT8(TX_MAX_REPEATS + 1, f.repeats); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_encode_56bit_job); + RUN_TEST(test_encode_80bit_job); + RUN_TEST(test_encode_job_copies_the_frame); + RUN_TEST(test_encode_job_limits_repeats); + RUN_TEST(test_repeat_job_counts_on_from_the_frame); + RUN_TEST(test_repeat_queued_advances_the_counter); + RUN_TEST(test_repeat_queued_uses_the_limited_repeats); + return UNITY_END(); +} diff --git a/test/test_txqueue/test_txqueue.cpp b/test/test_txqueue/test_txqueue.cpp deleted file mode 100644 index 667bf04..0000000 --- a/test/test_txqueue/test_txqueue.cpp +++ /dev/null @@ -1,216 +0,0 @@ -#include -#include "TxQueuePolicy.h" -#include "TxBuffer.h" - -static const uint32_t ADDR = 0x123456; - -// A frame as SomfyRemote::sendCommandAfter builds it. -static somfy_frame_t makeFrame(somfy_commands cmd, uint8_t bitLength = 56) { - somfy_frame_t f; - f.cmd = cmd; - f.remoteAddress = ADDR; - f.rollingCode = 0x0102; - f.encKey = 0xA2; - f.bitLength = bitLength; - f.valid = true; - return f; -} - -void setUp() {} -void tearDown() {} - -/********************************************************************* - * txAdmits - ********************************************************************/ -static void test_normal_job_leaves_the_reserved_slots() { - TEST_ASSERT_FALSE(txAdmits(0, false)); - TEST_ASSERT_FALSE(txAdmits(TX_RESERVED_SLOTS, false)); - TEST_ASSERT_TRUE(txAdmits(TX_RESERVED_SLOTS + 1, false)); - TEST_ASSERT_TRUE(txAdmits(TX_JOB_QUEUE_SIZE, false)); -} -static void test_priority_job_uses_the_reserved_slots() { - TEST_ASSERT_FALSE(txAdmits(0, true)); - TEST_ASSERT_TRUE(txAdmits(1, true)); - TEST_ASSERT_TRUE(txAdmits(TX_RESERVED_SLOTS, true)); -} - -/********************************************************************* - * Sequence numbers - ********************************************************************/ -static void test_nextTxSeq_counts_up() { - TEST_ASSERT_EQUAL_UINT32(1, nextTxSeq(0)); - TEST_ASSERT_EQUAL_UINT32(2, nextTxSeq(1)); -} -static void test_nextTxSeq_skips_zero_on_wrap() { - TEST_ASSERT_EQUAL_UINT32(1, nextTxSeq(0xFFFFFFFFu)); -} -static void test_txSeqQueued_zero_is_never_queued() { - TEST_ASSERT_FALSE(txSeqQueued(0, 0)); - TEST_ASSERT_FALSE(txSeqQueued(0, 10)); - TEST_ASSERT_FALSE(txSeqQueued(0, 0xFFFFFFF0u)); -} -static void test_txSeqQueued_after_taken() { - TEST_ASSERT_FALSE(txSeqQueued(9, 10)); - TEST_ASSERT_FALSE(txSeqQueued(10, 10)); - TEST_ASSERT_TRUE(txSeqQueued(11, 10)); -} -static void test_txSeqQueued_across_wrap() { - // Job 5 was numbered after the counter wrapped, so it is still waiting. - TEST_ASSERT_TRUE(txSeqQueued(5, 0xFFFFFFF0u)); - TEST_ASSERT_TRUE(txSeqQueued(0xFFFFFFFFu, 0xFFFFFFF0u)); - TEST_ASSERT_FALSE(txSeqQueued(0xFFFFFFF0u, 5)); -} -static void test_txSeqAfter_never_moves_back() { - // A flush marks every job up to 20 as taken. The radio task then reports - // job 15, which it took off the queue before the flush. That must not move - // the mark back or jobs 16-20 would look queued again. - uint32_t taken = 20; - if(txSeqAfter(15, taken)) taken = 15; - TEST_ASSERT_EQUAL_UINT32(20, taken); - if(txSeqAfter(21, taken)) taken = 21; - TEST_ASSERT_EQUAL_UINT32(21, taken); - TEST_ASSERT_FALSE(txSeqAfter(21, 21)); -} - -/********************************************************************* - * stopGoesToFront - ********************************************************************/ -static void test_stop_goes_to_front_when_nothing_waits() { - TEST_ASSERT_TRUE(stopGoesToFront(true, false)); -} -static void test_stop_queues_behind_its_remote() { - // Overtaking the remote's waiting frame would send the rolling codes out of - // order. - TEST_ASSERT_FALSE(stopGoesToFront(true, true)); -} -static void test_normal_job_never_goes_to_front() { - TEST_ASSERT_FALSE(stopGoesToFront(false, false)); - TEST_ASSERT_FALSE(stopGoesToFront(false, true)); -} - -/********************************************************************* - * txDueInMs - ********************************************************************/ -static void test_txDueInMs() { - TEST_ASSERT_EQUAL_INT32(500, txDueInMs(1500, 1000)); - TEST_ASSERT_EQUAL_INT32(0, txDueInMs(1000, 1000)); - TEST_ASSERT_EQUAL_INT32(-200, txDueInMs(1000, 1200)); -} -static void test_txDueInMs_across_millis_wrap() { - // Queued 0x100ms before the wrap with a 0x200ms delay. - const uint32_t notBefore = 0xFFFFFF00u + 0x200u; - TEST_ASSERT_EQUAL_INT32(0x200, txDueInMs(notBefore, 0xFFFFFF00u)); - TEST_ASSERT_EQUAL_INT32(0x100, txDueInMs(notBefore, 0)); - TEST_ASSERT_EQUAL_INT32(-0x10, txDueInMs(notBefore, 0x110u)); -} - -/********************************************************************* - * encodeTxJob - ********************************************************************/ -static void test_encode_56bit_job() { - somfy_frame_t f = makeFrame(somfy_commands::Up); - somfy_tx_job_t job; - encodeTxJob(job, f, 3); - byte expected[10] = {}; - somfy_frame_t copy = makeFrame(somfy_commands::Up); - copy.encodeFrame(expected); - TEST_ASSERT_EQUAL_UINT8(2, job.tx.hwsync); - TEST_ASSERT_EQUAL_UINT8(56, job.tx.bit_length); - TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, job.tx.payload, sizeof(expected)); - TEST_ASSERT_EQUAL_UINT8(3, job.repeats); -} -static void test_encode_80bit_job() { - somfy_frame_t f = makeFrame(somfy_commands::Down, 80); - somfy_tx_job_t job; - encodeTxJob(job, f, 1); - byte expected[10] = {}; - somfy_frame_t copy = makeFrame(somfy_commands::Down, 80); - copy.encodeFrame(expected); - TEST_ASSERT_EQUAL_UINT8(12, job.tx.hwsync); - TEST_ASSERT_EQUAL_UINT8(80, job.tx.bit_length); - TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, job.tx.payload, sizeof(expected)); -} -static void test_encode_job_copies_the_frame() { - somfy_frame_t f = makeFrame(somfy_commands::My); - somfy_tx_job_t job; - encodeTxJob(job, f, 1); - TEST_ASSERT_EQUAL_UINT32(ADDR, job.frame.remoteAddress); - TEST_ASSERT_EQUAL_UINT16(0x0102, job.frame.rollingCode); - // The radio task uses its own copy, so later changes to the remote's frame - // don't reach it. - f.rollingCode = 0x0200; - TEST_ASSERT_EQUAL_UINT16(0x0102, job.frame.rollingCode); -} -static void test_encode_job_limits_repeats() { - somfy_frame_t f = makeFrame(somfy_commands::My); - somfy_tx_job_t job; - encodeTxJob(job, f, TX_MAX_REPEATS); - TEST_ASSERT_EQUAL_UINT8(TX_MAX_REPEATS, job.repeats); - encodeTxJob(job, f, TX_MAX_REPEATS + 1); - TEST_ASSERT_EQUAL_UINT8(TX_MAX_REPEATS, job.repeats); - encodeTxJob(job, f, 255); - TEST_ASSERT_EQUAL_UINT8(TX_MAX_REPEATS, job.repeats); - encodeTxJob(job, f, 0); - TEST_ASSERT_EQUAL_UINT8(0, job.repeats); -} - -/********************************************************************* - * Repeat jobs - ********************************************************************/ -static void test_repeat_job_counts_on_from_the_frame() { - somfy_frame_t f = makeFrame(somfy_commands::Up, 80); - f.repeats = 4; - somfy_tx_job_t job; - encodeRepeatJob(job, f, 2); - TEST_ASSERT_EQUAL_UINT8(6, job.firstRepeat); - TEST_ASSERT_EQUAL_UINT8(2, job.repeats); - // Nothing moves until the radio takes the job. - TEST_ASSERT_EQUAL_UINT8(4, f.repeats); -} -static void test_repeat_queued_advances_the_counter() { - somfy_frame_t f = makeFrame(somfy_commands::Up, 80); - f.repeats = 4; - somfy_tx_job_t job; - encodeRepeatJob(job, f, 2); - noteRepeatQueued(f, job); - // The first frame plus two repeats went out. - TEST_ASSERT_EQUAL_UINT8(7, f.repeats); - // The next repeat job picks up where this one stops. - somfy_tx_job_t next; - encodeRepeatJob(next, f, 1); - TEST_ASSERT_EQUAL_UINT8(9, next.firstRepeat); -} -static void test_repeat_queued_uses_the_limited_repeats() { - somfy_frame_t f = makeFrame(somfy_commands::Up); - somfy_tx_job_t job; - encodeRepeatJob(job, f, 200); - noteRepeatQueued(f, job); - TEST_ASSERT_EQUAL_UINT8(TX_MAX_REPEATS + 1, f.repeats); -} - -int main(int argc, char **argv) { - (void)argc; - (void)argv; - UNITY_BEGIN(); - RUN_TEST(test_normal_job_leaves_the_reserved_slots); - RUN_TEST(test_priority_job_uses_the_reserved_slots); - RUN_TEST(test_nextTxSeq_counts_up); - RUN_TEST(test_nextTxSeq_skips_zero_on_wrap); - RUN_TEST(test_txSeqQueued_zero_is_never_queued); - RUN_TEST(test_txSeqQueued_after_taken); - RUN_TEST(test_txSeqQueued_across_wrap); - RUN_TEST(test_txSeqAfter_never_moves_back); - RUN_TEST(test_stop_goes_to_front_when_nothing_waits); - RUN_TEST(test_stop_queues_behind_its_remote); - RUN_TEST(test_normal_job_never_goes_to_front); - RUN_TEST(test_txDueInMs); - RUN_TEST(test_txDueInMs_across_millis_wrap); - RUN_TEST(test_encode_56bit_job); - RUN_TEST(test_encode_80bit_job); - RUN_TEST(test_encode_job_copies_the_frame); - RUN_TEST(test_encode_job_limits_repeats); - RUN_TEST(test_repeat_job_counts_on_from_the_frame); - RUN_TEST(test_repeat_queued_advances_the_counter); - RUN_TEST(test_repeat_queued_uses_the_limited_repeats); - return UNITY_END(); -}