Commit graph

721 commits

Author SHA1 Message Date
github-actions[bot]
1c66c310bd chore: bump version to v4.0.0 2026-10-29 17:12:26 +00:00
cjkas
ddbe106d8c
Merge pull request #119 from cjkas/test/config-records
test: shades.cfg, backups, rolling codes, firmware update and MQTT discovery
2026-10-29 18:08:20 +01:00
cjkas
afbfac5181 Merge origin/main into test/config-records
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>
2026-10-29 18:02:56 +01:00
cjkas
92cd54f097 fix: review fixes for the shades.cfg records
- A group in a backup from before version 23 has no rolling code, and a
  restore wrote 0 to NVS for it.  It keeps the code NVS has.
- A restore reads each record into one that starts as the shade, group
  or settings it replaces, so what an older version lacks stays as it
  was (the update check below version 20 among them), as before.
- restoreFile and loadFile read the rooms, shades and groups through one
  readModel(); the settings, network and radio are applied by helpers.
- The shade's tilt is forgotten where the shades are written, not in the
  conversion to a record.
- rollingCodeAfterLoad moves to RollingCode.h and its tests to
  test_rollingcode.
- The topics a deleted shade or group clears are one list each
  (MqttTopics.h), tested against what is published.
- e2e: a restore of the shades brings back every shade and group, and
  the README has the rollback check by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:25:07 +01:00
cjkas
b525a800b0 test: e2e firmware update, and restarts told apart by boot time
With --reboot, a restore of the radio alone brings it back as it was:
on a board without Ethernet it read the radio from the wrong place.

--firmware <firmware.bin> updates the device through /updateFirmware and
checks it still runs a minute later, after it marks the update good.

wait_for_restart compared uptimes, so a test run right after another
restart could see more uptime than before and fail.  It compares boot
times, and waits while the device hasn't restarted yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:59:53 +01:00
cjkas
64f166490f test: Home Assistant discovery config on the PC
The config a shade publishes for MQTT discovery moves to MqttDisco, with
11 unit tests: the topics, the device, which way each kind of shade
opens, tilt, tilt only and dry contacts.

A deleted group's retained sunSensor topic was never cleared: unpublish
named it SunSensor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:59:53 +01:00
cjkas
8c59cf4903 test: read and write shades.cfg and backups on the PC
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>
2026-10-29 16:59:52 +01:00
cjkas
6ddf7e5790
Merge pull request #118 from cjkas/feat/esp32c5-build
build: add the ESP32-C5
2026-10-29 16:25:32 +01:00
cjkas
3bb32c31e4 docs: name the C5 among the builds with no OTA slot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:21:50 +01:00
cjkas
247944bd2b Merge remote-tracking branch 'origin/main' into feat/esp32c5-build 2026-10-29 16:15:01 +01:00
cjkas
11a7088dd3
Merge pull request #116 from cjkas/feat/usb-only-firmware
feat: say when the firmware is flashed over USB
2026-10-29 16:12:50 +01:00
cjkas
9e8ac88893
Merge pull request #114 from cjkas/feat/esp32c3-build
build: bring back the ESP32-C3 build
2026-10-29 16:11:50 +01:00
cjkas
b3d00b0bf5 fix: name the release file the onboard image comes in
The release publishes SomfyController.onboard.esp32<chip>.bin inside a
.bin.zip, so the USB message names both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:52:20 +01:00
cjkas
0437cd2a88 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 <noreply@anthropic.com>
2026-10-28 22:49:42 +01:00
cjkas
38b5c31b79 Merge branch 'feat/esp32c3-build' into feat/esp32c5-build
# Conflicts:
#	docs/adr/0003-pioarduino-idf55.md
2026-10-28 22:33:41 +01:00
cjkas
527215d21a 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 <noreply@anthropic.com>
2026-10-28 22:32:37 +01:00
cjkas
657b345eaf 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 <noreply@anthropic.com>
2026-10-28 22:19:27 +01:00
cjkas
5721d34a34 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 <noreply@anthropic.com>
2026-10-28 22:06:26 +01:00
cjkas
298045ec99 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 <noreply@anthropic.com>
2026-10-28 21:51:15 +01:00
cjkas
a43f5aaaad test: the C3's asset name is a released one again
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:43:53 +01:00
cjkas
d596291677 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 <noreply@anthropic.com>
2026-10-28 21:31:10 +01:00
cjkas
6db449e878
Merge pull request #113 from cjkas/fix/restore-options
fix: restore reads the options the web UI ticks
2026-10-28 21:24:55 +01:00
cjkas
51e15f341e fix: refuse a restore with no part chosen
It rebooted the device and restored nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:19:22 +01:00
cjkas
d968f7e70c fix: review fixes for the restore fix
- A restore of the repeaters alone saves them before the reboot, instead
  of leaving that to somfy.loop().
- e2e: a refused restore leaves no upload behind, tested on its own.
- e2e: auth() sits next to upload(), whose file part is now file=.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:10:47 +01:00
cjkas
3d45ba59c1 fix: restore reads the options the web UI ticks
The web UI sends the restore options as the form field "data". The
param helpers looked up query parameters only, so /restore never saw
them and restored the shades alone, since 3.0.0. The helpers now take a
form field too.

/restore uses an upload once, and drops the file when the options aren't
JSON.

e2e: test_restore.py, a probe that the field is read and, with --reboot,
a settings-only round trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:02:39 +01:00
cjkas
f9ba009485
Merge pull request #112 from cjkas/feat/auth-rules
feat!: the token on every route, Secure Config Only goes (step 4b)
2026-10-28 20:22:44 +01:00
cjkas
0ee52d6d9f fix: close the token bypass below a route, review fixes for 4b
The routes matched any path below them, and a web asset name there
passed the token check: GET /backup/a.js returned the backup under
Secured. Every route is now an exact match, and a web asset sits in
the root only.

- e2e: those paths answer 401 or 404; the restore test now fails when
  the chunks skip the token check (checked with the check removed).
- WEB_CORS lets the socket take Origin: null instead of skipping the
  Origin check.
- ADR 0006, GLOSSARY and README: the Origin check in either mode, /ws
  as a public route, the Host header behind a reverse proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:39:51 +01:00
cjkas
ecb0260f61 feat!: the token on every route, Secure Config Only goes (step 4b)
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>
2026-10-27 23:03:59 +01:00
cjkas
13ec38823b
Merge pull request #111 from cjkas/feat/remove-old-api
feat!: remove the old API, ports 8081 and 8080 (step 4a)
2026-10-27 22:28:09 +01:00
cjkas
c57b4bf432 test(e2e): the slot tests fill the free slots
Home Assistant holds a socket slot whenever it runs, so the tests that
needed every slot never ran on a device it uses. They now fill the slots
left and skip only when none is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:24:48 +01:00
cjkas
cfa298f053 fix: review fixes for step 4a
- e2e: the heap check uses a socket slot now, so it waits for the device
  to free it, and test_every_client_reconnecting_at_once closes its
  clients before the check runs.
- Comments and docs that still named removed things (the /controller read,
  the event buffer, memStatus, SocketEmitter, ModelSnapshot).
- ESPNetwork::emitSockets is emitStatus: one socket, and it sends the status.
- Unused includes, whitespace-only lines left by the toJSON removal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:11:54 +01:00
cjkas
2a954aa158 feat!: remove the old API, ports 8081 and 8080 (step 4a)
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>
2026-10-27 21:49:38 +01:00
github-actions[bot]
c91c20d444 chore: bump version to v4.0.0.beta 2026-10-27 19:53:14 +00:00
cjkas
a8a2e10c5e
Merge pull request #110 from cjkas/feat/token-key
PR B: secret token key, constant-time token compare, HMAC leak fix
2026-10-27 20:49:30 +01:00
cjkas
f9eea3544c
Merge pull request #108 from cjkas/feat/login-backoff
feat: throttle failed logins per client IP
2026-10-27 20:48:59 +01:00
cjkas
86e387b71c Merge branch 'feat/login-backoff' into feat/token-key
# Conflicts:
#	docs/KNOWN_ISSUES.md
2026-10-27 20:39:20 +01:00
cjkas
97d8a90da8 fix: review findings on the token key
- Load the token key in Web::startup(), before either web server starts,
  so no token can be made with the all-zero key; log a failed
  pref.begin().
- createAPIToken checks mbedtls' return codes. On a failure it leaves
  the token empty, which tokenMatches never accepts, instead of hex
  from uninitialised stack.
- API_TOKEN_SIZE in ApiToken.h replaces the five char[65] buffers; the
  HMAC's description moves to createAPIToken, with the None form.
- handleLogin builds the candidate token once and compares it once.
- e2e: the serverId test skips on `secured` like its neighbours, names
  its values, and says it assumes the device sees this PC's address.
- KNOWN_ISSUES keeps #81, which #108 removes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:39:08 +01:00
cjkas
3082269e4b docs: #81 is fixed by the login throttle
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:39:04 +01:00
cjkas
43191ba312 feat: sign tokens with a secret key, compare them in constant time
Tokens were HMAC(serverId, credentials + client IP). serverId is public
(mDNS, MQTT, /loginContext), so anyone who saw a token could test PIN
guesses offline. The key is now 32 random bytes made on first boot in
their own NVS namespace, which no settings save clears and no backup
holds. It is made before WiFi starts, from the bootloader's entropy
source.

Tokens are compared with tokenMatches, which takes the same time
whatever the guess, on the apikey header, the socket upgrade and
/login.

createAPIToken never freed its mbedtls context, so every token it made
leaked: 563 bytes per login on the device, until it ran out of memory
after about 150 logins. It now frees it: 0 bytes over 500 logins.

Every token made before the upgrade is refused once. The web UI asks
for login again, and Home Assistant 3.0 logs in again by itself.

Closes #80

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:41:43 +01:00
cjkas
ff7517e10f fix: review findings on the login throttle
- The throttle runs on a 64-bit clock in ms (esp_timer_get_time()), so a
  slot left alone for 49.7 days isn't brought back by millis() wrapping
  round to its old failure.
- Web.cpp turns the client into the throttle's key in one place, and
  says that an IPv6 client would share key 0; names are clearer
  (retryMs, vacantSlot).
- The shared slot's test now succeeds from the IP counted in it, so a
  success that cleared the shared slot would fail it.
- GLOSSARY.md names it the login throttle, with lockouts, and
  SECURITY_PLAN.md and ADR 0006 use that name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:44:39 +01:00
cjkas
f8e97fb6dd Merge remote-tracking branch 'origin/main' into feat/login-backoff 2026-10-26 22:18:46 +01:00
cjkas
6232464a46
Merge pull request #109 from cjkas/chore/pin-pioarduino
ci: pin pioarduino 6.2.0
2026-10-26 22:18:03 +01:00
cjkas
4f7067388a ci: pin pioarduino 6.2.0
6.2.0.post1 reports itself as 6.2.0-post.1, which the pinned platform's
">=6.2.0" requirement reads as a pre-release, so every ESP32 build
fails at its first pio run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:11:26 +01:00
cjkas
bab83313ee feat: throttle failed logins per client IP
PR C of SECURITY_PLAN.md: after 5 failed /login attempts an IP is refused
for 30 s, and each failure after that refuses it for twice as long, up
to 15 min. A success resets it, as does an hour without a failure.

- LoginThrottle.h holds the schedule and 8 IPs, with the time passed in
  so native tests cover it. While all 8 hold recent failures, every
  other IP shares one more slot, so many addresses don't each get five
  free tries.
- A refused IP gets 429 with Retry-After before its credentials are
  checked, so a right guess during a lockout reveals nothing. Home
  Assistant retries a 429 instead of asking for new credentials.
- The web UI's login panel shows a 401's or 429's message, where it
  popped a service error.
- test/e2e --lockout checks it on a Secured device and waits it out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:08:30 +01:00
cjkas
1612fd1587
Merge pull request #105 from cjkas/feat/proto-settings-ops
feat: settings ops, WiFi and frequency scans, reboot (protocol 2 step 1d)
2026-10-25 21:23:48 +01:00
cjkas
b0d997798f
Merge pull request #106 from cjkas/feat/webui-proto-client
feat: web UI protocol 2 client layer and asset bundles (step 2a)
2026-10-25 21:23:37 +01:00
cjkas
b3b74c698f
Merge pull request #107 from cjkas/feat/webui-on-ops
feat: web UI on socket protocol 2 (step 2b)
2026-10-25 21:23:22 +01:00
cjkas
2fc45a13b2 test: survival tests skip while a web UI tab holds a socket slot
The web UI now uses /ws, one slot per open tab. The tests that fill all 5
slots failed with a timeout while a tab was open; they now skip and say
to close it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:21:05 +01:00
cjkas
dde6148c48 fix: app.css lost the first rule of widgets.css and icons.css
All three CSS files start with a UTF-8 byte order mark. Joined into app.css,
the marks of the second and third files sat mid-file, where the browser
reads one as part of the next selector and drops that rule: .container's
max-width (the page ran full width) and the base icss- icon rule (icons
drawn wrong). minify.py now reads the parts as utf-8-sig.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:05:47 +01:00
cjkas
2ae2dd8af0 fix: app.css lost the first rule of widgets.css and icons.css
All three CSS files start with a UTF-8 byte order mark. Joined into app.css,
the marks of the second and third files sat mid-file, where the browser
reads one as part of the next selector and drops that rule: .container's
max-width (the page ran full width) and the base icss- icon rule (icons
drawn wrong). minify.py now reads the parts as utf-8-sig.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:05:29 +01:00