mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 22:02:17 +01:00
Merge pull request #38 from cjkas/refactor/entry-points-outputs
refactor: move the transports into entryPoints and outputs, and plan the LoopQueue
This commit is contained in:
commit
5fd5feda28
20 changed files with 321 additions and 15 deletions
4
AGENTS.md
Normal file
4
AGENTS.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
## Testing instructions
|
||||
- Fix any test or type errors until the whole suite is green.
|
||||
- Add or update tests for the code you change, even if nobody asked.
|
||||
- to test run `pio test -e native`
|
||||
59
GLOSSARY.md
59
GLOSSARY.md
|
|
@ -36,6 +36,14 @@ Turns the times between edges of a received transmission into a frame's bits: it
|
|||
|
||||
## Shades
|
||||
|
||||
**Shade**
|
||||
Any motor the firmware drives: a roller, blind, awning, shutter, drapery, garage door, gate or dry contact.
|
||||
_Avoid_: blind (one type of shade), cover (Home Assistant's word)
|
||||
|
||||
**Shade model**
|
||||
Everything the user has set up: rooms, shades, groups and repeaters, with their state.
|
||||
_Avoid_: installation, config
|
||||
|
||||
**Favorite**
|
||||
The position a motor stores and goes to on My. A shade may have none.
|
||||
_Avoid_: My position
|
||||
|
|
@ -51,3 +59,54 @@ The per-shade switch that lets the sun move a shade. While it is on, lasting sun
|
|||
|
||||
**Extend / retract**
|
||||
Where the weather sends a shade: extend to its favorite, or fully when it has none; retract fully. A tilt-only shade extends and retracts its tilt, every other shade its lift.
|
||||
|
||||
## Entry points and outputs
|
||||
|
||||
**Entry point**
|
||||
Where a request from outside reaches the firmware: an HTTP route, an MQTT command topic or a frame received from a remote.
|
||||
_Avoid_: controller (it already names the owner of the shade list and the `/controller` read)
|
||||
|
||||
**WebUiRoutes**
|
||||
The HTTP routes on port 80 that the config web UI calls, plus the UI's own files.
|
||||
_Avoid_: WebUiController, Web
|
||||
|
||||
**ApiRoutes**
|
||||
The HTTP routes on port 8081 that integrations such as Home Assistant call.
|
||||
_Avoid_: WebApiController
|
||||
|
||||
**MqttCommands**
|
||||
The MQTT topics ending in `/set` that command a shade or group.
|
||||
_Avoid_: MQTTController
|
||||
|
||||
**MqttPublisher**
|
||||
What sends the state of rooms, shades and groups, and the Home Assistant discovery config, to the MQTT broker.
|
||||
_Avoid_: MQTTController
|
||||
|
||||
**SocketEmitter**
|
||||
What pushes state events to WebSocket clients on port 8080. A client can only join or leave a room; it sends no commands.
|
||||
_Avoid_: WebSocketController
|
||||
|
||||
## Domain modules
|
||||
|
||||
**Domain module**
|
||||
A module that holds the rules of one part of the domain, such as what a shade request does. Entry points call it; it never calls an output itself.
|
||||
_Avoid_: service, business service
|
||||
|
||||
**Shade request**
|
||||
A request from outside to command a shade or group, set or clear its favorite, unpair it, or correct its position or rolling code. It names the shade or group and gives positions as the client sees them.
|
||||
_Avoid_: web command
|
||||
|
||||
**ShadeRequests**
|
||||
The domain module that carries out shade requests, whichever entry point they came from.
|
||||
|
||||
**ModelEditor**
|
||||
The domain module that adds, changes, deletes, links and orders the rooms, shades, groups and repeaters of the shade model.
|
||||
_Avoid_: ShadeConfig, InstallationEditor
|
||||
|
||||
**ModelSnapshot**
|
||||
A copy of the shade model taken for one read, so the reply describes a single moment.
|
||||
_Avoid_: InstallationSnapshot
|
||||
|
||||
**LoopQueue**
|
||||
The single queue through which shade requests, model edits and snapshot requests reach `loop()` from other tasks, in the order they arrived.
|
||||
_Avoid_: web command queue
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
## Concurrent mutation during chunked `/controller` response
|
||||
|
||||
**Status:** open
|
||||
**Introduced:** 2026-04-19, alongside the chunked-response conversion of `/controller` (see [`ControllerChunker` in src/Web.cpp](../src/Web.cpp) and the original crash report about `cbuf::resize` aborting `async_tcp`).
|
||||
**Introduced:** 2026-04-19, alongside the chunked-response conversion of `/controller` (see [`ControllerChunker` in src/entryPoints/Web.cpp](../src/entryPoints/Web.cpp) and the original crash report about `cbuf::resize` aborting `async_tcp`).
|
||||
|
||||
### Summary
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ The new `ControllerChunker` reads state as the response drains. On a slow link o
|
|||
## Oversized `/controller` items are left out
|
||||
|
||||
**Status:** open
|
||||
**Location:** `ControllerChunker::overflowed` in [src/Web.cpp](../src/Web.cpp).
|
||||
**Location:** `ControllerChunker::overflowed` in [src/entryPoints/Web.cpp](../src/entryPoints/Web.cpp).
|
||||
|
||||
### Summary
|
||||
|
||||
|
|
@ -67,7 +67,7 @@ A shade with every linked remote populated is about 1.5 KB today, so nothing ove
|
|||
## Large websocket events are dropped
|
||||
|
||||
**Status:** open
|
||||
**Location:** [`JsonSockEvent` in src/WResp.cpp](../src/WResp.cpp), buffer `g_response[MAX_SOCK_RESPONSE]` (2048 bytes) in [src/Sockets.cpp](../src/Sockets.cpp).
|
||||
**Location:** [`JsonSockEvent` in src/WResp.cpp](../src/WResp.cpp), buffer `g_response[MAX_SOCK_RESPONSE]` (2048 bytes) in [src/outputs/Sockets.cpp](../src/outputs/Sockets.cpp).
|
||||
|
||||
### Summary
|
||||
|
||||
|
|
@ -90,3 +90,35 @@ Unlike the `/controller` HTTP crash, this path does **not** abort — there is n
|
|||
|
||||
- Not the same code path as the `/controller` crash. Solve independently.
|
||||
- Worth grepping for `JsonSockEvent` usages that iterate collections (see references in `SomfyEvents.cpp`, `Transceiver.cpp`, `ESPNetwork.cpp`, `GitOTA.cpp`) to identify the most at-risk events.
|
||||
- [Group reads emit events from the web server task](#group-reads-emit-events-from-the-web-server-task): a second task writing the same buffer.
|
||||
|
||||
## Group reads emit events from the web server task
|
||||
|
||||
**Status:** open
|
||||
**Location:** `serializeGroupRef` in [src/entryPoints/Web.cpp](../src/entryPoints/Web.cpp) calls `SomfyGroup::updateFlags()` in [src/SomfyGroup.cpp](../src/SomfyGroup.cpp).
|
||||
|
||||
### Summary
|
||||
|
||||
Serializing a group for an HTTP reply first calls `group->updateFlags()`. When the group's flags no longer match its shades', that writes `group->flags`, sends a `groupState` socket event and publishes the group's MQTT topics, all on the async_tcp task, while `loop()` sends events and publishes too.
|
||||
|
||||
Every socket event is formatted into the one static buffer `g_response` in [src/outputs/Sockets.cpp](../src/outputs/Sockets.cpp) through the one `JsonSockEvent` in `sockEmit`. Two tasks emitting at once write over each other's event. The MQTT client (PubSubClient) isn't safe to use from two tasks either.
|
||||
|
||||
Reached from every route that serializes a group: `/controller`, `/discovery`, `/groups`, `/group`, `/groupCommand`, `/addGroup`, `/saveGroup`, `/linkToGroup`, `/unlinkFromGroup` and others.
|
||||
|
||||
### Concrete failure modes
|
||||
|
||||
1. A `groupState` from a `/groups` request and a `shadeState` from `loop()` are formatted at the same time: clients receive garbled JSON, or an event with another event's fields.
|
||||
2. The group's MQTT publish interleaves with one from `loop()` in the client's buffer, and the broker gets a broken packet or the connection drops.
|
||||
3. `group->flags` is written on async_tcp while `loop()` updates the same group through `updateGroupFlags()`.
|
||||
|
||||
It needs a group whose flags are out of date at the moment a client reads it, for example right after a sensor frame changed a linked shade's sun or wind flags, so it is rare.
|
||||
|
||||
### Fix options
|
||||
|
||||
- **(a) Drop `updateFlags()` from the serializers.** `loop()` already keeps group flags current through `updateGroupFlags()`. This is part of step 2 of [LOOP_QUEUE_PLAN.md](LOOP_QUEUE_PLAN.md): reads come from a ModelSnapshot that `loop()` fills, and serializers only read.
|
||||
- **(b) Only emit from `loop()`.** Covered by ADR-0002 once nothing outside `loop()` changes the shade model.
|
||||
|
||||
### Related
|
||||
|
||||
- [ADR-0002](adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md): the loop task is the only writer of the shade model.
|
||||
- [Large websocket events are dropped](#large-websocket-events-are-dropped): the same `g_response` buffer.
|
||||
|
|
|
|||
144
docs/LOOP_QUEUE_PLAN.md
Normal file
144
docs/LOOP_QUEUE_PLAN.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# Plan: domain modules behind the LoopQueue
|
||||
|
||||
**Status:** planned, nothing built yet.
|
||||
**Written:** 2026-10-15, against commit `e624a41` on `refactor/entry-points-outputs`. Terms are defined in [GLOSSARY.md](../GLOSSARY.md); the threading rule is [ADR-0002](adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md).
|
||||
|
||||
## Why
|
||||
|
||||
- **Two translators for the same requests.** HTTP (`Web::processQueue`) and MQTT (`MQTTClass::receive`) each turn a request into shade calls, and they have drifted apart:
|
||||
- MQTT `tiltTarget`, `mypos` and `tiltPosition` ignore `flipPosition`;
|
||||
- `/setPositions` ignores it the other way round;
|
||||
- `repeat=0` means "default" for shades but "no repeats" for groups.
|
||||
- **Writers on the web task.** Model edits (`/addShade`, `/linkRemote`, `/setPaired`, ...) and every read (`/controller`, `/shades`, `/discovery`, ...) touch the shade model on the async_tcp task while `loop()` changes it. See [KNOWN_ISSUES.md](KNOWN_ISSUES.md) and ADR-0002.
|
||||
- **Publishing depends on discipline.** Each `p_*` setter publishes its own MQTT topic, and `emitState()` calls are placed by hand. Writes that skip a setter are never published, and MQTT `position` has been a constant 50 since `c94819e`.
|
||||
- **No native tests.** None of this compiles in the native env, so it has no tests.
|
||||
|
||||
## Shape
|
||||
|
||||
```
|
||||
WebUiRoutes ─┐ ┌─► ShadeRequests ─► shades/groups ─► radio
|
||||
ApiRoutes ──┼─► LoopQueue ─► loop() ┼─► ModelEditor ─► shade model
|
||||
MqttCommands ┘ (one FIFO) └─► ModelSnapshot fill ─► read reply (chunked)
|
||||
│ dirty bits, once per loop pass
|
||||
▼
|
||||
MqttPublisher · SocketEmitter
|
||||
```
|
||||
|
||||
| Module | Covers | Called by |
|
||||
|---|---|---|
|
||||
| **ShadeRequests** | shade and group commands; favorite and pairing; position and rolling-code correction | WebUiRoutes, ApiRoutes, MqttCommands |
|
||||
| **ModelEditor** | add, save, delete, link and sort rooms, shades, groups and repeaters | WebUiRoutes |
|
||||
| **ModelSnapshot** | the eight read routes: `/discovery` `/controller` `/rooms` `/shades` `/groups` `/room` `/shade` `/group` | WebUiRoutes, ApiRoutes |
|
||||
|
||||
**Settings, network, radio config, firmware and discovery** have one transport each and little logic of their own. They keep calling the modules they use today (`ConfigSettings`, `ESPNetwork`, `Transceiver`, `GitOTA`, `SSDP`); a new module would only pass calls through. The exception is pure validation, such as the pin-conflict check, which moves into a function the native tests can reach.
|
||||
|
||||
## Decisions
|
||||
|
||||
### LoopQueue
|
||||
- One FIFO from every other task into `loop()`. It carries shade requests, model edits and snapshot fills.
|
||||
- No item jumps the queue, model edits included.
|
||||
- MQTT goes through it too, although it already runs on `loop()`. That gives MQTT the radio back-pressure that web commands have (`holdWebCommand`) and keeps arrival order.
|
||||
|
||||
### ShadeRequests
|
||||
- **Interface:** one request value, grown out of `web_command_t`. Positions are given as the client sees them.
|
||||
- **It owns:**
|
||||
- looking up the shade or group;
|
||||
- flipping positions;
|
||||
- range checks;
|
||||
- repeat defaults;
|
||||
- refusing a request when the radio can't take it.
|
||||
- **Test seam:** the shade sits behind exactly the operations `processQueue` calls today:
|
||||
- `moveToTarget`, `moveToTiltTarget`;
|
||||
- `sendCommand`, `sendTiltCommand`, `repeatFrame`/`isLastCommand`, `sendSensorCommand`;
|
||||
- `setMyPosition`, unpair, `setRollingCode`, set positions;
|
||||
- a lookup giving the shade's or group's existence, flip and tilt type.
|
||||
- **Two implementations behind it:** the real one forwards to `SomfyShade`/`SomfyGroup`; a fake records the calls in native tests.
|
||||
- **Later:** when the shade behaviour itself is deepened (candidate #1 of the architecture review), only the real implementation changes.
|
||||
|
||||
### ModelEditor
|
||||
- **What it takes over** from the `Web.cpp` handlers and `SomfyShadeController`:
|
||||
- id and sort-order allocation (the six `getNext*Id`/`getMax*Order` copies);
|
||||
- limits;
|
||||
- validation;
|
||||
- linking rules: remote to shade, shade to group, repeaters;
|
||||
- what is committed and which events go out after each edit.
|
||||
- **What `SomfyShadeController` keeps:** storage and lookup.
|
||||
- **Request bodies:**
|
||||
- The handler copies the JSON body to the heap and queues a pointer to it.
|
||||
- `loop()` parses and validates it with the code in `SomfyJson.cpp`; keeping it there avoids a second ArduinoJson file.
|
||||
- At most one edit is in flight; another gets 503. A body over 4 KB gets 413.
|
||||
- **Reply and result:**
|
||||
- The HTTP reply is `{status: "accepted", requestId}`.
|
||||
- Once `loop()` has made the change, it sends the existing `…Added`/`…Removed`/`…State` events, with their payloads unchanged (Home Assistant reads `shadeRemoved`).
|
||||
- It also broadcasts `requestResult {requestId, ok, desc, id}`. The UI that sent the request picks it out by `requestId`.
|
||||
- **Web UI:**
|
||||
- It fetches the details it needs through a read route, for example `/shade?shadeId=` after an add.
|
||||
- If no `requestResult` arrives within about 5 s, it shows a popup that says the change wasn't confirmed and offers a reload.
|
||||
- **Socket-handler fixes** in `index.js`, part of this step:
|
||||
- `shadeAdded`/`shadeRemoved` are no-ops;
|
||||
- `procRoomRemoved` keeps the removed room;
|
||||
- the reply body is read even on errors.
|
||||
|
||||
### ModelSnapshot
|
||||
- **One snapshot type** with the reported fields of rooms, shades, groups and repeaters. That is about 5.5 KB at 32 shades, 16 groups and 16 rooms, against roughly 24 KB for a copy of the full `SomfyShade` objects.
|
||||
- **One snapshot per read:**
|
||||
1. The handler allocates the snapshot (one item for `/shade`, `/group` and `/room`) and queues a fill.
|
||||
2. The chunked response returns `RESPONSE_TRY_AGAIN` until `loop()` has filled the snapshot.
|
||||
3. It then streams JSON from that private copy.
|
||||
- **Not in the snapshot:** the parts outside the shade model are built on the fly. That covers the `/discovery` device info and memory figures, and the `/controller` header (limits, radio config, firmware version).
|
||||
- **Limit:** at most 2 reads in flight; a third gets 503.
|
||||
- **Serializers** work from the snapshot, not from `somfy`. They lose `group->updateFlags()`, which sends events from the web task today, and they become testable natively through `JsonFormatter`.
|
||||
|
||||
### Change fan-out
|
||||
- **Marking:**
|
||||
- Each shade and group gets a bitmask with one bit per reported value: position, target, direction, tilt, favorite, sun flags, rolling code, and so on.
|
||||
- The `p_*` setters set the bit, and only when the displayed value changes, as today.
|
||||
- **Sending:** once per `loop()` pass, for each marked shade or group:
|
||||
- the socket gets one `shadeState`/`groupState`;
|
||||
- MQTT gets only the topics whose bits are set;
|
||||
- then the bits are cleared.
|
||||
- **Reconnects:** what an output misses while disconnected comes back on reconnect. MQTT `connect()` publishes everything, and a new socket client gets `initClients()`.
|
||||
- **Events that aren't state** (`shadeCommand`, "remote X pressed Up") stay explicit calls.
|
||||
|
||||
### Flash
|
||||
- **Debug build:** `esp32devdbg` moves to `esp32_huge_app.csv` (one 3 MB app, no OTA) and stays there. It isn't shipped, and at 98.3% of the OTA slot it had 26 KB left.
|
||||
- **Release builds:** the release envs keep their partition tables, and CI builds them. `esp32dev` is at 1,396,957 bytes (88.8%), so 176 KB is free.
|
||||
- **Firmware-update testing** has to use a release build.
|
||||
|
||||
## Steps
|
||||
|
||||
Each step is a PR of its own that can be tested on the device. Each one runs `pio test -e native` and the `esp32devdbg` and `esp32dev` builds.
|
||||
|
||||
| # | Step | Closes |
|
||||
|---|---|---|
|
||||
| 0 | Move `esp32devdbg` to `esp32_huge_app.csv`, in a commit of its own. The device then needs one USB flash (see below). | flash pressure during the refactor |
|
||||
| 1 | LoopQueue + ShadeRequests. Web and MQTT both submit shade requests. Native tests run through the fake shade. | MQTT flip drift, repeat defaults, MQTT commands lost while the radio is full |
|
||||
| 2 | ModelSnapshot for all eight read routes. Native tests cover the serializers. | inconsistent `/controller` documents, events sent from the web task |
|
||||
| 3 | ModelEditor, `requestResult` and the `index.js` changes. | the rest of the web-task writers in KNOWN_ISSUES |
|
||||
| 4 | Change fan-out with dirty bits, replacing publishing from the `p_*` setters. | writes that skip MQTT, `position` stuck at 50 |
|
||||
| 5 | Split `Web.cpp` into WebUiRoutes and ApiRoutes, and `MQTT.cpp`/`SomfyMqtt.cpp` into MqttCommands, an MQTT connection and MqttPublisher. Edit parsing stays in `SomfyJson.cpp`. | the glossary names land in the code |
|
||||
|
||||
### Step 0 on a device
|
||||
NVS stays at `0x9000`, so the rolling codes survive. The file system partition shrinks from `0xE0000` to `0xD0000`, and the existing LittleFS won't mount, because `LittleFS.begin()` doesn't format. So:
|
||||
1. Take a backup through `/backup`.
|
||||
2. Flash the partition table, firmware and file system over USB: `pio run -e esp32devdbg -t upload -t uploadfs`.
|
||||
3. Restore the backup.
|
||||
|
||||
## Risks
|
||||
|
||||
- **async_tcp must never wait.** Every handler only queues and returns. The two kinds of waiting reply, the read routes, poll with `RESPONSE_TRY_AGAIN` and never block.
|
||||
- **Heap.** Edit bodies (one at a time, up to 4 KB) and snapshots (two at a time, about 5.5 KB each) come from the heap. Check the `memStatus` `max` figure on the device after steps 2 and 3.
|
||||
- **The 2 KB socket event limit** (see KNOWN_ISSUES). That is why `requestResult` carries an id and not the object.
|
||||
- **The UI changes ship with the firmware** in step 3, because the UI is served from LittleFS. An older UI against the new firmware would wait for HTTP reply bodies that no longer come.
|
||||
|
||||
## Found during the review, not part of this plan
|
||||
|
||||
These are open, and each can be fixed on its own:
|
||||
- MQTT `position`/`target` published as a constant 50 ([SomfyShade.cpp:305](../src/SomfyShade.cpp#L305), [SomfyMqtt.cpp:36-38](../src/outputs/SomfyMqtt.cpp#L36-L38)). It's a one-line fix that needn't wait for step 4.
|
||||
- A restore without shades skips the shade and group records but not the room records, so it reads the rest of the file from the wrong place ([ConfigFile.cpp:578-580](../src/ConfigFile.cpp#L578-L580)).
|
||||
- The Ethernet pin conflict is only checked for the up pin ([SomfyJson.cpp:63-65](../src/SomfyJson.cpp#L63-L65)).
|
||||
- `linkRepeater` never sets `isDirty`, so a newly linked repeater isn't saved ([ShadeController.cpp:323](../src/ShadeController.cpp#L323)).
|
||||
- `/linkRemote` and `/unlinkRemote` send a 500 and then a second response ([Web.cpp:2055](../src/entryPoints/Web.cpp#L2055), [2083](../src/entryPoints/Web.cpp#L2083)).
|
||||
- `settingPos` is never cleared when the lift arrives, while `settingTiltPos` is. A later arrival may send an extra stop. Not confirmed on a device.
|
||||
- The web UI's `procRoomRemoved` keeps the removed room and drops the others (`data-src/index.js:2184`).
|
||||
- The Home Assistant integration sends no `apikey` header on shade, group and tilt commands.
|
||||
47
docs/NICE_TO_HAVE.md
Normal file
47
docs/NICE_TO_HAVE.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
stop busy-waiting the CPU while transmitting
|
||||
Today the CPU generates the radio waveform itself. sendFrame flips the TX pin and spins in delayMicroseconds() between flips. While it spins, the calling task can do nothing else. There are two ways out.
|
||||
|
||||
Option A: let the RMT peripheral generate the waveform (best)
|
||||
The ESP32 has a hardware block called RMT (Remote Control Transceiver), built for IR and RF pulse trains:
|
||||
|
||||
You give it a list of {level, duration} items, and it drives the pin with exact timing.
|
||||
During that time the CPU is free.
|
||||
With a 1 µs tick, one item can last up to about 32 ms. That covers the longest pieces: the 10.9 ms wake-up pulse and the 27.4 ms gap between frames.
|
||||
The encoding maps directly onto the existing sendFrame logic:
|
||||
|
||||
Current code RMT items
|
||||
Wake-up: high 10920 µs, low 7357 µs 1 item
|
||||
Each hardware sync pulse: high 2560, low 2560 1 item each
|
||||
Software sync: high 4850, low 640 1 item
|
||||
Bit 1: low 640, high 640 1 item
|
||||
Bit 0: high 640, low 640 1 item
|
||||
Gap between frames: low 27434 1 item
|
||||
A 56-bit frame comes to about 75 items, and a frame with all its repeats is a few hundred. The legacy driver in the Arduino 2.x core you use (driver/rmt.h) handles buffers larger than the channel's 64-item memory by refilling it from an interrupt. You call rmt_write_items(ch, items, n, true), and the calling task sleeps on a semaphore until the transmission is done instead of spinning. The C6 env is on core 3.x, which uses the newer rmt_tx API, so it needs its own #if branch.
|
||||
|
||||
What you gain:
|
||||
|
||||
No CPU time is spent on timing.
|
||||
The timing is exact. Today, async_tcp (priority 10) and interrupts can preempt loopTask (priority 1) in the middle of a frame and stretch a pulse. You probably get away with it because Somfy receivers are tolerant.
|
||||
You can still call it synchronously from loop() if you want, because the wait doesn't hold up other tasks.
|
||||
The cost:
|
||||
|
||||
sendFrame has to be rewritten.
|
||||
The waveforms should be checked on a logic analyser, especially the 80-bit frames and their repeat encoding.
|
||||
|
||||
The chips differ in channel count and buffer size:
|
||||
|
||||
Chip RMT channels Memory per channel (items) DMA In this project
|
||||
ESP32 (classic) 8, each can be TX or RX 64 No esp32dev, esp32devdbg
|
||||
ESP32-S2 4, each can be TX or RX 64 No –
|
||||
ESP32-S3 4 TX + 4 RX 48 Yes (one TX channel) esp32s3
|
||||
ESP32-C3 2 TX + 2 RX 48 No esp32c3
|
||||
ESP32-C6 2 TX + 2 RX 48 No esp32c6
|
||||
ESP32-H2 2 TX + 2 RX 48 No –
|
||||
ESP32-P4 4 TX + 4 RX 48 Yes –
|
||||
ESP32-C2 (ESP8684) None – – –
|
||||
What matters for the Somfy transmitter
|
||||
One TX channel is enough. The radio has a single TX pin, so even the C3/C6 with 2 TX channels have one to spare.
|
||||
Memory size doesn't limit you. A 56-bit frame is about 75 items, more than one 48- or 64-item block. The driver refills the buffer from an interrupt while it transmits, or the S3/P4 can use DMA. Either way you hand it the whole frame in one call.
|
||||
The API depends on the core version, not the chip. This is where the real porting work is:
|
||||
esp32dev, esp32c3, esp32s3 use platform espressif32 @ 6.13.0, which is Arduino core 2.x / IDF 4.4. That has the legacy driver/rmt.h API (rmt_config, rmt_write_items).
|
||||
esp32c6 uses pioarduino, which is Arduino core 3.x / IDF 5. The legacy driver is deprecated there, and you use the new driver/rmt_tx.h API with an encoder, or Arduino's rmtInit() / rmtWrite().
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
**Status:** done. Phases 0 and 1 in `bbfb23e`..`505108c`, phase 2 in `0eb0e63`..`a176017` (2026-10-13).
|
||||
**Written:** 2026-10-13, against commit `4544eb6` on `fix/live-bugs`. Line numbers in the analysis below refer to that commit. The code now lives in the files listed under [Phase 1](#phase-1-split-the-file-by-concern).
|
||||
Since then `SomfyMqtt.cpp` and `SomfyEvents.cpp` have moved to `src/outputs/`.
|
||||
|
||||
## Why
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
status: accepted
|
||||
---
|
||||
|
||||
# The loop task is the only writer of the shade model
|
||||
|
||||
The shade model (rooms, shades, groups and repeaters) is changed only from `loop()` (the Arduino `loopTask`). Code on any other task, mainly the HTTP handlers on the async_tcp task, hands its change over as a request through the LoopQueue and reads the shade model only from a ModelSnapshot that `loop()` fills. On a dual-core ESP32 the two tasks run at the same instant, and on a single-core one async_tcp preempts `loop()` mid-step, so a second writer races `loop()`. Examples: `/linkRemote` setting a rolling code that a received frame overwrites a moment later, `/deleteShade` clearing a shade halfway through `checkMovement`, both tasks writing `/shades.cfg` and the plain `isDirty` flag, and `/controller` streaming a document in which one shade has two positions.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **A mutex around the shade model.** Rejected: every reader and writer has to remember to take it, and a missed lock is a silent bug. A chunked `/controller` response would hold it for seconds and stop `loop()` from receiving frames and moving shades in the meantime.
|
||||
- **Keep the mix.** Shade commands were already queued while model edits and reads ran on async_tcp. Rejected because the races above stay.
|
||||
|
||||
## Consequences
|
||||
|
||||
- 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).
|
||||
- With one writer, the domain modules are plain single-threaded code and can be tested natively without threads.
|
||||
|
|
@ -13,11 +13,11 @@ static inline void safe_wdt_reset() {
|
|||
}
|
||||
#include "ConfigSettings.h"
|
||||
#include "ESPNetwork.h"
|
||||
#include "Web.h"
|
||||
#include "Sockets.h"
|
||||
#include "entryPoints/Web.h"
|
||||
#include "outputs/Sockets.h"
|
||||
#include "Utils.h"
|
||||
#include "SSDP.h"
|
||||
#include "MQTT.h"
|
||||
#include "entryPoints/MQTT.h"
|
||||
|
||||
static const char *TAG = "Network";
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
#include "ConfigSettings.h"
|
||||
#include "GitOTA.h"
|
||||
#include "Utils.h"
|
||||
#include "Sockets.h"
|
||||
#include "outputs/Sockets.h"
|
||||
#include "Somfy.h"
|
||||
#include "Web.h"
|
||||
#include "entryPoints/Web.h"
|
||||
#include "WResp.h"
|
||||
#include "ESPNetwork.h"
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
#include <esp_task_wdt.h>
|
||||
#include "ConfigSettings.h"
|
||||
#include "ESPNetwork.h"
|
||||
#include "Web.h"
|
||||
#include "Sockets.h"
|
||||
#include "entryPoints/Web.h"
|
||||
#include "outputs/Sockets.h"
|
||||
#include "Utils.h"
|
||||
#include "Somfy.h"
|
||||
#include "MQTT.h"
|
||||
#include "entryPoints/MQTT.h"
|
||||
#include "GitOTA.h"
|
||||
#include "esp_core_dump.h"
|
||||
#include "esp_system.h"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
#include "Utils.h"
|
||||
#include "ConfigSettings.h"
|
||||
#include "Somfy.h"
|
||||
#include "Sockets.h"
|
||||
#include "outputs/Sockets.h"
|
||||
|
||||
static const char *TAG = "Somfy";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
#include <stdint.h>
|
||||
#include "SomfyFrame.h"
|
||||
|
||||
// Commands the web servers hand to the main loop (src/Web.cpp). Kept free of
|
||||
// Commands the web servers hand to the main loop (src/entryPoints/Web.cpp). Kept free of
|
||||
// the web server and FreeRTOS so it can be unit tested on the PC
|
||||
// (pio test -e native).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
#include <esp_task_wdt.h>
|
||||
#include "Somfy.h"
|
||||
#include "Sockets.h"
|
||||
#include "MQTT.h"
|
||||
#include "entryPoints/MQTT.h"
|
||||
|
||||
extern SomfyShadeController somfy;
|
||||
extern SocketEmitter sockEmit;
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
#include "ConfigSettings.h"
|
||||
#include "Somfy.h"
|
||||
#include "Sockets.h"
|
||||
#include "MQTT.h"
|
||||
#include "entryPoints/MQTT.h"
|
||||
|
||||
extern SomfyShadeController somfy;
|
||||
extern SocketEmitter sockEmit;
|
||||
Loading…
Add table
Add a link
Reference in a new issue