From d5962916776b15fccaeba601197ebd3ce329b0ab Mon Sep 17 00:00:00 2001 From: cjkas Date: Wed, 28 Oct 2026 21:31:10 +0100 Subject: [PATCH 1/9] build: bring back the ESP32-C3 build With the old API gone, the C3 image is 1,498,512 bytes at CORE_DEBUG_LEVEL=2 and leaves 72.6 KB of its 1.5 MB OTA slot. It was 76.5 KB over when ESP-IDF 5.5 dropped it. - platformio.ini: env esp32c3, single core, esp32_3MB.csv, level 2. - CI builds and releases it with the other chips. - ADR 0003: amended. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yaml | 12 +++++++++--- docs/adr/0003-pioarduino-idf55.md | 2 ++ platformio.ini | 8 ++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 7ef5508..d4b1f33 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -60,9 +60,7 @@ jobs: # fwname: firmware-only binary for OTA updates # obname: onboard image (bootloader + partitions + firmware + littlefs merged) # for flashing a new chip via USB/serial - # addr_bootloader: chip-dependent (ESP32: 0x1000, S3/C6: 0x0) - # No C3: on ESP-IDF 5.5 its image no longer fits the 1.5 MB OTA slot - # (docs/adr/0003-pioarduino-idf55.md). C3 boards stay on 3.0.x. + # addr_bootloader: chip-dependent (ESP32: 0x1000, S3/C3/C6: 0x0) # addr_fs: must match spiffs/littlefs offset in esp32_3MB.csv include: - env: esp32dev @@ -81,6 +79,14 @@ jobs: fwname: SomfyController.ino.esp32s3.bin fsname: SomfyController.littlefs.esp32s3.bin obname: SomfyController.onboard.esp32s3.bin + - env: esp32c3 + name: ESP32-C3 + chip: ESP32-C3 + addr_bootloader: "0x0" + addr_fs: "0x310000" + fwname: SomfyController.ino.esp32c3.bin + fsname: SomfyController.littlefs.esp32c3.bin + obname: SomfyController.onboard.esp32c3.bin - env: esp32c6 name: ESP32-C6 chip: ESP32-C6 diff --git a/docs/adr/0003-pioarduino-idf55.md b/docs/adr/0003-pioarduino-idf55.md index 3a6baf0..cb06ae1 100644 --- a/docs/adr/0003-pioarduino-idf55.md +++ b/docs/adr/0003-pioarduino-idf55.md @@ -12,6 +12,8 @@ We moved every firmware env from `espressif32 @ 6.13.0` (Arduino 2.0.17 on ESP-I - **Firmware size.** The ESP32 image grows from 1,382,640 bytes (v3.0.14) to 1,555,472 at `CORE_DEBUG_LEVEL=2`, leaving 17 KB of the 1.5 MB OTA slot. At level 3 it doesn't fit, so release envs build at level 2 (warnings and errors) and debug envs stay at 3. PlatformIO's "Flash:" figure counts ELF sections, not the image, and reads about 45 KB low, so CI checks `firmware.bin` against the app partition and fails under 8 KB free (`.github/size_check.py`). - **No more C3 builds.** Its image grows by 214 KB to 1,651,216 bytes, 76.5 KB over the OTA slot even at level 2. C3 boards stay on the 3.0.x releases. + + > **Amended in 4.0.0.** The C3 builds again. Without the old API (ports 8080 and 8081), its image is 1,498,512 bytes at level 2 and leaves 72.6 KB of the OTA slot. - **Ethernet PHY ids are ours.** Arduino 3.x renumbered `eth_phy_type_t` (`ETH_PHY_GENERIC` is 0). NVS, backups and the web UI keep the Arduino 2.x numbers as `eth_phy_id_t` (`src/EthPhy.h`), translated by name when Ethernet starts. Never cast a stored id to the framework enum. - **Ethernet only on the original ESP32.** Arduino 3.x has the RMII MAC API only where `CONFIG_ETH_USE_ESP32_EMAC` is set. S3 and C6 build without Ethernet. - **Updates roll back until proven.** We override the core's `verifyRollbackLater()` hook to return true, and `loop()` marks the new firmware valid once it has run 60 s and the network is up. A reset before then boots the previous firmware. The LittleFS image an update wrote stays, so the old firmware runs with the new web UI. diff --git a/platformio.ini b/platformio.ini index e7e5b60..aaed9a4 100644 --- a/platformio.ini +++ b/platformio.ini @@ -95,6 +95,14 @@ build_flags = ${dualcore.build_flags} -DCORE_DEBUG_LEVEL=2 +[env:esp32c3] +extends = esp32 +board = esp32-c3-devkitm-1 +board_build.partitions = esp32_3MB.csv +build_flags = + ${esp32.build_flags} + -DCORE_DEBUG_LEVEL=2 + ; Released as a debug build with one 3 MB app slot, so C6 boards update over ; USB, not OTA. [env:esp32c6] From a43f5aaaad2218decec435db6d48516af528f1bf Mon Sep 17 00:00:00 2001 From: cjkas Date: Wed, 28 Oct 2026 21:43:53 +0100 Subject: [PATCH 2/9] test: the C3's asset name is a released one again Co-Authored-By: Claude Opus 5.5 --- test/test_utils/test_utils.cpp | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/test/test_utils/test_utils.cpp b/test/test_utils/test_utils.cpp index 7b8bfba..040d34f 100644 --- a/test/test_utils/test_utils.cpp +++ b/test/test_utils/test_utils.cpp @@ -278,14 +278,10 @@ static void test_releaseAssetName_matches_the_release_workflow() { TEST_ASSERT_EQUAL_STRING("SomfyController.ino.esp32.bin", buf); releaseAssetName(buf, sizeof(buf), "littlefs", "s3"); TEST_ASSERT_EQUAL_STRING("SomfyController.littlefs.esp32s3.bin", buf); - releaseAssetName(buf, sizeof(buf), "littlefs", "c6"); - TEST_ASSERT_EQUAL_STRING("SomfyController.littlefs.esp32c6.bin", buf); -} -static void test_releaseAssetName_keeps_c3() { - // C3 is out of the IDF 5.5 builds for now (ADR 0003) but is coming back. - char buf[64]; releaseAssetName(buf, sizeof(buf), "ino", "c3"); TEST_ASSERT_EQUAL_STRING("SomfyController.ino.esp32c3.bin", buf); + releaseAssetName(buf, sizeof(buf), "littlefs", "c6"); + TEST_ASSERT_EQUAL_STRING("SomfyController.littlefs.esp32c6.bin", buf); } /********************************************************************* @@ -432,7 +428,6 @@ int main(int argc, char **argv) { RUN_TEST(test_otaProven_not_before_the_minimum_uptime); RUN_TEST(test_otaProven_not_without_network); RUN_TEST(test_releaseAssetName_matches_the_release_workflow); - RUN_TEST(test_releaseAssetName_keeps_c3); RUN_TEST(test_formatBacktrace_frames_the_dump_holds); RUN_TEST(test_formatBacktrace_garbage_depth_stays_in_bounds); RUN_TEST(test_formatBacktrace_stops_when_the_buffer_is_full); From 298045ec99e6f17c121afd1b3bbf252d718db185 Mon Sep 17 00:00:00 2001 From: cjkas Date: Wed, 28 Oct 2026 21:43:21 +0100 Subject: [PATCH 3/9] build: add the ESP32-C5 The C5 builds like the C6: a debug build with one 3 MB app slot, so it updates over USB. At CORE_DEBUG_LEVEL=2 its image left 3.4 KB of the 1.5 MB OTA slot, under CI's 8 KB. - platformio.ini: env esp32c5 (esp32-c5-devkitc-1). - Chip model "c5", so the updater and releases name its files SomfyController.*.esp32c5.bin. - Radio defaults: the framework's SPI pins (CSN 6, MOSI 8, MISO 9, SCK 10), TX 4, RX 5. - Web UI: GPIO 0-28, without 13-14 (USB) and 15-22 (flash, PSRAM). - CI builds and releases it; its bootloader sits at 0x2000. - ADR 0003: amended. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yaml | 10 +++++++++- data-src/index.js | 1 + docs/adr/0003-pioarduino-idf55.md | 2 +- platformio.ini | 12 ++++++++++++ src/ConfigSettings.cpp | 5 +++++ src/EthPhy.h | 4 ++-- src/Transceiver.cpp | 12 ++++++++++++ src/Utils.h | 2 +- test/test_utils/test_utils.cpp | 2 ++ 9 files changed, 45 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d4b1f33..09fa666 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -60,7 +60,7 @@ jobs: # fwname: firmware-only binary for OTA updates # obname: onboard image (bootloader + partitions + firmware + littlefs merged) # for flashing a new chip via USB/serial - # addr_bootloader: chip-dependent (ESP32: 0x1000, S3/C3/C6: 0x0) + # addr_bootloader: chip-dependent (ESP32: 0x1000, S3/C3/C6: 0x0, C5: 0x2000) # addr_fs: must match spiffs/littlefs offset in esp32_3MB.csv include: - env: esp32dev @@ -87,6 +87,14 @@ jobs: fwname: SomfyController.ino.esp32c3.bin fsname: SomfyController.littlefs.esp32c3.bin obname: SomfyController.onboard.esp32c3.bin + - env: esp32c5 + name: ESP32-C5 + chip: ESP32-C5 + addr_bootloader: "0x2000" + addr_fs: "0x310000" + fwname: SomfyController.ino.esp32c5.bin + fsname: SomfyController.littlefs.esp32c5.bin + obname: SomfyController.onboard.esp32c5.bin - env: esp32c6 name: ESP32-C6 chip: ESP32-C6 diff --git a/data-src/index.js b/data-src/index.js index 0c3b735..4f00273 100644 --- a/data-src/index.js +++ b/data-src/index.js @@ -2719,6 +2719,7 @@ class Somfy { { name: 's2', maxPins: 46, inputs: [0, 19, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 45], outputs: [0, 19, 20, 26, 27, 28, 29, 30, 31, 32, 45, 46]}, { name: 's3', maxPins: 48, inputs: [19, 20, 22, 23, 24, 25, 27, 28, 29, 30, 31, 32], outputs: [19, 20, 22, 23, 24, 25, 27, 28, 29, 30, 31, 32] }, { name: 'c3', maxPins: 21, inputs: [11, 12, 13, 14, 15, 16, 17, 18, 19, 20], outputs: [11, 12, 13, 14, 15, 16, 17, 21] }, + { name: 'c5', maxPins: 28, inputs: [13, 14, 15, 16, 17, 18, 19, 20, 21, 22], outputs: [13, 14, 15, 16, 17, 18, 19, 20, 21, 22] }, { name: 'c6', maxPins: 23, inputs: [], outputs: [] } ]; diff --git a/docs/adr/0003-pioarduino-idf55.md b/docs/adr/0003-pioarduino-idf55.md index cb06ae1..d7a4da2 100644 --- a/docs/adr/0003-pioarduino-idf55.md +++ b/docs/adr/0003-pioarduino-idf55.md @@ -13,7 +13,7 @@ We moved every firmware env from `espressif32 @ 6.13.0` (Arduino 2.0.17 on ESP-I - **Firmware size.** The ESP32 image grows from 1,382,640 bytes (v3.0.14) to 1,555,472 at `CORE_DEBUG_LEVEL=2`, leaving 17 KB of the 1.5 MB OTA slot. At level 3 it doesn't fit, so release envs build at level 2 (warnings and errors) and debug envs stay at 3. PlatformIO's "Flash:" figure counts ELF sections, not the image, and reads about 45 KB low, so CI checks `firmware.bin` against the app partition and fails under 8 KB free (`.github/size_check.py`). - **No more C3 builds.** Its image grows by 214 KB to 1,651,216 bytes, 76.5 KB over the OTA slot even at level 2. C3 boards stay on the 3.0.x releases. - > **Amended in 4.0.0.** The C3 builds again. Without the old API (ports 8080 and 8081), its image is 1,498,512 bytes at level 2 and leaves 72.6 KB of the OTA slot. + > **Amended in 4.0.0.** The C3 builds again. Without the old API (ports 8080 and 8081), its image is 1,498,512 bytes at level 2 and leaves 72.6 KB of the OTA slot. The C5 is new in 4.0.0 and builds like the C6: a debug build with one 3 MB app slot, updated over USB, since at level 2 its image left only 3.4 KB of the OTA slot. - **Ethernet PHY ids are ours.** Arduino 3.x renumbered `eth_phy_type_t` (`ETH_PHY_GENERIC` is 0). NVS, backups and the web UI keep the Arduino 2.x numbers as `eth_phy_id_t` (`src/EthPhy.h`), translated by name when Ethernet starts. Never cast a stored id to the framework enum. - **Ethernet only on the original ESP32.** Arduino 3.x has the RMII MAC API only where `CONFIG_ETH_USE_ESP32_EMAC` is set. S3 and C6 build without Ethernet. - **Updates roll back until proven.** We override the core's `verifyRollbackLater()` hook to return true, and `loop()` marks the new firmware valid once it has run 60 s and the network is up. A reset before then boots the previous firmware. The LittleFS image an update wrote stays, so the old firmware runs with the new web UI. diff --git a/platformio.ini b/platformio.ini index aaed9a4..86d37d8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -103,6 +103,18 @@ build_flags = ${esp32.build_flags} -DCORE_DEBUG_LEVEL=2 +; Released as a debug build with one 3 MB app slot, so C5 and C6 boards +; update over USB, not OTA. The C5's image leaves 3.4 KB of the 1.5 MB OTA +; slot even at level 2. +[env:esp32c5] +extends = esp32 +board = esp32-c5-devkitc-1 +build_type = debug +board_build.partitions = esp32_huge_app.csv +build_flags = + ${esp32.build_flags} + -DCORE_DEBUG_LEVEL=3 + ; Released as a debug build with one 3 MB app slot, so C6 boards update over ; USB, not OTA. [env:esp32c6] diff --git a/src/ConfigSettings.cpp b/src/ConfigSettings.cpp index 42def2d..3d0cf97 100644 --- a/src/ConfigSettings.cpp +++ b/src/ConfigSettings.cpp @@ -134,6 +134,11 @@ bool ConfigSettings::begin() { case esp_chip_model_t::CHIP_ESP32C3: strcpy(this->chipModel, "c3"); break; + #ifdef CONFIG_IDF_TARGET_ESP32C5 + case esp_chip_model_t::CHIP_ESP32C5: + strcpy(this->chipModel, "c5"); + break; + #endif #ifdef CONFIG_IDF_TARGET_ESP32C6 case esp_chip_model_t::CHIP_ESP32C6: strcpy(this->chipModel, "c6"); diff --git a/src/EthPhy.h b/src/EthPhy.h index 6b2ec46..004b5b4 100644 --- a/src/EthPhy.h +++ b/src/EthPhy.h @@ -5,8 +5,8 @@ #include #endif -// Ethernet needs the RMII MAC, which only the original ESP32 has. The C3, S3 -// and C6 build without it. +// Ethernet needs the RMII MAC, which only the original ESP32 has. The C3, C5, +// S3 and C6 build without it. #if defined(CONFIG_ETH_USE_ESP32_EMAC) && CONFIG_ETH_USE_ESP32_EMAC #define HAS_ETHERNET 1 #else diff --git a/src/Transceiver.cpp b/src/Transceiver.cpp index 9f10d2b..9fe00ba 100644 --- a/src/Transceiver.cpp +++ b/src/Transceiver.cpp @@ -481,6 +481,18 @@ void transceiver_config_t::load() { this->SCKPin = 15; this->CSNPin = 14; break; +#ifdef CONFIG_IDF_TARGET_ESP32C5 + case esp_chip_model_t::CHIP_ESP32C5: + // The framework's default SPI pins (variants/esp32c5). 4 and 5 aren't + // strapping pins. + this->TXPin = 4; + this->RXPin = 5; + this->MOSIPin = 8; + this->MISOPin = 9; + this->SCKPin = 10; + this->CSNPin = 6; + break; +#endif #ifdef CONFIG_IDF_TARGET_ESP32C6 case esp_chip_model_t::CHIP_ESP32C6: // Pinout applicable for the ESP32-C6-WROOM-1 module (ESP32-C6-DevKitC-1-N4) diff --git a/src/Utils.h b/src/Utils.h index f01cf05..ffd3483 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -85,7 +85,7 @@ const char *wifiAuthModeName(uint8_t authMode); bool otaProven(bool networkUp, unsigned long uptimeMs, unsigned long minUptimeMs); // A release asset's name: "SomfyController..esp32.bin", kind // "ino" for the firmware or "littlefs" for the web UI image, chipModel as in -// ConfigSettings ("" for the ESP32, "s3", "c3", "c6"). +// ConfigSettings ("" for the ESP32, "s3", "c3", "c5", "c6"). void releaseAssetName(char *buf, size_t size, const char *kind, const char *chipModel); // A coredump backtrace as " 0x%08x" per frame. bt holds maxFrames entries and // depth is how many the dump claims are valid. diff --git a/test/test_utils/test_utils.cpp b/test/test_utils/test_utils.cpp index 040d34f..c95a91e 100644 --- a/test/test_utils/test_utils.cpp +++ b/test/test_utils/test_utils.cpp @@ -280,6 +280,8 @@ static void test_releaseAssetName_matches_the_release_workflow() { TEST_ASSERT_EQUAL_STRING("SomfyController.littlefs.esp32s3.bin", buf); releaseAssetName(buf, sizeof(buf), "ino", "c3"); TEST_ASSERT_EQUAL_STRING("SomfyController.ino.esp32c3.bin", buf); + releaseAssetName(buf, sizeof(buf), "ino", "c5"); + TEST_ASSERT_EQUAL_STRING("SomfyController.ino.esp32c5.bin", buf); releaseAssetName(buf, sizeof(buf), "littlefs", "c6"); TEST_ASSERT_EQUAL_STRING("SomfyController.littlefs.esp32c6.bin", buf); } From 5721d34a3496c4e1b43d1aaa9e4b012a71ec22e8 Mon Sep 17 00:00:00 2001 From: cjkas Date: Wed, 28 Oct 2026 22:06:26 +0100 Subject: [PATCH 4/9] feat: say when the firmware is flashed over USB Builds with one app slot (C6, debug, and the C5 to come) have nowhere for /updateFirmware to write. It answered "Error updating firmware: " and rebooted. - ConfigSettings::otaSlot, from esp_ota_get_next_update_partition(). - The header sends otaSlot, default 1, so only those builds send it. - /updateFirmware refuses with 400 and a reason, and doesn't stop the radio or reboot. - The web UI's Update Firmware says to flash the onboard image over USB, with a backup first. Firmware before the field leaves it undefined, which keeps the uploader. Co-Authored-By: Claude Opus 5.5 --- data-src/index.js | 7 ++++++- data-src/js/model.js | 2 +- docs/PROTOCOL.md | 2 +- src/ConfigSettings.cpp | 2 ++ src/ConfigSettings.h | 3 +++ src/ProtoSchema.h | 1 + src/entryPoints/HttpRoutes.cpp | 7 ++++++- src/outputs/ProtoSocket.cpp | 1 + test/e2e/test_routes.py | 22 +++++++++++++++++++++- test/js/model.test.js | 13 +++++++++++++ 10 files changed, 55 insertions(+), 5 deletions(-) diff --git a/data-src/index.js b/data-src/index.js index 0c3b735..c7a3f3c 100644 --- a/data-src/index.js +++ b/data-src/index.js @@ -4169,9 +4169,14 @@ class Firmware { div.style.display = ''; } updateFirmware() { + let chip = document.getElementById('divContainer').getAttribute('data-chipmodel') || ''; + // Firmware from before the field leaves it out: undefined, not false. + if (proto.model.header.otaSlot === false) { + ui.infoMessage(`This device has no OTA slot, so its firmware is flashed over USB with SomfyController.onboard.esp32${chip}.bin. That image holds the web UI and an empty configuration too, so take a backup first and restore it afterwards.`); + return; + } let div = this.createFileUploader('/updateFirmware'); let inst = div.querySelector('div[id=divInstText]'); - let chip = document.getElementById('divContainer').getAttribute('data-chipmodel') || ''; let html = `
Select a binary file [SomfyController.ino.esp32${chip}.bin] containing the device firmware then press the Upload File button.
`; if (this.isMobile()) { html += `
WARNING
`; diff --git a/data-src/js/model.js b/data-src/js/model.js index 2411996..df80ea1 100644 --- a/data-src/js/model.js +++ b/data-src/js/model.js @@ -140,7 +140,7 @@ ProtoModel.ITEMS = [ // have it. ProtoModel.FIELDS = { header: ['maxRooms', 'maxShades', 'maxGroups', 'maxGroupedShades', 'maxLinkedRemotes', 'startingAddress', 'bitLength', - 'proto', 'serverId', 'hostname', 'model', 'chipModel', 'fw', 'connType'], + 'proto', 'serverId', 'hostname', 'model', 'chipModel', 'otaSlot', 'fw', 'connType'], room: ['roomId', 'name', 'sortOrder'], shadeState: ['shadeId', 'position', 'target', 'direction', 'myPos', 'tiltPosition', 'tiltTarget', 'tiltDirection', 'myTiltPos', 'flags', 'lastRollingCode'], diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 1937f23..3e5d18d 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -53,7 +53,7 @@ A number is never reused. A retired type stays reserved. - `F`: fields, as `[key, name, kind, default]`, kind being `b` bool, `i` integer, `n` number, `s` string, `l` list, `o` object - `E`: error codes, as `[number, name]` - `Q`: feeds, as `[number, name]` -3. Then the device: `header` (limits, starting address, what a new shade gets, server id, hostname, model, chip, firmware, connection type), `rooms`, `shades`, `groups`, `repeaters` and `status` (memory, and WiFi or Ethernet). A frame is at most 4 KB, an item is never split across frames, and several sections can share one. +3. Then the device: `header` (limits, starting address, what a new shade gets, server id, hostname, model, chip, whether it has an OTA slot (`otaSlot`, left out when it has), firmware, connection type), `rooms`, `shades`, `groups`, `repeaters` and `status` (memory, and WiFi or Ethernet). A frame is at most 4 KB, an item is never split across frames, and several sections can share one. 4. The client sends `next` after each `sync` frame, and the device answers with the following one. With no `next` for 30 s the device closes the socket. 5. `syncEnd` follows the last `sync` frame. diff --git a/src/ConfigSettings.cpp b/src/ConfigSettings.cpp index 42def2d..3424e66 100644 --- a/src/ConfigSettings.cpp +++ b/src/ConfigSettings.cpp @@ -7,6 +7,7 @@ #include "Utils.h" #include "Secrets.h" #include "esp_chip_info.h" +#include "esp_ota_ops.h" #include "esp_log.h" static const char *TAG = "Config"; @@ -144,6 +145,7 @@ bool ConfigSettings::begin() { break; } ESP_LOGD(TAG, "Chip Model ESP32-%s", this->chipModel); + this->otaSlot = esp_ota_get_next_update_partition(nullptr) != nullptr; this->fwVersion.parse(FW_VERSION); uint64_t mac = ESP.getEfuseMac(); for(int i=0; i<17; i=i+8) { diff --git a/src/ConfigSettings.h b/src/ConfigSettings.h index 043ebe4..1aa5946 100644 --- a/src/ConfigSettings.h +++ b/src/ConfigSettings.h @@ -163,6 +163,9 @@ class ConfigSettings: BaseSettings { char serverId[10] = ""; char hostname[32] = "ESPSomfyRTS"; char chipModel[10] = "ESP32"; + // Whether the partition table has a second app slot for /updateFirmware + // to write. Builds with one 3 MB slot (C5, C6, debug) are flashed over USB. + bool otaSlot = true; conn_types_t connType = conn_types_t::unset; appver_t fwVersion; appver_t appVersion; diff --git a/src/ProtoSchema.h b/src/ProtoSchema.h index dc5a853..a46644e 100644 --- a/src/ProtoSchema.h +++ b/src/ProtoSchema.h @@ -169,6 +169,7 @@ FIELD(hostname, "hn", 's', 0) \ FIELD(model, "md", 's', 0) \ FIELD(chipModel, "cp", 's', 0) \ + FIELD(otaSlot, "os", 'b', 1) \ FIELD(connType, "ct", 's', 0) \ /* memory, wifi and ethernet */ \ FIELD(freeHeap, "fr", 'i', 0) \ diff --git a/src/entryPoints/HttpRoutes.cpp b/src/entryPoints/HttpRoutes.cpp index 14f1bac..5379de4 100644 --- a/src/entryPoints/HttpRoutes.cpp +++ b/src/entryPoints/HttpRoutes.cpp @@ -164,6 +164,11 @@ void registerHttpRoutes(AsyncWebServer &server) { // updateFirmware - file upload server.on(AsyncURIMatcher::exact("/updateFirmware"), HTTP_POST, [](AsyncWebServerRequest *request) { + // Nothing was written or stopped, so there is nothing to reboot for. + if(!settings.otaSlot) { + request->send(400, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"This device has no OTA slot, so its firmware is flashed over USB.\"}")); + return; + } if(Update.hasError()) request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating firmware: \"}"); else @@ -172,7 +177,7 @@ void registerHttpRoutes(AsyncWebServer &server) { rebootDelay.rebootTime = millis() + 500; }, [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { - if(!uploadAllowed(request, index)) return; + if(!uploadAllowed(request, index) || !settings.otaSlot) return; if(index == 0) { webServer.uploadSuccess = false; ESP_LOGI(TAG, "Update: %s", filename.c_str()); diff --git a/src/outputs/ProtoSocket.cpp b/src/outputs/ProtoSocket.cpp index 81c5533..9c381e8 100644 --- a/src/outputs/ProtoSocket.cpp +++ b/src/outputs/ProtoSocket.cpp @@ -77,6 +77,7 @@ static void writeHeader(ProtoWriter &w) { w.add(proto_field_t::hostname, settings.hostname); w.add(proto_field_t::model, "ESPSomfyRTS"); w.add(proto_field_t::chipModel, settings.chipModel); + w.add(proto_field_t::otaSlot, settings.otaSlot); w.add(proto_field_t::fw, settings.fwVersion.name); w.add(proto_field_t::connType, net.connTypeName()); } diff --git a/test/e2e/test_routes.py b/test/e2e/test_routes.py index 8bf4859..d6df206 100644 --- a/test/e2e/test_routes.py +++ b/test/e2e/test_routes.py @@ -11,7 +11,8 @@ import urllib.request import pytest -from proto_client import ProtoClient +from proto_client import ProtoClient, run_sync +from test_settings import uptime # Every route that needs the token under Secured, sent without a body or # parameters, so a route that let one through would still change nothing. @@ -169,3 +170,22 @@ def test_a_socket_from_the_page_the_device_served_is_accepted(host, token): c.close() # The device frees the slot when it sees the disconnect. time.sleep(0.5) + + +def test_a_firmware_upload_without_an_ota_slot_is_refused(host, token, connect, schema): + """Builds with one app slot (C5, C6, debug) say so in the header, and + refuse the upload without stopping the radio or rebooting.""" + c = connect(schema=schema.hash) + _, frames, _ = run_sync(c) + key = schema.keys["header"] + header = next(schema.expand(f["d"][key]) for f in frames if key in f["d"]) + if header.get("otaSlot", schema.default("otaSlot")): + pytest.skip("the device has an OTA slot: an upload would start an update") + uptime_before = uptime(schema, frames) + status, _, body = upload(host, "/updateFirmware", auth(token)) + assert status == 400, body + assert json.loads(body)["desc"] == "This device has no OTA slot, so its firmware is flashed over USB." + # The old reply rebooted half a second later. + time.sleep(2) + _, frames, _ = run_sync(connect(schema=schema.hash)) + assert uptime(schema, frames) > uptime_before, "the device restarted" diff --git a/test/js/model.test.js b/test/js/model.test.js index 3d8cd4e..d48fadb 100644 --- a/test/js/model.test.js +++ b/test/js/model.test.js @@ -37,6 +37,19 @@ test('a field the sync left out is its default', () => { for (const g of m.groupList()) assert.ok(Array.isArray(g.linkedShades)); }); +test('otaSlot is false only when the device says so', () => { + // The recording is from firmware before the field. + assert.equal(synced().header.otaSlot, undefined); + const schema = recordedSchema(); + schema.add({ F: [['os', 'otaSlot', 'b', 1]] }); + const header = deviceSections(schema).find((d) => d.header).header; + const m = new ProtoModel(schema); + m.applySync({ header: header }); + assert.equal(m.header.otaSlot, true); + m.applySync({ header: schema.expand(Object.assign({ os: 0 }, schema.pack(header))) }); + assert.equal(m.header.otaSlot, false); +}); + test('the lists come in their sort order', () => { const m = synced(); const orders = m.shadeList().map((s) => s.sortOrder); From 657b345eafbf0aea6b9468cfa367650f95c5b987 Mon Sep 17 00:00:00 2001 From: cjkas Date: Wed, 28 Oct 2026 22:19:27 +0100 Subject: [PATCH 5/9] fix: review fixes for the OTA slot - The field is hasOtaSlot, like hasEthernet and the other "has" bools. - The web UI shows the device's reason for any failed upload, not only for /restore, so it shows "no OTA slot" from a page that missed the header. The update routes' 500 replies give Update.errorString() instead of an empty reason. - Native: the writer leaves hasOtaSlot out when true and writes "os":0. - e2e: header() next to uptime(); the test skips on firmware without the field instead of failing, and matches the reason loosely. The README says what the test sends. - PROTOCOL.md and the comments name only the builds that exist. Co-Authored-By: Claude Opus 5.5 --- data-src/index.js | 15 ++++++--------- data-src/js/model.js | 2 +- docs/PROTOCOL.md | 2 +- src/ConfigSettings.cpp | 2 +- src/ConfigSettings.h | 4 ++-- src/ProtoSchema.h | 2 +- src/entryPoints/HttpRoutes.cpp | 9 +++++---- src/outputs/ProtoSocket.cpp | 2 +- test/e2e/README.md | 2 +- test/e2e/test_routes.py | 16 +++++++--------- test/e2e/test_settings.py | 9 +++++++-- test/js/model.test.js | 10 +++++----- test/test_proto/test_proto.cpp | 4 +++- 13 files changed, 41 insertions(+), 38 deletions(-) diff --git a/data-src/index.js b/data-src/index.js index c7a3f3c..a7a6dc3 100644 --- a/data-src/index.js +++ b/data-src/index.js @@ -4171,7 +4171,7 @@ class Firmware { updateFirmware() { let chip = document.getElementById('divContainer').getAttribute('data-chipmodel') || ''; // Firmware from before the field leaves it out: undefined, not false. - if (proto.model.header.otaSlot === false) { + if (proto.model.header.hasOtaSlot === false) { ui.infoMessage(`This device has no OTA slot, so its firmware is flashed over USB with SomfyController.onboard.esp32${chip}.bin. That image holds the web UI and an empty configuration too, so take a backup first and restore it afterwards.`); return; } @@ -4307,18 +4307,15 @@ class Firmware { security.tokenRefused(); return; } - if (service !== '/restore' && (xhr.status < 200 || xhr.status >= 300)) { - ui.errorMessage(el, `The upload failed (${xhr.status}).`); + if (xhr.status < 200 || xhr.status >= 300) { + // The device's reason, when it sent one. + let desc = `The upload failed (${xhr.status}).`; + try { desc = JSON.parse(xhr.responseText).desc || desc; } catch (e) { } + ui.errorMessage(el, desc); return; } switch (service) { case '/restore': - if (xhr.status < 200 || xhr.status >= 300) { - let desc = 'The restore failed.'; - try { desc = JSON.parse(xhr.responseText).desc || desc; } catch (e) { } - ui.errorMessage(el, desc); - break; - } (async () => { await somfy.init(); if (document.getElementById('divUploadFile')) document.getElementById('divUploadFile').remove(); diff --git a/data-src/js/model.js b/data-src/js/model.js index df80ea1..b23c370 100644 --- a/data-src/js/model.js +++ b/data-src/js/model.js @@ -140,7 +140,7 @@ ProtoModel.ITEMS = [ // have it. ProtoModel.FIELDS = { header: ['maxRooms', 'maxShades', 'maxGroups', 'maxGroupedShades', 'maxLinkedRemotes', 'startingAddress', 'bitLength', - 'proto', 'serverId', 'hostname', 'model', 'chipModel', 'otaSlot', 'fw', 'connType'], + 'proto', 'serverId', 'hostname', 'model', 'chipModel', 'hasOtaSlot', 'fw', 'connType'], room: ['roomId', 'name', 'sortOrder'], shadeState: ['shadeId', 'position', 'target', 'direction', 'myPos', 'tiltPosition', 'tiltTarget', 'tiltDirection', 'myTiltPos', 'flags', 'lastRollingCode'], diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 3e5d18d..0f27463 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -53,7 +53,7 @@ A number is never reused. A retired type stays reserved. - `F`: fields, as `[key, name, kind, default]`, kind being `b` bool, `i` integer, `n` number, `s` string, `l` list, `o` object - `E`: error codes, as `[number, name]` - `Q`: feeds, as `[number, name]` -3. Then the device: `header` (limits, starting address, what a new shade gets, server id, hostname, model, chip, whether it has an OTA slot (`otaSlot`, left out when it has), firmware, connection type), `rooms`, `shades`, `groups`, `repeaters` and `status` (memory, and WiFi or Ethernet). A frame is at most 4 KB, an item is never split across frames, and several sections can share one. +3. Then the device: `header` (limits, starting address, what a new shade gets, server id, hostname, model, chip, whether it has an OTA slot, firmware, connection type), `rooms`, `shades`, `groups`, `repeaters` and `status` (memory, and WiFi or Ethernet). A frame is at most 4 KB, an item is never split across frames, and several sections can share one. 4. The client sends `next` after each `sync` frame, and the device answers with the following one. With no `next` for 30 s the device closes the socket. 5. `syncEnd` follows the last `sync` frame. diff --git a/src/ConfigSettings.cpp b/src/ConfigSettings.cpp index 3424e66..8b23346 100644 --- a/src/ConfigSettings.cpp +++ b/src/ConfigSettings.cpp @@ -145,7 +145,7 @@ bool ConfigSettings::begin() { break; } ESP_LOGD(TAG, "Chip Model ESP32-%s", this->chipModel); - this->otaSlot = esp_ota_get_next_update_partition(nullptr) != nullptr; + this->hasOtaSlot = esp_ota_get_next_update_partition(nullptr) != nullptr; this->fwVersion.parse(FW_VERSION); uint64_t mac = ESP.getEfuseMac(); for(int i=0; i<17; i=i+8) { diff --git a/src/ConfigSettings.h b/src/ConfigSettings.h index 1aa5946..81c5838 100644 --- a/src/ConfigSettings.h +++ b/src/ConfigSettings.h @@ -164,8 +164,8 @@ class ConfigSettings: BaseSettings { char hostname[32] = "ESPSomfyRTS"; char chipModel[10] = "ESP32"; // Whether the partition table has a second app slot for /updateFirmware - // to write. Builds with one 3 MB slot (C5, C6, debug) are flashed over USB. - bool otaSlot = true; + // to write. Builds with one 3 MB slot (C6, debug) are flashed over USB. + bool hasOtaSlot = true; conn_types_t connType = conn_types_t::unset; appver_t fwVersion; appver_t appVersion; diff --git a/src/ProtoSchema.h b/src/ProtoSchema.h index a46644e..85ae094 100644 --- a/src/ProtoSchema.h +++ b/src/ProtoSchema.h @@ -169,7 +169,7 @@ FIELD(hostname, "hn", 's', 0) \ FIELD(model, "md", 's', 0) \ FIELD(chipModel, "cp", 's', 0) \ - FIELD(otaSlot, "os", 'b', 1) \ + FIELD(hasOtaSlot, "os", 'b', 1) \ FIELD(connType, "ct", 's', 0) \ /* memory, wifi and ethernet */ \ FIELD(freeHeap, "fr", 'i', 0) \ diff --git a/src/entryPoints/HttpRoutes.cpp b/src/entryPoints/HttpRoutes.cpp index 5379de4..2e2e21d 100644 --- a/src/entryPoints/HttpRoutes.cpp +++ b/src/entryPoints/HttpRoutes.cpp @@ -165,19 +165,20 @@ void registerHttpRoutes(AsyncWebServer &server) { server.on(AsyncURIMatcher::exact("/updateFirmware"), HTTP_POST, [](AsyncWebServerRequest *request) { // Nothing was written or stopped, so there is nothing to reboot for. - if(!settings.otaSlot) { + if(!settings.hasOtaSlot) { request->send(400, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"This device has no OTA slot, so its firmware is flashed over USB.\"}")); return; } + // errorString() is one of the updater's fixed messages, none with a quote. if(Update.hasError()) - request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating firmware: \"}"); + request->send(500, _encoding_json, String("{\"status\":\"ERROR\",\"desc\":\"Error updating firmware: ") + Update.errorString() + "\"}"); else request->send(200, _encoding_json, "{\"status\":\"SUCCESS\",\"desc\":\"Successfully updated firmware\"}"); rebootDelay.reboot = true; rebootDelay.rebootTime = millis() + 500; }, [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { - if(!uploadAllowed(request, index) || !settings.otaSlot) return; + if(!uploadAllowed(request, index) || !settings.hasOtaSlot) return; if(index == 0) { webServer.uploadSuccess = false; ESP_LOGI(TAG, "Update: %s", filename.c_str()); @@ -212,7 +213,7 @@ void registerHttpRoutes(AsyncWebServer &server) { server.on(AsyncURIMatcher::exact("/updateApplication"), HTTP_POST, [](AsyncWebServerRequest *request) { if(Update.hasError()) - request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating application: \"}"); + request->send(500, _encoding_json, String("{\"status\":\"ERROR\",\"desc\":\"Error updating application: ") + Update.errorString() + "\"}"); else request->send(200, _encoding_json, "{\"status\":\"SUCCESS\",\"desc\":\"Successfully updated application\"}"); rebootDelay.reboot = true; diff --git a/src/outputs/ProtoSocket.cpp b/src/outputs/ProtoSocket.cpp index 9c381e8..8a979cd 100644 --- a/src/outputs/ProtoSocket.cpp +++ b/src/outputs/ProtoSocket.cpp @@ -77,7 +77,7 @@ static void writeHeader(ProtoWriter &w) { w.add(proto_field_t::hostname, settings.hostname); w.add(proto_field_t::model, "ESPSomfyRTS"); w.add(proto_field_t::chipModel, settings.chipModel); - w.add(proto_field_t::otaSlot, settings.otaSlot); + w.add(proto_field_t::hasOtaSlot, settings.hasOtaSlot); w.add(proto_field_t::fw, settings.fwVersion.name); w.add(proto_field_t::connType, net.connTypeName()); } diff --git a/test/e2e/README.md b/test/e2e/README.md index f2086f1..1467e54 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -16,7 +16,7 @@ Runs against a real device on your network, by hand, never in CI. It checks sock | `--lockout` | on a Secured device, also fail `/login` until this PC is refused, then wait out the 30 s lockout | | `--record` | save hello, the sync and the schema to `test/fixtures/proto2/`, for the web UI and Home Assistant tests | -A plain run sends nothing on air, changes no setting (it saves only values the device already has) and doesn't reboot the device. The command tests send ops that are refused before they reach the radio, and one `setPositions` that sets a shade to the position the sync gave: that drops the fraction of a percent the shade's believed position may have had. The edit tests send edits that are refused, and one shade sort order set to the order it has, which saves the shades again unchanged. The settings tests send saves that are refused, and one that saves the NTP server and time zone the device already has. The restore test sends options that aren't JSON with a file that isn't a backup, so nothing is restored. The frequency scan test pauses the radio's sending for a second or two, as a scan does. It does fill every socket slot for a few seconds, and replays a page load. +A plain run sends nothing on air, changes no setting (it saves only values the device already has) and doesn't reboot the device. The command tests send ops that are refused before they reach the radio, and one `setPositions` that sets a shade to the position the sync gave: that drops the fraction of a percent the shade's believed position may have had. The edit tests send edits that are refused, and one shade sort order set to the order it has, which saves the shades again unchanged. The settings tests send saves that are refused, and one that saves the NTP server and time zone the device already has. The restore test sends options that aren't JSON with a file that isn't a backup, so nothing is restored. A firmware upload goes only to a device without an OTA slot, which refuses it. The frequency scan test pauses the radio's sending for a second or two, as a scan does. It does fill every socket slot for a few seconds, and replays a page load. The web UI holds one of the device's 5 socket slots for each open tab, and Home Assistant holds one. The tests that fill every slot fill the ones left, so close the UI's tabs for the full load. diff --git a/test/e2e/test_routes.py b/test/e2e/test_routes.py index d6df206..a992284 100644 --- a/test/e2e/test_routes.py +++ b/test/e2e/test_routes.py @@ -12,7 +12,7 @@ import urllib.request import pytest from proto_client import ProtoClient, run_sync -from test_settings import uptime +from test_settings import header, uptime # Every route that needs the token under Secured, sent without a body or # parameters, so a route that let one through would still change nothing. @@ -173,18 +173,16 @@ def test_a_socket_from_the_page_the_device_served_is_accepted(host, token): def test_a_firmware_upload_without_an_ota_slot_is_refused(host, token, connect, schema): - """Builds with one app slot (C5, C6, debug) say so in the header, and - refuse the upload without stopping the radio or rebooting.""" - c = connect(schema=schema.hash) - _, frames, _ = run_sync(c) - key = schema.keys["header"] - header = next(schema.expand(f["d"][key]) for f in frames if key in f["d"]) - if header.get("otaSlot", schema.default("otaSlot")): + """Builds with one app slot (C6, debug) say so in the header, and refuse + the upload without stopping the radio or rebooting.""" + _, frames, _ = run_sync(connect(schema=schema.hash)) + # Left out at its default, true, as is a field older firmware doesn't have. + if header(schema, frames).get("hasOtaSlot", True): pytest.skip("the device has an OTA slot: an upload would start an update") uptime_before = uptime(schema, frames) status, _, body = upload(host, "/updateFirmware", auth(token)) assert status == 400, body - assert json.loads(body)["desc"] == "This device has no OTA slot, so its firmware is flashed over USB." + assert "no OTA slot" in json.loads(body)["desc"] # The old reply rebooted half a second later. time.sleep(2) _, frames, _ = run_sync(connect(schema=schema.hash)) diff --git a/test/e2e/test_settings.py b/test/e2e/test_settings.py index 45d23f9..7f06154 100644 --- a/test/e2e/test_settings.py +++ b/test/e2e/test_settings.py @@ -86,9 +86,8 @@ def test_a_save_tells_the_other_clients(connect, schema): def test_a_wifi_scan_lists_the_networks(connect, schema): c = connect(schema=schema.hash) _, frames, _ = run_sync(c) - header = next(schema.expand(f["d"][schema.keys["header"]]) for f in frames if schema.keys["header"] in f["d"]) # The connection in use: Ethernet with WiFi as the fallback may be on either. - if header.get("connType") != "Wifi": + if header(schema, frames).get("connType") != "Wifi": pytest.skip("the device isn't on WiFi") assert is_ok(schema, op(c, schema, "scanWifi", {})) found = c.recv_until(lambda f: f["t"] == schema.type_numbers["wifiNetworks"], timeout=20)[-1] @@ -152,6 +151,12 @@ def wait_for_restart(host, token, schema, uptime_before): time.sleep(2) +def header(schema, frames): + """The header a sync starts with.""" + key = schema.keys["header"] + return next(schema.expand(f["d"][key]) for f in frames if key in f["d"]) + + def uptime(schema, frames): """The uptime, in ms, from the status a sync ends with.""" key = schema.keys["status"] diff --git a/test/js/model.test.js b/test/js/model.test.js index d48fadb..cf58bad 100644 --- a/test/js/model.test.js +++ b/test/js/model.test.js @@ -37,17 +37,17 @@ test('a field the sync left out is its default', () => { for (const g of m.groupList()) assert.ok(Array.isArray(g.linkedShades)); }); -test('otaSlot is false only when the device says so', () => { +test('the device has an OTA slot unless it says it has none', () => { // The recording is from firmware before the field. - assert.equal(synced().header.otaSlot, undefined); + assert.equal(synced().header.hasOtaSlot, undefined); const schema = recordedSchema(); - schema.add({ F: [['os', 'otaSlot', 'b', 1]] }); + schema.add({ F: [['os', 'hasOtaSlot', 'b', 1]] }); const header = deviceSections(schema).find((d) => d.header).header; const m = new ProtoModel(schema); m.applySync({ header: header }); - assert.equal(m.header.otaSlot, true); + assert.equal(m.header.hasOtaSlot, true); m.applySync({ header: schema.expand(Object.assign({ os: 0 }, schema.pack(header))) }); - assert.equal(m.header.otaSlot, false); + assert.equal(m.header.hasOtaSlot, false); }); test('the lists come in their sort order', () => { diff --git a/test/test_proto/test_proto.cpp b/test/test_proto/test_proto.cpp index 17f2259..f548966 100644 --- a/test/test_proto/test_proto.cpp +++ b/test/test_proto/test_proto.cpp @@ -119,15 +119,17 @@ static void test_values_at_their_default_are_left_out() { w.add(proto_field_t::myPos, (int8_t)-1); w.add(proto_field_t::repeats, (uint8_t)1); w.add(proto_field_t::paired, false); + w.add(proto_field_t::hasOtaSlot, true); // a bool whose default is true w.add(proto_field_t::name, ""); w.add(proto_field_t::name, (const char *)nullptr); }).c_str()); } static void test_values_off_their_default_are_written_by_key() { - TEST_ASSERT_EQUAL_STRING("{\"p\":50,\"m\":0,\"i\":1,\"n\":\"Office\",\"a\":4294967295,\"q\":-5}", writeObject([](ProtoWriter &w) { + TEST_ASSERT_EQUAL_STRING("{\"p\":50,\"m\":0,\"i\":1,\"os\":0,\"n\":\"Office\",\"a\":4294967295,\"q\":-5}", writeObject([](ProtoWriter &w) { w.add(proto_field_t::position, (int8_t)50); w.add(proto_field_t::myPos, (int8_t)0); w.add(proto_field_t::paired, true); + w.add(proto_field_t::hasOtaSlot, false); w.add(proto_field_t::name, "Office"); w.add(proto_field_t::remoteAddress, (uint32_t)0xFFFFFFFF); w.add(proto_field_t::tiltPosition, (int8_t)-5); From 527215d21a9b4a9d4d1a666425d527a08591b3a8 Mon Sep 17 00:00:00 2001 From: cjkas Date: Wed, 28 Oct 2026 22:32:37 +0100 Subject: [PATCH 6/9] fix: review fixes for the C3 build - The C3's default radio pins were GPIO 12-17, which hold its SPI flash. They are now the framework's SPI pins (SCK 4, MISO 5, MOSI 6, CSN 7) with TX 10 and RX 3. A board with pins saved keeps them. - ADR 0003: the amendment links ADR 0006, says the C3 needs chip revision v0.3 or later (CONFIG_ESP32C3_REV_MIN_3), and the Ethernet bullet names the C3. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0003-pioarduino-idf55.md | 4 ++-- src/Transceiver.cpp | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/adr/0003-pioarduino-idf55.md b/docs/adr/0003-pioarduino-idf55.md index cb06ae1..96fbc8b 100644 --- a/docs/adr/0003-pioarduino-idf55.md +++ b/docs/adr/0003-pioarduino-idf55.md @@ -13,8 +13,8 @@ We moved every firmware env from `espressif32 @ 6.13.0` (Arduino 2.0.17 on ESP-I - **Firmware size.** The ESP32 image grows from 1,382,640 bytes (v3.0.14) to 1,555,472 at `CORE_DEBUG_LEVEL=2`, leaving 17 KB of the 1.5 MB OTA slot. At level 3 it doesn't fit, so release envs build at level 2 (warnings and errors) and debug envs stay at 3. PlatformIO's "Flash:" figure counts ELF sections, not the image, and reads about 45 KB low, so CI checks `firmware.bin` against the app partition and fails under 8 KB free (`.github/size_check.py`). - **No more C3 builds.** Its image grows by 214 KB to 1,651,216 bytes, 76.5 KB over the OTA slot even at level 2. C3 boards stay on the 3.0.x releases. - > **Amended in 4.0.0.** The C3 builds again. Without the old API (ports 8080 and 8081), its image is 1,498,512 bytes at level 2 and leaves 72.6 KB of the OTA slot. + > **Amended by [ADR 0006](0006-two-security-modes-eleven-http-routes.md) (4.0.0).** The C3 builds again. Without the old API (ports 8080 and 8081), its image is 1,498,512 bytes at level 2 and leaves 72.6 KB of the OTA slot. It runs on chip revision v0.3 or later only: the framework's C3 libraries are built with `CONFIG_ESP32C3_REV_MIN_3`. Its default radio pins moved off GPIO 12-17, which hold the SPI flash. - **Ethernet PHY ids are ours.** Arduino 3.x renumbered `eth_phy_type_t` (`ETH_PHY_GENERIC` is 0). NVS, backups and the web UI keep the Arduino 2.x numbers as `eth_phy_id_t` (`src/EthPhy.h`), translated by name when Ethernet starts. Never cast a stored id to the framework enum. -- **Ethernet only on the original ESP32.** Arduino 3.x has the RMII MAC API only where `CONFIG_ETH_USE_ESP32_EMAC` is set. S3 and C6 build without Ethernet. +- **Ethernet only on the original ESP32.** Arduino 3.x has the RMII MAC API only where `CONFIG_ETH_USE_ESP32_EMAC` is set. S3, C3 and C6 build without Ethernet. - **Updates roll back until proven.** We override the core's `verifyRollbackLater()` hook to return true, and `loop()` marks the new firmware valid once it has run 60 s and the network is up. A reset before then boots the previous firmware. The LittleFS image an update wrote stays, so the old firmware runs with the new web UI. - **Tooling.** pioarduino needs its own Core fork (`pip install pioarduino`, the pioarduino IDE extension in VS Code), which it installs into `~/.platformio/penv` in place of PlatformIO Core. diff --git a/src/Transceiver.cpp b/src/Transceiver.cpp index 9f10d2b..48ca4cf 100644 --- a/src/Transceiver.cpp +++ b/src/Transceiver.cpp @@ -474,12 +474,14 @@ void transceiver_config_t::load() { this->CSNPin = 34; break; case esp_chip_model_t::CHIP_ESP32C3: - this->TXPin = 13; - this->RXPin = 12; - this->MOSIPin = 16; - this->MISOPin = 17; - this->SCKPin = 15; - this->CSNPin = 14; + // The framework's default SPI pins (variants/esp32c3). 12-17 hold the + // SPI flash, and 2, 8 and 9 are strapping pins. + this->TXPin = 10; + this->RXPin = 3; + this->MOSIPin = 6; + this->MISOPin = 5; + this->SCKPin = 4; + this->CSNPin = 7; break; #ifdef CONFIG_IDF_TARGET_ESP32C6 case esp_chip_model_t::CHIP_ESP32C6: From 0437cd2a88b2c51a3308088969a715fc5efa4c22 Mon Sep 17 00:00:00 2001 From: cjkas Date: Wed, 28 Oct 2026 22:49:42 +0100 Subject: [PATCH 7/9] fix: review fixes for the C5 build - ADR 0003: the C5 gets its own consequence instead of sitting in the ADR 0006 amendment. It names the single-slot table, the chip revision minimum (v1.0, CONFIG_ESP32C5_REV_MIN_FULL=100) and the longer WiFi scans with 5 GHz. - platformio.ini: the [esp32] comment names the C5 and C6 as released debug builds; the C5 comment no longer repeats the C6's. - Transceiver: the C5 comment says why TX and RX are 4 and 5. - CI: the addr_fs comment names both partition tables. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yaml | 3 ++- docs/adr/0003-pioarduino-idf55.md | 3 ++- platformio.ini | 6 +++--- src/Transceiver.cpp | 5 +++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 09fa666..730b265 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -61,7 +61,8 @@ jobs: # obname: onboard image (bootloader + partitions + firmware + littlefs merged) # for flashing a new chip via USB/serial # addr_bootloader: chip-dependent (ESP32: 0x1000, S3/C3/C6: 0x0, C5: 0x2000) - # addr_fs: must match spiffs/littlefs offset in esp32_3MB.csv + # addr_fs: must match the spiffs offset in the env's partition table + # (0x310000 in both esp32_3MB.csv and esp32_huge_app.csv) include: - env: esp32dev name: ESP32 diff --git a/docs/adr/0003-pioarduino-idf55.md b/docs/adr/0003-pioarduino-idf55.md index ddbbf14..fb87a35 100644 --- a/docs/adr/0003-pioarduino-idf55.md +++ b/docs/adr/0003-pioarduino-idf55.md @@ -13,7 +13,8 @@ We moved every firmware env from `espressif32 @ 6.13.0` (Arduino 2.0.17 on ESP-I - **Firmware size.** The ESP32 image grows from 1,382,640 bytes (v3.0.14) to 1,555,472 at `CORE_DEBUG_LEVEL=2`, leaving 17 KB of the 1.5 MB OTA slot. At level 3 it doesn't fit, so release envs build at level 2 (warnings and errors) and debug envs stay at 3. PlatformIO's "Flash:" figure counts ELF sections, not the image, and reads about 45 KB low, so CI checks `firmware.bin` against the app partition and fails under 8 KB free (`.github/size_check.py`). - **No more C3 builds.** Its image grows by 214 KB to 1,651,216 bytes, 76.5 KB over the OTA slot even at level 2. C3 boards stay on the 3.0.x releases. - > **Amended by [ADR 0006](0006-two-security-modes-eleven-http-routes.md) (4.0.0).** The C3 builds again. Without the old API (ports 8080 and 8081), its image is 1,498,512 bytes at level 2 and leaves 72.6 KB of the OTA slot. It runs on chip revision v0.3 or later only: the framework's C3 libraries are built with `CONFIG_ESP32C3_REV_MIN_3`. Its default radio pins moved off GPIO 12-17, which hold the SPI flash. The C5 is new in 4.0.0 and builds like the C6: a debug build with one 3 MB app slot, updated over USB, since at level 2 its image left only 3.4 KB of the OTA slot. + > **Amended by [ADR 0006](0006-two-security-modes-eleven-http-routes.md) (4.0.0).** The C3 builds again. Without the old API (ports 8080 and 8081), its image is 1,498,512 bytes at level 2 and leaves 72.6 KB of the OTA slot. It runs on chip revision v0.3 or later only: the framework's C3 libraries are built with `CONFIG_ESP32C3_REV_MIN_3`. Its default radio pins moved off GPIO 12-17, which hold the SPI flash. +- **The C5 is updated over USB.** New in 4.0.0. At level 2 its image left 3.4 KB of the OTA slot, under CI's 8 KB, so like the C6 it is released as a debug build with one 3 MB app slot (`esp32_huge_app.csv`), which no OTA can write. The framework's C5 libraries run on chip revision v1.0 or later only (`CONFIG_ESP32C5_REV_MIN_FULL=100`). Its WiFi has 5 GHz too, so the passive scans for the configured network (`src/ESPNetwork.cpp`, 300 ms a channel) cover about 38 channels instead of 13: about 11 s instead of 4 s, depending on the country's channels. - **Ethernet PHY ids are ours.** Arduino 3.x renumbered `eth_phy_type_t` (`ETH_PHY_GENERIC` is 0). NVS, backups and the web UI keep the Arduino 2.x numbers as `eth_phy_id_t` (`src/EthPhy.h`), translated by name when Ethernet starts. Never cast a stored id to the framework enum. - **Ethernet only on the original ESP32.** Arduino 3.x has the RMII MAC API only where `CONFIG_ETH_USE_ESP32_EMAC` is set. S3, C3, C5 and C6 build without Ethernet. - **Updates roll back until proven.** We override the core's `verifyRollbackLater()` hook to return true, and `loop()` marks the new firmware valid once it has run 60 s and the network is up. A reset before then boots the previous firmware. The LittleFS image an update wrote stays, so the old firmware runs with the new web UI. diff --git a/platformio.ini b/platformio.ini index 86d37d8..de180a2 100644 --- a/platformio.ini +++ b/platformio.ini @@ -33,6 +33,7 @@ extra_scripts = board_build.filesystem = littlefs ; Each env adds a CORE_DEBUG_LEVEL: 2 (warnings) for release builds, which ; only fit the 1.5 MB OTA slot without the info logs, 3 for debug builds. +; The C5 and C6 release their debug build, which has a 3 MB slot. build_flags = -DCONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=1 -DCONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=1 @@ -103,9 +104,8 @@ build_flags = ${esp32.build_flags} -DCORE_DEBUG_LEVEL=2 -; Released as a debug build with one 3 MB app slot, so C5 and C6 boards -; update over USB, not OTA. The C5's image leaves 3.4 KB of the 1.5 MB OTA -; slot even at level 2. +; Released like the C6 below. At level 2 its image left 3.4 KB of the +; 1.5 MB OTA slot, under CI's 8 KB. [env:esp32c5] extends = esp32 board = esp32-c5-devkitc-1 diff --git a/src/Transceiver.cpp b/src/Transceiver.cpp index 7c9fe14..c66280c 100644 --- a/src/Transceiver.cpp +++ b/src/Transceiver.cpp @@ -485,8 +485,9 @@ void transceiver_config_t::load() { break; #ifdef CONFIG_IDF_TARGET_ESP32C5 case esp_chip_model_t::CHIP_ESP32C5: - // The framework's default SPI pins (variants/esp32c5). 4 and 5 aren't - // strapping pins. + // The framework's default SPI pins (variants/esp32c5). TX and RX take + // 4 and 5, the LP UART's pins, which nothing here uses; 2, 3, 7 and + // 25-28 are strapping pins and 11-22 hold UART0, USB and the flash. this->TXPin = 4; this->RXPin = 5; this->MOSIPin = 8; From b3d00b0bf5de476f84abde497b5ab40b83341870 Mon Sep 17 00:00:00 2001 From: cjkas Date: Wed, 28 Oct 2026 22:52:20 +0100 Subject: [PATCH 8/9] fix: name the release file the onboard image comes in The release publishes SomfyController.onboard.esp32.bin inside a .bin.zip, so the USB message names both. Co-Authored-By: Claude Opus 5.5 --- data-src/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data-src/index.js b/data-src/index.js index a7a6dc3..be90fed 100644 --- a/data-src/index.js +++ b/data-src/index.js @@ -4172,7 +4172,7 @@ class Firmware { let chip = document.getElementById('divContainer').getAttribute('data-chipmodel') || ''; // Firmware from before the field leaves it out: undefined, not false. if (proto.model.header.hasOtaSlot === false) { - ui.infoMessage(`This device has no OTA slot, so its firmware is flashed over USB with SomfyController.onboard.esp32${chip}.bin. That image holds the web UI and an empty configuration too, so take a backup first and restore it afterwards.`); + ui.infoMessage(`This device has no OTA slot, so its firmware is flashed over USB with SomfyController.onboard.esp32${chip}.bin, from the release's SomfyController.onboard.esp32${chip}.bin.zip. That image holds the web UI and an empty configuration too, so take a backup first and restore it afterwards.`); return; } let div = this.createFileUploader('/updateFirmware'); From 3bb32c31e47d0b7a3216bdf2058a1c5d3f8caaf7 Mon Sep 17 00:00:00 2001 From: cjkas Date: Thu, 29 Oct 2026 16:21:50 +0100 Subject: [PATCH 9/9] docs: name the C5 among the builds with no OTA slot Co-Authored-By: Claude Opus 5.5 --- src/ConfigSettings.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ConfigSettings.h b/src/ConfigSettings.h index 81c5838..a0b1e6b 100644 --- a/src/ConfigSettings.h +++ b/src/ConfigSettings.h @@ -164,7 +164,7 @@ class ConfigSettings: BaseSettings { char hostname[32] = "ESPSomfyRTS"; char chipModel[10] = "ESP32"; // Whether the partition table has a second app slot for /updateFirmware - // to write. Builds with one 3 MB slot (C6, debug) are flashed over USB. + // to write. Builds with one 3 MB slot (C5, C6, debug) are flashed over USB. bool hasOtaSlot = true; conn_types_t connType = conn_types_t::unset; appver_t fwVersion;