Both sides added a helper at the end of test/e2e/test_settings.py
(boot_time here, header on main); both are kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The records move to ConfigRecords, free of the file system and the
shade model, with 33 unit tests: each record's size against the header,
a round trip of each, the layouts of versions 2 to 24, and a real backup
when CONFIG_BACKUP names one.
The tests found, and this fixes:
- A quoted value as long as its field (a hostname of 30 or more
characters, a full NTP server, MQTT host or time zone) was read short,
and every value after it shifted.
- A comma in a shade, group or room name shifted the rest of its record.
Names are read at their fixed width.
- 4.0 boards without Ethernet declared the network record 28 bytes longer
than they wrote it, so a restore of the radio without the network read
the radio from the middle of its record. Records are read and skipped
to their newline, and backup() measures what it writes.
- A restore cleared rooms beyond the backup's only on load.
A restore now takes the backup's rolling codes even when they are lower
than NVS's; a load still keeps the higher.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Under Secured every HTTP route but the public ones needs the token
(docs/adr/0006-two-security-modes-eleven-http-routes.md):
- isPublicRoute, isWebAsset and originMatchesHost in src/HttpAccess.h,
with native tests.
- One middleware checks the token. It runs once the body is in, so
/restore, /updateFirmware and /updateApplication check it on their
first chunk and drop a refused upload.
- Static serving answers the web UI's files only: shades.cfg and
shades.tmp are no longer served.
- The wildcard CORS headers go; WEB_CORS turns them back on for UI work
from file://.
- The socket refuses a handshake whose Origin doesn't name its Host.
- "Secure Config Only" goes: a device set to it is Secured, and
/loginContext drops permissions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 4a of docs/SECURITY_PLAN.md. Port 80 keeps the eleven HTTP routes of
ADR 0006 and the socket at /ws; everything else moved to protocol 2.
- Port 8081 (ApiRoutes) and the socket on 8080 (SocketEmitter) are gone.
Protocol 2 owns its frame buffer and loop-task check.
- Port 80 drops every route the old web UI called: reads, edits,
settings, scans, reboot, /shades.cfg, /discovery. HttpRoutes.cpp holds
what stays.
- ModelSnapshot, SnapshotSlots and the read documents are gone. The item
snapshot types stay, as ItemSnapshot, for events, MQTT and the command
replies.
- Model edits reply only to their protocol 2 op: requestResult goes.
- The settings' toJSON writers, used only by the settings routes, go.
- The _espsomfy_rts._tcp mDNS record moves to port 80, with path=/ws.
- The memory event reports the loop-rate window it closes.
- A deleted room no longer re-publishes its retained MQTT topics.
- e2e: the heap check reads the sync's status; the sync is checked on
its own instead of against /controller.
- README gets an API and ports section.
BREAKING CHANGE: ports 8081 and 8080 and the old HTTP routes are gone.
Home Assistant needs integration 3.0.0 or newer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release check, release list, download, cancel and
/recoverFilesystem go, with the GitHub button and the Auto Check for
Updates setting in the web UI (docs/adr/0007-no-github-update.md).
The HTTPS session it needed failed for want of a 35-40 KB block (#76),
it checked no certificate, and the web UI's buttons had been getting
404. Updates are uploads only (/updateFirmware, /updateApplication).
The old socket still sends fwStatus with the installed versions, which
the web UI and Home Assistant read. checkForUpdate stays in NVS and the
backup file so their formats don't change.
The release image shrinks by 126.5 KB: 142 KB of the OTA slot is free.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prepares the move to Arduino 3.x / ESP-IDF 5.5, which renumbers
eth_phy_type_t (ETH_PHY_GENERIC is now 0) and only has the RMII MAC
API on the original ESP32.
- Store the PHY chip as our own id (eth_phy_id_t, the 2.x values) and
translate it by name at ETH.begin(), so NVS, backups and the web UI
keep their numbers on either framework.
- Build Ethernet only where CONFIG_ETH_USE_ESP32_EMAC is set instead of
everywhere but the C6. C3 and S3 could never use it. A stored
Ethernet connection falls back to Wi-Fi on those chips.
- /networksettings reports hasEthernet; the UI hides the Ethernet
options without it.
- Fix the UI PHY list: 5 is the KSZ8041, add the KSZ8081 (6), drop the
DM9051, an SPI chip the MAC can't drive.
- SSDP takes its address from WiFi/ETH.localIP() instead of the
tcpip_adapter API IDF 5 removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NTPSettings::apply() waited up to 5 s in getLocalTime() for a clock that
can't be set before the network is up, then returned before it set TZ.
Every cold boot was 5 s slower and ran without its time zone. It now
calls configTzTime(), which sets the zone and starts SNTP without
waiting, and save() goes through apply(). /setgeneral checked
ntpServer twice, so a request that changed only the zone was ignored.
A restore ignored the result of ShadeConfigFile::restore() and rebooted
either way, so a bad file failed with no word to the user. The /restore
reply now validates the file and answers 400, which the restore dialog
shows, and an incomplete upload gets a 400 instead of no reply. A
failed restore changes nothing, so loop() no longer reboots after one.
/shades.tmp, a full copy of the backup with its rolling codes, is
deleted after every restore and after /updateShadeConfig.
The 6 extra bytes in the 2026-10-18 backup: TRANS_REC_SIZE stayed 74
when noiseDetection was added to the 80-byte transceiver record, so
every backup since declares it short. It is 80 now. The size check
stays a warning so those backups still restore, and the sum moves to
expectedFileBytes() with tests. The repeater record length is checked
as a warning only, since validate() also runs on shades.cfg at boot.
Commands for an unknown shade, tilt or group now log a warning, and a
socket that never pinged says so. KNOWN_ISSUES.md is updated, including
the real cause of the ~365 ms loop stall (radio_tx busy-waits each frame
on loop()'s core).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Firmware and UI files update separately, so an old index.js can run
against firmware that no longer returns secrets. It sends "" for the
field it can't fill in, which cleared the WiFi passphrase (reboot into
AP mode) or the MQTT password.
The firmware now decides: "" keeps the stored secret, unless the same
request changes the SSID or MQTT username, then it clears it (open
network). /setNetwork compares the settings after applying them, so a
kept passphrase no longer triggers a reboot. The UI cache-buster moves
to 3.0.14d.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The settings routes returned the WiFi passphrase, MQTT password and the
login password and PIN in plain text, and the debug log printed them and
the API key. Responses now carry hasPassphrase/hasPassword/hasPin flags,
logs mask the values, and an omitted secret keeps the stored one.
The UI loads secret fields empty and treats a blank field as "keep" while
the SSID or username is unchanged. Clearing a secret without changing the
SSID or username is listed in docs/KNOWN_ISSUES.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
loop() and the async web server task shared one global Preferences. A
begin() from one task failed while the other had a namespace open, and the
clear() or remove() after it then hit the other task's namespace: saving the
radio settings while a command committed its rolling code could erase every
rolling code.
Each function now opens NVS with a local Preferences. NVS serializes access
internally and the destructor closes the handle, so no lock is added (see
docs/adr/0001). No new tests: nothing here builds natively, and deleting the
global makes any missed use a compile error.
The read-then-commit races on rolling codes across tasks remain and are
tracked in docs/KNOWN_ISSUES.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>