docs: ADRs 0005-0007 for socket protocol 2

ADR 0005: the web UI and Home Assistant talk to the device over one
websocket protocol (ops on loop(), sync with next, a JSON envelope a
binary encoding can reuse, short keys described by a schema).
ADR 0006: two security modes and eleven HTTP routes on port 80.
ADR 0007: the firmware no longer updates itself from GitHub.

ADR 0002 is amended: its single-writer rule stands, but edits now get
one reply and reads no longer go through a ModelSnapshot.
SECURITY_PLAN.md is revised to match, and keeps the token key, login
backoff, CORS and Origin work. PROTOCOL.md starts as an outline, and
GLOSSARY.md gains the socket protocol and security terms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-22 20:50:33 +02:00
parent 539a49eb77
commit e25747d395
7 changed files with 202 additions and 52 deletions

51
docs/PROTOCOL.md Normal file
View file

@ -0,0 +1,51 @@
# Socket protocol 2
**Status:** outline. The type numbers, field keys and payloads are filled in with the firmware protocol core. Why the protocol looks like this: [ADR 0005](adr/0005-one-websocket-protocol.md). Terms: [GLOSSARY.md](../GLOSSARY.md).
## Connection
- `ws://host/ws` on port 80. Under Secured the upgrade needs `?apikey=<token>` ([ADR 0006](adr/0006-two-security-modes-eleven-http-routes.md)).
- Optional `?schema=<hash>`: the client already has that schema and doesn't need it again.
- Text frames only. A binary encoding, if one comes, would be asked for at the upgrade (for example `?enc=bin`) and use binary frames.
## Envelope
Every frame, in both directions, is one JSON object:
```json
{"v":2,"t":20,"id":17,"d":{}}
```
| Key | Meaning |
|---|---|
| `v` | protocol version |
| `t` | message type, a number from the table below |
| `id` | only on an op and its reply: the client's number for the op |
| `d` | the payload, with short keys from the schema |
Within `d`, booleans are `1`/`0` and fields at their default are left out. Keys are one or two characters from one dictionary shared by every type.
## Message types
| Range | Sender | Kinds |
|---|---|---|
| 1–15 | either | control: `hello`, `reply`, `next`, `sync`, `syncEnd`, `subscribe`, `unsubscribe` |
| 16–127 | client | ops: the commands, reads, model edits and `getSettings`/`saveSettings` |
| 128–255 | device | events: `shadeState`, `shade`, `group`, `room`, `*Removed`, `settingsChanged`, status, feeds |
A number is never reused. A retired type stays reserved.
## Connect sequence
1. The device sends `hello`: protocol version, firmware version, schema hash.
2. Unless the client passed the matching `?schema=`, the sync starts with the schema: types, fields (key, name, kind, default) and error codes.
3. Sync: header, rooms, shades, groups, repeaters, status. Each message is at most 4 KB, items are never split, and the client sends `next` after each one.
4. `syncEnd`. From then on, events keep the client current. Events also arrive during the sync, so the client upserts by id.
## Replies
Every op gets exactly one reply, to its sender only: `{"ok":1, ...}`, or `{"ok":0,"code":<n>,"desc":"..."}`. The error codes are bad request, unknown op, unauthorized, not found, busy, radio unavailable and internal; their numbers are in the schema.
## Feeds
`subscribe`/`unsubscribe` with a feed: `frames` (received remote frames) or `frequencyScan`. Feed events go only to subscribed clients.

View file

@ -1,17 +1,17 @@
# Plan: authentication on every entry point, one HTTP server
**Status:** agreed, nothing built yet.
**Written:** 2026-10-18, on top of PR #64 (stored secrets are write-only). Terms are defined in [GLOSSARY.md](../GLOSSARY.md).
**Written:** 2026-10-18, on top of PR #64 (stored secrets are write-only). **Revised:** 2026-10-22 for socket protocol 2: two security modes, eleven HTTP routes, the socket on port 80 ([ADR 0005](adr/0005-one-websocket-protocol.md), [ADR 0006](adr/0006-two-security-modes-eleven-http-routes.md)). Terms are defined in [GLOSSARY.md](../GLOSSARY.md).
## Why
- **Port 80 doesn't check login.** About 60 of its 70 routes call no auth check at all: settings, security, network, MQTT, radio, firmware, `/restore`, pairing, rolling codes. Only the ten that share a handler with port 8081 check it (`/controller`, `/rooms`, `/shades`, `/groups`, `/room`, `/shade`, `/group`, `/backup`, `/reboot`, `/downloadFirmware`). Logging in only hides parts of the UI.
- **"Secure Config Only" lets everything through.** No caller ever asks for the config level, so with that mode every check passes.
- **The token key is public.** API tokens are `HMAC(serverId, PIN-or-password + client IP)`. `serverId` is the last 3 bytes of the MAC address, which every device on the LAN can see, and it is also published in mDNS, in the retained MQTT `serverId` topic and in `/discovery`. Anyone who sees a token can recover a 4-character PIN offline. Tokens never expire.
- **`/login` has no rate limit.** A digits-only PIN (10,000 options) falls to an online attack within minutes.
- **CORS is wide open.** Every response carries `Access-Control-Allow-Origin: *` and `Allow-Headers: *`. Any website open in a browser on the LAN can call the device and read the replies.
- **The websocket has no auth.** It is read-only, but room 0 (the frame log) carries received remote addresses and rolling codes.
- **The websocket has no auth.** With protocol 2 it carries every command and edit, and its frame feed carries received remote addresses and rolling codes.
- **`serveStatic("/")` serves all of LittleFS**, including `shades.cfg` and `shades.tmp`.
- **Three servers for one device.** Port 80 (UI), 8081 (API) and 8080 (websocket). 8081 dates from upstream's blocking `WebServer`, so that Home Assistant calls didn't wait behind UI file downloads. Every server is now an `AsyncWebServer` on the same AsyncTCP task, and every API route except `/discovery` is already registered on port 80 too.
## Threat model
@ -20,34 +20,22 @@
## Decisions
### Auth rules
- Every entry point applies the rules `Web::isAuthenticated(request, cfg)` already applies on 8081. No new rules are invented.
- **Security None:** open.
- **PIN or password:** a valid `apikey` is required.
- **Secure Config Only:** control and read routes are open; config routes need `apikey`.
### Security modes
- **None:** everything is open.
- **Secured** (PIN or password): the socket upgrade and every HTTP route need a valid token, except the public ones below.
- "Secure Config Only" goes. A device set to it becomes Secured when it upgrades, so its main page asks for login. The 4.0.0 release notes say so.
- `serverId` stays public everywhere, including `/loginContext`. It is an identity, and HA uses it as the device's unique id. Only the token key changes (PR B).
### Route access levels (port 80 and the 8081 alias)
### HTTP routes ([ADR 0006](adr/0006-two-security-modes-eleven-http-routes.md))
- **Public:** static web assets, `/loginContext`, `/login`, `/upnp.xml` (SSDP), OPTIONS preflight requests.
- **Control** (`cfg=false`):
- reads: `/controller`, `/rooms`, `/shades`, `/groups`, `/room`, `/shade`, `/group`, `/discovery`
- commands: `/shadeCommand`, `/groupCommand`, `/tiltCommand`, `/repeatCommand`, `/setPositions`, `/setSensor`, `/setMyPosition`
- `/groupOptions`
- **Config** (`cfg=true`): everything else:
- settings get/set: security, network, MQTT, radio, general, IP, `/connectwifi`, `/scanaps`, `/modulesettings`
- pairing, rolling code, linking, deleting, `/getNext*`
- firmware and filesystem: `/getReleases`, `/downloadFirmware`, `/cancelFirmware`, `/update*`, `/recoverFilesystem`
- `/backup`, `/restore`, `/reboot`, `/shades.cfg`, `/shades.tmp`
- `/sendRemoteCommand`, `/beginFrequencyScan`, `/endFrequencyScan`
- The table lives in one pure function with native tests, not in ~70 hand-placed checks.
- `serveStatic` refuses `*.cfg` and `*.tmp`, so the rolling codes are only reachable through the authenticated routes.
- **Token under Secured:** `/backup`, `/restore`, `/updateFirmware`, `/updateApplication`, `/shadeCommand`, `/groupCommand`, `/tiltCommand`.
- Which routes are public is one pure function with native tests.
- Static serving covers the web assets only, so the rolling codes in `shades.cfg` are reachable only through the authenticated `/backup`.
- The web UI sends `apikey` on its backup and upload requests too (today those XHRs leave it out).
### Websocket
- **Handshake:** the token goes in `?apikey=<token>`, because browsers can't set headers on a websocket. `handleHandshake` checks it with the same comparison the HTTP routes use and stores an `authenticated` flag on the client slot. `request->url()` is the path only, so the token doesn't reach the slow-request log.
- **Rules:**
- **Security None:** open.
- **PIN or password:** the handshake needs a valid token.
- **Secure Config Only:** anyone can connect; only an authenticated connection can join room 0.
- **Handshake:** the socket is `ws://host/ws` on port 80. The token goes in `?apikey=<token>`, because browsers can't set headers on a websocket. `handleHandshake` checks it with the same comparison the HTTP routes use. `request->url()` is the path only, so the token doesn't reach the slow-request log.
- **Rules:** under None anyone can connect. Under Secured the handshake needs a valid token, and a connection that has one may send every op and subscribe to every feed.
- **Logging in with a socket already open:** the UI reconnects the socket with the token. There is no in-band upgrade message.
- **Origin:** a handshake whose `Origin` header doesn't match `Host` is refused.
@ -56,57 +44,55 @@
- A build flag turns the headers back on for UI development from `file://`, which `baseUrl` in `index.js` supports.
### UI
- On a 401, the UI opens the login panel (`security.authUser()`) and drops that request, with no automatic retry. The user repeats the action.
- The token is kept in `sessionStorage`: it survives a reload, is dropped when the tab closes, and is cleared when the socket upgrade is refused.
- On a 401, or a refused upgrade, the UI opens the login panel (`security.authUser()`) and drops that request, with no automatic retry. The user repeats the action.
### Token key (PR B)
- The token keeps its shape, `HMAC(key, PIN-or-password + client IP)`: stateless, and it survives reboots. Only the key changes.
- **Key:** 32 random bytes from `esp_fill_random`, generated on first boot in their own NVS namespace. Never sent anywhere, never in a backup, replaced only when NVS is erased. There is no rotate button: changing the PIN or password already changes every token.
- **Comparison:** constant-time, used for HTTP and the websocket handshake alike.
- **Migration:** none needed. The browser keeps its token in memory only, and HA logs in again at runtime (HA PR).
- **Migration:** none needed. The browser logs in again when its token is refused, and HA logs in again at runtime.
### Login throttling (PR C)
- Per client IP: after 5 failed `/login` attempts, refuse that IP for 30 s, doubling each time up to 15 min. A success resets it.
- Only `/login` is throttled. After PR B a token is a 256-bit HMAC with a secret key, so guessing one through `apikey` is hopeless.
### One server on port 80
- Recorded as ADR-0003, "One HTTP server on port 80; 8081 and 8080 are deprecated aliases", plus an "API and ports" section in the README for script and Node-RED users.
- **Port 80 serves everything:** the UI, the API (with `/discovery` added) and the websocket at `ws://host/ws`. The mDNS service record moves to port 80, with a TXT entry for the websocket path.
- **Aliases:** 8081 and 8080 keep working for one release, with the same routes and the same auth.
- **Deprecation signal:** `ESP_LOGW` once per boot on the first use of 8081 or 8080, plus a release-notes entry.
- **Removal:** in the first firmware release after the HA fork's port-80 release is out.
### One server on port 80 ([ADR 0006](adr/0006-two-security-modes-eleven-http-routes.md))
- Port 80 serves the UI, the eleven HTTP routes and the socket at `/ws`. Ports 8081 and 8080 are removed in 4.0.0 with no alias period.
- The `_espsomfy_rts._tcp` mDNS record moves from port 8080 to 80, with the socket path as a TXT entry.
- The README gets an "API and ports" section for script and Node-RED users.
### Home Assistant ([ESPSomfy-RTS-HA](https://github.com/cjkas/ESPSomfy-RTS-HA))
- Today HA logs in only during the config flow, on a temporary API object, and sends `apikey: ""` at runtime. It only works with security None or Secure Config Only.
- **Ports:** it probes `http://host/discovery` on port 80 and falls back to 8081/8080 on a 404, so it works with firmware from before PR 0.
- **Login:** at `async_setup_entry` it logs in with the PIN or password stored in `entry.data`. On a 401 it logs in again once and retries; if that fails, it raises HA's reauth flow.
- **Websocket:** it adds `?apikey=` to the websocket URL.
- **Setup:** the config flow reads the device info and security mode from `/loginContext`, then logs in with `/login`.
- **Login:** at `async_setup_entry` it logs in with the PIN or password stored in `entry.data`. On a refusal it logs in again once and retries; if that fails, it raises HA's reauth flow.
- **Socket:** it connects to `ws://host/ws?apikey=<token>` and speaks protocol 2. Against firmware that sends no `hello`, it falls back to the old HTTP API on 8081 and events on 8080.
## PR sequence
## Order
| # | Repo | Scope | Breaks anything? |
|---|---|---|---|
| 0 | firmware | `/discovery` and `/ws` on port 80, UI switches to `/ws`; deprecation log on 8081/8080; ADR-0003 and README "API and ports" | no |
| HA | HA fork | port-80 probe with fallback; runtime login, re-login on 401, reauth flow; `?apikey=` on the websocket | no |
| A | firmware | access table on port 80 and the 8081 alias; websocket handshake auth and room 0; `*.cfg`/`*.tmp` static filter; UI 401 handling; CORS headers removed; Origin check | old HA with full security (already broken today) |
| B | firmware | random HMAC key in NVS; constant-time token comparison | no (tokens are re-issued on login) |
| C | firmware | per-IP `/login` backoff | no |
| D | firmware | remove the 8081 and 8080 aliases, one release after HA's port-80 release | scripts still on 8081/8080 |
Protocol 2 lands as one PR per step, and `main` keeps working throughout because the HTTP routes are removed last:
The HA PR must merge and ship before PR A.
| # | Repo | Scope |
|---|---|---|
| 1 | firmware | protocol core next to the existing HTTP: `hello`, socket auth, ops through the LoopQueue, sync, events, schema; the e2e suite starts |
| 2 | firmware | web UI on ops; the CSS files bundled; token in `sessionStorage`; banner on a version mismatch |
| 3 | HA | protocol 2 with the old-firmware fallback; `/loginContext` setup; runtime login and reauth |
| 4 | firmware | removals: the old routes, ports 8081 and 8080, `/discovery`, ModelSnapshot, the GitHub update, "Secure Config Only"; static serving limited to web assets; CORS headers; Origin check |
PR B (token key) and PR C (login backoff) are separate firmware PRs that can land at any point.
## Tests
The rules go into small pure helpers with native tests, following [src/Secrets.h](../src/Secrets.h):
- the route-to-access-level table
- which routes are public
- the token comparison
- the Origin/Host match
- the backoff schedule
Everything else is checked with the `esp32devdbg` build, plus curl and websocket checks against a device under each security type.
The e2e suite (`test/e2e`) checks, when given credentials, that a socket without a token is refused, that the HTTP routes answer 401, and that a token works. It never changes the device's security settings itself.
## Not in this plan
- TLS.
- Checking GitHub's certificate in `GitOTA`. It calls `setInsecure()`, so anyone who can intercept the update download can serve their own firmware. ESP-IDF 5.5 ([ADR 0003](adr/0003-pioarduino-idf55.md)) ships a certificate bundle, so this is possible now. It needs its own change: the full bundle costs more flash than the ESP32 build has spare, and pinning GitHub's root certificates might fit.
- Session tokens with expiry.
- Contents of the binary backup file (it holds no passwords, but it does hold rolling codes; `/backup` becomes a config route).
- Contents of the binary backup file (it holds no passwords, but it does hold rolling codes; `/backup` needs the token under Secured).

View file

@ -1,5 +1,5 @@
---
status: accepted
status: accepted, amended by ADR-0005
---
# The loop task is the only writer of the shade model
@ -13,6 +13,8 @@ The shade model (rooms, shades, groups and repeaters) is changed only from `loop
## Consequences
> **Amended by [ADR 0005](0005-one-websocket-protocol.md) (socket protocol 2).** The rule above stands. These consequences are replaced: model edits no longer answer "accepted" and report through `requestResult`, because every socket op gets one reply from `loop()`. Reads no longer go through a ModelSnapshot, because `loop()` formats a sync from the live model. The caps on reads and the UI's 5 s `requestResult` wait go with them.
- async_tcp must still never wait for `loop()`, so a model edit's HTTP reply can't block until the change is made. The reply only says the request was accepted; the result reaches clients as a WebSocket event once `loop()` has made the change. We chose that over a chunked HTTP response that `loop()` completes, because pushing results to clients is what the WebSocket is for and it needs no pool of pending responses.
- A read has to reply with data, so it does wait, without blocking: its chunked response sends nothing until `loop()` has filled the read's ModelSnapshot.
- MQTT commands already run on `loop()` (`mqtt.loop()` is called from it), but they go through the LoopQueue too. That gives them the radio back-pressure web commands have (`holdWebCommand`): while the radio is full they wait instead of being refused, and they run in the order they arrived with web requests.

View file

@ -0,0 +1,27 @@
---
status: accepted
---
# The web UI and Home Assistant talk to the device over one websocket protocol
The device was aborting while a browser loaded the web UI. Every open HTTP response holds its own TCP send window, the WiFi driver's copies of frames still on air, and a send buffer, which comes to about 14 KB each. A page load opens about ten of them. Six files with three answered at a time took 41 KB, a websocket connect about 10 KB more, and a `/controller` read about 22 KB with its ModelSnapshot. The idle heap is 80–89 KB. Over a weak WiFi link (-73 dBm, about 6 KB/s) these overlap for seconds until an allocation inside the web server fails and aborts.
We decided that the web UI and the Home Assistant integration do everything except the file transfers over one websocket (protocol 2). One connection means one send window per client, however much the client asks for. HTTP keeps only what a socket can't do; [ADR 0006](0006-two-security-modes-eleven-http-routes.md) lists what stays. The wire format is in [PROTOCOL.md](../PROTOCOL.md).
## Considered Options
- **Cap concurrent HTTP responses (a request gate) and budget the heap per response.** We built it and measured it, then dropped it. Each response still costs its full send window, a page load still opens ten requests, and the socket and `/controller` costs come on top of the gate's budget.
- **Keep HTTP, but make `/controller` smaller.** Rejected: it removes one cost and leaves the parallel connections.
- **Push the whole sync without flow control.** Rejected: after a reboot or a WiFi drop, every client reconnects within seconds. Five clients each queuing a full sync (about 20 KB at the model's maximum size) is more than the free heap.
## Consequences
- **Ops run on `loop()`.** async_tcp only parses an op and hands it over through the LoopQueue. `loop()` reads and writes the live shade model and sends the reply, so [ADR 0002](0002-loop-task-is-the-only-writer-of-the-shade-model.md)'s single-writer rule holds and no ModelSnapshot copy is needed. Long work, such as a WiFi scan, starts at once and reports through events, so `loop()` never stalls.
- **Every op has exactly one reply,** to its sender only, including model edits. `requestResult` and the edit's separate "accepted" step go. A command's reply carries no state; the movement arrives as events.
- **Sync on connect.** The device sends its schema (unless the client has it cached), then the shade model and status, in messages of at most 4 KB. Items are never split across messages. The client sends `next` after each message, and the sync ends with `syncEnd`. Each message is formatted from the live model, and events keep flowing during the sync, so the client upserts items by id and the result is exact without a copy. Settings aren't part of sync; a client asks for them when it needs them.
- **The send queue stays at 32 messages per client.** A full queue closes the client, which reconnects and syncs again. Dropping messages would leave a shade showing the wrong position with no way to notice.
- **A JSON envelope that a binary encoding can reuse.** Every frame is `{"v":2,"t":<type>,"id":<n>,"d":{...}}`, with `id` only on ops and replies. Message types and error codes are numbers that fit one byte and are never reused. A later binary encoding would put the same four fields in a fixed header, be asked for at the upgrade, and use binary websocket frames. The `42[...]` socket.io framing goes.
- **Short keys, described by a schema the device sends.** Payload keys are one or two characters from one global dictionary. Booleans are 1/0, and fields at their default are left out. The schema lists every type, field (name, kind, default) and error code. The firmware's table is the only copy, and its writers can only emit declared fields. `hello` carries the schema's hash, and a client that has it cached reconnects with `?schema=<hash>` to skip it. Clients expand frames to full names, so client code stays readable.
- **Feeds.** High-rate events (received frames, frequency scan) go only to clients that subscribed to that feed.
- **A clean break, released as 4.0.0.** The firmware speaks only protocol 2. The new HA integration falls back to the old HTTP API and events when a device doesn't send `hello`; old integrations stop working against 4.0.0. The web UI checks `v` and shows a banner when the firmware and the web application don't match.
- **Tests.** Native tests cover parsing, the op table, permissions, the sync stream and the largest item fitting in 4 KB. A Python e2e suite, run by hand against a device, checks the protocol and survival under a reconnect storm and a page-load replay, and records fixtures. The web UI's client layer is tested with `node --test`. The HA integration tests its protocol module with plain pytest and the integration with pytest-homeassistant-custom-component, against a fake device that replays the fixtures.

View file

@ -0,0 +1,32 @@
---
status: accepted
---
# Two security modes, and eleven HTTP routes on one server
With [ADR 0005](0005-one-websocket-protocol.md) moving everything else to the websocket, HTTP keeps only what has to happen before a socket exists, or moves a file. There is one server, on port 80, and the socket is served from it at `ws://host/ws`. Ports 8081 and 8080 are removed in 4.0.0 with no alias period, because that release breaks the HA integration contract anyway.
| Route | Why it stays HTTP |
|---|---|
| static web assets | the page has to load before it can open a socket |
| `/loginContext` | the security mode and device info, needed before login |
| `/login` | gets the token the socket needs |
| `/upnp.xml` | SSDP description (Windows' Network view, routers) |
| `/backup` | a file the browser saves |
| `/restore`, `/updateFirmware`, `/updateApplication` | file uploads |
| `/shadeCommand`, `/groupCommand`, `/tiltCommand` | one-line commands for scripts and Node-RED |
Static serving covers the web assets only, so `shades.cfg` and the other config files are never served. The script commands keep today's full field names, since people write those requests by hand. `/discovery` goes: Home Assistant's setup reads the device info from `/loginContext`, and mDNS still advertises `serverId`.
There are two security modes. **None** leaves everything open. **Secured** (PIN or password) needs the token on the socket upgrade (`?apikey=`) and on every route except the static files, `/loginContext`, `/login` and `/upnp.xml`. Devices set to "Secure Config Only" become Secured when they upgrade. The rest of the security work (the token key, the login backoff, CORS, the Origin check) is in [SECURITY_PLAN.md](../SECURITY_PLAN.md).
## Considered Options
- **Keep control and config access levels ("Secure Config Only").** Rejected: every op would need a level, and the mode was bypassed in practice, since nothing checked config routes for the config level.
- **Keep port 8081 for scripts.** Rejected: one server costs less memory, and scripts only change a port number.
- **Keep `/discovery` for scripts.** Rejected: it was the only HTTP read left of the shade model, and it alone kept the ModelSnapshot copy alive. A script that needs live state should use the socket.
## Consequences
- The release notes for 4.0.0 must cover: scripts move from port 8081 to 80, the HA integration must be updated, and "Secure Config Only" devices now ask for login on the main page.
- The `_espsomfy_rts._tcp` mDNS record, which Home Assistant's discovery matches, moves from port 8080 to 80 and gains the socket path as a TXT entry.

View file

@ -0,0 +1,17 @@
---
status: accepted
---
# The firmware no longer updates itself from GitHub
The device fetched GitHub's release list, downloaded firmware and filesystem images, and checked daily for a newer release, all over HTTPS on the device itself. A TLS session needs one contiguous block of about 35–40 KB. On IDF 5.5 the heap's largest free block is often smaller (issue #76: "SSL - Memory allocation failed" with a 34.8 KB largest block). The download checked no certificate (`setInsecure()`), so anyone who could intercept it could serve their own firmware. Parts of it ran blocking on the web server's task, and its socket events from there were dropped. The web UI's update buttons sent PUT to routes that accept only GET, so they had been answering 404.
We removed it: the release check and its setting, the release list, download and cancel, `/recoverFilesystem` and the `updateProgress` event. Updates are done by uploading `firmware.bin` and `littlefs.bin` (`/updateFirmware`, `/updateApplication`), or over USB.
## Consequences
- Users download releases from GitHub themselves. The web UI's firmware page shows the installed version and the upload form.
- Home Assistant's update entity shows the installed version only; it can no longer install.
- A broken filesystem is recovered by uploading `littlefs.bin`, or over USB, rather than with `/recoverFilesystem`.
- Issue #76 and PR #72 (GitOTA's per-chip littlefs file name) become obsolete.
- The HTTPS client leaves the firmware, which frees flash in the 1.5 MB OTA slot.