From 77703a9855bd22ec25870b659bed060011c8842a Mon Sep 17 00:00:00 2001 From: cjkas Date: Fri, 16 Oct 2026 10:05:22 +0200 Subject: [PATCH 1/7] refactor: serve the read routes from a ModelSnapshot that loop() fills /controller, /discovery, /rooms, /shades, /groups, /room, /shade and /group now take a SnapshotSlots slot, queue a fill on the LoopQueue and send a chunked response whose body waits (RESPONSE_TRY_AGAIN) until loop() has copied the shade model into the slot. SnapshotWriter then streams the document from that copy a unit at a time, so each document describes one moment. At most two reads run at once; a third gets 503. A slot's 5.6 KB snapshot is allocated only while a read holds it, and its atomic state decides whether loop() or the read frees it when the read goes away first. The JSON of each item is unchanged and now lives in ModelSnapshot.cpp, where the native tests check it. Replies still written on the web server task copy their one item with SomfySnapshot.cpp, so serializing a group no longer calls updateFlags(), which emitted groupState and published MQTT from async_tcp. Also: every read route is chunked and leaves out an item too big for its 3 KB unit, like /controller did; /discovery drops its timing log; the SOMFY_MAX_* limits move to ShadeTypes.h. 21 new native tests, 292 in all. Flash: esp32devdbg +440 bytes, esp32dev +2,064 bytes. Co-Authored-By: Claude Opus 5.5 --- docs/KNOWN_ISSUES.md | 47 +-- docs/LOOP_QUEUE_PLAN.md | 16 +- platformio.ini | 2 +- src/LoopQueue.cpp | 5 + src/LoopQueue.h | 4 +- src/ModelSnapshot.cpp | 394 +++++++++++++++++++ src/ModelSnapshot.h | 187 +++++++++ src/ShadeTypes.h | 12 +- src/Somfy.h | 8 - src/SomfyController.ino | 2 + src/SomfySnapshot.cpp | 102 +++++ src/SomfySnapshot.h | 17 + src/entryPoints/Web.cpp | 548 ++++++--------------------- test/test_snapshot/test_snapshot.cpp | 296 +++++++++++++++ 14 files changed, 1163 insertions(+), 477 deletions(-) create mode 100644 src/ModelSnapshot.cpp create mode 100644 src/ModelSnapshot.h create mode 100644 src/SomfySnapshot.cpp create mode 100644 src/SomfySnapshot.h create mode 100644 test/test_snapshot/test_snapshot.cpp diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index e939526..003cc61 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -2,51 +2,36 @@ ## Concurrent mutation during chunked `/controller` response -**Status:** open -**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`). +**Status:** fixed for the read routes; the web-task writers below are still open. +**Introduced:** 2026-04-19, alongside the chunked-response conversion of `/controller` (the original crash report was about `cbuf::resize` aborting `async_tcp`). +**Fixed:** 2026-10-16, step 2 of [LOOP_QUEUE_PLAN.md](LOOP_QUEUE_PLAN.md). -### Summary +### What it was -The chunked streaming of `/controller` lazily reads `somfy.rooms`, `somfy.shades`, `somfy.groups`, and `somfy.repeaters` across many `async_tcp` callback invocations as the client's TCP send window opens. If another task mutates those arrays mid-stream, the rendered JSON can be internally inconsistent. +The chunked `/controller` response read `somfy.rooms`, `somfy.shades`, `somfy.groups` and `somfy.repeaters` on async_tcp a piece at a time as the client's TCP window opened, for hundreds of ms to seconds. Anything that changed them meanwhile could make the document contradict itself: a deleted shade in `shades` but not in a group's `linkedShades`, two positions for one shade, a linked shade skipped or written twice. `/discovery`, `/shades` and the other reads had the same exposure, only shorter. -### Why this is strictly worse than the pre-chunked code +All eight read routes (`/controller`, `/discovery`, `/rooms`, `/shades`, `/groups`, `/room`, `/shade`, `/group`) now stream a ModelSnapshot that `loop()` fills, so each document describes one moment ([ADR-0002](adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md)). That is fix option (c), taken per read in a heap slot of about 5.6 KB. -The old `handleController` ran the serializers synchronously inside a single request-handler invocation, so the full JSON was built in memory *before* any bytes were sent. Window of exposure: a few milliseconds. +### Still open -The new `ControllerChunker` reads state as the response drains. On a slow link or under backpressure, that window is hundreds of ms to seconds. - -### Concrete failure modes - -1. A shade is deleted mid-stream (e.g. via `/deleteShade`) — it may appear in the `shades` array but be missing from a later group's `linkedShades`, or vice versa. -2. `lastRollingCode` / position fields change between the `shades` pass and a later group's `linkedShades` pass — the client sees two values for the same shade in one document. -3. A group's `linkedShades` list is mutated while the chunker iterates inside `S_GROUPS` — an entry is skipped or emitted twice. - -### Fix options (pick one later) - -- **(a) Document and accept.** In practice users rarely mutate shades while the config UI is loading. Zero code change. -- **(b) FreeRTOS mutex around `somfy` reads/writes.** Acquire for the full duration of the chunked response and in every mutating path (RF RX, MQTT, web handlers). Cleanest but wide-reaching. -- **(c) Up-front snapshot.** At handler start, copy the subset of `somfy` state the response will serialize into the `ControllerChunker`. Defeats part of the memory benefit — a full snapshot of shades + groups is close in size to the old growing cbuf. Could be reduced by snapshotting only minimal fields (IDs, names, rolling codes) and reading the rest live. - -### Related - -- Same exposure exists in any other endpoint converted to chunked responses next (`/discovery`, `/shades`). Resolve this issue before expanding the pattern. -- Other handlers on the web server task still change the shade model that `loop()` owns: +- Command and edit replies (`/shadeCommand`, `/saveShade`, `/linkRemote`, ...) still copy the one item they report from the live shade model on async_tcp, so a reply can show a value from the middle of a change. `/room`, `/shade` and `/group` also check on the live model whether the item exists, because the status goes out before the snapshot is filled. +- Other handlers on the web server task still change the shade model that `loop()` owns (step 3 of the plan): - `/addRoom`, `/saveRoom`, `/deleteRoom`, `/addShade`, `/saveShade`, `/deleteShade`, `/addGroup`, `/saveGroup` and `/deleteGroup` add, change and remove rooms, shades and groups. - `/linkRemote` and `/unlinkRemote` change a shade's linked remotes. `/linkRemote` also sets a linked remote's rolling code, which `loop()` updates whenever it receives a frame from that remote: the last write wins, so a received frame can undo the code just set. - `/linkToGroup` and `/unlinkFromGroup` change a group's linked shades. - `/setPaired` sets `paired`. Shade requests (shade and group commands, set-My, unpair, `/setPositions`, `/setRollingCode`, and every MQTT command), a shades restore and `/updateShadeConfig` already go through the LoopQueue to `loop()`. -- `/controller` leaves out a room, shade, group or linked shade that doesn't fit in its chunk buffer, see [Oversized `/controller` items are left out](#oversized-controller-items-are-left-out). +- The read routes leave out an item that doesn't fit their 3 KB unit, see [Oversized read items are left out](#oversized-read-items-are-left-out). -## Oversized `/controller` items are left out +## Oversized read items are left out **Status:** open -**Location:** `ControllerChunker::overflowed` in [src/entryPoints/Web.cpp](../src/entryPoints/Web.cpp). +**Location:** `SnapshotWriter::overflowed` in [src/ModelSnapshot.cpp](../src/ModelSnapshot.cpp). ### Summary -`ControllerChunker` builds each room, shade, group and linked shade into a 3 KB buffer before sending it. When one doesn't fit, the chunker logs an `ESP_LOGE` line, skips that item and carries on, so the document stays valid JSON. The client gets no sign that anything is missing: the item just isn't in the list. +Every read route builds each room, shade, group and linked shade into a 3 KB unit before sending it. When one doesn't fit, the writer logs an `ESP_LOGE` line, skips that item and carries on, so the document stays valid JSON. The client gets no sign that anything is missing: the item just isn't in the list. Before step 2 only `/controller` did this; `/discovery`, `/shades` and the rest grew their buffer instead. A shade with every linked remote populated is about 1.5 KB today, so nothing overflows yet. It becomes a silent loss as soon as a change to the shade, room or group fields pushes an item past 3 KB. @@ -90,12 +75,12 @@ 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](#group-reads-emit-events-from-the-web-server-task) (fixed): 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). +**Status:** fixed 2026-10-16, step 2 of [LOOP_QUEUE_PLAN.md](LOOP_QUEUE_PLAN.md) (fix option (a)). Read routes write from a ModelSnapshot, and the replies still written on async_tcp copy the group with `snapshotGroup()`, which works its flags out without changing the group or sending anything. +**Location:** `serializeGroupRef` in [src/entryPoints/Web.cpp](../src/entryPoints/Web.cpp) called `SomfyGroup::updateFlags()` in [src/SomfyGroup.cpp](../src/SomfyGroup.cpp). ### Summary diff --git a/docs/LOOP_QUEUE_PLAN.md b/docs/LOOP_QUEUE_PLAN.md index f9fcba3..7a68701 100644 --- a/docs/LOOP_QUEUE_PLAN.md +++ b/docs/LOOP_QUEUE_PLAN.md @@ -1,6 +1,6 @@ # Plan: domain modules behind the LoopQueue -**Status:** steps 0 and 1 built (PRs #39 and #40); steps 2–5 planned. +**Status:** steps 0–2 built (PRs #39, #40 and #41); steps 3–5 planned. **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 @@ -130,6 +130,20 @@ Each step is a PR of its own that can be tested on the device. Each one runs `pi Flash: `esp32devdbg` 1,546,405 → 1,547,381 bytes, `esp32dev` 1,396,957 → 1,398,337 bytes. +### Step 2: what changed in behaviour +- **Each read describes one moment.** `/controller`, `/discovery`, `/rooms`, `/shades`, `/groups`, `/room`, `/shade` and `/group` stream a ModelSnapshot that `loop()` fills. The first two KNOWN_ISSUES items about `/controller` are fixed for reads. +- **Every read is chunked.** `/discovery`, `/shades` and the rest used to be built whole into a growing response buffer, the path of the old `cbuf::resize` crash. +- **A read waits for `loop()`.** The headers go out at once; the body follows once the snapshot is filled. That is usually one `loop()` pass, but if the headers' ACK comes back first the library only asks again on its next poll, up to about 500 ms later. A read queued behind a command that waits for the radio waits with it. +- **At most two reads at once.** A third gets 503 "Busy, try the read again". Each read holds a 5.6 KB snapshot and a 3 KB unit on the heap only while it runs. +- **An item too big for the 3 KB unit is left out of every read route**, not only `/controller` (see KNOWN_ISSUES). +- **`/room`, `/shade` and `/group`** still answer 500 for an id that doesn't exist. If the item disappears between that check and the snapshot, the reply is 200 with `{"status":"ERROR","desc":"… Id not found."}`. +- **Group flags in replies are worked out without side effects.** Serializing a group no longer calls `updateFlags()`, which sent `groupState` and published MQTT from the web server task. +- **`/discovery` no longer logs where its time went.** Building it is now spread over the chunks. + +`ModelSnapshot.h/.cpp` holds the snapshot types, the JSON for each item, `SnapshotWriter` (the eight documents, built a unit at a time) and `SnapshotSlots`. `SomfySnapshot.cpp` copies the shade model into it, for `loop()`'s fills and for the replies still written on the web server task. The `SOMFY_MAX_*` limits moved to `ShadeTypes.h` so the native tests can use them. 21 new native tests, 292 in all. + +Flash: `esp32devdbg` 1,547,381 → 1,547,821 bytes, `esp32dev` 1,398,337 → 1,400,401 bytes. + ### 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`. diff --git a/platformio.ini b/platformio.ini index 0ecef26..eb3ef2a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -109,7 +109,7 @@ lib_deps = platform = native test_framework = unity test_build_src = yes -build_src_filter = -<*> + + + + + + + + + + +build_src_filter = -<*> + + + + + + + + + + + build_flags = -std=gnu++17 -Isrc diff --git a/src/LoopQueue.cpp b/src/LoopQueue.cpp index 36e350a..1f52aa3 100644 --- a/src/LoopQueue.cpp +++ b/src/LoopQueue.cpp @@ -4,12 +4,14 @@ #include "LoopQueue.h" #include "ConfigFile.h" #include "Somfy.h" +#include "SomfySnapshot.h" #include "Utils.h" static const char *TAG = "LoopQueue"; extern SomfyShadeController somfy; extern ShadeRequests shadeRequests; +extern SnapshotSlots snapshotSlots; extern rebootDelay_t rebootDelay; static restore_options_t restoreOptions(uint8_t parts) { @@ -61,6 +63,9 @@ void LoopQueue::drain() { case loop_item_type_t::load_shades: somfy.loadShadesFile("/shades.tmp"); break; + case loop_item_type_t::snapshot_fill: + snapshotSlots.fill(item.slot, fillSnapshot); + break; } } } diff --git a/src/LoopQueue.h b/src/LoopQueue.h index d4551af..9cc545f 100644 --- a/src/LoopQueue.h +++ b/src/LoopQueue.h @@ -22,12 +22,14 @@ enum class loop_item_type_t : uint8_t { shade_request, // run by ShadeRequests restore_shades, // restore from /shades.tmp, then reboot - load_shades // replace the shades with /shades.tmp + load_shades, // replace the shades with /shades.tmp + snapshot_fill // fill a read's ModelSnapshot }; struct loop_item_t { loop_item_type_t type = loop_item_type_t::shade_request; shade_request_t request; // shade_request uint8_t restoreParts = 0; // restore_shades: RESTORE_* bits + uint8_t slot = 0; // snapshot_fill: the SnapshotSlots slot }; // Whether the item sends anything on air. diff --git a/src/ModelSnapshot.cpp b/src/ModelSnapshot.cpp new file mode 100644 index 0000000..2fb2cb8 --- /dev/null +++ b/src/ModelSnapshot.cpp @@ -0,0 +1,394 @@ +#include +#include +#include +#include "esp_log.h" +#include "ModelSnapshot.h" + +static const char *TAG = "Snapshot"; + +const room_snapshot_t *model_snapshot_t::room(uint8_t roomId) const { + for(uint8_t i = 0; i < this->roomCount; i++) + if(this->rooms[i].roomId == roomId) return &this->rooms[i]; + return nullptr; +} +const shade_snapshot_t *model_snapshot_t::shade(uint8_t shadeId) const { + for(uint8_t i = 0; i < this->shadeCount; i++) + if(this->shades[i].shadeId == shadeId) return &this->shades[i]; + return nullptr; +} +const group_snapshot_t *model_snapshot_t::group(uint8_t groupId) const { + for(uint8_t i = 0; i < this->groupCount; i++) + if(this->groups[i].groupId == groupId) return &this->groups[i]; + return nullptr; +} + +/********************************************************************* + * Items + ********************************************************************/ +void writeRoom(JsonFormatter &json, const room_snapshot_t &room) { + json.addElem("roomId", room.roomId); + json.addElem("name", room.name); + json.addElem("sortOrder", room.sortOrder); +} +void writeShadeRef(JsonFormatter &json, const shade_snapshot_t &shade) { + json.addElem("shadeId", shade.shadeId); + json.addElem("roomId", shade.roomId); + json.addElem("name", shade.name); + json.addElem("remoteAddress", shade.remoteAddress); + json.addElem("paired", shade.paired); + json.addElem("shadeType", shade.shadeType); + json.addElem("flipCommands", shade.flipCommands); + json.addElem("flipPosition", shade.flipPosition); + json.addElem("bitLength", shade.bitLength); + json.addElem("proto", shade.proto); + json.addElem("flags", shade.flags); + json.addElem("sunSensor", shade.sunSensor); + json.addElem("hasLight", shade.light); + json.addElem("repeats", shade.repeats); +} +void writeShade(JsonFormatter &json, const shade_snapshot_t &shade) { + json.addElem("shadeId", shade.shadeId); + json.addElem("roomId", shade.roomId); + json.addElem("name", shade.name); + json.addElem("remoteAddress", shade.remoteAddress); + json.addElem("upTime", shade.upTime); + json.addElem("downTime", shade.downTime); + json.addElem("paired", shade.paired); + json.addElem("lastRollingCode", static_cast(shade.lastRollingCode)); + json.addElem("position", shade.position); + json.addElem("tiltType", shade.tiltType); + json.addElem("tiltPosition", shade.tiltPosition); + json.addElem("tiltDirection", shade.tiltDirection); + json.addElem("tiltTime", shade.tiltTime); + json.addElem("stepSize", static_cast(shade.stepSize)); + json.addElem("tiltTarget", shade.tiltTarget); + json.addElem("target", shade.target); + json.addElem("myPos", shade.myPos); + json.addElem("myTiltPos", shade.myTiltPos); + json.addElem("direction", shade.direction); + json.addElem("shadeType", shade.shadeType); + json.addElem("bitLength", shade.bitLength); + json.addElem("proto", shade.proto); + json.addElem("flags", shade.flags); + json.addElem("flipCommands", shade.flipCommands); + json.addElem("flipPosition", shade.flipPosition); + json.addElem("inGroup", shade.inGroup); + json.addElem("sunSensor", shade.sunSensor); + json.addElem("light", shade.light); + json.addElem("repeats", shade.repeats); + json.addElem("sortOrder", shade.sortOrder); + json.addElem("gpioUp", shade.gpioUp); + json.addElem("gpioDown", shade.gpioDown); + json.addElem("gpioMy", shade.gpioMy); + json.addElem("gpioLLTrigger", shade.gpioLLTrigger); + json.addElem("simMy", shade.simMy); + json.beginArray("linkedRemotes"); + for(uint8_t i = 0; i < shade.linkedRemoteCount; i++) { + json.beginObject(); + json.addElem("remoteAddress", shade.linkedRemotes[i].remoteAddress); + json.addElem("lastRollingCode", static_cast(shade.linkedRemotes[i].lastRollingCode)); + json.endObject(); + } + json.endArray(); +} +void writeGroupRef(JsonFormatter &json, const group_snapshot_t &group) { + json.addElem("groupId", group.groupId); + json.addElem("roomId", group.roomId); + json.addElem("name", group.name); + json.addElem("remoteAddress", group.remoteAddress); + json.addElem("lastRollingCode", static_cast(group.lastRollingCode)); + json.addElem("bitLength", group.bitLength); + json.addElem("proto", group.proto); + json.addElem("sunSensor", group.sunSensor); + json.addElem("flipCommands", group.flipCommands); + json.addElem("flags", group.flags); + json.addElem("repeats", group.repeats); + json.addElem("sortOrder", group.sortOrder); +} +void writeGroup(JsonFormatter &json, const group_snapshot_t &group, shade_lookup_t lookup, void *ctx) { + writeGroupRef(json, group); + json.beginArray("linkedShades"); + for(uint8_t i = 0; i < group.linkedShadeCount; i++) { + const shade_snapshot_t *shade = lookup(group.linkedShades[i], ctx); + if(!shade) continue; + json.beginObject(); + writeShadeRef(json, *shade); + json.endObject(); + } + json.endArray(); +} + +/********************************************************************* + * Documents + ********************************************************************/ +enum : uint8_t { + ST_HEADER, // {
,"rooms":[ + ST_ROOMS, + ST_SHADES, + ST_GROUPS, + ST_REPEATERS, + ST_ROOM, + ST_SHADE, + ST_GROUP, + ST_OPEN_ARRAY, // [ + ST_CLOSE_ARRAY, // ] + ST_TO_SHADES, // ],"shades":[ + ST_TO_GROUPS, // ],"groups":[ + ST_TO_REPEATERS, // ],"repeaters":[ + ST_CLOSE_DOC, // ]} + ST_END +}; +static const uint8_t CONTROLLER_STEPS[] = { ST_HEADER, ST_ROOMS, ST_TO_SHADES, ST_SHADES, ST_TO_GROUPS, ST_GROUPS, ST_TO_REPEATERS, ST_REPEATERS, ST_CLOSE_DOC, ST_END }; +static const uint8_t DISCOVERY_STEPS[] = { ST_HEADER, ST_ROOMS, ST_TO_SHADES, ST_SHADES, ST_TO_GROUPS, ST_GROUPS, ST_CLOSE_DOC, ST_END }; +static const uint8_t ROOMS_STEPS[] = { ST_OPEN_ARRAY, ST_ROOMS, ST_CLOSE_ARRAY, ST_END }; +static const uint8_t SHADES_STEPS[] = { ST_OPEN_ARRAY, ST_SHADES, ST_CLOSE_ARRAY, ST_END }; +static const uint8_t GROUPS_STEPS[] = { ST_OPEN_ARRAY, ST_GROUPS, ST_CLOSE_ARRAY, ST_END }; +static const uint8_t ROOM_STEPS[] = { ST_ROOM, ST_END }; +static const uint8_t SHADE_STEPS[] = { ST_SHADE, ST_END }; +static const uint8_t GROUP_STEPS[] = { ST_GROUP, ST_END }; + +static const uint8_t *stepsFor(snapshot_doc_t doc) { + switch(doc) { + case snapshot_doc_t::controller: return CONTROLLER_STEPS; + case snapshot_doc_t::discovery: return DISCOVERY_STEPS; + case snapshot_doc_t::rooms: return ROOMS_STEPS; + case snapshot_doc_t::shades: return SHADES_STEPS; + case snapshot_doc_t::groups: return GROUPS_STEPS; + case snapshot_doc_t::room: return ROOM_STEPS; + case snapshot_doc_t::shade: return SHADE_STEPS; + case snapshot_doc_t::group: return GROUP_STEPS; + } + return SHADES_STEPS; +} +static const shade_snapshot_t *snapshotShade(uint8_t shadeId, void *ctx) { + return static_cast(ctx)->shade(shadeId); +} + +SnapshotWriter::SnapshotWriter(const model_snapshot_t &snap, snapshot_doc_t doc, uint8_t id, snapshot_header_t header, char *unit, size_t unitSize) + : snap(snap), steps(stepsFor(doc)), id(id), header(header), unit(unit), unitSize(unitSize) {} + +size_t SnapshotWriter::generate(uint8_t *out, size_t maxLen) { + size_t written = 0; + while(written < maxLen) { + if(this->consumed < this->unitLen) { + size_t n = this->unitLen - this->consumed; + if(n > maxLen - written) n = maxLen - written; + memcpy(out + written, this->unit + this->consumed, n); + this->consumed += n; + written += n; + } + else { + // A step or item can produce nothing, so go on until something is + // produced or the document is done. + this->consumed = 0; + do this->produceUnit(); while(this->unitLen == 0 && this->steps[this->step] != ST_END); + if(this->unitLen == 0) return written; + } + } + return written; +} +void SnapshotWriter::nextStep() { + this->step++; + this->idx = 0; + this->firstItem = true; + this->groupOpen = false; +} +void SnapshotWriter::literal(const char *text) { + strncpy(this->unit, text, this->unitSize - 1); + this->unit[this->unitSize - 1] = '\0'; + this->unitLen = strlen(this->unit); + this->nextStep(); +} +void SnapshotWriter::resetFmt(size_t offset) { + this->unit[offset] = '\0'; + this->fmt.setBuffer(this->unit + offset, this->unitSize - offset); +} +bool SnapshotWriter::overflowed(const char *what, uint8_t itemId) { + if(!this->fmt.overflowed()) return false; + ESP_LOGE(TAG, "%s %u doesn't fit in %u bytes, leaving it out", what, itemId, (unsigned)this->unitSize); + this->unitLen = 0; + return true; +} +// Starts a list item, after a comma unless it is the first. +size_t SnapshotWriter::itemStart() { + size_t pos = 0; + if(!this->firstItem) this->unit[pos++] = ','; + this->resetFmt(pos); + return pos; +} +// One piece of a group: its ref, then each linked shade, then the close. +// inList is whether it is an item of a list rather than the whole document. +void SnapshotWriter::groupUnit(const group_snapshot_t &group, bool inList) { + auto done = [this, inList]() { + this->groupOpen = false; + if(inList) this->idx++; + else this->nextStep(); + }; + if(!this->groupOpen) { + const size_t pos = inList ? this->itemStart() : 0; + if(!inList) this->resetFmt(0); + this->fmt.beginObject(); + writeGroupRef(this->fmt, group); + this->fmt.beginArray("linkedShades"); + if(this->overflowed("group", group.groupId)) { done(); return; } + this->unitLen = pos + this->fmt.length(); + this->groupOpen = true; + this->lsIdx = 0; + this->firstLinked = true; + if(inList) this->firstItem = false; + return; + } + const shade_snapshot_t *shade = nullptr; + while(this->lsIdx < group.linkedShadeCount && !(shade = this->snap.shade(group.linkedShades[this->lsIdx]))) this->lsIdx++; + if(!shade) { + strcpy(this->unit, "]}"); + this->unitLen = 2; + done(); + return; + } + size_t pos = 0; + if(!this->firstLinked) this->unit[pos++] = ','; + this->resetFmt(pos); + this->fmt.beginObject(); + writeShadeRef(this->fmt, *shade); + this->fmt.endObject(); + this->lsIdx++; + if(this->overflowed("linked shade", shade->shadeId)) return; + this->unitLen = pos + this->fmt.length(); + this->firstLinked = false; +} +void SnapshotWriter::produceUnit() { + this->unitLen = 0; + switch(this->steps[this->step]) { + case ST_HEADER: + this->resetFmt(0); + this->fmt.beginObject(); + if(this->header) this->header(this->fmt, this->snap); + this->fmt.beginArray("rooms"); + // Only fixed size values so this can't happen short of a bug. + if(this->fmt.overflowed()) ESP_LOGE(TAG, "Header doesn't fit in %u bytes", (unsigned)this->unitSize); + this->unitLen = this->fmt.length(); + this->nextStep(); + return; + case ST_ROOMS: { + if(this->idx >= this->snap.roomCount) { this->nextStep(); return; } + const room_snapshot_t &room = this->snap.rooms[this->idx++]; + const size_t pos = this->itemStart(); + this->fmt.beginObject(); + writeRoom(this->fmt, room); + this->fmt.endObject(); + if(this->overflowed("room", room.roomId)) return; + this->unitLen = pos + this->fmt.length(); + this->firstItem = false; + return; + } + case ST_SHADES: { + if(this->idx >= this->snap.shadeCount) { this->nextStep(); return; } + const shade_snapshot_t &shade = this->snap.shades[this->idx++]; + const size_t pos = this->itemStart(); + this->fmt.beginObject(); + writeShade(this->fmt, shade); + this->fmt.endObject(); + if(this->overflowed("shade", shade.shadeId)) return; + this->unitLen = pos + this->fmt.length(); + this->firstItem = false; + return; + } + case ST_GROUPS: + if(this->idx >= this->snap.groupCount) { this->nextStep(); return; } + this->groupUnit(this->snap.groups[this->idx], true); + return; + case ST_REPEATERS: { + if(this->idx >= this->snap.repeaterCount) { this->nextStep(); return; } + size_t pos = 0; + if(!this->firstItem) this->unit[pos++] = ','; + pos += snprintf(this->unit + pos, this->unitSize - pos, "%lu", (unsigned long)this->snap.repeaters[this->idx++]); + this->unitLen = pos; + this->firstItem = false; + return; + } + case ST_ROOM: { + const room_snapshot_t *room = this->snap.room(this->id); + if(!room) { this->literal("{\"status\":\"ERROR\",\"desc\":\"Room Id not found.\"}"); return; } + this->resetFmt(0); + this->fmt.beginObject(); + writeRoom(this->fmt, *room); + this->fmt.endObject(); + if(!this->overflowed("room", room->roomId)) this->unitLen = this->fmt.length(); + this->nextStep(); + return; + } + case ST_SHADE: { + const shade_snapshot_t *shade = this->snap.shade(this->id); + if(!shade) { this->literal("{\"status\":\"ERROR\",\"desc\":\"Shade Id not found.\"}"); return; } + this->resetFmt(0); + this->fmt.beginObject(); + writeShade(this->fmt, *shade); + this->fmt.endObject(); + if(!this->overflowed("shade", shade->shadeId)) this->unitLen = this->fmt.length(); + this->nextStep(); + return; + } + case ST_GROUP: { + const group_snapshot_t *group = this->snap.group(this->id); + if(!group) { this->literal("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}"); return; } + this->groupUnit(*group, false); + return; + } + case ST_OPEN_ARRAY: this->literal("["); return; + case ST_CLOSE_ARRAY: this->literal("]"); return; + case ST_TO_SHADES: this->literal("],\"shades\":["); return; + case ST_TO_GROUPS: this->literal("],\"groups\":["); return; + case ST_TO_REPEATERS: this->literal("],\"repeaters\":["); return; + case ST_CLOSE_DOC: this->literal("]}"); return; + default: + return; + } +} + +/********************************************************************* + * Slots + ********************************************************************/ +int8_t SnapshotSlots::acquire() { + for(uint8_t i = 0; i < SNAPSHOT_SLOTS; i++) { + uint32_t expected = FREE; + if(!this->state[i].compare_exchange_strong(expected, WAITING)) continue; + // Only the holder of a WAITING slot touches its pointer until it is queued. + this->snaps[i] = new (std::nothrow) model_snapshot_t; + if(!this->snaps[i]) { + this->state[i].store(FREE); + return -1; + } + return static_cast(i); + } + return -1; +} +// The snapshot goes before the slot is marked free, so acquire() never sees a +// free slot that still holds one. +void SnapshotSlots::freeSlot(uint8_t slot) { + delete this->snaps[slot]; + this->snaps[slot] = nullptr; + this->state[slot].store(FREE); +} +void SnapshotSlots::fill(uint8_t slot, void (*fill)(model_snapshot_t &snap)) { + uint32_t expected = WAITING; + if(!this->state[slot].compare_exchange_strong(expected, FILLING)) { + // The read went away before it was filled; nobody else will free it. + if(expected == ABANDONED) this->freeSlot(slot); + return; + } + model_snapshot_t &snap = *this->snaps[slot]; + snap.startingAddress = 0; + snap.roomCount = snap.shadeCount = snap.groupCount = snap.repeaterCount = 0; + fill(snap); + expected = FILLING; + // Released while being filled: the read is gone, so free it here. + if(!this->state[slot].compare_exchange_strong(expected, FILLED)) this->freeSlot(slot); +} +bool SnapshotSlots::filled(uint8_t slot) const { return this->state[slot].load() == FILLED; } +void SnapshotSlots::cancel(uint8_t slot) { this->freeSlot(slot); } +void SnapshotSlots::release(uint8_t slot) { + // Still waiting for or being filled by loop(): it frees the slot when it sees + // ABANDONED. Filled: nothing else touches it, so free it now. + if(this->state[slot].exchange(ABANDONED) == FILLED) this->freeSlot(slot); +} diff --git a/src/ModelSnapshot.h b/src/ModelSnapshot.h new file mode 100644 index 0000000..9c036dc --- /dev/null +++ b/src/ModelSnapshot.h @@ -0,0 +1,187 @@ +#ifndef MODEL_SNAPSHOT_H +#define MODEL_SNAPSHOT_H +#include +#include +#include +#include "ShadeTypes.h" +#include "JsonFormatter.h" + +// A copy of the shade model taken on loop() for one read, so the reply +// describes a single moment (docs/adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md), +// and the JSON the read routes write from it. Kept free of the shade model and +// the web server so it can be unit tested on the PC (pio test -e native). + +// Positions are as clients see them: whole percentages, flipped for a shade +// with flipPosition, -1 when unset. +// No default member values: with them the SnapshotSlots would be stored in +// flash as initialized data (11 KB). The snapshot functions set every field; +// anything else starts from {}. +struct room_snapshot_t { + uint8_t roomId; + char name[21]; + int8_t sortOrder; +}; +struct linked_remote_snapshot_t { + uint32_t remoteAddress; + uint16_t lastRollingCode; +}; +struct shade_snapshot_t { + uint8_t shadeId; + uint8_t roomId; + char name[21]; + uint32_t remoteAddress; + uint32_t upTime; + uint32_t downTime; + uint32_t tiltTime; + uint16_t stepSize; + bool paired; + uint16_t lastRollingCode; + int8_t position; + int8_t target; + int8_t myPos; + int8_t direction; + uint8_t tiltType; + int8_t tiltPosition; + int8_t tiltTarget; + int8_t myTiltPos; + int8_t tiltDirection; + uint8_t shadeType; + uint8_t bitLength; + uint8_t proto; + uint8_t flags; + bool flipCommands; + bool flipPosition; + bool inGroup; + bool sunSensor; + bool light; + bool simMy; + uint8_t repeats; + int8_t sortOrder; + uint8_t gpioUp; + uint8_t gpioDown; + uint8_t gpioMy; + bool gpioLLTrigger; + uint8_t linkedRemoteCount; + linked_remote_snapshot_t linkedRemotes[SOMFY_MAX_LINKED_REMOTES]; +}; +struct group_snapshot_t { + uint8_t groupId; + uint8_t roomId; + char name[21]; + uint32_t remoteAddress; + uint16_t lastRollingCode; + uint8_t bitLength; + uint8_t proto; + bool sunSensor; + bool flipCommands; + uint8_t flags; // its linked shades' flags together + uint8_t repeats; + int8_t sortOrder; + uint8_t linkedShadeCount; + uint8_t linkedShades[SOMFY_MAX_GROUPED_SHADES]; +}; +// Only the rooms, shades, groups and repeaters that exist, in model order. +struct model_snapshot_t { + uint32_t startingAddress; + uint8_t roomCount; + uint8_t shadeCount; + uint8_t groupCount; + uint8_t repeaterCount; + room_snapshot_t rooms[SOMFY_MAX_ROOMS]; + shade_snapshot_t shades[SOMFY_MAX_SHADES]; + group_snapshot_t groups[SOMFY_MAX_GROUPS]; + uint32_t repeaters[SOMFY_MAX_REPEATERS]; + const room_snapshot_t *room(uint8_t roomId) const; + const shade_snapshot_t *shade(uint8_t shadeId) const; + const group_snapshot_t *group(uint8_t groupId) const; +}; + +// The fields of one item, as every route has always written them. +void writeRoom(JsonFormatter &json, const room_snapshot_t &room); +void writeShadeRef(JsonFormatter &json, const shade_snapshot_t &shade); +void writeShade(JsonFormatter &json, const shade_snapshot_t &shade); +void writeGroupRef(JsonFormatter &json, const group_snapshot_t &group); +// The shade a group links to, or nullptr when it has gone. +typedef const shade_snapshot_t *(*shade_lookup_t)(uint8_t shadeId, void *ctx); +// writeGroupRef plus linkedShades, the refs of the shades it links to. +void writeGroup(JsonFormatter &json, const group_snapshot_t &group, shade_lookup_t lookup, void *ctx); + +// The documents the read routes send. +enum class snapshot_doc_t : uint8_t { + controller, // /controller + discovery, // /discovery + rooms, // /rooms + shades, // /shades + groups, // /groups + room, // /room?roomId= + shade, // /shade?shadeId= + group // /group?groupId= +}; +// Writes the fields of /controller or /discovery that aren't in the snapshot +// (device, radio and firmware details) into the open top-level object. +typedef void (*snapshot_header_t)(JsonFormatter &json, const model_snapshot_t &snap); + +// Writes a document from a snapshot a piece at a time for a chunked response. +// Each room, shade, group and linked shade is formatted into unit on its own; +// one that doesn't fit is logged and left out so the JSON stays valid. +class SnapshotWriter { + public: + // id picks the item for the single-item documents. header is only called + // for controller and discovery. unit must outlive the writer. + SnapshotWriter(const model_snapshot_t &snap, snapshot_doc_t doc, uint8_t id, snapshot_header_t header, char *unit, size_t unitSize); + // Copies up to maxLen more bytes of the document into out. 0 once it is all + // out. + size_t generate(uint8_t *out, size_t maxLen); + private: + const model_snapshot_t &snap; + const uint8_t *steps; + uint8_t step = 0; + uint8_t id; + snapshot_header_t header; + BufferedJsonFormatter fmt; + char *unit; + size_t unitSize; + size_t unitLen = 0; + size_t consumed = 0; + uint8_t idx = 0; + uint8_t lsIdx = 0; // next linked shade of the group being written + bool groupOpen = false; // the group's ref is out, its linked shades are next + bool firstItem = true; + bool firstLinked = true; + void produceUnit(); + void nextStep(); + void literal(const char *text); + void resetFmt(size_t offset); + bool overflowed(const char *what, uint8_t itemId); + size_t itemStart(); + void groupUnit(const group_snapshot_t &group, bool comma); +}; + +// The snapshots reads are served from. A read takes a slot on the web server +// task and queues a fill; loop() fills it; the read streams it and lets it go. +// The read may go away before loop() gets to it, so the slot's state decides +// who frees it: whichever side is last. A slot's snapshot (about 5.6 KB) is +// only allocated while a read holds it. +#define SNAPSHOT_SLOTS 2 +class SnapshotSlots { + public: + // A free slot with its snapshot allocated, or -1 when every slot is in use + // or there is no memory. Any task. + int8_t acquire(); + // Fills the slot with fill, unless its read has gone. loop() only. + void fill(uint8_t slot, void (*fill)(model_snapshot_t &snap)); + // Whether loop() has filled the slot. The read's task. + bool filled(uint8_t slot) const; + // Valid from acquire() until the read releases the slot. + model_snapshot_t &snapshot(uint8_t slot) { return *this->snaps[slot]; } + // The read is done with the slot, or gone. The read's task. + void release(uint8_t slot); + // Frees a slot whose fill was never queued. The read's task. + void cancel(uint8_t slot); + private: + enum : uint32_t { FREE, WAITING, FILLING, FILLED, ABANDONED }; + model_snapshot_t *snaps[SNAPSHOT_SLOTS] = {}; + std::atomic state[SNAPSHOT_SLOTS] = {}; + void freeSlot(uint8_t slot); +}; +#endif diff --git a/src/ShadeTypes.h b/src/ShadeTypes.h index 60a8930..bdbe7aa 100644 --- a/src/ShadeTypes.h +++ b/src/ShadeTypes.h @@ -2,8 +2,16 @@ #define SHADE_TYPES_H #include -// The kinds of shade, tilt and group. Kept free of the shade model so the -// shade maths can use them in the unit tests on the PC (pio test -e native). +// The kinds of shade, tilt and group, and how many of each the shade model +// holds. Kept free of the shade model so the shade maths can use them in the +// unit tests on the PC (pio test -e native). + +#define SOMFY_MAX_SHADES 32 +#define SOMFY_MAX_GROUPS 16 +#define SOMFY_MAX_LINKED_REMOTES 7 +#define SOMFY_MAX_GROUPED_SHADES 32 +#define SOMFY_MAX_ROOMS 16 +#define SOMFY_MAX_REPEATERS 7 enum class group_types : byte { channel = 0x00 diff --git a/src/Somfy.h b/src/Somfy.h index ba513cc..c508600 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -4,14 +4,6 @@ #include "ConfigSettings.h" #include "WResp.h" -#define SOMFY_MAX_SHADES 32 -#define SOMFY_MAX_GROUPS 16 -#define SOMFY_MAX_LINKED_REMOTES 7 -#define SOMFY_MAX_GROUPED_SHADES 32 -#define SOMFY_MAX_ROOMS 16 -#define SOMFY_MAX_REPEATERS 7 - - #include "SomfyFrame.h" #include "TxBuffer.h" #include "RadioScheduler.h" diff --git a/src/SomfyController.ino b/src/SomfyController.ino index c1a5653..5cb9b40 100644 --- a/src/SomfyController.ino +++ b/src/SomfyController.ino @@ -12,6 +12,7 @@ #include "LoopQueue.h" #include "ShadeRequests.h" #include "SomfyShadeOps.h" +#include "ModelSnapshot.h" #include "GitOTA.h" #include "esp_core_dump.h" #include "esp_system.h" @@ -39,6 +40,7 @@ GitUpdater git; SomfyShadeOps shadeOps; ShadeRequests shadeRequests(shadeOps); LoopQueue loopQueue; +SnapshotSlots snapshotSlots; uint32_t oldheap = 0; diff --git a/src/SomfySnapshot.cpp b/src/SomfySnapshot.cpp new file mode 100644 index 0000000..3fc7167 --- /dev/null +++ b/src/SomfySnapshot.cpp @@ -0,0 +1,102 @@ +#include +#include "SomfySnapshot.h" + +extern SomfyShadeController somfy; + +static void copyName(char *out, const char *name) { + strncpy(out, name, 20); + out[20] = '\0'; +} + +void snapshotRoom(SomfyRoom &room, room_snapshot_t &out) { + out.roomId = room.roomId; + copyName(out.name, room.name); + out.sortOrder = room.sortOrder; +} +void snapshotShade(SomfyShade &shade, shade_snapshot_t &out) { + out.shadeId = shade.getShadeId(); + out.roomId = shade.roomId; + copyName(out.name, shade.name); + out.remoteAddress = shade.getRemoteAddress(); + out.upTime = shade.upTime; + out.downTime = shade.downTime; + out.tiltTime = shade.tiltTime; + out.stepSize = shade.stepSize; + out.paired = shade.paired; + out.lastRollingCode = shade.lastRollingCode; + out.position = shade.transformPosition(shade.currentPos); + out.target = shade.transformPosition(shade.target); + out.myPos = shade.transformPosition(shade.myPos); + out.direction = shade.direction; + out.tiltType = static_cast(shade.tiltType); + out.tiltPosition = shade.transformPosition(shade.currentTiltPos); + out.tiltTarget = shade.transformPosition(shade.tiltTarget); + out.myTiltPos = shade.transformPosition(shade.myTiltPos); + out.tiltDirection = shade.tiltDirection; + out.shadeType = static_cast(shade.shadeType); + out.bitLength = shade.bitLength; + out.proto = static_cast(shade.proto); + out.flags = shade.flags; + out.flipCommands = shade.flipCommands; + out.flipPosition = shade.flipPosition; + out.inGroup = shade.isInGroup(); + out.sunSensor = shade.hasSunSensor(); + out.light = shade.hasLight(); + out.simMy = shade.simMy(); + out.repeats = shade.repeats; + out.sortOrder = shade.sortOrder; + out.gpioUp = shade.gpioUp; + out.gpioDown = shade.gpioDown; + out.gpioMy = shade.gpioMy; + out.gpioLLTrigger = (shade.gpioFlags & static_cast(gpio_flags_t::LowLevelTrigger)) != 0; + out.linkedRemoteCount = 0; + for(uint8_t i = 0; i < SOMFY_MAX_LINKED_REMOTES; i++) { + SomfyLinkedRemote &remote = shade.linkedRemotes[i]; + if(remote.getRemoteAddress() == 0) continue; + linked_remote_snapshot_t &l = out.linkedRemotes[out.linkedRemoteCount++]; + l.remoteAddress = remote.getRemoteAddress(); + l.lastRollingCode = remote.lastRollingCode; + } +} +void snapshotGroup(SomfyGroup &group, group_snapshot_t &out) { + out.groupId = group.getGroupId(); + out.roomId = group.roomId; + copyName(out.name, group.name); + out.remoteAddress = group.getRemoteAddress(); + out.lastRollingCode = group.lastRollingCode; + out.bitLength = group.bitLength; + out.proto = static_cast(group.proto); + out.sunSensor = group.hasSunSensor(); + out.flipCommands = group.flipCommands; + out.repeats = group.repeats; + out.sortOrder = group.sortOrder; + // The same sum as SomfyGroup::updateFlags, without its event. + out.flags = 0; + for(uint8_t i = 0; i < SOMFY_MAX_GROUPED_SHADES && group.linkedShades[i] != 0; i++) { + SomfyShade *shade = somfy.getShadeById(group.linkedShades[i]); + if(shade) out.flags |= shade->flags; + } + out.linkedShadeCount = 0; + for(uint8_t i = 0; i < SOMFY_MAX_GROUPED_SHADES; i++) { + const uint8_t shadeId = group.linkedShades[i]; + if(shadeId > 0 && shadeId < 255) out.linkedShades[out.linkedShadeCount++] = shadeId; + } +} +void fillSnapshot(model_snapshot_t &snap) { + snap.startingAddress = somfy.startingAddress; + for(uint8_t i = 0; i < SOMFY_MAX_ROOMS; i++) + if(somfy.rooms[i].roomId != 0) snapshotRoom(somfy.rooms[i], snap.rooms[snap.roomCount++]); + for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) + if(somfy.shades[i].getShadeId() != 255) snapshotShade(somfy.shades[i], snap.shades[snap.shadeCount++]); + for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) + if(somfy.groups[i].getGroupId() != 255) snapshotGroup(somfy.groups[i], snap.groups[snap.groupCount++]); + for(uint8_t i = 0; i < SOMFY_MAX_REPEATERS; i++) + if(somfy.repeaters[i] != 0) snap.repeaters[snap.repeaterCount++] = somfy.repeaters[i]; +} +const shade_snapshot_t *liveShade(uint8_t shadeId, void *ctx) { + SomfyShade *shade = somfy.getShadeById(shadeId); + if(!shade) return nullptr; + shade_snapshot_t *out = static_cast(ctx); + snapshotShade(*shade, *out); + return out; +} diff --git a/src/SomfySnapshot.h b/src/SomfySnapshot.h new file mode 100644 index 0000000..75feb46 --- /dev/null +++ b/src/SomfySnapshot.h @@ -0,0 +1,17 @@ +#ifndef SOMFY_SNAPSHOT_H +#define SOMFY_SNAPSHOT_H +#include "ModelSnapshot.h" +#include "Somfy.h" + +// Copies the shade model into the ModelSnapshot types. + +void snapshotRoom(SomfyRoom &room, room_snapshot_t &out); +void snapshotShade(SomfyShade &shade, shade_snapshot_t &out); +// Works the group's flags out from its shades without changing the group. +void snapshotGroup(SomfyGroup &group, group_snapshot_t &out); +// The whole shade model. loop() only. +void fillSnapshot(model_snapshot_t &snap); +// A shade_lookup_t over the live shade model for writeGroup. ctx is a +// shade_snapshot_t to copy into. +const shade_snapshot_t *liveShade(uint8_t shadeId, void *ctx); +#endif diff --git a/src/entryPoints/Web.cpp b/src/entryPoints/Web.cpp index d5c60b8..fa1c700 100644 --- a/src/entryPoints/Web.cpp +++ b/src/entryPoints/Web.cpp @@ -11,6 +11,8 @@ #include "Somfy.h" #include "WResp.h" #include "LoopQueue.h" +#include "ModelSnapshot.h" +#include "SomfySnapshot.h" #include "Web.h" #include "MQTT.h" #include "GitOTA.h" @@ -30,12 +32,11 @@ extern MQTTClass mqtt; extern GitUpdater git; extern ESPNetwork net; extern LoopQueue loopQueue; +extern SnapshotSlots snapshotSlots; //#define WEB_MAX_RESPONSE 34768 #define WEB_MAX_RESPONSE 4096 static char g_async_content[WEB_MAX_RESPONSE]; -// /discovery logs where its time went when it takes at least this long. -#define DISCOVERY_SLOW_MS 50 // General responses @@ -202,140 +203,42 @@ static bool asyncHasParam(AsyncWebServerRequest *request, const char *name) { return request->hasParam(name); } -// -- Serialization helpers (accept JsonFormatter& so both sync and async can use them) -- +// -- Serialization helpers -- +// Replies that still read the live shade model on this task (command and edit +// replies) copy one item into the ModelSnapshot types and write it the way the +// read routes do. The read routes themselves write from a ModelSnapshot that +// loop() fills (sendSnapshot). static void serializeRoom(SomfyRoom *room, JsonFormatter &json) { - json.addElem("roomId", room->roomId); - json.addElem("name", room->name); - json.addElem("sortOrder", room->sortOrder); + room_snapshot_t s; + snapshotRoom(*room, s); + writeRoom(json, s); } static void serializeShadeRef(SomfyShade *shade, JsonFormatter &json) { - json.addElem("shadeId", shade->getShadeId()); - json.addElem("roomId", shade->roomId); - json.addElem("name", shade->name); - json.addElem("remoteAddress", (uint32_t)shade->getRemoteAddress()); - json.addElem("paired", shade->paired); - json.addElem("shadeType", static_cast(shade->shadeType)); - json.addElem("flipCommands", shade->flipCommands); - json.addElem("flipPosition", shade->flipPosition); - json.addElem("bitLength", shade->bitLength); - json.addElem("proto", static_cast(shade->proto)); - json.addElem("flags", shade->flags); - json.addElem("sunSensor", shade->hasSunSensor()); - json.addElem("hasLight", shade->hasLight()); - json.addElem("repeats", shade->repeats); + shade_snapshot_t s; + snapshotShade(*shade, s); + writeShadeRef(json, s); } // paired and lastRollingCode are passed in for a response that reports the // change the main loop is about to make. An unpair is held until it goes on // air (holdLoopItem), so the answer comes true. static void serializeShade(SomfyShade *shade, JsonFormatter &json, bool paired, uint16_t lastRollingCode) { - json.addElem("shadeId", shade->getShadeId()); - json.addElem("roomId", shade->roomId); - json.addElem("name", shade->name); - json.addElem("remoteAddress", (uint32_t)shade->getRemoteAddress()); - json.addElem("upTime", (uint32_t)shade->upTime); - json.addElem("downTime", (uint32_t)shade->downTime); - json.addElem("paired", paired); - json.addElem("lastRollingCode", (uint32_t)lastRollingCode); - json.addElem("position", shade->transformPosition(shade->currentPos)); - json.addElem("tiltType", static_cast(shade->tiltType)); - json.addElem("tiltPosition", shade->transformPosition(shade->currentTiltPos)); - json.addElem("tiltDirection", shade->tiltDirection); - json.addElem("tiltTime", (uint32_t)shade->tiltTime); - json.addElem("stepSize", (uint32_t)shade->stepSize); - json.addElem("tiltTarget", shade->transformPosition(shade->tiltTarget)); - json.addElem("target", shade->transformPosition(shade->target)); - json.addElem("myPos", shade->transformPosition(shade->myPos)); - json.addElem("myTiltPos", shade->transformPosition(shade->myTiltPos)); - json.addElem("direction", shade->direction); - json.addElem("shadeType", static_cast(shade->shadeType)); - json.addElem("bitLength", shade->bitLength); - json.addElem("proto", static_cast(shade->proto)); - json.addElem("flags", shade->flags); - json.addElem("flipCommands", shade->flipCommands); - json.addElem("flipPosition", shade->flipPosition); - json.addElem("inGroup", shade->isInGroup()); - json.addElem("sunSensor", shade->hasSunSensor()); - json.addElem("light", shade->hasLight()); - json.addElem("repeats", shade->repeats); - json.addElem("sortOrder", shade->sortOrder); - json.addElem("gpioUp", shade->gpioUp); - json.addElem("gpioDown", shade->gpioDown); - json.addElem("gpioMy", shade->gpioMy); - json.addElem("gpioLLTrigger", ((shade->gpioFlags & (uint8_t)gpio_flags_t::LowLevelTrigger) == 0) ? false : true); - json.addElem("simMy", shade->simMy()); - json.beginArray("linkedRemotes"); - for(uint8_t i = 0; i < SOMFY_MAX_LINKED_REMOTES; i++) { - SomfyLinkedRemote &lremote = shade->linkedRemotes[i]; - if(lremote.getRemoteAddress() != 0) { - json.beginObject(); - json.addElem("remoteAddress", (uint32_t)lremote.getRemoteAddress()); - json.addElem("lastRollingCode", (uint32_t)lremote.lastRollingCode); - json.endObject(); - } - } - json.endArray(); + shade_snapshot_t s; + snapshotShade(*shade, s); + s.paired = paired; + s.lastRollingCode = lastRollingCode; + writeShade(json, s); } static void serializeShade(SomfyShade *shade, JsonFormatter &json) { serializeShade(shade, json, shade->paired, shade->lastRollingCode); } static void serializeGroupRef(SomfyGroup *group, JsonFormatter &json) { - group->updateFlags(); - json.addElem("groupId", group->getGroupId()); - json.addElem("roomId", group->roomId); - json.addElem("name", group->name); - json.addElem("remoteAddress", (uint32_t)group->getRemoteAddress()); - json.addElem("lastRollingCode", (uint32_t)group->lastRollingCode); - json.addElem("bitLength", group->bitLength); - json.addElem("proto", static_cast(group->proto)); - json.addElem("sunSensor", group->hasSunSensor()); - json.addElem("flipCommands", group->flipCommands); - json.addElem("flags", group->flags); - json.addElem("repeats", group->repeats); - json.addElem("sortOrder", group->sortOrder); + group_snapshot_t s; + snapshotGroup(*group, s); + writeGroupRef(json, s); } static void serializeGroup(SomfyGroup *group, JsonFormatter &json) { - serializeGroupRef(group, json); - json.beginArray("linkedShades"); - for(uint8_t i = 0; i < SOMFY_MAX_GROUPED_SHADES; i++) { - uint8_t shadeId = group->linkedShades[i]; - if(shadeId > 0 && shadeId < 255) { - SomfyShade *shade = somfy.getShadeById(shadeId); - if(shade) { - json.beginObject(); - serializeShadeRef(shade, json); - json.endObject(); - } - } - } - json.endArray(); -} -static void serializeRooms(JsonFormatter &json) { - for(uint8_t i = 0; i < SOMFY_MAX_ROOMS; i++) { - SomfyRoom *room = &somfy.rooms[i]; - if(room->roomId != 0) { - json.beginObject(); - serializeRoom(room, json); - json.endObject(); - } - } -} -static void serializeShades(JsonFormatter &json) { - for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { - SomfyShade &shade = somfy.shades[i]; - if(shade.getShadeId() != 255) { - json.beginObject(); - serializeShade(&shade, json); - json.endObject(); - } - } -} -static void serializeGroups(JsonFormatter &json) { - for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { - SomfyGroup &group = somfy.groups[i]; - if(group.getGroupId() != 255) { - json.beginObject(); - serializeGroup(&group, json); - json.endObject(); - } - } + group_snapshot_t s; + snapshotGroup(*group, s); + shade_snapshot_t linked; + writeGroup(json, s, liveShade, &linked); } static void serializeRepeaters(JsonFormatter &json) { for(uint8_t i = 0; i < SOMFY_MAX_REPEATERS; i++) { @@ -401,318 +304,117 @@ static void serializeGitRelease(GitRelease *rel, JsonFormatter &json) { json.endObject(); } -// Memory-bounded streaming builder for /controller. Produces the JSON payload -// one small unit at a time into a fixed 3KB buffer, so a chunked HTTP response -// can drain it as the TCP send window allows — no growing cbuf in the async -// response stream. -class ControllerChunker { -public: - enum : uint8_t { S_HEADER, S_ROOMS, S_SHADES, S_GROUPS, S_REPEATERS, S_DONE }; - static constexpr uint16_t LS_OPEN = 0xFFFF; +// The parts of /controller that aren't in the shade model. +static void controllerHeader(JsonFormatter &json, const model_snapshot_t &snap) { + json.addElem("maxRooms", (uint8_t)SOMFY_MAX_ROOMS); + json.addElem("maxShades", (uint8_t)SOMFY_MAX_SHADES); + json.addElem("maxGroups", (uint8_t)SOMFY_MAX_GROUPS); + json.addElem("maxGroupedShades", (uint8_t)SOMFY_MAX_GROUPED_SHADES); + json.addElem("maxLinkedRemotes", (uint8_t)SOMFY_MAX_LINKED_REMOTES); + json.addElem("startingAddress", snap.startingAddress); + json.beginObject("transceiver"); + json.beginObject("config"); + serializeTransceiverConfig(json); + json.endObject(); + json.endObject(); + json.beginObject("version"); + serializeGitVersion(json); + json.endObject(); +} +// The parts of /discovery that aren't in the shade model. +static void discoveryHeader(JsonFormatter &json, const model_snapshot_t &snap) { + (void)snap; + const char *connType = "Unknown"; + if(net.connType == conn_types_t::ethernet) connType = "Ethernet"; + else if(net.connType == conn_types_t::wifi) connType = "Wifi"; + json.addElem("serverId", settings.serverId); + json.addElem("version", settings.fwVersion.name); + json.addElem("latest", git.latest.name); + json.addElem("model", "ESPSomfyRTS"); + json.addElem("hostname", settings.hostname); + json.addElem("authType", static_cast(settings.Security.type)); + json.addElem("permissions", settings.Security.permissions); + json.addElem("chipModel", settings.chipModel); + json.addElem("connType", connType); + json.addElem("checkForUpdate", settings.checkForUpdate); + json.beginObject("memory"); + json.addElem("max", ESP.getMaxAllocHeap()); + json.addElem("free", ESP.getFreeHeap()); + json.addElem("min", ESP.getMinFreeHeap()); + json.addElem("total", ESP.getHeapSize()); + json.addElem("uptime", (uint64_t)millis()); + json.endObject(); +} - BufferedJsonFormatter fmt; +// A read served from a ModelSnapshot that loop() fills, so the document +// describes one moment (docs/adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md). +// Each item is formatted into a fixed 3 KB unit and streamed as the TCP send +// window allows, so there is no growing response buffer either. +struct SnapshotRead { + uint8_t slot; char unit[3072]; - size_t unitLen = 0; - size_t consumed = 0; - uint8_t section = S_HEADER; - uint16_t idx = 0; - uint16_t lsIdx = LS_OPEN; - bool firstInArray = true; - bool firstInLSArray = true; - - size_t generate(uint8_t *out, size_t maxLen) { - size_t written = 0; - while(written < maxLen) { - if(consumed < unitLen) { - size_t n = unitLen - consumed; - if(n > maxLen - written) n = maxLen - written; - memcpy(out + written, unit + consumed, n); - consumed += n; - written += n; - } else { - produceNext(); - if(unitLen == 0) return written; - } - } - return written; - } - -private: - void resetFmt(size_t offset = 0) { - unit[offset] = 0; - fmt.setBuffer(unit + offset, sizeof(unit) - offset); - } - - // An item too big for the unit buffer is left out so the JSON stays valid. - // Skipping one produces nothing, so go on to the next until something is - // produced or the document is done. - void produceNext() { - consumed = 0; - do produceUnit(); while(unitLen == 0 && section != S_DONE); - } - - // True when the unit just formatted didn't fit. It is logged and skipped. - bool overflowed(const char *what, uint8_t id) { - if(!fmt.overflowed()) return false; - ESP_LOGE(TAG, "/controller: %s %u doesn't fit in %u bytes, leaving it out", what, id, (unsigned)sizeof(unit)); - unitLen = 0; - return true; - } - - void produceUnit() { - unitLen = 0; - switch(section) { - case S_HEADER: { - resetFmt(); - fmt.beginObject(); - fmt.addElem("maxRooms", (uint8_t)SOMFY_MAX_ROOMS); - fmt.addElem("maxShades", (uint8_t)SOMFY_MAX_SHADES); - fmt.addElem("maxGroups", (uint8_t)SOMFY_MAX_GROUPS); - fmt.addElem("maxGroupedShades", (uint8_t)SOMFY_MAX_GROUPED_SHADES); - fmt.addElem("maxLinkedRemotes", (uint8_t)SOMFY_MAX_LINKED_REMOTES); - fmt.addElem("startingAddress", (uint32_t)somfy.startingAddress); - fmt.beginObject("transceiver"); - fmt.beginObject("config"); - serializeTransceiverConfig(fmt); - fmt.endObject(); - fmt.endObject(); - fmt.beginObject("version"); - serializeGitVersion(fmt); - fmt.endObject(); - fmt.beginArray("rooms"); - // Only fixed size values so this can't happen short of a bug. - if(fmt.overflowed()) ESP_LOGE(TAG, "/controller: header doesn't fit in %u bytes", (unsigned)sizeof(unit)); - unitLen = fmt.length(); - section = S_ROOMS; - idx = 0; - firstInArray = true; - return; - } - case S_ROOMS: { - while(idx < SOMFY_MAX_ROOMS && somfy.rooms[idx].roomId == 0) idx++; - if(idx >= SOMFY_MAX_ROOMS) { - strcpy(unit, "],\"shades\":["); - unitLen = strlen(unit); - section = S_SHADES; - idx = 0; - firstInArray = true; - return; - } - size_t pos = 0; - if(!firstInArray) unit[pos++] = ','; - resetFmt(pos); - fmt.beginObject(); - serializeRoom(&somfy.rooms[idx], fmt); - fmt.endObject(); - if(overflowed("room", somfy.rooms[idx].roomId)) { idx++; return; } - unitLen = pos + fmt.length(); - idx++; - firstInArray = false; - return; - } - case S_SHADES: { - while(idx < SOMFY_MAX_SHADES && somfy.shades[idx].getShadeId() == 255) idx++; - if(idx >= SOMFY_MAX_SHADES) { - strcpy(unit, "],\"groups\":["); - unitLen = strlen(unit); - section = S_GROUPS; - idx = 0; - lsIdx = LS_OPEN; - firstInArray = true; - return; - } - size_t pos = 0; - if(!firstInArray) unit[pos++] = ','; - resetFmt(pos); - fmt.beginObject(); - serializeShade(&somfy.shades[idx], fmt); - fmt.endObject(); - if(overflowed("shade", somfy.shades[idx].getShadeId())) { idx++; return; } - unitLen = pos + fmt.length(); - idx++; - firstInArray = false; - return; - } - case S_GROUPS: { - while(idx < SOMFY_MAX_GROUPS && somfy.groups[idx].getGroupId() == 255) idx++; - if(idx >= SOMFY_MAX_GROUPS) { - strcpy(unit, "],\"repeaters\":["); - unitLen = strlen(unit); - section = S_REPEATERS; - idx = 0; - firstInArray = true; - return; - } - SomfyGroup *g = &somfy.groups[idx]; - if(lsIdx == LS_OPEN) { - size_t pos = 0; - if(!firstInArray) unit[pos++] = ','; - resetFmt(pos); - fmt.beginObject(); - serializeGroupRef(g, fmt); - fmt.beginArray("linkedShades"); - if(overflowed("group", g->getGroupId())) { idx++; return; } - unitLen = pos + fmt.length(); - lsIdx = 0; - firstInArray = false; - firstInLSArray = true; - return; - } - SomfyShade *lshade = nullptr; - while(lsIdx < SOMFY_MAX_GROUPED_SHADES) { - uint8_t sid = g->linkedShades[lsIdx]; - if(sid > 0 && sid < 255) { - lshade = somfy.getShadeById(sid); - if(lshade) break; - } - lsIdx++; - } - if(!lshade) { - strcpy(unit, "]}"); - unitLen = 2; - idx++; - lsIdx = LS_OPEN; - return; - } - size_t pos = 0; - if(!firstInLSArray) unit[pos++] = ','; - resetFmt(pos); - fmt.beginObject(); - serializeShadeRef(lshade, fmt); - fmt.endObject(); - if(overflowed("linked shade", lshade->getShadeId())) { lsIdx++; return; } - unitLen = pos + fmt.length(); - lsIdx++; - firstInLSArray = false; - return; - } - case S_REPEATERS: { - while(idx < SOMFY_MAX_REPEATERS && somfy.repeaters[idx] == 0) idx++; - if(idx >= SOMFY_MAX_REPEATERS) { - strcpy(unit, "]}"); - unitLen = 2; - section = S_DONE; - return; - } - size_t pos = 0; - if(!firstInArray) unit[pos++] = ','; - pos += snprintf(unit + pos, sizeof(unit) - pos, "%lu", (unsigned long)somfy.repeaters[idx]); - unitLen = pos; - idx++; - firstInArray = false; - return; - } - case S_DONE: - default: - unitLen = 0; - return; - } - } + SnapshotWriter writer; + SnapshotRead(uint8_t slot, snapshot_doc_t doc, uint8_t id, snapshot_header_t header) + : slot(slot), writer(snapshotSlots.snapshot(slot), doc, id, header, unit, sizeof(unit)) {} + // The response is gone, whether it finished or the client went away. + ~SnapshotRead() { snapshotSlots.release(this->slot); } }; +static void sendSnapshot(AsyncWebServerRequest *request, snapshot_doc_t doc, uint8_t id = 0, snapshot_header_t header = nullptr) { + const int8_t slot = snapshotSlots.acquire(); + if(slot < 0) { + request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Busy, try the read again\"}")); + return; + } + loop_item_t item; + item.type = loop_item_type_t::snapshot_fill; + item.slot = static_cast(slot); + if(!loopQueue.submit(item)) { + snapshotSlots.cancel(slot); + sendQueueFull(request); + return; + } + auto read = std::make_shared(static_cast(slot), doc, id, header); + // The headers go now; the body waits until loop() has filled the snapshot. + // The library asks again on the next ACK or poll. + AsyncWebServerResponse *response = request->beginChunkedResponse(_encoding_json, + [read](uint8_t *buffer, size_t maxLen, size_t index) -> size_t { + (void)index; + if(!snapshotSlots.filled(read->slot)) return RESPONSE_TRY_AGAIN; + return read->writer.generate(buffer, maxLen); + }); + request->send(response); +} // -- Async handler implementations -- void Web::handleDiscovery(AsyncWebServerRequest *request) { if(request->method() == HTTP_POST || request->method() == HTTP_GET) { ESP_LOGD(TAG, "Async Discovery Requested"); - char connType[10] = "Unknown"; - if(net.connType == conn_types_t::ethernet) strcpy(connType, "Ethernet"); - else if(net.connType == conn_types_t::wifi) strcpy(connType, "Wifi"); - // This handler runs on the async_tcp task, which also serves the sockets, - // so a slow one is logged with where its time went. - ElapsedTimer total, step; - uint32_t usHead, usMem, usRooms, usShades, usGroups, usSend; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - resp.addElem("serverId", settings.serverId); - resp.addElem("version", settings.fwVersion.name); - resp.addElem("latest", git.latest.name); - resp.addElem("model", "ESPSomfyRTS"); - resp.addElem("hostname", settings.hostname); - resp.addElem("authType", static_cast(settings.Security.type)); - resp.addElem("permissions", settings.Security.permissions); - resp.addElem("chipModel", settings.chipModel); - resp.addElem("connType", connType); - resp.addElem("checkForUpdate", settings.checkForUpdate); - usHead = step.lap(); - resp.beginObject("memory"); - resp.addElem("max", ESP.getMaxAllocHeap()); - resp.addElem("free", ESP.getFreeHeap()); - resp.addElem("min", ESP.getMinFreeHeap()); - resp.addElem("total", ESP.getHeapSize()); - resp.addElem("uptime", (uint64_t)millis()); - resp.endObject(); - usMem = step.lap(); - resp.beginArray("rooms"); - serializeRooms(resp); - resp.endArray(); - usRooms = step.lap(); - resp.beginArray("shades"); - serializeShades(resp); - resp.endArray(); - usShades = step.lap(); - resp.beginArray("groups"); - serializeGroups(resp); - resp.endArray(); - resp.endObject(); - usGroups = step.lap(); - resp.endResponse(); - usSend = step.us(); + sendSnapshot(request, snapshot_doc_t::discovery, 0, discoveryHeader); net.needsBroadcast = true; - if(total.us() >= DISCOVERY_SLOW_MS * 1000) { - ESP_LOGW(TAG, "discovery: head %.1f mem %.1f rooms %.1f shades %.1f groups %.1f send %.1f ms", - usHead / 1000.0f, usMem / 1000.0f, usRooms / 1000.0f, usShades / 1000.0f, usGroups / 1000.0f, usSend / 1000.0f); - } } else request->send(500, _encoding_text, "Invalid http method"); } void Web::handleGetRooms(AsyncWebServerRequest *request) { if(!this->isAuthenticated(request)) return; - if(request->method() == HTTP_POST || request->method() == HTTP_GET) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginArray(); - serializeRooms(resp); - resp.endArray(); - resp.endResponse(); - } + if(request->method() == HTTP_POST || request->method() == HTTP_GET) sendSnapshot(request, snapshot_doc_t::rooms); else request->send(404, _encoding_text, _response_404); } void Web::handleGetShades(AsyncWebServerRequest *request) { if(!this->isAuthenticated(request)) return; - if(request->method() == HTTP_POST || request->method() == HTTP_GET) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginArray(); - serializeShades(resp); - resp.endArray(); - resp.endResponse(); - } + if(request->method() == HTTP_POST || request->method() == HTTP_GET) sendSnapshot(request, snapshot_doc_t::shades); else request->send(404, _encoding_text, _response_404); } void Web::handleGetGroups(AsyncWebServerRequest *request) { if(!this->isAuthenticated(request)) return; - if(request->method() == HTTP_POST || request->method() == HTTP_GET) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginArray(); - serializeGroups(resp); - resp.endArray(); - resp.endResponse(); - } + if(request->method() == HTTP_POST || request->method() == HTTP_GET) sendSnapshot(request, snapshot_doc_t::groups); else request->send(404, _encoding_text, _response_404); } void Web::handleController(AsyncWebServerRequest *request) { if(!this->isAuthenticated(request)) return; if(request->method() == HTTP_POST || request->method() == HTTP_GET) { settings.printAvailHeap(); - auto state = std::make_shared(); - AsyncWebServerResponse *response = request->beginChunkedResponse(_encoding_json, - [state](uint8_t *buffer, size_t maxLen, size_t index) -> size_t { - return state->generate(buffer, maxLen); - }); - request->send(response); + sendSnapshot(request, snapshot_doc_t::controller, 0, controllerHeader); } else request->send(404, _encoding_text, _response_404); } @@ -923,15 +625,9 @@ void Web::handleRoom(AsyncWebServerRequest *request) { if(request->method() == HTTP_GET) { if(asyncHasParam(request, "roomId")) { int roomId = asyncParam(request, "roomId").toInt(); - SomfyRoom *room = somfy.getRoomById(roomId); - if(room) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeRoom(room, resp); - resp.endObject(); - resp.endResponse(); - } + // The status goes out before the snapshot is filled, so whether the room + // exists is checked here, on the live model. + if(somfy.getRoomById(roomId)) sendSnapshot(request, snapshot_doc_t::room, roomId); else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Room Id not found.\"}")); } else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"You must supply a valid room id.\"}")); @@ -944,15 +640,8 @@ void Web::handleShade(AsyncWebServerRequest *request) { if(request->method() == HTTP_GET) { if(asyncHasParam(request, "shadeId")) { int shadeId = asyncParam(request, "shadeId").toInt(); - SomfyShade *shade = somfy.getShadeById(shadeId); - if(shade) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp); - resp.endObject(); - resp.endResponse(); - } + // As for /room, existence is checked on the live model. + if(somfy.getShadeById(shadeId)) sendSnapshot(request, snapshot_doc_t::shade, shadeId); else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade Id not found.\"}")); } else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"You must supply a valid shade id.\"}")); @@ -965,15 +654,8 @@ void Web::handleGroup(AsyncWebServerRequest *request) { if(request->method() == HTTP_GET) { if(asyncHasParam(request, "groupId")) { int groupId = asyncParam(request, "groupId").toInt(); - SomfyGroup *group = somfy.getGroupById(groupId); - if(group) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeGroup(group, resp); - resp.endObject(); - resp.endResponse(); - } + // As for /room, existence is checked on the live model. + if(somfy.getGroupById(groupId)) sendSnapshot(request, snapshot_doc_t::group, groupId); else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}")); } else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"You must supply a valid group id.\"}")); diff --git a/test/test_snapshot/test_snapshot.cpp b/test/test_snapshot/test_snapshot.cpp new file mode 100644 index 0000000..54ee004 --- /dev/null +++ b/test/test_snapshot/test_snapshot.cpp @@ -0,0 +1,296 @@ +#include +#include +#include +#include "ModelSnapshot.h" + +static model_snapshot_t snap; +static char unitBuf[3072]; + +static room_snapshot_t room(uint8_t id, const char *name) { + room_snapshot_t r{}; + r.roomId = id; + strcpy(r.name, name); + return r; +} +static shade_snapshot_t shade(uint8_t id, const char *name) { + shade_snapshot_t s{}; + s.shadeId = id; + s.roomId = 2; + strcpy(s.name, name); + s.remoteAddress = 1000 + id; + s.upTime = 10000; + s.downTime = 11000; + s.tiltTime = 7000; + s.stepSize = 100; + s.paired = true; + s.lastRollingCode = 42; + s.position = 30; + s.target = 30; + s.myPos = -1; + s.tiltType = 2; + s.tiltPosition = 40; + s.tiltTarget = 40; + s.myTiltPos = -1; + s.shadeType = 1; + s.bitLength = 56; + s.inGroup = true; + s.repeats = 1; + return s; +} +static group_snapshot_t group(uint8_t id, const char *name) { + group_snapshot_t g{}; + g.groupId = id; + strcpy(g.name, name); + g.remoteAddress = 2000 + id; + g.lastRollingCode = 5; + g.bitLength = 80; + g.repeats = 2; + return g; +} +static std::string json(void (*write)(JsonFormatter &)) { + static char buf[2048]; + BufferedJsonFormatter fmt; + fmt.setBuffer(buf, sizeof(buf)); + fmt.beginObject(); + write(fmt); + fmt.endObject(); + return std::string(buf, fmt.length()); +} +static std::string shadeRefJson(uint8_t id) { + static uint8_t which; + which = id; + return json([](JsonFormatter &f) { writeShadeRef(f, *snap.shade(which)); }); +} +static std::string shadeJson(uint8_t id) { + static uint8_t which; + which = id; + return json([](JsonFormatter &f) { writeShade(f, *snap.shade(which)); }); +} +static std::string roomJson(uint8_t id) { + static uint8_t which; + which = id; + return json([](JsonFormatter &f) { writeRoom(f, *snap.room(which)); }); +} +static std::string groupRefJson(uint8_t id) { + static uint8_t which; + which = id; + return json([](JsonFormatter &f) { writeGroupRef(f, *snap.group(which)); }); +} +static void testHeader(JsonFormatter &json, const model_snapshot_t &s) { + json.addElem("startingAddress", s.startingAddress); +} +// The whole document, taken chunkSize bytes at a time like the web server does. +static std::string document(snapshot_doc_t doc, uint8_t id = 0, size_t chunkSize = 7, size_t unitSize = sizeof(unitBuf)) { + SnapshotWriter writer(snap, doc, id, testHeader, unitBuf, unitSize); + std::string out; + uint8_t chunk[64]; + for(int guard = 0; guard < 100000; guard++) { + const size_t n = writer.generate(chunk, chunkSize); + if(n == 0) break; + out.append(reinterpret_cast(chunk), n); + } + return out; +} + +void setUp() { + snap = model_snapshot_t{}; + snap.startingAddress = 77; + snap.rooms[snap.roomCount++] = room(2, "Living"); + snap.rooms[snap.roomCount++] = room(3, "Bed"); + snap.shades[snap.shadeCount++] = shade(1, "Kitchen"); + snap.shades[snap.shadeCount++] = shade(4, "Porch"); + group_snapshot_t g = group(6, "All"); + g.linkedShades[g.linkedShadeCount++] = 4; + g.linkedShades[g.linkedShadeCount++] = 9; // gone from the model + g.linkedShades[g.linkedShadeCount++] = 1; + snap.groups[snap.groupCount++] = g; + snap.repeaters[snap.repeaterCount++] = 12345; + snap.repeaters[snap.repeaterCount++] = 678; +} +void tearDown() {} + +/********************************************************************* + * Items, as the routes have always written them + ********************************************************************/ +static void test_a_shade() { + snap.shades[0].linkedRemotes[snap.shades[0].linkedRemoteCount++] = { 5555, 7 }; + TEST_ASSERT_EQUAL_STRING( + "{\"shadeId\":1,\"roomId\":2,\"name\":\"Kitchen\",\"remoteAddress\":1001,\"upTime\":10000,\"downTime\":11000," + "\"paired\":true,\"lastRollingCode\":42,\"position\":30,\"tiltType\":2,\"tiltPosition\":40,\"tiltDirection\":0," + "\"tiltTime\":7000,\"stepSize\":100,\"tiltTarget\":40,\"target\":30,\"myPos\":-1,\"myTiltPos\":-1,\"direction\":0," + "\"shadeType\":1,\"bitLength\":56,\"proto\":0,\"flags\":0,\"flipCommands\":false,\"flipPosition\":false," + "\"inGroup\":true,\"sunSensor\":false,\"light\":false,\"repeats\":1,\"sortOrder\":0,\"gpioUp\":0,\"gpioDown\":0," + "\"gpioMy\":0,\"gpioLLTrigger\":false,\"simMy\":false,\"linkedRemotes\":[{\"remoteAddress\":5555,\"lastRollingCode\":7}]}", + shadeJson(1).c_str()); +} +static void test_a_shade_ref() { + TEST_ASSERT_EQUAL_STRING( + "{\"shadeId\":1,\"roomId\":2,\"name\":\"Kitchen\",\"remoteAddress\":1001,\"paired\":true,\"shadeType\":1," + "\"flipCommands\":false,\"flipPosition\":false,\"bitLength\":56,\"proto\":0,\"flags\":0,\"sunSensor\":false," + "\"hasLight\":false,\"repeats\":1}", + shadeRefJson(1).c_str()); +} +static void test_a_room() { + TEST_ASSERT_EQUAL_STRING("{\"roomId\":2,\"name\":\"Living\",\"sortOrder\":0}", roomJson(2).c_str()); +} +static void test_a_group_ref() { + TEST_ASSERT_EQUAL_STRING( + "{\"groupId\":6,\"roomId\":0,\"name\":\"All\",\"remoteAddress\":2006,\"lastRollingCode\":5,\"bitLength\":80," + "\"proto\":0,\"sunSensor\":false,\"flipCommands\":false,\"flags\":0,\"repeats\":2,\"sortOrder\":0}", + groupRefJson(6).c_str()); +} +static void test_names_are_escaped() { + strcpy(snap.rooms[0].name, "A \"big\" room"); + TEST_ASSERT_EQUAL_STRING("{\"roomId\":2,\"name\":\"A \\\"big\\\" room\",\"sortOrder\":0}", roomJson(2).c_str()); +} + +/********************************************************************* + * Documents + ********************************************************************/ +static std::string groupWithLinked() { + std::string g = groupRefJson(6); + g.pop_back(); // reopen it for linkedShades + return g + ",\"linkedShades\":[" + shadeRefJson(4) + "," + shadeRefJson(1) + "]}"; +} +static void test_rooms_list() { + TEST_ASSERT_EQUAL_STRING(("[" + roomJson(2) + "," + roomJson(3) + "]").c_str(), document(snapshot_doc_t::rooms).c_str()); +} +static void test_shades_list() { + TEST_ASSERT_EQUAL_STRING(("[" + shadeJson(1) + "," + shadeJson(4) + "]").c_str(), document(snapshot_doc_t::shades).c_str()); +} +static void test_groups_list_skips_a_linked_shade_that_is_gone() { + TEST_ASSERT_EQUAL_STRING(("[" + groupWithLinked() + "]").c_str(), document(snapshot_doc_t::groups).c_str()); +} +static void test_empty_lists() { + snap = model_snapshot_t{}; + TEST_ASSERT_EQUAL_STRING("[]", document(snapshot_doc_t::rooms).c_str()); + TEST_ASSERT_EQUAL_STRING("[]", document(snapshot_doc_t::shades).c_str()); + TEST_ASSERT_EQUAL_STRING("[]", document(snapshot_doc_t::groups).c_str()); +} +static void test_controller() { + const std::string expected = "{\"startingAddress\":77,\"rooms\":[" + roomJson(2) + "," + roomJson(3) + + "],\"shades\":[" + shadeJson(1) + "," + shadeJson(4) + + "],\"groups\":[" + groupWithLinked() + + "],\"repeaters\":[12345,678]}"; + TEST_ASSERT_EQUAL_STRING(expected.c_str(), document(snapshot_doc_t::controller).c_str()); +} +static void test_discovery_has_no_repeaters() { + const std::string expected = "{\"startingAddress\":77,\"rooms\":[" + roomJson(2) + "," + roomJson(3) + + "],\"shades\":[" + shadeJson(1) + "," + shadeJson(4) + + "],\"groups\":[" + groupWithLinked() + "]}"; + TEST_ASSERT_EQUAL_STRING(expected.c_str(), document(snapshot_doc_t::discovery).c_str()); +} +static void test_controller_of_an_empty_model() { + snap = model_snapshot_t{}; + TEST_ASSERT_EQUAL_STRING("{\"startingAddress\":0,\"rooms\":[],\"shades\":[],\"groups\":[],\"repeaters\":[]}", + document(snapshot_doc_t::controller).c_str()); +} +static void test_chunk_size_does_not_change_the_document() { + const std::string whole = document(snapshot_doc_t::controller, 0, 64); + for(size_t n = 1; n < 20; n++) + TEST_ASSERT_EQUAL_STRING(whole.c_str(), document(snapshot_doc_t::controller, 0, n).c_str()); +} +static void test_single_items() { + TEST_ASSERT_EQUAL_STRING(roomJson(3).c_str(), document(snapshot_doc_t::room, 3).c_str()); + TEST_ASSERT_EQUAL_STRING(shadeJson(4).c_str(), document(snapshot_doc_t::shade, 4).c_str()); + TEST_ASSERT_EQUAL_STRING(groupWithLinked().c_str(), document(snapshot_doc_t::group, 6).c_str()); +} +static void test_a_single_item_gone_by_the_snapshot_is_an_error() { + TEST_ASSERT_EQUAL_STRING("{\"status\":\"ERROR\",\"desc\":\"Shade Id not found.\"}", document(snapshot_doc_t::shade, 9).c_str()); + TEST_ASSERT_EQUAL_STRING("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}", document(snapshot_doc_t::group, 9).c_str()); + TEST_ASSERT_EQUAL_STRING("{\"status\":\"ERROR\",\"desc\":\"Room Id not found.\"}", document(snapshot_doc_t::room, 9).c_str()); +} +static void test_an_item_too_big_for_the_unit_is_left_out() { + // Room JSON is about 40 bytes and a shade's well over 300, so a 200 byte + // unit takes the rooms and leaves the shades out, keeping the JSON valid. + TEST_ASSERT_EQUAL_STRING(("[" + roomJson(2) + "," + roomJson(3) + "]").c_str(), + document(snapshot_doc_t::rooms, 0, 7, 200).c_str()); + TEST_ASSERT_EQUAL_STRING("[]", document(snapshot_doc_t::shades, 0, 7, 200).c_str()); +} +static void test_a_left_out_item_does_not_leave_a_stray_comma() { + // Give the first shade every linked remote so it no longer fits a unit the + // second one does. + snap.shades[0].linkedRemoteCount = SOMFY_MAX_LINKED_REMOTES; + for(uint8_t i = 0; i < SOMFY_MAX_LINKED_REMOTES; i++) snap.shades[0].linkedRemotes[i] = { 4000000000u, 65535 }; + const size_t fits = shadeJson(4).length() + 8; + TEST_ASSERT_TRUE(shadeJson(1).length() > fits); + TEST_ASSERT_EQUAL_STRING(("[" + shadeJson(4) + "]").c_str(), document(snapshot_doc_t::shades, 0, 7, fits).c_str()); +} + +/********************************************************************* + * Slots + ********************************************************************/ +static SnapshotSlots slots; +static int fills = 0; +static void countFill(model_snapshot_t &s) { + fills++; + s.shadeCount = 1; +} + +static void test_two_reads_at_once_and_no_more() { + const int8_t a = slots.acquire(); + const int8_t b = slots.acquire(); + TEST_ASSERT_TRUE(a >= 0 && b >= 0 && a != b); + TEST_ASSERT_EQUAL_INT8(-1, slots.acquire()); + slots.cancel(a); + slots.cancel(b); +} +static void test_a_read_is_filled_then_freed() { + fills = 0; + const int8_t s = slots.acquire(); + TEST_ASSERT_FALSE(slots.filled(s)); + slots.fill(s, countFill); + TEST_ASSERT_TRUE(slots.filled(s)); + TEST_ASSERT_EQUAL_UINT8(1, slots.snapshot(s).shadeCount); + slots.release(s); + TEST_ASSERT_EQUAL_INT8(s, slots.acquire()); + slots.cancel(s); + TEST_ASSERT_EQUAL_INT(1, fills); +} +static void test_a_read_gone_before_its_fill_is_not_filled() { + fills = 0; + const int8_t s = slots.acquire(); + const int8_t other = slots.acquire(); + slots.release(s); + // Not free yet: loop() still has the fill queued. + TEST_ASSERT_EQUAL_INT8(-1, slots.acquire()); + slots.fill(s, countFill); + TEST_ASSERT_EQUAL_INT(0, fills); + TEST_ASSERT_EQUAL_INT8(s, slots.acquire()); + slots.cancel(s); + slots.cancel(other); +} +static void test_a_cancelled_slot_is_free_again() { + const int8_t s = slots.acquire(); + slots.cancel(s); + TEST_ASSERT_EQUAL_INT8(s, slots.acquire()); + slots.cancel(s); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_a_shade); + RUN_TEST(test_a_shade_ref); + RUN_TEST(test_a_room); + RUN_TEST(test_a_group_ref); + RUN_TEST(test_names_are_escaped); + RUN_TEST(test_rooms_list); + RUN_TEST(test_shades_list); + RUN_TEST(test_groups_list_skips_a_linked_shade_that_is_gone); + RUN_TEST(test_empty_lists); + RUN_TEST(test_controller); + RUN_TEST(test_discovery_has_no_repeaters); + RUN_TEST(test_controller_of_an_empty_model); + RUN_TEST(test_chunk_size_does_not_change_the_document); + RUN_TEST(test_single_items); + RUN_TEST(test_a_single_item_gone_by_the_snapshot_is_an_error); + RUN_TEST(test_an_item_too_big_for_the_unit_is_left_out); + RUN_TEST(test_a_left_out_item_does_not_leave_a_stray_comma); + RUN_TEST(test_two_reads_at_once_and_no_more); + RUN_TEST(test_a_read_is_filled_then_freed); + RUN_TEST(test_a_read_gone_before_its_fill_is_not_filled); + RUN_TEST(test_a_cancelled_slot_is_free_again); + return UNITY_END(); +} From e70e499d752546c04f30608df3d49f09e6ef20e7 Mon Sep 17 00:00:00 2001 From: cjkas Date: Fri, 16 Oct 2026 10:26:45 +0200 Subject: [PATCH 2/7] refactor: apply shade model edits on loop() through ModelEditor The 19 routes that changed rooms, shades, groups and repeaters on the web server task (add, save, delete, link and unlink, setPaired, the sort orders) now copy their request into a model_edit_t, queue it on the LoopQueue and reply {status:accepted,requestId:N}. loop() applies it (SomfyJson.cpp, next to fromJSON) and broadcasts requestResult {requestId, ok, id, code, desc}. One edit at a time; a second gets 503, a body over 4 KB gets 413. ModelEditor.h/.cpp holds the rules, tested natively: the lowest free id, the next sort order, a free remote address, adding to and removing from the group-member and repeater lists, and the GPIO pin-conflict check. They replace the getNext*Id/getMax*Order copies, the remote-address search, the list code and the inline pin checks. The pin check now uses the protocol being saved and checks every pin against Ethernet, not only up. /getNextRoom, /getNextShade, /getNextGroup, /groupOptions and a new /repeaters (the UI already called it) are ModelSnapshot reads. The web UI's editJSON() sends an edit, waits for its requestResult, then reads the item back from /shade, /group, /room or /repeaters, so the callbacks that used the old reply bodies are unchanged. No result within 5 s offers a reload; reads and edits told Busy are retried. It now also refreshes its lists on shadeAdded/shadeRemoved/groupAdded/groupRemoved from other clients, and procRoomRemoved keeps the right rooms. Also fixed: a shade that fails validation is no longer added; linked repeaters and sort orders are saved; linking reports a full list. 27 new native tests, 319 in all. Flash: esp32devdbg -11,484 bytes, esp32dev -7,180 bytes. Co-Authored-By: Claude Opus 5.5 --- data-src/index.js | 162 ++++- docs/KNOWN_ISSUES.md | 17 +- docs/LOOP_QUEUE_PLAN.md | 31 +- platformio.ini | 2 +- src/LoopQueue.cpp | 4 + src/LoopQueue.h | 11 +- src/ModelEditor.cpp | 78 +++ src/ModelEditor.h | 77 +++ src/ModelSnapshot.cpp | 103 ++- src/ModelSnapshot.h | 18 +- src/ShadeController.cpp | 180 +----- src/Somfy.h | 5 - src/SomfyEditor.h | 29 + src/SomfyGroup.cpp | 61 +- src/SomfyJson.cpp | 238 +++++-- src/SomfySnapshot.cpp | 2 + src/entryPoints/Web.cpp | 712 +++------------------ src/outputs/SomfyEvents.cpp | 13 + test/test_modeleditor/test_modeleditor.cpp | 161 +++++ test/test_snapshot/test_snapshot.cpp | 49 ++ 20 files changed, 1035 insertions(+), 918 deletions(-) create mode 100644 src/ModelEditor.cpp create mode 100644 src/ModelEditor.h create mode 100644 src/SomfyEditor.h create mode 100644 test/test_modeleditor/test_modeleditor.cpp diff --git a/data-src/index.js b/data-src/index.js index b0506b2..e435753 100644 --- a/data-src/index.js +++ b/data-src/index.js @@ -245,7 +245,7 @@ var httpStatusText = { '504': 'Gateway Timeout', '505': 'HTTP Version Not Supported' }; -function getJSON(url, cb) { +function getJSON(url, cb, attempt) { let xhr = new XMLHttpRequest(); console.log({ get: url }); xhr.open('GET', baseUrl.length > 0 ? `${baseUrl}${url}` : url, true); @@ -253,6 +253,10 @@ function getJSON(url, cb) { xhr.responseType = 'json'; xhr.onload = () => { let status = xhr.status; + if (isBusy(status, xhr.response) && (attempt || 0) < 10) { + setTimeout(() => getJSON(url, cb, (attempt || 0) + 1), 250); + return; + } if (status !== 200) { let err = xhr.response || {}; err.htmlError = status; @@ -274,12 +278,17 @@ function getJSON(url, cb) { }; xhr.send(); } -function getJSONSync(url, cb) { +function getJSONSync(url, cb, attempt) { let overlay = ui.waitMessage(document.getElementById('divContainer')); let xhr = new XMLHttpRequest(); xhr.responseType = 'json'; xhr.onload = () => { let status = xhr.status; + if (isBusy(status, xhr.response) && (attempt || 0) < 10) { + if (typeof overlay !== 'undefined') overlay.remove(); + setTimeout(() => getJSONSync(url, cb, (attempt || 0) + 1), 250); + return; + } if (status !== 200) { let err = xhr.response || {}; err.htmlError = status; @@ -453,6 +462,79 @@ function putJSONSync(url, data, cb) { xhr.send(JSON.stringify(data)); } catch (err) { ui.serviceError(document.getElementById('divContainer'), err); } } +// Edits of rooms, shades, groups and repeaters run on the device's main loop +// after the reply, so the reply only says the edit was accepted. How it went +// comes over the socket as requestResult with the same requestId. On success +// readUrl(id) is read back, so cb gets what the edit route itself used to +// return; without readUrl cb gets the result. +var pendingEdits = {}; +var earlyResults = {}; +function editJSON(url, data, readUrl, cb) { + let overlay = ui.waitMessage(document.getElementById('divContainer')); + let attempts = 0; + let finish = (result) => { + overlay.remove(); + if (!result.ok) { + let err = { htmlError: 500, service: `PUT ${url}`, desc: result.desc, data: data }; + if (result.code) err.code = result.code; + cb(err, null); + } + else if (typeof readUrl === 'function') getJSONSync(readUrl(result.id), cb); + else cb(null, result); + }; + let send = () => { + putJSON(url, data, (err, reply) => { + // Another edit is still being applied. + if (err && err.htmlError === 503 && typeof err.desc === 'string' && err.desc.startsWith('Busy') && ++attempts < 10) { + setTimeout(send, 300); + return; + } + if (err) { + overlay.remove(); + cb(err, null); + return; + } + let id = reply.requestId; + if (typeof earlyResults[id] !== 'undefined') { + let result = earlyResults[id]; + delete earlyResults[id]; + finish(result); + return; + } + let timer = setTimeout(() => { + delete pendingEdits[id]; + overlay.remove(); + let prompt = ui.promptMessage('The device did not confirm the change.', () => { location.reload(); }); + prompt.querySelector('.sub-message').innerHTML = '

It may or may not have been made. Press YES to reload the page and see.

'; + }, 5000); + pendingEdits[id] = (result) => { + clearTimeout(timer); + finish(result); + }; + }); + }; + send(); +} +function procRequestResult(result) { + let fn = pendingEdits[result.requestId]; + if (typeof fn === 'function') { + delete pendingEdits[result.requestId]; + fn(result); + } + // The socket can beat the reply that carries the id. Results of other + // clients' edits land here too, so only the latest few are kept. + else { + earlyResults[result.requestId] = result; + let ids = Object.keys(earlyResults); + if (ids.length > 10) delete earlyResults[ids[0]]; + } +} +function editsPending() { return Object.keys(pendingEdits).length > 0; } +// Reads are served from a copy of the shade model the device takes for each, +// and it takes at most two at once: a third is told to try again. +function isBusy(status, response) { + return status === 503 && response && typeof response.desc === 'string' && response.desc.startsWith('Busy'); +} var socket; var tConnect = null; var sockIsOpen = false; @@ -515,15 +597,24 @@ async function initSockets() { case 'shadeCommand': console.log(msg); break; + case 'requestResult': + procRequestResult(msg); + break; case 'roomRemoved': somfy.procRoomRemoved(msg); break; case 'roomAdded': somfy.procRoomAdded(msg); break; + // Another client changed the lists. This one's own edits + // refresh them when their result comes in. case 'shadeRemoved': - break; case 'shadeAdded': + if (!editsPending()) somfy.updateShadeList(); + break; + case 'groupRemoved': + case 'groupAdded': + if (!editsPending()) somfy.updateGroupList(); break; case 'ethernet': wifi.procEthernet(msg); @@ -2181,7 +2272,7 @@ class Somfy { if (room.roomId === 0) return; let r = _rooms.find(x => x.roomId === room.roomId); if (typeof r !== 'undefined' && r.roomId === room.roomId) { - _rooms = _rooms.filter(x => x.roomId === room.roomId); + _rooms = _rooms.filter(x => x.roomId !== room.roomId); _rooms.sort((a, b) => { return a.sortOrder - b.sortOrder }); this.setRoomsList(_rooms); let rs = document.getElementById('divRoomSelector'); @@ -2259,7 +2350,7 @@ class Somfy { order.push(parseInt(items[i].getAttribute('data-roomid'), 10)); // Reorder the shades on the main page. } - putJSONSync('/roomSortOrder', order, (err) => { + editJSON('/roomSortOrder', order, null, (err) => { if (err) ui.serviceError(err); else this.updateRoomsList(); }); @@ -2455,7 +2546,7 @@ class Somfy { order.push(parseInt(items[i].getAttribute('data-shadeid'), 10)); // Reorder the shades on the main page. } - putJSONSync('/shadeSortOrder', order, (err) => { + editJSON('/shadeSortOrder', order, null, (err) => { for (let i = order.length - 1; i >= 0; i--) { let el = shadeControls.querySelector(`.somfyShadeCtl[data-shadeid="${order[i]}"`); if (el) { @@ -2700,7 +2791,7 @@ class Somfy { order.push(parseInt(items[i].getAttribute('data-groupid'), 10)); // Reorder the shades on the main page. } - putJSONSync('/groupSortOrder', order, (err) => { + editJSON('/groupSortOrder', order, null, (err) => { for (let i = order.length - 1; i >= 0; i--) { let el = groupControls.querySelector(`.somfyGroupCtl[data-groupid="${order[i]}"`); if (el) { @@ -2909,18 +3000,17 @@ class Somfy { remoteAddress: frame.address, rollingCode: frame.rcode }; - let overlay = ui.waitMessage(document.getElementById('divLinking')); - putJSON('/linkRemote', obj, (err, shade) => { + editJSON('/linkRemote', obj, (id) => `/shade?shadeId=${id}`, (err, shade) => { console.log(shade); - overlay.remove(); lnk.remove(); - this.setLinkedRemotesList(shade); + if (err) ui.serviceError(err); + else this.setLinkedRemotesList(shade); }); } else { lnk = document.getElementById('divLinkRepeater'); if (lnk) { - putJSONSync(`/linkRepeater`, {address:frame.address}, (err, repeaters) => { + editJSON(`/linkRepeater`, {address:frame.address}, () => '/repeaters', (err, repeaters) => { lnk.remove(); if (err) ui.serviceError(err); else this.setRepeaterList(repeaters); @@ -3251,7 +3341,7 @@ class Somfy { if (valid) { if (isNaN(roomId) || roomId === 0) { // We are adding. - putJSONSync('/addRoom', obj, (err, room) => { + editJSON('/addRoom', obj, (id) => `/room?roomId=${id}`, (err, room) => { if (err) { ui.serviceError(err); console.log(err); @@ -3267,7 +3357,7 @@ class Somfy { } else { obj.roomId = roomId; - putJSONSync('/saveRoom', obj, (err, room) => { + editJSON('/saveRoom', obj, (id) => `/room?roomId=${id}`, (err, room) => { if (err) ui.serviceError(err); else this.updateRoomsList(); console.log(room); @@ -3324,7 +3414,7 @@ class Somfy { if (valid) { if (isNaN(shadeId) || shadeId >= 255) { // We are adding. - putJSONSync('/addShade', obj, (err, shade) => { + editJSON('/addShade', obj, (id) => `/shade?shadeId=${id}`, (err, shade) => { if (err) { ui.serviceError(err); console.log(err); @@ -3343,9 +3433,12 @@ class Somfy { } else { obj.shadeId = shadeId; - putJSONSync('/saveShade', obj, (err, shade) => { - if (err) ui.serviceError(err); - else this.updateShadeList(); + editJSON('/saveShade', obj, (id) => `/shade?shadeId=${id}`, (err, shade) => { + if (err) { + ui.serviceError(err); + return; + } + this.updateShadeList(); console.log(shade); let ico = document.getElementById('icoShade'); let tilt = ico.parentElement.querySelector('i.icss-window-tilt'); @@ -3376,7 +3469,7 @@ class Somfy { if (valid) { if (isNaN(groupId) || groupId >= 255) { // We are adding. - putJSONSync('/addGroup', obj, (err, group) => { + editJSON('/addGroup', obj, (id) => `/group?groupId=${id}`, (err, group) => { if (err) ui.serviceError(err); else { console.log(group); @@ -3391,7 +3484,7 @@ class Somfy { } else { obj.groupId = groupId; - putJSONSync('/saveGroup', obj, (err, shade) => { + editJSON('/saveGroup', obj, (id) => `/group?groupId=${id}`, (err, shade) => { if (err) ui.serviceError(err); else this.updateGroupList(); console.log(shade); @@ -3458,7 +3551,7 @@ class Somfy { else { let prompt = ui.promptMessage(`Are you sure you want to delete this room?`, () => { ui.clearErrors(); - putJSONSync('/deleteRoom', { roomId: roomId }, (err, room) => { + editJSON('/deleteRoom', { roomId: roomId }, null, (err, room) => { prompt.remove(); if (err) ui.serviceError(err); else @@ -3483,9 +3576,10 @@ class Somfy { else { let prompt = ui.promptMessage(`Are you sure you want to delete this shade?`, () => { ui.clearErrors(); - putJSONSync('/deleteShade', { shadeId: shadeId }, (err, shade) => { + editJSON('/deleteShade', { shadeId: shadeId }, null, (err, shade) => { + prompt.remove(); + if (err) ui.serviceError(err); this.updateShadeList(); - prompt.remove; }); }); prompt.querySelector('.sub-message').innerHTML = `

If this shade was previously paired with a motor, you should first unpair it from the motor and remove it from any groups. Otherwise its address will remain in the motor memory.

Press YES to delete ${shade.name} or NO to cancel this operation.

`; @@ -3508,7 +3602,7 @@ class Somfy { } else { let prompt = ui.promptMessage(`Are you sure you want to delete this group?`, () => { - putJSONSync('/deleteGroup', { groupId: groupId }, (err, g) => { + editJSON('/deleteGroup', { groupId: groupId }, null, (err, g) => { if (err) ui.serviceError(err); this.updateGroupList(); prompt.remove(); @@ -3631,7 +3725,7 @@ class Somfy { let obj = { shadeId: shadeId, paired: paired || false }; let div = document.getElementById('divPairing'); let overlay = typeof div === 'undefined' ? undefined : ui.waitMessage(div); - putJSONSync('/setPaired', obj, (err, shade) => { + editJSON('/setPaired', obj, (id) => `/shade?shadeId=${id}`, (err, shade) => { if (overlay) overlay.remove(); if (err) { console.log(err); @@ -4004,9 +4098,10 @@ class Somfy { mouseDown = false; let obj = ui.fromElement(div); let prompt = ui.promptMessage('Confirm Motor Response', () => { - putJSONSync('/linkToGroup', { groupId: groupId, shadeId: obj.shadeId }, (err, group) => { + editJSON('/linkToGroup', { groupId: groupId, shadeId: obj.shadeId }, (id) => `/group?groupId=${id}`, (err, group) => { console.log(group); - somfy.setLinkedShadesList(group); + if (err) ui.serviceError(err); + else somfy.setLinkedShadesList(group); this.updateGroupList(); }); prompt.remove(); @@ -4098,9 +4193,10 @@ class Somfy { if (err) ui.serviceError(err); else { let prompt = ui.promptMessage('Confirm Motor Response', () => { - putJSONSync('/unlinkFromGroup', { groupId: groupId, shadeId: shadeId }, (err, group) => { + editJSON('/unlinkFromGroup', { groupId: groupId, shadeId: shadeId }, (id) => `/group?groupId=${id}`, (err, group) => { console.log(group); - somfy.setLinkedShadesList(group); + if (err) ui.serviceError(err); + else somfy.setLinkedShadesList(group); this.updateGroupList(); }); prompt.remove(); @@ -4136,7 +4232,7 @@ class Somfy { } unlinkRepeater(address) { let prompt = ui.promptMessage('Are you sure you want to stop repeating frames from this address?', () => { - putJSONSync('/unlinkRepeater', { address: address }, (err, repeaters) => { + editJSON('/unlinkRepeater', { address: address }, () => '/repeaters', (err, repeaters) => { if (err) ui.serviceError(err); else this.setRepeaterList(repeaters); prompt.remove(); @@ -4151,11 +4247,11 @@ class Somfy { shadeId: shadeId, remoteAddress: remoteAddress }; - putJSONSync('/unlinkRemote', obj, (err, shade) => { - + editJSON('/unlinkRemote', obj, (id) => `/shade?shadeId=${id}`, (err, shade) => { console.log(shade); prompt.remove(); - this.setLinkedRemotesList(shade); + if (err) ui.serviceError(err); + else this.setLinkedRemotesList(shade); }); }); diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 003cc61..abb19f4 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -2,9 +2,9 @@ ## Concurrent mutation during chunked `/controller` response -**Status:** fixed for the read routes; the web-task writers below are still open. +**Status:** fixed: reads in step 2, edits in step 3; command replies still read the live model (below). **Introduced:** 2026-04-19, alongside the chunked-response conversion of `/controller` (the original crash report was about `cbuf::resize` aborting `async_tcp`). -**Fixed:** 2026-10-16, step 2 of [LOOP_QUEUE_PLAN.md](LOOP_QUEUE_PLAN.md). +**Fixed:** 2026-10-16, steps 2 and 3 of [LOOP_QUEUE_PLAN.md](LOOP_QUEUE_PLAN.md). ### What it was @@ -12,16 +12,13 @@ The chunked `/controller` response read `somfy.rooms`, `somfy.shades`, `somfy.gr All eight read routes (`/controller`, `/discovery`, `/rooms`, `/shades`, `/groups`, `/room`, `/shade`, `/group`) now stream a ModelSnapshot that `loop()` fills, so each document describes one moment ([ADR-0002](adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md)). That is fix option (c), taken per read in a heap slot of about 5.6 KB. +### Edits (step 3) + +The handlers that changed the shade model on the web server task (adding, saving and deleting rooms, shades and groups; linking remotes, repeaters and group members; `/setPaired`; the sort orders) now hand the edit to `loop()` through the LoopQueue, and every client learns the result from a `requestResult` socket event. So `/linkRemote` can no longer race a received frame for a linked remote's rolling code: both now change it on `loop()`, in the order they happen. + ### Still open -- Command and edit replies (`/shadeCommand`, `/saveShade`, `/linkRemote`, ...) still copy the one item they report from the live shade model on async_tcp, so a reply can show a value from the middle of a change. `/room`, `/shade` and `/group` also check on the live model whether the item exists, because the status goes out before the snapshot is filled. -- Other handlers on the web server task still change the shade model that `loop()` owns (step 3 of the plan): - - `/addRoom`, `/saveRoom`, `/deleteRoom`, `/addShade`, `/saveShade`, `/deleteShade`, `/addGroup`, `/saveGroup` and `/deleteGroup` add, change and remove rooms, shades and groups. - - `/linkRemote` and `/unlinkRemote` change a shade's linked remotes. `/linkRemote` also sets a linked remote's rolling code, which `loop()` updates whenever it receives a frame from that remote: the last write wins, so a received frame can undo the code just set. - - `/linkToGroup` and `/unlinkFromGroup` change a group's linked shades. - - `/setPaired` sets `paired`. - - Shade requests (shade and group commands, set-My, unpair, `/setPositions`, `/setRollingCode`, and every MQTT command), a shades restore and `/updateShadeConfig` already go through the LoopQueue to `loop()`. +- Command replies (`/shadeCommand`, `/tiltCommand`, `/unpairShade`, `/setRollingCode`, ...) still copy the one item they report from the live shade model on async_tcp, so a reply can show a value from the middle of a change. `/room`, `/shade`, `/group` and `/groupOptions` also check on the live model whether the item exists, because the status goes out before the snapshot is filled. - The read routes leave out an item that doesn't fit their 3 KB unit, see [Oversized read items are left out](#oversized-read-items-are-left-out). ## Oversized read items are left out diff --git a/docs/LOOP_QUEUE_PLAN.md b/docs/LOOP_QUEUE_PLAN.md index 7a68701..d69dc0d 100644 --- a/docs/LOOP_QUEUE_PLAN.md +++ b/docs/LOOP_QUEUE_PLAN.md @@ -1,6 +1,6 @@ # Plan: domain modules behind the LoopQueue -**Status:** steps 0–2 built (PRs #39, #40 and #41); steps 3–5 planned. +**Status:** steps 0–3 built (PRs #39, #40, #41 and #42); steps 4 and 5 planned. **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 @@ -144,6 +144,20 @@ Flash: `esp32devdbg` 1,546,405 → 1,547,381 bytes, `esp32dev` 1,396,957 → 1,3 Flash: `esp32devdbg` 1,547,381 → 1,547,821 bytes, `esp32dev` 1,398,337 → 1,400,401 bytes. +### Step 3: what changed in behaviour +- **Every edit of the shade model runs on `loop()`.** The 19 edit routes reply `{"status":"accepted","requestId":N}` and queue a copy of the request. `loop()` applies it and broadcasts `requestResult {requestId, ok, id, code, desc}`. The errors that used to come in the HTTP reply ("Shade Id not found.", "Data Error." with its code, and so on) come in `requestResult` instead. +- **One edit at a time.** A second edit gets 503 "Busy, try the change again", and the web UI retries it. A body over 4 KB gets 413. +- **The web UI waits for the result** and then reads the item back (`/shade`, `/group`, `/room`, `/repeaters`), so the code that used the old reply body is unchanged. If no result comes within 5 s, it offers to reload the page. Reads that get 503 "Busy" are retried too. +- **Added, removed and saved items reach every client.** Saving sends `shadeState`, `groupState` or `roomState`. The web UI now acts on `shadeAdded`, `shadeRemoved`, `groupAdded` and `groupRemoved` from other clients. +- **A shade that fails validation is no longer added.** `/addShade` used to keep the new shade with defaults when its pins clashed. +- **Sort orders and linked repeaters are saved.** Linking a remote, a repeater or a group member reports when the list is full. +- **`/getNextRoom`, `/getNextShade`, `/getNextGroup`, `/groupOptions` and the new `/repeaters`** are snapshot reads. +- **ModelEditor's rules** (`ModelEditor.h/.cpp`) replace the six `getNext*Id`/`getMax*Order` copies, the remote-address search, the repeater and group-member list code, and the pin-conflict check. The pin check now uses the protocol being saved and checks every pin against Ethernet. + +`SomfyEditor.h` declares the edit handed to `loop()`. The code that applies it lives in `SomfyJson.cpp`, next to `fromJSON`, so ArduinoJson stays in one file. 27 new native tests, 319 in all. + +Flash: `esp32devdbg` 1,547,821 → 1,536,337 bytes, `esp32dev` 1,400,401 → 1,393,221 bytes. The 19 handler bodies became one path. + ### 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`. @@ -159,12 +173,17 @@ NVS stays at `0x9000`, so the rolling codes survive. The file system partition s ## Found during the review, not part of this plan -These are open, and each can be fixed on its own: +Still 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. +- New ids stop two short of the maximum: 30 shades, 14 groups and 14 rooms instead of 32, 16 and 16 (`getNext*Id` stopped at `MAX - 1`). ModelEditor keeps those limits for now. +- `SomfyGroup::fromJSON` reads `linkedShades` into a local array and drops it, so a group's members only change through `/linkToGroup` and `/unlinkFromGroup`. + +Fixed by step 3: +- The Ethernet pin conflict was only checked for the up pin, and the check used the shade's current protocol instead of the one being saved. +- `linkRepeater` never set `isDirty`, so a newly linked repeater wasn't saved. The sort orders weren't saved either. +- `/linkRemote` and `/unlinkRemote` sent a 500 and then a second response. +- The web UI's `procRoomRemoved` kept the removed room and dropped the others, and `deleteShade` never closed its prompt (`prompt.remove;`). +- The web UI called `/repeaters`, which didn't exist. diff --git a/platformio.ini b/platformio.ini index eb3ef2a..d829029 100644 --- a/platformio.ini +++ b/platformio.ini @@ -109,7 +109,7 @@ lib_deps = platform = native test_framework = unity test_build_src = yes -build_src_filter = -<*> + + + + + + + + + + + +build_src_filter = -<*> + + + + + + + + + + + + build_flags = -std=gnu++17 -Isrc diff --git a/src/LoopQueue.cpp b/src/LoopQueue.cpp index 1f52aa3..d5baa1c 100644 --- a/src/LoopQueue.cpp +++ b/src/LoopQueue.cpp @@ -5,6 +5,7 @@ #include "ConfigFile.h" #include "Somfy.h" #include "SomfySnapshot.h" +#include "SomfyEditor.h" #include "Utils.h" static const char *TAG = "LoopQueue"; @@ -66,6 +67,9 @@ void LoopQueue::drain() { case loop_item_type_t::snapshot_fill: snapshotSlots.fill(item.slot, fillSnapshot); break; + case loop_item_type_t::model_edit: + runModelEdit(item.edit); + break; } } } diff --git a/src/LoopQueue.h b/src/LoopQueue.h index 9cc545f..0f4a12a 100644 --- a/src/LoopQueue.h +++ b/src/LoopQueue.h @@ -23,13 +23,16 @@ enum class loop_item_type_t : uint8_t { shade_request, // run by ShadeRequests restore_shades, // restore from /shades.tmp, then reboot load_shades, // replace the shades with /shades.tmp - snapshot_fill // fill a read's ModelSnapshot + snapshot_fill, // fill a read's ModelSnapshot + model_edit // apply an edit with ModelEditor }; +struct model_edit_t; struct loop_item_t { loop_item_type_t type = loop_item_type_t::shade_request; - shade_request_t request; // shade_request - uint8_t restoreParts = 0; // restore_shades: RESTORE_* bits - uint8_t slot = 0; // snapshot_fill: the SnapshotSlots slot + shade_request_t request; // shade_request + uint8_t restoreParts = 0; // restore_shades: RESTORE_* bits + uint8_t slot = 0; // snapshot_fill: the SnapshotSlots slot + model_edit_t *edit = nullptr; // model_edit: loop() runs and frees it }; // Whether the item sends anything on air. diff --git a/src/ModelEditor.cpp b/src/ModelEditor.cpp new file mode 100644 index 0000000..c20b680 --- /dev/null +++ b/src/ModelEditor.cpp @@ -0,0 +1,78 @@ +#include "ModelEditor.h" + +uint8_t firstFreeId(const uint8_t *usedIds, size_t count, uint8_t lastId) { + for(uint16_t id = 1; id <= lastId; id++) { + bool used = false; + for(size_t i = 0; i < count && !used; i++) used = usedIds[i] == id; + if(!used) return static_cast(id); + } + return 0; +} +int8_t nextSortOrder(const int8_t *orders, size_t count) { + int8_t order = -1; + for(size_t i = 0; i < count; i++) + if(orders[i] > order) order = orders[i]; + return order + 1; +} +uint32_t freeRemoteAddress(uint32_t candidate, const uint32_t *used, size_t count) { + // Start over after every bump: the new address may be taken too. + for(size_t i = 0; i < count; i++) { + if(used[i] == candidate) { + candidate++; + i = static_cast(-1); + } + } + return candidate; +} + +template +static bool addTo(T *list, size_t size, T item) { + for(size_t i = 0; i < size; i++) + if(list[i] == item) return true; + for(size_t i = 0; i < size; i++) { + if(list[i] == 0) { + list[i] = item; + return true; + } + } + return false; +} +template +static bool removeFrom(T *list, size_t size, T item) { + bool removed = false; + size_t j = 0; + for(size_t i = 0; i < size; i++) { + if(list[i] == 0) continue; + if(list[i] == item) removed = true; + else list[j++] = list[i]; + } + while(j < size) list[j++] = 0; + return removed; +} +void packList(uint8_t *list, size_t size) { removeFrom(list, size, 0); } +bool addToList(uint8_t *list, size_t size, uint8_t item) { return addTo(list, size, item); } +bool addToList(uint32_t *list, size_t size, uint32_t item) { return addTo(list, size, item); } +bool removeFromList(uint8_t *list, size_t size, uint8_t item) { return removeFrom(list, size, item); } +bool removeFromList(uint32_t *list, size_t size, uint32_t item) { return removeFrom(list, size, item); } + +int8_t gpioPinConflict(shade_types type, radio_proto proto, uint8_t upPin, uint8_t downPin, uint8_t myPin, + const pin_users_t &radio, const pin_users_t ðernet, const pin_users_t &otherShades) { + if(proto != radio_proto::GP_Relay && proto != radio_proto::GP_Remote) return 0; + // A dry contact or a single button gate driven by a remote only uses the down + // pin; a two way dry contact and a relay don't use My. + if(type == shade_types::drycontact || + ((type == shade_types::garage1 || type == shade_types::lgate1 || type == shade_types::cgate1 || type == shade_types::rgate1) + && proto == radio_proto::GP_Remote)) upPin = myPin = 255; + else if(type == shade_types::drycontact2) myPin = 255; + if(proto == radio_proto::GP_Relay) myPin = 255; + const uint8_t pins[] = { upPin, downPin, myPin }; + auto clash = [&pins](const pin_users_t &users) { + for(uint8_t pin : pins) + if(pin != 255 && users.usesPin(pin)) return true; + return false; + }; + if(clash(radio)) return PIN_CONFLICT_RADIO; + if(clash(ethernet)) return PIN_CONFLICT_ETHERNET; + if(clash(otherShades)) return PIN_CONFLICT_SHADE; + return 0; +} diff --git a/src/ModelEditor.h b/src/ModelEditor.h new file mode 100644 index 0000000..f949261 --- /dev/null +++ b/src/ModelEditor.h @@ -0,0 +1,77 @@ +#ifndef MODEL_EDITOR_H +#define MODEL_EDITOR_H +#include +#include +#include "SomfyFrame.h" +#include "ShadeTypes.h" + +// The rules ModelEditor applies when the shade model is edited: which id, sort +// order and remote address a new room, shade or group gets, how ids go into +// and out of a list, and which GPIO pins a shade may not use. Kept free of the +// shade model so it can be unit tested on the PC (pio test -e native). The +// edits themselves are applied on loop() (SomfyJson.cpp), since only loop() +// changes the shade model (docs/adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md). + +// The edits, one per edit route. +enum class model_edit_type_t : uint8_t { + add_room, add_shade, add_group, + save_room, save_shade, save_group, + delete_room, delete_shade, delete_group, + link_remote, unlink_remote, + link_to_group, unlink_from_group, + link_repeater, unlink_repeater, + set_paired, + room_order, shade_order, group_order +}; +// What an edit did, sent to the clients as requestResult. id is the room, +// shade or group it added or changed, 0 when none. code is the validation +// error of a shade that wasn't saved, 0 otherwise. +struct edit_result_t { + bool ok = true; + uint8_t id = 0; + int8_t code = 0; + const char *desc = ""; + static edit_result_t error(const char *desc, int8_t code = 0) { + edit_result_t r; + r.ok = false; + r.desc = desc; + r.code = code; + return r; + } +}; + +// The lowest id from 1 to lastId that isn't in usedIds, or 0 when every one +// is taken. +uint8_t firstFreeId(const uint8_t *usedIds, size_t count, uint8_t lastId); +// The sort order that puts a new item after every existing one. +int8_t nextSortOrder(const int8_t *orders, size_t count); +// candidate, or the next address after it that isn't in used. No two shades or +// groups may share a remote address. +uint32_t freeRemoteAddress(uint32_t candidate, const uint32_t *used, size_t count); + +// A list of ids or addresses with 0 for an empty slot, kept packed at the front. +// Adding one that is already there changes nothing and succeeds; false when the +// list is full. +bool addToList(uint8_t *list, size_t size, uint8_t item); +bool addToList(uint32_t *list, size_t size, uint32_t item); +// Removes every copy and packs the rest to the front. False when it wasn't there. +bool removeFromList(uint8_t *list, size_t size, uint8_t item); +bool removeFromList(uint32_t *list, size_t size, uint32_t item); +// Packs the items to the front, keeping their order. +void packList(uint8_t *list, size_t size); + +// Who else drives a GPIO pin. +struct pin_users_t { + bool (*uses)(uint8_t pin, void *ctx) = nullptr; + void *ctx = nullptr; + bool usesPin(uint8_t pin) const { return this->uses && this->uses(pin, this->ctx); } +}; +#define PIN_CONFLICT_RADIO -10 +#define PIN_CONFLICT_ETHERNET -11 +#define PIN_CONFLICT_SHADE -12 +// Whether a shade of type driven over proto may use these pins (255 for none): +// 0, or which of the radio, the Ethernet PHY or another shade already uses one. +// Only the pins the shade type and protocol actually drive are checked. +int8_t gpioPinConflict(shade_types type, radio_proto proto, uint8_t upPin, uint8_t downPin, uint8_t myPin, + const pin_users_t &radio, const pin_users_t ðernet, const pin_users_t &otherShades); +#endif diff --git a/src/ModelSnapshot.cpp b/src/ModelSnapshot.cpp index 2fb2cb8..ec56cd5 100644 --- a/src/ModelSnapshot.cpp +++ b/src/ModelSnapshot.cpp @@ -3,6 +3,7 @@ #include #include "esp_log.h" #include "ModelSnapshot.h" +#include "ModelEditor.h" static const char *TAG = "Snapshot"; @@ -118,6 +119,35 @@ void writeGroup(JsonFormatter &json, const group_snapshot_t &group, shade_lookup json.endArray(); } +/********************************************************************* + * What the next room, shade or group would get (ModelEditor's rules, + * as SomfyShadeController applies them when one is added) + ********************************************************************/ +uint8_t nextRoomId(const model_snapshot_t &snap) { + uint8_t ids[SOMFY_MAX_ROOMS]; + for(uint8_t i = 0; i < snap.roomCount; i++) ids[i] = snap.rooms[i].roomId; + return firstFreeId(ids, snap.roomCount, SOMFY_MAX_ROOMS - 2); +} +uint8_t nextShadeId(const model_snapshot_t &snap) { + uint8_t ids[SOMFY_MAX_SHADES]; + for(uint8_t i = 0; i < snap.shadeCount; i++) ids[i] = snap.shades[i].shadeId; + const uint8_t id = firstFreeId(ids, snap.shadeCount, SOMFY_MAX_SHADES - 2); + return id == 0 ? 255 : id; +} +uint8_t nextGroupId(const model_snapshot_t &snap) { + uint8_t ids[SOMFY_MAX_GROUPS]; + for(uint8_t i = 0; i < snap.groupCount; i++) ids[i] = snap.groups[i].groupId; + const uint8_t id = firstFreeId(ids, snap.groupCount, SOMFY_MAX_GROUPS - 2); + return id == 0 ? 255 : id; +} +uint32_t nextRemoteAddress(const model_snapshot_t &snap, uint8_t id) { + uint32_t used[SOMFY_MAX_SHADES + SOMFY_MAX_GROUPS]; + size_t count = 0; + for(uint8_t i = 0; i < snap.shadeCount; i++) used[count++] = snap.shades[i].remoteAddress; + for(uint8_t i = 0; i < snap.groupCount; i++) used[count++] = snap.groups[i].remoteAddress; + return freeRemoteAddress(snap.startingAddress + id, used, count); +} + /********************************************************************* * Documents ********************************************************************/ @@ -136,6 +166,11 @@ enum : uint8_t { ST_TO_GROUPS, // ],"groups":[ ST_TO_REPEATERS, // ],"repeaters":[ ST_CLOSE_DOC, // ]} + ST_NEXT_ROOM, + ST_NEXT_SHADE, + ST_NEXT_GROUP, + ST_GROUP_OPTIONS, // the group, then ],"availShades":[ + ST_AVAIL_SHADES, // the shades not in that group ST_END }; static const uint8_t CONTROLLER_STEPS[] = { ST_HEADER, ST_ROOMS, ST_TO_SHADES, ST_SHADES, ST_TO_GROUPS, ST_GROUPS, ST_TO_REPEATERS, ST_REPEATERS, ST_CLOSE_DOC, ST_END }; @@ -146,6 +181,11 @@ static const uint8_t GROUPS_STEPS[] = { ST_OPEN_ARRAY, ST_GROUPS, ST_CLOSE_ARRAY static const uint8_t ROOM_STEPS[] = { ST_ROOM, ST_END }; static const uint8_t SHADE_STEPS[] = { ST_SHADE, ST_END }; static const uint8_t GROUP_STEPS[] = { ST_GROUP, ST_END }; +static const uint8_t REPEATERS_STEPS[] = { ST_OPEN_ARRAY, ST_REPEATERS, ST_CLOSE_ARRAY, ST_END }; +static const uint8_t NEXT_ROOM_STEPS[] = { ST_NEXT_ROOM, ST_END }; +static const uint8_t NEXT_SHADE_STEPS[] = { ST_NEXT_SHADE, ST_END }; +static const uint8_t NEXT_GROUP_STEPS[] = { ST_NEXT_GROUP, ST_END }; +static const uint8_t GROUP_OPTIONS_STEPS[] = { ST_GROUP_OPTIONS, ST_AVAIL_SHADES, ST_CLOSE_DOC, ST_END }; static const uint8_t *stepsFor(snapshot_doc_t doc) { switch(doc) { @@ -157,6 +197,11 @@ static const uint8_t *stepsFor(snapshot_doc_t doc) { case snapshot_doc_t::room: return ROOM_STEPS; case snapshot_doc_t::shade: return SHADE_STEPS; case snapshot_doc_t::group: return GROUP_STEPS; + case snapshot_doc_t::repeaters: return REPEATERS_STEPS; + case snapshot_doc_t::nextRoom: return NEXT_ROOM_STEPS; + case snapshot_doc_t::nextShade: return NEXT_SHADE_STEPS; + case snapshot_doc_t::nextGroup: return NEXT_GROUP_STEPS; + case snapshot_doc_t::groupOptions: return GROUP_OPTIONS_STEPS; } return SHADES_STEPS; } @@ -199,6 +244,11 @@ void SnapshotWriter::literal(const char *text) { this->unitLen = strlen(this->unit); this->nextStep(); } +// Writes text as the whole rest of the document. +void SnapshotWriter::finish(const char *text) { + this->literal(text); + while(this->steps[this->step] != ST_END) this->step++; +} void SnapshotWriter::resetFmt(size_t offset) { this->unit[offset] = '\0'; this->fmt.setBuffer(this->unit + offset, this->unitSize - offset); @@ -218,7 +268,7 @@ size_t SnapshotWriter::itemStart() { } // One piece of a group: its ref, then each linked shade, then the close. // inList is whether it is an item of a list rather than the whole document. -void SnapshotWriter::groupUnit(const group_snapshot_t &group, bool inList) { +void SnapshotWriter::groupUnit(const group_snapshot_t &group, bool inList, const char *close) { auto done = [this, inList]() { this->groupOpen = false; if(inList) this->idx++; @@ -241,8 +291,8 @@ void SnapshotWriter::groupUnit(const group_snapshot_t &group, bool inList) { const shade_snapshot_t *shade = nullptr; while(this->lsIdx < group.linkedShadeCount && !(shade = this->snap.shade(group.linkedShades[this->lsIdx]))) this->lsIdx++; if(!shade) { - strcpy(this->unit, "]}"); - this->unitLen = 2; + strcpy(this->unit, close); + this->unitLen = strlen(close); done(); return; } @@ -335,6 +385,52 @@ void SnapshotWriter::produceUnit() { this->groupUnit(*group, false); return; } + case ST_NEXT_ROOM: + case ST_NEXT_SHADE: + case ST_NEXT_GROUP: + this->resetFmt(0); + this->fmt.beginObject(); + if(this->steps[this->step] == ST_NEXT_ROOM) this->fmt.addElem("roomId", nextRoomId(this->snap)); + else if(this->steps[this->step] == ST_NEXT_SHADE) { + const uint8_t shadeId = nextShadeId(this->snap); + this->fmt.addElem("shadeId", shadeId); + this->fmt.addElem("remoteAddress", nextRemoteAddress(this->snap, shadeId)); + this->fmt.addElem("bitLength", this->snap.radioBitLength); + this->fmt.addElem("stepSize", (uint8_t)100); + this->fmt.addElem("proto", this->snap.radioProto); + } + else { + const uint8_t groupId = nextGroupId(this->snap); + this->fmt.addElem("groupId", groupId); + this->fmt.addElem("remoteAddress", nextRemoteAddress(this->snap, groupId)); + this->fmt.addElem("bitLength", this->snap.radioBitLength); + this->fmt.addElem("proto", this->snap.radioProto); + } + this->fmt.endObject(); + this->unitLen = this->fmt.length(); + this->nextStep(); + return; + case ST_GROUP_OPTIONS: { + const group_snapshot_t *group = this->snap.group(this->id); + if(!group) { this->finish("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}"); return; } + this->groupUnit(*group, false, "],\"availShades\":["); + return; + } + case ST_AVAIL_SHADES: { + const group_snapshot_t *group = this->snap.group(this->id); + if(!group || this->idx >= this->snap.shadeCount) { this->nextStep(); return; } + const shade_snapshot_t &shade = this->snap.shades[this->idx++]; + for(uint8_t i = 0; i < group->linkedShadeCount; i++) + if(group->linkedShades[i] == shade.shadeId) return; + const size_t pos = this->itemStart(); + this->fmt.beginObject(); + writeShadeRef(this->fmt, shade); + this->fmt.endObject(); + if(this->overflowed("shade", shade.shadeId)) return; + this->unitLen = pos + this->fmt.length(); + this->firstItem = false; + return; + } case ST_OPEN_ARRAY: this->literal("["); return; case ST_CLOSE_ARRAY: this->literal("]"); return; case ST_TO_SHADES: this->literal("],\"shades\":["); return; @@ -379,6 +475,7 @@ void SnapshotSlots::fill(uint8_t slot, void (*fill)(model_snapshot_t &snap)) { } model_snapshot_t &snap = *this->snaps[slot]; snap.startingAddress = 0; + snap.radioBitLength = snap.radioProto = 0; snap.roomCount = snap.shadeCount = snap.groupCount = snap.repeaterCount = 0; fill(snap); expected = FILLING; diff --git a/src/ModelSnapshot.h b/src/ModelSnapshot.h index 9c036dc..86d5f97 100644 --- a/src/ModelSnapshot.h +++ b/src/ModelSnapshot.h @@ -83,6 +83,8 @@ struct group_snapshot_t { // Only the rooms, shades, groups and repeaters that exist, in model order. struct model_snapshot_t { uint32_t startingAddress; + uint8_t radioBitLength; // what a new shade or group gets + uint8_t radioProto; uint8_t roomCount; uint8_t shadeCount; uint8_t groupCount; @@ -115,7 +117,12 @@ enum class snapshot_doc_t : uint8_t { groups, // /groups room, // /room?roomId= shade, // /shade?shadeId= - group // /group?groupId= + group, // /group?groupId= + repeaters, // /repeaters + nextRoom, // /getNextRoom: the id and defaults a new room would get + nextShade, // /getNextShade + nextGroup, // /getNextGroup + groupOptions // /groupOptions?groupId=: the group and the shades it could link }; // Writes the fields of /controller or /discovery that aren't in the snapshot // (device, radio and firmware details) into the open top-level object. @@ -154,8 +161,15 @@ class SnapshotWriter { void resetFmt(size_t offset); bool overflowed(const char *what, uint8_t itemId); size_t itemStart(); - void groupUnit(const group_snapshot_t &group, bool comma); + void groupUnit(const group_snapshot_t &group, bool inList, const char *close = "]}"); + void finish(const char *text); }; +// What the next room, shade or group added would get: its id (0 for a room and +// 255 for a shade or group when there is no room for one) and remote address. +uint8_t nextRoomId(const model_snapshot_t &snap); +uint8_t nextShadeId(const model_snapshot_t &snap); +uint8_t nextGroupId(const model_snapshot_t &snap); +uint32_t nextRemoteAddress(const model_snapshot_t &snap, uint8_t id); // The snapshots reads are served from. A read takes a slot on the web server // task and queues a fill; loop() fills it; the read streams it and lets it go. diff --git a/src/ShadeController.cpp b/src/ShadeController.cpp index 4c8fef1..86fdd03 100644 --- a/src/ShadeController.cpp +++ b/src/ShadeController.cpp @@ -5,6 +5,7 @@ #include "Somfy.h" #include "ConfigFile.h" #include "GitOTA.h" +#include "ModelEditor.h" static const char *TAG = "Somfy"; @@ -117,17 +118,6 @@ void SomfyRoom::clear() { this->roomId = 0; strcpy(this->name, ""); } -void SomfyShadeController::compressRepeaters() { - for(uint8_t i = 0, j = 0; i < SOMFY_MAX_REPEATERS; i++) { - if(this->repeaters[i] != 0) { - if(i != j) { - this->repeaters[j] = this->repeaters[i]; - this->repeaters[i] = 0; - } - j++; - } - } -} bool SomfyRoom::save() { somfy.commit(); return true; } void SomfyShadeController::processFrame(somfy_frame_t &frame, bool internal) { for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { @@ -158,89 +148,45 @@ void SomfyShadeController::processWaitingFrame() { for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) if(this->shades[i].getShadeId() != 255) this->shades[i].processWaitingFrame(); } +// A deleted shade, group or room leaves a gap, so a new one takes the lowest +// free id. Ids stop two short of the maximum, as they always have. uint8_t SomfyShadeController::getNextShadeId() { - // There is no shortcut for this since the deletion of - // a shade in the middle makes all of this very difficult. - for(uint8_t i = 1; i < SOMFY_MAX_SHADES - 1; i++) { - bool id_exists = false; - for(uint8_t j = 0; j < SOMFY_MAX_SHADES; j++) { - SomfyShade *shade = &this->shades[j]; - if(shade->getShadeId() == i) { - id_exists = true; - break; - } - } - if(!id_exists) { - ESP_LOGD(TAG, "Got next Shade Id:%d", i); - return i; - } - } - return 255; + uint8_t ids[SOMFY_MAX_SHADES]; + for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) ids[i] = this->shades[i].getShadeId(); + const uint8_t id = firstFreeId(ids, SOMFY_MAX_SHADES, SOMFY_MAX_SHADES - 2); + return id == 0 ? 255 : id; } int8_t SomfyShadeController::getMaxShadeOrder() { - int8_t order = -1; - for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { - SomfyShade *shade = &this->shades[i]; - if(shade->getShadeId() == 255) continue; - if(order < shade->sortOrder) order = shade->sortOrder; - } - return order; + int8_t orders[SOMFY_MAX_SHADES]; + size_t count = 0; + for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) + if(this->shades[i].getShadeId() != 255) orders[count++] = this->shades[i].sortOrder; + return nextSortOrder(orders, count) - 1; } int8_t SomfyShadeController::getMaxGroupOrder() { - int8_t order = -1; - for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { - SomfyGroup *group = &this->groups[i]; - if(group->getGroupId() == 255) continue; - if(order < group->sortOrder) order = group->sortOrder; - } - return order; + int8_t orders[SOMFY_MAX_GROUPS]; + size_t count = 0; + for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) + if(this->groups[i].getGroupId() != 255) orders[count++] = this->groups[i].sortOrder; + return nextSortOrder(orders, count) - 1; } uint8_t SomfyShadeController::getNextGroupId() { - // There is no shortcut for this since the deletion of - // a group in the middle makes all of this very difficult. - for(uint8_t i = 1; i < SOMFY_MAX_GROUPS - 1; i++) { - bool id_exists = false; - for(uint8_t j = 0; j < SOMFY_MAX_GROUPS; j++) { - SomfyGroup *group = &this->groups[j]; - if(group->getGroupId() == i) { - id_exists = true; - break; - } - } - if(!id_exists) { - ESP_LOGD(TAG, "Got next Group Id:%d", i); - return i; - } - } - return 255; + uint8_t ids[SOMFY_MAX_GROUPS]; + for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) ids[i] = this->groups[i].getGroupId(); + const uint8_t id = firstFreeId(ids, SOMFY_MAX_GROUPS, SOMFY_MAX_GROUPS - 2); + return id == 0 ? 255 : id; } uint8_t SomfyShadeController::getNextRoomId() { - // There is no shortcut for this since the deletion of - // a room in the middle makes all of this very difficult. - for(uint8_t i = 1; i < SOMFY_MAX_ROOMS - 1; i++) { - bool id_exists = false; - for(uint8_t j = 0; j < SOMFY_MAX_ROOMS; j++) { - SomfyRoom *room = &this->rooms[j]; - if(room->roomId == i) { - id_exists = true; - break; - } - } - if(!id_exists) { - ESP_LOGD(TAG, "Got next room Id:%d", i); - return i; - } - } - return 0; + uint8_t ids[SOMFY_MAX_ROOMS]; + for(uint8_t i = 0; i < SOMFY_MAX_ROOMS; i++) ids[i] = this->rooms[i].roomId; + return firstFreeId(ids, SOMFY_MAX_ROOMS, SOMFY_MAX_ROOMS - 2); } int8_t SomfyShadeController::getMaxRoomOrder() { - int8_t order = -1; - for(uint8_t i = 0; i < SOMFY_MAX_ROOMS; i++) { - SomfyRoom *room = &this->rooms[i]; - if(room->roomId == 0) continue; - if(order < room->sortOrder) order = room->sortOrder; - } - return order; + int8_t orders[SOMFY_MAX_ROOMS]; + size_t count = 0; + for(uint8_t i = 0; i < SOMFY_MAX_ROOMS; i++) + if(this->rooms[i].roomId != 0) orders[count++] = this->rooms[i].sortOrder; + return nextSortOrder(orders, count) - 1; } uint8_t SomfyShadeController::repeaterCount() { uint8_t count = 0; @@ -270,31 +216,6 @@ uint8_t SomfyShadeController::groupCount() { } return count; } -uint32_t SomfyShadeController::getNextRemoteAddress(uint8_t id) { - uint32_t address = this->startingAddress + id; - uint8_t i = 0; - // The assumption here is that the max number of groups will - // always be less than or equal to the max number of shades. - while(i < SOMFY_MAX_SHADES) { - if((i < SOMFY_MAX_SHADES && this->shades[i].getShadeId() != 255 && this->shades[i].getRemoteAddress() == address) || - (i < SOMFY_MAX_GROUPS && this->groups[i].getGroupId() != 255 && this->groups[i].getRemoteAddress() == address)) { - address++; - i = 0; // Start over we cannot share addresses. - } - else i++; - } - i = 0; - return address; -} -SomfyShade *SomfyShadeController::addShade(JsonObject &obj) { - SomfyShade *shade = this->addShade(); - if(shade) { - shade->fromJSON(obj); - shade->save(); - shade->emitState("shadeAdded"); - } - return shade; -} SomfyShade *SomfyShadeController::addShade() { Preferences pref; uint8_t shadeId = this->getNextShadeId(); @@ -313,37 +234,15 @@ SomfyShade *SomfyShadeController::addShade() { return shade; } bool SomfyShadeController::unlinkRepeater(uint32_t address) { - for(uint8_t i = 0; i < SOMFY_MAX_REPEATERS; i++) { - if(this->repeaters[i] == address) this->repeaters[i] = 0; - } - this->compressRepeaters(); + removeFromList(this->repeaters, SOMFY_MAX_REPEATERS, address); this->isDirty = true; - return true; -} -bool SomfyShadeController::linkRepeater(uint32_t address) { - bool bSet = false; - for(uint8_t i = 0; i < SOMFY_MAX_REPEATERS; i++) { - if(!bSet && this->repeaters[i] == address) bSet = true; - else if(bSet && this->repeaters[i] == address) this->repeaters[i] = 0; - } - if(!bSet) { - for(uint8_t i = 0; i < SOMFY_MAX_REPEATERS; i++) { - if(this->repeaters[i] == 0) { - this->repeaters[i] = address; - return true; - } - } - } return true; } -SomfyRoom *SomfyShadeController::addRoom(JsonObject &obj) { - SomfyRoom *room = this->addRoom(); - if(room) { - room->fromJSON(obj); - room->save(); - room->emitState("roomAdded"); - } - return room; +// False when every repeater slot is taken. +bool SomfyShadeController::linkRepeater(uint32_t address) { + if(!addToList(this->repeaters, SOMFY_MAX_REPEATERS, address)) return false; + this->isDirty = true; + return true; } SomfyRoom *SomfyShadeController::addRoom() { uint8_t roomId = this->getNextRoomId(); @@ -357,15 +256,6 @@ SomfyRoom *SomfyShadeController::addRoom() { } return room; } -SomfyGroup *SomfyShadeController::addGroup(JsonObject &obj) { - SomfyGroup *group = this->addGroup(); - if(group) { - group->fromJSON(obj); - group->save(); - group->emitState("groupAdded"); - } - return group; -} SomfyGroup *SomfyShadeController::addGroup() { uint8_t groupId = this->getNextGroupId(); // So the next shade id will be the first one we run into with an id of 255 so diff --git a/src/Somfy.h b/src/Somfy.h index c508600..1d1670c 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -274,22 +274,17 @@ class SomfyShadeController { int8_t getMaxRoomOrder(); int8_t getMaxShadeOrder(); int8_t getMaxGroupOrder(); - uint32_t getNextRemoteAddress(uint8_t shadeId); SomfyShadeController(); Transceiver transceiver; SomfyRoom *addRoom(); - SomfyRoom *addRoom(JsonObject &obj); SomfyShade *addShade(); - SomfyShade *addShade(JsonObject &obj); SomfyGroup *addGroup(); - SomfyGroup *addGroup(JsonObject &obj); bool deleteRoom(uint8_t roomId); bool deleteShade(uint8_t shadeId); bool deleteGroup(uint8_t groupId); bool begin(); void loop(); void end(); - void compressRepeaters(); uint32_t repeaters[SOMFY_MAX_REPEATERS] = {0}; SomfyRoom rooms[SOMFY_MAX_ROOMS]; SomfyShade shades[SOMFY_MAX_SHADES]; diff --git a/src/SomfyEditor.h b/src/SomfyEditor.h new file mode 100644 index 0000000..22d6a4e --- /dev/null +++ b/src/SomfyEditor.h @@ -0,0 +1,29 @@ +#ifndef SOMFY_EDITOR_H +#define SOMFY_EDITOR_H +#include +#include "ModelEditor.h" + +// ModelEditor on the device: an edit of the shade model handed from the web +// server task to loop(), which applies it with the ModelEditor rules and tells +// the clients how it went. + +// One edit on its way to loop(): the edit route and a copy of its request. +struct model_edit_t { + model_edit_type_t type = model_edit_type_t::add_room; + uint32_t requestId = 0; + JsonDocument doc; +}; + +// One edit at a time: false when one is already waiting or running. Any task. +bool beginModelEdit(); +// The id the HTTP reply and the requestResult event carry. Any task. +uint32_t nextEditRequestId(); +// Gives up an edit that was begun but never queued. Any task. +void cancelModelEdit(model_edit_t *edit); +// Applies the edit, saves it, sends requestResult and frees it. loop() only. +void runModelEdit(model_edit_t *edit); +// Applies the edit to the shade model and saves it. loop() only. +edit_result_t applyModelEdit(model_edit_t &edit); +// Tells every client how the edit went (SomfyEvents.cpp). +void emitRequestResult(uint32_t requestId, const edit_result_t &result); +#endif diff --git a/src/SomfyGroup.cpp b/src/SomfyGroup.cpp index a948fbd..f09b09a 100644 --- a/src/SomfyGroup.cpp +++ b/src/SomfyGroup.cpp @@ -1,5 +1,6 @@ #include "esp_log.h" #include "Somfy.h" +#include "ModelEditor.h" static const char *TAG = "Somfy"; @@ -13,60 +14,12 @@ void SomfyGroup::clear() { this->name[0] = 0x00; memset(&this->linkedShades, 0x00, sizeof(this->linkedShades)); } -bool SomfyGroup::linkShade(uint8_t shadeId) { - // Check to see if the shade is already linked. If it is just return true - for(uint8_t i = 0; i < SOMFY_MAX_GROUPED_SHADES; i++) { - if(this->linkedShades[i] == shadeId) { - return true; - } - } - for(uint8_t i = 0; i < SOMFY_MAX_GROUPED_SHADES; i++) { - if(this->linkedShades[i] == 0) { - this->linkedShades[i] = shadeId; - somfy.commit(); - return true; - } - } - return false; -} -bool SomfyGroup::unlinkShade(uint8_t shadeId) { - bool removed = false; - for(uint8_t i = 0; i < SOMFY_MAX_GROUPED_SHADES; i++) { - if(this->linkedShades[i] == shadeId) { - this->linkedShades[i] = 0; - removed = true; - } - } - // Compress the linked shade ids so we can stop looking on the first 0 - if(removed) { - this->compressLinkedShadeIds(); - somfy.commit(); - } - return removed; -} -void SomfyGroup::compressLinkedShadeIds() { - // [1,0,4,3,0,0,0] i:0,j:0 - // [1,0,4,3,0,0,0] i:1,j:1 - // [1,4,0,3,0,0,0] i:2,j:1 - // [1,4,3,0,0,0,0] i:3,j:2 - // [1,4,3,0,0,0,0] i:4,j:2 - - // [1,2,0,0,3,0,0] i:0,j:0 - // [1,2,0,0,3,0,0] i:1,j:1 - // [1,2,0,0,3,0,0] i:2,j:2 - // [1,2,0,0,3,0,0] i:3,j:2 - // [1,2,3,0,0,0,0] i:4,j:2 - // [1,2,3,0,0,0,0] i:5,j:3 - for(uint8_t i = 0, j = 0; i < SOMFY_MAX_GROUPED_SHADES; i++) { - if(this->linkedShades[i] != 0) { - if(i != j) { - this->linkedShades[j] = this->linkedShades[i]; - this->linkedShades[i] = 0; - } - j++; - } - } -} +// False when the group is full. The caller saves the change. +bool SomfyGroup::linkShade(uint8_t shadeId) { return addToList(this->linkedShades, SOMFY_MAX_GROUPED_SHADES, shadeId); } +// False when the shade wasn't in the group. The caller saves the change. +bool SomfyGroup::unlinkShade(uint8_t shadeId) { return removeFromList(this->linkedShades, SOMFY_MAX_GROUPED_SHADES, shadeId); } +// Linked shade ids are kept packed so a search can stop at the first 0. +void SomfyGroup::compressLinkedShadeIds() { packList(this->linkedShades, SOMFY_MAX_GROUPED_SHADES); } bool SomfyGroup::hasShadeId(uint8_t shadeId) { for(uint8_t i = 0; i < SOMFY_MAX_GROUPED_SHADES; i++) { if(this->linkedShades[i] == 0) break; diff --git a/src/SomfyJson.cpp b/src/SomfyJson.cpp index 7ca7c30..1887c77 100644 --- a/src/SomfyJson.cpp +++ b/src/SomfyJson.cpp @@ -1,6 +1,9 @@ #include "esp_log.h" #include "ConfigSettings.h" #include "Somfy.h" +#include +#include "ModelEditor.h" +#include "SomfyEditor.h" static const char *TAG = "Somfy"; @@ -36,50 +39,32 @@ int8_t SomfyShade::validateJSON(JsonObject &obj) { type = shade_types::drycontact; } else { - this->shadeType = static_cast(obj["shadeType"].as()); + type = static_cast(obj["shadeType"].as()); } } - if(!obj["proto"].isNull()) { - radio_proto proto = this->proto; - if(proto == radio_proto::GP_Relay || proto == radio_proto::GP_Remote) { - // Check to see if we are using the up and or down - // GPIOs anywhere else. - uint8_t upPin = !obj["gpioUp"].isNull() ? obj["gpioUp"].as() : this->gpioUp; - uint8_t downPin = !obj["gpioDown"].isNull() ? obj["gpioDown"].as() : this->gpioDown; - uint8_t myPin = !obj["gpioMy"].isNull() ? obj["gpioMy"].as() : this->gpioMy; - if(type == shade_types::drycontact || - ((type == shade_types::garage1 || type == shade_types::lgate1 || type == shade_types::cgate1 || type == shade_types::rgate1) - && proto == radio_proto::GP_Remote)) upPin = myPin = 255; - else if(type == shade_types::drycontact2) myPin = 255; - if(proto == radio_proto::GP_Relay) myPin = 255; - if(somfy.transceiver.config.enabled) { - if((upPin != 255 && somfy.transceiver.usesPin(upPin)) || - (downPin != 255 && somfy.transceiver.usesPin(downPin)) || - (myPin != 255 && somfy.transceiver.usesPin(myPin))) - ret = -10; - } + // The pins and protocol the shade is about to have. + const radio_proto proto = !obj["proto"].isNull() ? static_cast(obj["proto"].as()) : this->proto; + const uint8_t upPin = !obj["gpioUp"].isNull() ? obj["gpioUp"].as() : this->gpioUp; + const uint8_t downPin = !obj["gpioDown"].isNull() ? obj["gpioDown"].as() : this->gpioDown; + const uint8_t myPin = !obj["gpioMy"].isNull() ? obj["gpioMy"].as() : this->gpioMy; + pin_users_t radio, ethernet, others; + if(somfy.transceiver.config.enabled) + radio.uses = [](uint8_t pin, void *) { return somfy.transceiver.usesPin(pin); }; #ifndef CONFIG_IDF_TARGET_ESP32C6 - if(settings.connType == conn_types_t::ethernet || settings.connType == conn_types_t::ethernetpref) { - if((upPin != 255 && settings.Ethernet.usesPin(upPin)) || - (downPin != 255 && somfy.transceiver.usesPin(downPin)) || - (myPin != 255 && somfy.transceiver.usesPin(myPin))) - ret = -11; - } + if(settings.connType == conn_types_t::ethernet || settings.connType == conn_types_t::ethernetpref) + ethernet.uses = [](uint8_t pin, void *) { return settings.Ethernet.usesPin(pin); }; #endif - if(ret == 0) { - for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { - SomfyShade *shade = &somfy.shades[i]; - if(shade->getShadeId() == this->getShadeId() || shade->getShadeId() == 255) continue; - if((upPin != 255 && shade->usesPin(upPin)) || - (downPin != 255 && shade->usesPin(downPin)) || - (myPin != 255 && shade->usesPin(myPin))){ - ret = -12; - break; - } - } - } + others.ctx = this; + others.uses = [](uint8_t pin, void *ctx) { + const uint8_t self = static_cast(ctx)->getShadeId(); + for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { + SomfyShade &shade = somfy.shades[i]; + if(shade.getShadeId() == self || shade.getShadeId() == 255) continue; + if(shade.usesPin(pin)) return true; } - } + return false; + }; + ret = gpioPinConflict(type, proto, upPin, downPin, myPin, radio, ethernet, others); return ret; } int8_t SomfyShade::fromJSON(JsonObject &obj) { @@ -233,3 +218,178 @@ void transceiver_config_t::fromJSON(JsonObject& obj) { if(!obj["noiseDetection"].isNull()) this->noiseDetection = obj["noiseDetection"]; ESP_LOGD(TAG, "SCK:%u MISO:%u MOSI:%u CSN:%u RX:%u TX:%u", this->SCKPin, this->MISOPin, this->MOSIPin, this->CSNPin, this->RXPin, this->TXPin); } + +/********************************************************************* + * ModelEditor on the device (SomfyEditor.h). Here rather than in a file of + * its own because every file that uses ArduinoJson carries its own copy of the + * library's helpers (docs/SOMFY_SPLIT_PLAN.md). + ********************************************************************/ +static std::atomic editBusy(false); +static std::atomic lastRequestId(0); + +bool beginModelEdit() { return !editBusy.exchange(true); } +uint32_t nextEditRequestId() { return ++lastRequestId; } +void cancelModelEdit(model_edit_t *edit) { + delete edit; + editBusy.store(false); +} +void runModelEdit(model_edit_t *edit) { + const edit_result_t result = applyModelEdit(*edit); + if(!result.ok) ESP_LOGW(TAG, "Edit %u refused: %s", static_cast(edit->type), result.desc); + emitRequestResult(edit->requestId, result); + cancelModelEdit(edit); +} + +static edit_result_t done(uint8_t id = 0) { + edit_result_t r; + r.id = id; + return r; +} +// Gives each id in the request the next sort order, in the order given. +template +static edit_result_t applyOrder(JsonDocument &doc, Find find) { + if(!doc.is()) return edit_result_t::error("JSON parse error"); + int8_t order = 0; + for(JsonVariant v : doc.as()) { + auto *item = find(v.as()); + if(item) item->sortOrder = order++; + } + somfy.commit(); + return done(); +} +edit_result_t applyModelEdit(model_edit_t &edit) { + JsonObject obj = edit.doc.as(); + switch(edit.type) { + case model_edit_type_t::add_room: { + SomfyRoom *room = somfy.addRoom(); + if(!room) return edit_result_t::error("Maximum number of rooms exceeded."); + room->fromJSON(obj); + room->save(); + room->emitState("roomAdded"); + return done(room->roomId); + } + case model_edit_type_t::add_shade: { + SomfyShade *shade = somfy.addShade(); + if(!shade) return edit_result_t::error("Maximum number of shades exceeded."); + const int8_t err = shade->fromJSON(obj); + if(err != 0) { + // Not added after all: free the slot it took. + shade->clear(); + return edit_result_t::error("Data Error.", err); + } + shade->save(); + shade->emitState("shadeAdded"); + return done(shade->getShadeId()); + } + case model_edit_type_t::add_group: { + SomfyGroup *group = somfy.addGroup(); + if(!group) return edit_result_t::error("Maximum number of groups exceeded."); + group->fromJSON(obj); + group->save(); + group->emitState("groupAdded"); + return done(group->getGroupId()); + } + case model_edit_type_t::save_room: { + if(obj["roomId"].isNull()) return edit_result_t::error("No room id was supplied."); + SomfyRoom *room = somfy.getRoomById(obj["roomId"]); + if(!room) return edit_result_t::error("Room Id not found."); + room->fromJSON(obj); + room->save(); + room->emitState(); + return done(room->roomId); + } + case model_edit_type_t::save_shade: { + if(obj["shadeId"].isNull()) return edit_result_t::error("No shade id was supplied."); + SomfyShade *shade = somfy.getShadeById(obj["shadeId"]); + if(!shade) return edit_result_t::error("Shade Id not found."); + const int8_t err = shade->fromJSON(obj); + if(err != 0) return edit_result_t::error("Data Error.", err); + shade->save(); + shade->emitState(); + return done(shade->getShadeId()); + } + case model_edit_type_t::save_group: { + if(obj["groupId"].isNull()) return edit_result_t::error("No group id was supplied."); + SomfyGroup *group = somfy.getGroupById(obj["groupId"]); + if(!group) return edit_result_t::error("Group Id not found."); + group->fromJSON(obj); + group->save(); + group->emitState(); + return done(group->getGroupId()); + } + case model_edit_type_t::delete_room: { + if(obj["roomId"].isNull()) return edit_result_t::error("No room id was supplied."); + const uint8_t roomId = obj["roomId"]; + if(!somfy.getRoomById(roomId)) return edit_result_t::error("Room with the specified id not found."); + somfy.deleteRoom(roomId); + return done(roomId); + } + case model_edit_type_t::delete_shade: { + if(obj["shadeId"].isNull()) return edit_result_t::error("No shade id was supplied."); + const uint8_t shadeId = obj["shadeId"]; + SomfyShade *shade = somfy.getShadeById(shadeId); + if(!shade) return edit_result_t::error("Shade with the specified id not found."); + if(shade->isInGroup()) return edit_result_t::error("This shade is a member of a group and cannot be deleted."); + somfy.deleteShade(shadeId); + return done(shadeId); + } + case model_edit_type_t::delete_group: { + if(obj["groupId"].isNull()) return edit_result_t::error("No group id was supplied."); + const uint8_t groupId = obj["groupId"]; + if(!somfy.getGroupById(groupId)) return edit_result_t::error("Group with the specified id not found."); + somfy.deleteGroup(groupId); + return done(groupId); + } + case model_edit_type_t::link_remote: + case model_edit_type_t::unlink_remote: { + if(obj["shadeId"].isNull()) return edit_result_t::error("No shade id was supplied."); + SomfyShade *shade = somfy.getShadeById(obj["shadeId"]); + if(!shade) return edit_result_t::error("Shade Id not found."); + if(obj["remoteAddress"].isNull()) return edit_result_t::error("Remote address not provided."); + const uint32_t address = obj["remoteAddress"]; + if(edit.type == model_edit_type_t::unlink_remote) shade->unlinkRemote(address); + else if(!shade->linkRemote(address, obj["rollingCode"] | (uint16_t)0)) + return edit_result_t::error("This shade already has the most linked remotes it can."); + return done(shade->getShadeId()); + } + case model_edit_type_t::link_to_group: + case model_edit_type_t::unlink_from_group: { + const uint8_t groupId = obj["groupId"] | (uint8_t)0; + const uint8_t shadeId = obj["shadeId"] | (uint8_t)0; + if(groupId == 0) return edit_result_t::error("Group id not provided."); + if(shadeId == 0) return edit_result_t::error("Shade id not provided."); + SomfyGroup *group = somfy.getGroupById(groupId); + if(!group) return edit_result_t::error("Group id not found."); + if(!somfy.getShadeById(shadeId)) return edit_result_t::error("Shade id not found."); + if(edit.type == model_edit_type_t::unlink_from_group) group->unlinkShade(shadeId); + else if(!group->linkShade(shadeId)) return edit_result_t::error("This group already has the most shades it can."); + somfy.commit(); + group->emitState(); + return done(groupId); + } + case model_edit_type_t::link_repeater: + case model_edit_type_t::unlink_repeater: { + const uint32_t address = !obj["address"].isNull() ? obj["address"].as() : (obj["remoteAddress"] | (uint32_t)0); + if(address == 0) return edit_result_t::error("No repeater address was supplied."); + if(edit.type == model_edit_type_t::unlink_repeater) somfy.unlinkRepeater(address); + else if(!somfy.linkRepeater(address)) return edit_result_t::error("Maximum number of repeaters exceeded."); + somfy.commit(); + return done(); + } + case model_edit_type_t::set_paired: { + SomfyShade *shade = obj["shadeId"].isNull() ? nullptr : somfy.getShadeById(obj["shadeId"]); + if(!shade) return edit_result_t::error("Shade not found to pair"); + shade->paired = obj["paired"] | false; + shade->save(); + shade->emitState(); + return done(shade->getShadeId()); + } + case model_edit_type_t::room_order: + return applyOrder(edit.doc, [](uint8_t id) { return id != 0 ? somfy.getRoomById(id) : nullptr; }); + case model_edit_type_t::shade_order: + return applyOrder(edit.doc, [](uint8_t id) { return id != 255 ? somfy.getShadeById(id) : nullptr; }); + case model_edit_type_t::group_order: + return applyOrder(edit.doc, [](uint8_t id) { return id != 255 ? somfy.getGroupById(id) : nullptr; }); + } + return edit_result_t::error("Unknown edit."); +} diff --git a/src/SomfySnapshot.cpp b/src/SomfySnapshot.cpp index 3fc7167..de99ad6 100644 --- a/src/SomfySnapshot.cpp +++ b/src/SomfySnapshot.cpp @@ -84,6 +84,8 @@ void snapshotGroup(SomfyGroup &group, group_snapshot_t &out) { } void fillSnapshot(model_snapshot_t &snap) { snap.startingAddress = somfy.startingAddress; + snap.radioBitLength = somfy.transceiver.config.type; + snap.radioProto = static_cast(somfy.transceiver.config.proto); for(uint8_t i = 0; i < SOMFY_MAX_ROOMS; i++) if(somfy.rooms[i].roomId != 0) snapshotRoom(somfy.rooms[i], snap.rooms[snap.roomCount++]); for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) diff --git a/src/entryPoints/Web.cpp b/src/entryPoints/Web.cpp index fa1c700..62d6621 100644 --- a/src/entryPoints/Web.cpp +++ b/src/entryPoints/Web.cpp @@ -13,6 +13,7 @@ #include "LoopQueue.h" #include "ModelSnapshot.h" #include "SomfySnapshot.h" +#include "SomfyEditor.h" #include "Web.h" #include "MQTT.h" #include "GitOTA.h" @@ -208,11 +209,6 @@ static bool asyncHasParam(AsyncWebServerRequest *request, const char *name) { // replies) copy one item into the ModelSnapshot types and write it the way the // read routes do. The read routes themselves write from a ModelSnapshot that // loop() fills (sendSnapshot). -static void serializeRoom(SomfyRoom *room, JsonFormatter &json) { - room_snapshot_t s; - snapshotRoom(*room, s); - writeRoom(json, s); -} static void serializeShadeRef(SomfyShade *shade, JsonFormatter &json) { shade_snapshot_t s; snapshotShade(*shade, s); @@ -240,11 +236,6 @@ static void serializeGroup(SomfyGroup *group, JsonFormatter &json) { shade_snapshot_t linked; writeGroup(json, s, liveShade, &linked); } -static void serializeRepeaters(JsonFormatter &json) { - for(uint8_t i = 0; i < SOMFY_MAX_REPEATERS; i++) { - if(somfy.repeaters[i] != 0) json.addElem((uint32_t)somfy.repeaters[i]); - } -} static void serializeTransceiverConfig(JsonFormatter &json) { auto &cfg = somfy.transceiver.config; json.addElem("type", cfg.type); @@ -385,6 +376,58 @@ static void sendSnapshot(AsyncWebServerRequest *request, snapshot_doc_t doc, uin request->send(response); } +// Hands an edit of the shade model to loop() (docs/adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md). +// The reply only says it was accepted; the result reaches every client as a +// requestResult event carrying the same requestId once loop() has applied it. +static void queueModelEdit(AsyncWebServerRequest *request, model_edit_type_t type, JsonVariantConst body) { + if(!beginModelEdit()) { + request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Busy, try the change again\"}")); + return; + } + model_edit_t *edit = new (std::nothrow) model_edit_t(); + if(!edit) { + cancelModelEdit(nullptr); + sendQueueFull(request); + return; + } + edit->type = type; + edit->requestId = nextEditRequestId(); + edit->doc.set(body); + const uint32_t requestId = edit->requestId; + loop_item_t item; + item.type = loop_item_type_t::model_edit; + item.edit = edit; + if(edit->doc.overflowed() || !loopQueue.submit(item)) { + cancelModelEdit(edit); + sendQueueFull(request); + return; + } + char reply[64]; + snprintf(reply, sizeof(reply), "{\"status\":\"accepted\",\"requestId\":%lu}", (unsigned long)requestId); + request->send(200, _encoding_json, reply); +} +// The JSON (PUT/POST) form of an edit route. Bodies over 4 KB are refused with +// 413 before they reach the heap. +static AsyncCallbackJsonWebHandler *editHandler(const char *uri, model_edit_type_t type) { + auto *handler = new AsyncCallbackJsonWebHandler(uri, [type](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No object supplied.\"}")); return; } + queueModelEdit(request, type, json); + }); + handler->setMaxContentLength(4096); + return handler; +} +// The GET form of an edit route: its query parameters become the request. +static void queueModelEditFromParams(AsyncWebServerRequest *request, model_edit_type_t type, std::initializer_list names) { + JsonDocument body; + for(const char *name : names) { + if(!asyncHasParam(request, name)) continue; + const String value = asyncParam(request, name); + if(strcmp(name, "paired") == 0) body[name] = toBoolean(value.c_str(), false); + else body[name] = strtoul(value.c_str(), nullptr, 10); + } + queueModelEdit(request, type, body.as()); +} + // -- Async handler implementations -- void Web::handleDiscovery(AsyncWebServerRequest *request) { if(request->method() == HTTP_POST || request->method() == HTTP_GET) { @@ -1057,220 +1100,29 @@ void Web::begin() { asyncServer.on("/shade", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleShade(request); }); asyncServer.on("/group", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGroup(request); }); - asyncServer.on("/getNextRoom", HTTP_GET, [](AsyncWebServerRequest *request) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - resp.addElem("roomId", somfy.getNextRoomId()); - resp.endObject(); - resp.endResponse(); - }); - - asyncServer.on("/getNextShade", HTTP_GET, [](AsyncWebServerRequest *request) { - uint8_t shadeId = somfy.getNextShadeId(); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - resp.addElem("shadeId", shadeId); - resp.addElem("remoteAddress", (uint32_t)somfy.getNextRemoteAddress(shadeId)); - resp.addElem("bitLength", somfy.transceiver.config.type); - resp.addElem("stepSize", (uint8_t)100); - resp.addElem("proto", static_cast(somfy.transceiver.config.proto)); - resp.endObject(); - resp.endResponse(); - }); - - asyncServer.on("/getNextGroup", HTTP_GET, [](AsyncWebServerRequest *request) { - uint8_t groupId = somfy.getNextGroupId(); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - resp.addElem("groupId", groupId); - resp.addElem("remoteAddress", (uint32_t)somfy.getNextRemoteAddress(groupId)); - resp.addElem("bitLength", somfy.transceiver.config.type); - resp.addElem("proto", static_cast(somfy.transceiver.config.proto)); - resp.endObject(); - resp.endResponse(); - }); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/addRoom", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No room object supplied.\"}")); return; } - ESP_LOGD(TAG, "Adding a room"); - JsonObject obj = json.as(); - if(somfy.roomCount() > SOMFY_MAX_ROOMS) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Maximum number of rooms exceeded.\"}")); - return; - } - SomfyRoom *room = somfy.addRoom(obj); - if(room) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeRoom(room, resp); - resp.endObject(); - resp.endResponse(); - } - else { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Error adding room.\"}")); - } - })); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/addShade", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; } - ESP_LOGD(TAG, "Adding a shade"); - JsonObject obj = json.as(); - if(somfy.shadeCount() > SOMFY_MAX_SHADES) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Maximum number of shades exceeded.\"}")); - return; - } - SomfyShade *shade = somfy.addShade(obj); - if(shade) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp); - resp.endObject(); - resp.endResponse(); - } - else { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Error adding shade.\"}")); - } - })); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/addGroup", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group object supplied.\"}")); return; } - ESP_LOGD(TAG, "Adding a group"); - JsonObject obj = json.as(); - if(somfy.groupCount() > SOMFY_MAX_GROUPS) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Maximum number of groups exceeded.\"}")); - return; - } - SomfyGroup *group = somfy.addGroup(obj); - if(group) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeGroup(group, resp); - resp.endObject(); - resp.endResponse(); - } - else { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Error adding group.\"}")); - } - })); - + // What a new room, shade or group would get, from a snapshot. + asyncServer.on("/getNextRoom", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextRoom); }); + asyncServer.on("/getNextShade", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextShade); }); + asyncServer.on("/getNextGroup", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextGroup); }); asyncServer.on("/groupOptions", HTTP_GET, [](AsyncWebServerRequest *request) { - if(asyncHasParam(request, "groupId")) { - int groupId = atoi(asyncParam(request, "groupId").c_str()); - SomfyGroup* group = somfy.getGroupById(groupId); - if(group) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeGroup(group, resp); - resp.beginArray("availShades"); - for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { - SomfyShade *shade = &somfy.shades[i]; - if(shade->getShadeId() != 255) { - bool isLinked = false; - for(uint8_t j = 0; j < SOMFY_MAX_GROUPED_SHADES; j++) { - if(group->linkedShades[j] == shade->getShadeId()) { - isLinked = true; - break; - } - } - if(!isLinked) { - resp.beginObject(); - serializeShadeRef(shade, resp); - resp.endObject(); - } - } - } - resp.endArray(); - resp.endObject(); - resp.endResponse(); - } - else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}")); - } - else { + if(!asyncHasParam(request, "groupId")) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"You must supply a valid group id.\"}")); + return; } + const int groupId = atoi(asyncParam(request, "groupId").c_str()); + // As for /group, existence is checked on the live model. + if(somfy.getGroupById(groupId)) sendSnapshot(request, snapshot_doc_t::groupOptions, groupId); + else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}")); }); + // Adding and saving rooms, shades and groups. Edits of the shade model: + // loop() applies them and sends requestResult. + asyncServer.addHandler(editHandler("/addRoom", model_edit_type_t::add_room)); + asyncServer.addHandler(editHandler("/addShade", model_edit_type_t::add_shade)); + asyncServer.addHandler(editHandler("/addGroup", model_edit_type_t::add_group)); + asyncServer.addHandler(editHandler("/saveRoom", model_edit_type_t::save_room)); + asyncServer.addHandler(editHandler("/saveShade", model_edit_type_t::save_shade)); + asyncServer.addHandler(editHandler("/saveGroup", model_edit_type_t::save_group)); - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/saveRoom", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No room object supplied.\"}")); return; } - ESP_LOGD(TAG, "Updating a room"); - JsonObject obj = json.as(); - if(!obj["roomId"].isNull()) { - SomfyRoom* room = somfy.getRoomById(obj["roomId"]); - if(room) { - room->fromJSON(obj); - room->save(); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeRoom(room, resp); - resp.endObject(); - resp.endResponse(); - } - else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Room Id not found.\"}")); - } - else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No room id was supplied.\"}")); - })); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/saveShade", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; } - ESP_LOGD(TAG, "Updating a shade"); - JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) { - SomfyShade* shade = somfy.getShadeById(obj["shadeId"]); - if(shade) { - int8_t err = shade->fromJSON(obj); - if(err == 0) { - shade->save(); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp); - resp.endObject(); - resp.endResponse(); - } - else { - snprintf(g_async_content, sizeof(g_async_content), "{\"status\":\"DATA\",\"desc\":\"Data Error.\", \"code\":%d}", err); - request->send(500, _encoding_json, g_async_content); - } - } - else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade Id not found.\"}")); - } - else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade id was supplied.\"}")); - })); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/saveGroup", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group object supplied.\"}")); return; } - ESP_LOGD(TAG, "Updating a group"); - JsonObject obj = json.as(); - if(!obj["groupId"].isNull()) { - SomfyGroup* group = somfy.getGroupById(obj["groupId"]); - if(group) { - group->fromJSON(obj); - group->save(); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeGroup(group, resp); - resp.endObject(); - resp.endResponse(); - } - else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}")); - } - else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group id was supplied.\"}")); - })); // setMyPosition - supports both GET with query params and POST/PUT with JSON body asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setMyPosition", @@ -1374,54 +1226,9 @@ void Web::begin() { } }); - // setPaired - supports both query params and JSON body - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setPaired", - [](AsyncWebServerRequest *request, JsonVariant &json) { - uint8_t shadeId = 255; - bool paired = false; - if(!json.isNull()) { - JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; - if(!obj["paired"].isNull()) paired = obj["paired"]; - } - SomfyShade* shade = nullptr; - if(shadeId != 255) shade = somfy.getShadeById(shadeId); - if(!shade) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to pair\"}")); - } - else { - shade->paired = paired; - shade->save(); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp); - resp.endObject(); - resp.endResponse(); - } - })); + asyncServer.addHandler(editHandler("/setPaired", model_edit_type_t::set_paired)); asyncServer.on("/setPaired", HTTP_GET, [](AsyncWebServerRequest *request) { - uint8_t shadeId = 255; - bool paired = false; - if(asyncHasParam(request, "shadeId")) - shadeId = atoi(asyncParam(request, "shadeId").c_str()); - if(asyncHasParam(request, "paired")) - paired = toBoolean(asyncParam(request, "paired").c_str(), false); - SomfyShade* shade = nullptr; - if(shadeId != 255) shade = somfy.getShadeById(shadeId); - if(!shade) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to pair\"}")); - } - else { - shade->paired = paired; - shade->save(); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp); - resp.endObject(); - resp.endResponse(); - } + queueModelEditFromParams(request, model_edit_type_t::set_paired, { "shadeId", "paired" }); }); // unpairShade @@ -1467,310 +1274,35 @@ void Web::begin() { } }); - // linkRepeater - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/linkRepeater", - [](AsyncWebServerRequest *request, JsonVariant &json) { - uint32_t address = 0; - if(!json.isNull()) { - ESP_LOGD(TAG, "Linking a repeater"); - JsonObject obj = json.as(); - if(!obj["address"].isNull()) address = obj["address"]; - else if(!obj["remoteAddress"].isNull()) address = obj["remoteAddress"]; - } - if(address == 0) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No repeater address was supplied.\"}")); - } - else { - somfy.linkRepeater(address); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginArray(); - serializeRepeaters(resp); - resp.endArray(); - resp.endResponse(); - } - })); + // Linking repeaters, remotes and group members, and deleting. Edits of the + // shade model: loop() applies them and sends requestResult. + asyncServer.addHandler(editHandler("/linkRepeater", model_edit_type_t::link_repeater)); asyncServer.on("/linkRepeater", HTTP_GET, [](AsyncWebServerRequest *request) { - uint32_t address = 0; - if(asyncHasParam(request, "address")) - address = atoi(asyncParam(request, "address").c_str()); - if(address == 0) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No repeater address was supplied.\"}")); - } - else { - somfy.linkRepeater(address); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginArray(); - serializeRepeaters(resp); - resp.endArray(); - resp.endResponse(); - } + queueModelEditFromParams(request, model_edit_type_t::link_repeater, { "address" }); }); - - // unlinkRepeater - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/unlinkRepeater", - [](AsyncWebServerRequest *request, JsonVariant &json) { - uint32_t address = 0; - if(!json.isNull()) { - ESP_LOGD(TAG, "Unlinking a repeater"); - JsonObject obj = json.as(); - if(!obj["address"].isNull()) address = obj["address"]; - else if(!obj["remoteAddress"].isNull()) address = obj["remoteAddress"]; - } - if(address == 0) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No repeater address was supplied.\"}")); - } - else { - somfy.unlinkRepeater(address); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginArray(); - serializeRepeaters(resp); - resp.endArray(); - resp.endResponse(); - } - })); + asyncServer.addHandler(editHandler("/unlinkRepeater", model_edit_type_t::unlink_repeater)); asyncServer.on("/unlinkRepeater", HTTP_GET, [](AsyncWebServerRequest *request) { - uint32_t address = 0; - if(asyncHasParam(request, "address")) - address = atoi(asyncParam(request, "address").c_str()); - if(address == 0) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No repeater address was supplied.\"}")); - } - else { - somfy.unlinkRepeater(address); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginArray(); - serializeRepeaters(resp); - resp.endArray(); - resp.endResponse(); - } + queueModelEditFromParams(request, model_edit_type_t::unlink_repeater, { "address" }); }); - - // unlinkRemote - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/unlinkRemote", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No remote object supplied.\"}")); return; } - JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) { - SomfyShade* shade = somfy.getShadeById(obj["shadeId"]); - if(shade) { - if(!obj["remoteAddress"].isNull()) { - shade->unlinkRemote(obj["remoteAddress"]); - } - else { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Remote address not provided.\"}")); - } - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp); - resp.endObject(); - resp.endResponse(); - } - else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade Id not found.\"}")); - } - else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade id was supplied.\"}")); - })); - - // linkRemote - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/linkRemote", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No remote object supplied.\"}")); return; } - ESP_LOGD(TAG, "Linking a remote"); - JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) { - SomfyShade* shade = somfy.getShadeById(obj["shadeId"]); - if(shade) { - if(!obj["remoteAddress"].isNull()) { - if(!obj["rollingCode"].isNull()) shade->linkRemote(obj["remoteAddress"], obj["rollingCode"]); - else shade->linkRemote(obj["remoteAddress"]); - } - else { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Remote address not provided.\"}")); - } - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp); - resp.endObject(); - resp.endResponse(); - } - else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade Id not found.\"}")); - } - else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade id was supplied.\"}")); - })); - - // linkToGroup - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/linkToGroup", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No linking object supplied.\"}")); return; } - ESP_LOGD(TAG, "Linking a shade to a group"); - JsonObject obj = json.as(); - uint8_t shadeId = !obj["shadeId"].isNull() ? obj["shadeId"] : 0; - uint8_t groupId = !obj["groupId"].isNull() ? obj["groupId"] : 0; - if(groupId == 0) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group id not provided.\"}")); - return; - } - if(shadeId == 0) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade id not provided.\"}")); - return; - } - SomfyGroup *group = somfy.getGroupById(groupId); - if(!group) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group id not found.\"}")); - return; - } - SomfyShade *shade = somfy.getShadeById(shadeId); - if(!shade) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade id not found.\"}")); - return; - } - group->linkShade(shadeId); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeGroup(group, resp); - resp.endObject(); - resp.endResponse(); - })); - - // unlinkFromGroup - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/unlinkFromGroup", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No unlinking object supplied.\"}")); return; } - ESP_LOGD(TAG, "Unlinking a shade from a group"); - JsonObject obj = json.as(); - uint8_t shadeId = !obj["shadeId"].isNull() ? obj["shadeId"] : 0; - uint8_t groupId = !obj["groupId"].isNull() ? obj["groupId"] : 0; - if(groupId == 0) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group id not provided.\"}")); - return; - } - if(shadeId == 0) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade id not provided.\"}")); - return; - } - SomfyGroup *group = somfy.getGroupById(groupId); - if(!group) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group id not found.\"}")); - return; - } - SomfyShade *shade = somfy.getShadeById(shadeId); - if(!shade) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade id not found.\"}")); - return; - } - group->unlinkShade(shadeId); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeGroup(group, resp); - resp.endObject(); - resp.endResponse(); - })); - - // deleteRoom - supports GET with query params and POST/PUT with JSON body - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/deleteRoom", - [](AsyncWebServerRequest *request, JsonVariant &json) { - uint8_t roomId = 0; - if(!json.isNull()) { - ESP_LOGD(TAG, "Deleting a Room"); - JsonObject obj = json.as(); - if(!obj["roomId"].isNull()) roomId = obj["roomId"]; - else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No room id was supplied.\"}")); return; } - } - SomfyRoom* room = somfy.getRoomById(roomId); - if(!room) request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Room with the specified id not found.\"}")); - else { - somfy.deleteRoom(roomId); - request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Room deleted.\"}")); - } - })); + asyncServer.addHandler(editHandler("/linkRemote", model_edit_type_t::link_remote)); + asyncServer.addHandler(editHandler("/unlinkRemote", model_edit_type_t::unlink_remote)); + asyncServer.addHandler(editHandler("/linkToGroup", model_edit_type_t::link_to_group)); + asyncServer.addHandler(editHandler("/unlinkFromGroup", model_edit_type_t::unlink_from_group)); + asyncServer.addHandler(editHandler("/deleteRoom", model_edit_type_t::delete_room)); asyncServer.on("/deleteRoom", HTTP_GET, [](AsyncWebServerRequest *request) { - uint8_t roomId = 0; - if(asyncHasParam(request, "roomId")) { - roomId = atoi(asyncParam(request, "roomId").c_str()); - } - else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No room object supplied.\"}")); return; } - SomfyRoom* room = somfy.getRoomById(roomId); - if(!room) request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Room with the specified id not found.\"}")); - else { - somfy.deleteRoom(roomId); - request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Room deleted.\"}")); - } + queueModelEditFromParams(request, model_edit_type_t::delete_room, { "roomId" }); }); - - // deleteShade - supports GET with query params and POST/PUT with JSON body - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/deleteShade", - [](AsyncWebServerRequest *request, JsonVariant &json) { - uint8_t shadeId = 255; - if(!json.isNull()) { - ESP_LOGD(TAG, "Deleting a shade"); - JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; - else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade id was supplied.\"}")); return; } - } - SomfyShade* shade = somfy.getShadeById(shadeId); - if(!shade) request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); - else if(shade->isInGroup()) { - request->send(400, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"This shade is a member of a group and cannot be deleted.\"}")); - } - else { - somfy.deleteShade(shadeId); - request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Shade deleted.\"}")); - } - })); + asyncServer.addHandler(editHandler("/deleteShade", model_edit_type_t::delete_shade)); asyncServer.on("/deleteShade", HTTP_GET, [](AsyncWebServerRequest *request) { - uint8_t shadeId = 255; - if(asyncHasParam(request, "shadeId")) { - shadeId = atoi(asyncParam(request, "shadeId").c_str()); - } - else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; } - SomfyShade* shade = somfy.getShadeById(shadeId); - if(!shade) request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); - else if(shade->isInGroup()) { - request->send(400, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"This shade is a member of a group and cannot be deleted.\"}")); - } - else { - somfy.deleteShade(shadeId); - request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Shade deleted.\"}")); - } + queueModelEditFromParams(request, model_edit_type_t::delete_shade, { "shadeId" }); }); - - // deleteGroup - supports GET with query params and POST/PUT with JSON body - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/deleteGroup", - [](AsyncWebServerRequest *request, JsonVariant &json) { - uint8_t groupId = 255; - if(!json.isNull()) { - ESP_LOGD(TAG, "Deleting a group"); - JsonObject obj = json.as(); - if(!obj["groupId"].isNull()) groupId = obj["groupId"]; - else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group id was supplied.\"}")); return; } - } - SomfyGroup *group = somfy.getGroupById(groupId); - if(!group) request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group with the specified id not found.\"}")); - else { - somfy.deleteGroup(groupId); - request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Group deleted.\"}")); - } - })); + asyncServer.addHandler(editHandler("/deleteGroup", model_edit_type_t::delete_group)); asyncServer.on("/deleteGroup", HTTP_GET, [](AsyncWebServerRequest *request) { - uint8_t groupId = 255; - if(asyncHasParam(request, "groupId")) { - groupId = atoi(asyncParam(request, "groupId").c_str()); - } - else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No group object supplied.\"}")); return; } - SomfyGroup *group = somfy.getGroupById(groupId); - if(!group) request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group with the specified id not found.\"}")); - else { - somfy.deleteGroup(groupId); - request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Group deleted.\"}")); - } + queueModelEditFromParams(request, model_edit_type_t::delete_group, { "groupId" }); }); + // The repeaters, from a snapshot like the other reads. + asyncServer.on("/repeaters", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::repeaters); }); + // updateFirmware - file upload asyncServer.on("/updateFirmware", HTTP_POST, @@ -2195,62 +1727,10 @@ void Web::begin() { request->send(200, _encoding_json, g_async_content); }); - // roomSortOrder - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/roomSortOrder", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - JsonArray arr = json.as(); - uint8_t order = 0; - for(JsonVariant v : arr) { - uint8_t roomId = v.as(); - if(roomId != 0) { - SomfyRoom *room = somfy.getRoomById(roomId); - if(room) room->sortOrder = order++; - } - } - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set room order\"}"); - })); - - // shadeSortOrder - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/shadeSortOrder", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - JsonArray arr = json.as(); - uint8_t order = 0; - for(JsonVariant v : arr) { - uint8_t shadeId = v.as(); - if(shadeId != 255) { - SomfyShade *shade = somfy.getShadeById(shadeId); - if(shade) shade->sortOrder = order++; - } - } - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set shade order\"}"); - })); - - // groupSortOrder - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/groupSortOrder", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - JsonArray arr = json.as(); - uint8_t order = 0; - for(JsonVariant v : arr) { - uint8_t groupId = v.as(); - if(groupId != 255) { - SomfyGroup *group = somfy.getGroupById(groupId); - if(group) group->sortOrder = order++; - } - } - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set group order\"}"); - })); + // Sort orders: the ids in the order the client lists them. + asyncServer.addHandler(editHandler("/roomSortOrder", model_edit_type_t::room_order)); + asyncServer.addHandler(editHandler("/shadeSortOrder", model_edit_type_t::shade_order)); + asyncServer.addHandler(editHandler("/groupSortOrder", model_edit_type_t::group_order)); asyncServer.on("/beginFrequencyScan", HTTP_GET, [](AsyncWebServerRequest *request) { somfy.transceiver.beginFrequencyScan(); diff --git a/src/outputs/SomfyEvents.cpp b/src/outputs/SomfyEvents.cpp index eeab2f0..16917ad 100644 --- a/src/outputs/SomfyEvents.cpp +++ b/src/outputs/SomfyEvents.cpp @@ -1,5 +1,6 @@ #include #include "Somfy.h" +#include "SomfyEditor.h" #include "Sockets.h" #include "entryPoints/MQTT.h" @@ -89,6 +90,18 @@ void SomfyGroup::emitState(uint8_t num, const char *evt) { sockEmit.endEmit(num); this->publish(); } +// To every client: the sender picks out its own by requestId. +void emitRequestResult(uint32_t requestId, const edit_result_t &result) { + JsonSockEvent *json = sockEmit.beginEmit("requestResult"); + json->beginObject(); + json->addElem("requestId", requestId); + json->addElem("ok", result.ok); + json->addElem("id", result.id); + json->addElem("code", result.code); + json->addElem("desc", result.desc); + json->endObject(); + sockEmit.endEmit(); +} void SomfyShadeController::emitState(uint8_t num) { for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { SomfyShade *shade = &this->shades[i]; diff --git a/test/test_modeleditor/test_modeleditor.cpp b/test/test_modeleditor/test_modeleditor.cpp new file mode 100644 index 0000000..894c190 --- /dev/null +++ b/test/test_modeleditor/test_modeleditor.cpp @@ -0,0 +1,161 @@ +#include +#include +#include "ModelEditor.h" + +void setUp() {} +void tearDown() {} + +/********************************************************************* + * Ids, sort orders and remote addresses + ********************************************************************/ +static void test_first_free_id_fills_gaps() { + const uint8_t ids[] = { 1, 2, 4, 255, 255 }; + TEST_ASSERT_EQUAL_UINT8(3, firstFreeId(ids, 5, 30)); +} +static void test_first_free_id_of_an_empty_list_is_1() { + TEST_ASSERT_EQUAL_UINT8(1, firstFreeId(nullptr, 0, 30)); +} +static void test_first_free_id_is_0_when_every_one_is_taken() { + const uint8_t ids[] = { 1, 2, 3 }; + TEST_ASSERT_EQUAL_UINT8(0, firstFreeId(ids, 3, 3)); + TEST_ASSERT_EQUAL_UINT8(4, firstFreeId(ids, 3, 4)); +} +static void test_next_sort_order_goes_after_the_last() { + const int8_t orders[] = { 3, 0, 7, 2 }; + TEST_ASSERT_EQUAL_INT8(8, nextSortOrder(orders, 4)); + TEST_ASSERT_EQUAL_INT8(0, nextSortOrder(nullptr, 0)); +} +static void test_free_remote_address_skips_every_taken_one() { + // 101 is taken, so 102 is tried, which is taken too, and so on. + const uint32_t used[] = { 103, 101, 102, 500 }; + TEST_ASSERT_EQUAL_UINT32(104, freeRemoteAddress(101, used, 4)); + TEST_ASSERT_EQUAL_UINT32(100, freeRemoteAddress(100, used, 4)); +} + +/********************************************************************* + * Lists + ********************************************************************/ +static void test_add_goes_into_the_first_empty_slot() { + uint8_t list[4] = { 5, 7, 0, 0 }; + TEST_ASSERT_TRUE(addToList(list, 4, 9)); + const uint8_t expected[] = { 5, 7, 9, 0 }; + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, list, 4); +} +static void test_adding_one_already_there_changes_nothing() { + uint8_t list[4] = { 5, 7, 0, 0 }; + TEST_ASSERT_TRUE(addToList(list, 4, 7)); + const uint8_t expected[] = { 5, 7, 0, 0 }; + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, list, 4); +} +static void test_adding_to_a_full_list_fails() { + uint8_t list[2] = { 5, 7 }; + TEST_ASSERT_FALSE(addToList(list, 2, 9)); +} +static void test_remove_packs_the_rest_in_order() { + uint8_t list[5] = { 1, 4, 3, 4, 0 }; + TEST_ASSERT_TRUE(removeFromList(list, 5, 4)); + const uint8_t expected[] = { 1, 3, 0, 0, 0 }; + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, list, 5); +} +static void test_removing_one_not_there_reports_it() { + uint8_t list[3] = { 1, 2, 0 }; + TEST_ASSERT_FALSE(removeFromList(list, 3, 9)); +} +static void test_pack_closes_gaps() { + uint8_t list[7] = { 1, 0, 4, 3, 0, 0, 6 }; + packList(list, 7); + const uint8_t expected[] = { 1, 4, 3, 6, 0, 0, 0 }; + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, list, 7); +} +static void test_repeater_addresses_use_the_same_rules() { + uint32_t list[3] = { 0, 0, 0 }; + TEST_ASSERT_TRUE(addToList(list, 3, 0xABCDEFu)); + TEST_ASSERT_TRUE(addToList(list, 3, 0x123456u)); + TEST_ASSERT_TRUE(addToList(list, 3, 0xABCDEFu)); + TEST_ASSERT_TRUE(removeFromList(list, 3, 0xABCDEFu)); + TEST_ASSERT_EQUAL_UINT32(0x123456u, list[0]); + TEST_ASSERT_EQUAL_UINT32(0, list[1]); +} + +/********************************************************************* + * GPIO pins + ********************************************************************/ +static std::set radioPins, ethernetPins, shadePins; +static bool inSet(uint8_t pin, void *ctx) { return static_cast *>(ctx)->count(pin) > 0; } +static pin_users_t users(std::set &pins) { + pin_users_t u; + u.uses = inSet; + u.ctx = &pins; + return u; +} +static int8_t conflict(shade_types type, radio_proto proto, uint8_t up, uint8_t down, uint8_t my) { + return gpioPinConflict(type, proto, up, down, my, users(radioPins), users(ethernetPins), users(shadePins)); +} + +static void test_a_radio_shade_uses_no_pins() { + radioPins = { 4 }; + TEST_ASSERT_EQUAL_INT8(0, conflict(shade_types::roller, radio_proto::RTS, 4, 4, 4)); +} +static void test_free_pins_are_fine() { + radioPins = { 4 }; ethernetPins = { 18 }; shadePins = { 21 }; + TEST_ASSERT_EQUAL_INT8(0, conflict(shade_types::roller, radio_proto::GP_Remote, 25, 26, 27)); +} +static void test_a_pin_the_radio_uses() { + radioPins = { 26 }; ethernetPins = {}; shadePins = {}; + TEST_ASSERT_EQUAL_INT8(PIN_CONFLICT_RADIO, conflict(shade_types::roller, radio_proto::GP_Remote, 25, 26, 27)); +} +static void test_every_pin_is_checked_against_ethernet() { + // It used to check only the up pin against Ethernet. + radioPins = {}; shadePins = {}; + ethernetPins = { 26 }; + TEST_ASSERT_EQUAL_INT8(PIN_CONFLICT_ETHERNET, conflict(shade_types::roller, radio_proto::GP_Remote, 25, 26, 27)); + ethernetPins = { 27 }; + TEST_ASSERT_EQUAL_INT8(PIN_CONFLICT_ETHERNET, conflict(shade_types::roller, radio_proto::GP_Remote, 25, 26, 27)); +} +static void test_a_pin_another_shade_uses() { + radioPins = {}; ethernetPins = {}; + shadePins = { 25 }; + TEST_ASSERT_EQUAL_INT8(PIN_CONFLICT_SHADE, conflict(shade_types::roller, radio_proto::GP_Remote, 25, 26, 27)); +} +static void test_only_the_pins_a_shade_drives_are_checked() { + radioPins = {}; ethernetPins = {}; + shadePins = { 25, 27 }; + // A relay never drives My. + TEST_ASSERT_EQUAL_INT8(PIN_CONFLICT_SHADE, conflict(shade_types::roller, radio_proto::GP_Relay, 25, 26, 27)); + TEST_ASSERT_EQUAL_INT8(0, conflict(shade_types::roller, radio_proto::GP_Relay, 24, 26, 27)); + // A dry contact only drives down; a two way dry contact up and down. + TEST_ASSERT_EQUAL_INT8(0, conflict(shade_types::drycontact, radio_proto::GP_Remote, 25, 26, 27)); + TEST_ASSERT_EQUAL_INT8(0, conflict(shade_types::drycontact2, radio_proto::GP_Remote, 24, 26, 27)); + // A single button gate on a remote only drives down. + TEST_ASSERT_EQUAL_INT8(0, conflict(shade_types::garage1, radio_proto::GP_Remote, 25, 26, 27)); +} +static void test_255_means_no_pin() { + radioPins = { 255 }; ethernetPins = { 255 }; shadePins = { 255 }; + TEST_ASSERT_EQUAL_INT8(0, conflict(shade_types::roller, radio_proto::GP_Remote, 255, 26, 255)); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_first_free_id_fills_gaps); + RUN_TEST(test_first_free_id_of_an_empty_list_is_1); + RUN_TEST(test_first_free_id_is_0_when_every_one_is_taken); + RUN_TEST(test_next_sort_order_goes_after_the_last); + RUN_TEST(test_free_remote_address_skips_every_taken_one); + RUN_TEST(test_add_goes_into_the_first_empty_slot); + RUN_TEST(test_adding_one_already_there_changes_nothing); + RUN_TEST(test_adding_to_a_full_list_fails); + RUN_TEST(test_remove_packs_the_rest_in_order); + RUN_TEST(test_removing_one_not_there_reports_it); + RUN_TEST(test_pack_closes_gaps); + RUN_TEST(test_repeater_addresses_use_the_same_rules); + RUN_TEST(test_a_radio_shade_uses_no_pins); + RUN_TEST(test_free_pins_are_fine); + RUN_TEST(test_a_pin_the_radio_uses); + RUN_TEST(test_every_pin_is_checked_against_ethernet); + RUN_TEST(test_a_pin_another_shade_uses); + RUN_TEST(test_only_the_pins_a_shade_drives_are_checked); + RUN_TEST(test_255_means_no_pin); + return UNITY_END(); +} diff --git a/test/test_snapshot/test_snapshot.cpp b/test/test_snapshot/test_snapshot.cpp index 54ee004..4984b21 100644 --- a/test/test_snapshot/test_snapshot.cpp +++ b/test/test_snapshot/test_snapshot.cpp @@ -217,6 +217,47 @@ static void test_a_left_out_item_does_not_leave_a_stray_comma() { TEST_ASSERT_EQUAL_STRING(("[" + shadeJson(4) + "]").c_str(), document(snapshot_doc_t::shades, 0, 7, fits).c_str()); } +/********************************************************************* + * Repeaters, what a new item would get, and group options + ********************************************************************/ +static void test_repeaters_list() { + TEST_ASSERT_EQUAL_STRING("[12345,678]", document(snapshot_doc_t::repeaters).c_str()); +} +static void test_next_room_fills_the_lowest_gap() { + TEST_ASSERT_EQUAL_STRING("{\"roomId\":1}", document(snapshot_doc_t::nextRoom).c_str()); +} +static void test_next_shade() { + // Ids 1 and 4 are taken, so 2; its address is startingAddress + id unless taken. + snap.radioBitLength = 80; + snap.radioProto = 1; + TEST_ASSERT_EQUAL_STRING("{\"shadeId\":2,\"remoteAddress\":79,\"bitLength\":80,\"stepSize\":100,\"proto\":1}", + document(snapshot_doc_t::nextShade).c_str()); +} +static void test_next_address_skips_one_already_used() { + snap.shades[0].remoteAddress = 79; // what shade 2 would get + snap.shades[1].remoteAddress = 80; + TEST_ASSERT_EQUAL_UINT32(81, nextRemoteAddress(snap, 2)); +} +static void test_next_group() { + TEST_ASSERT_EQUAL_STRING("{\"groupId\":1,\"remoteAddress\":78,\"bitLength\":0,\"proto\":0}", + document(snapshot_doc_t::nextGroup).c_str()); +} +static void test_no_room_for_another_shade() { + snap.shadeCount = 0; + for(uint8_t id = 1; id <= SOMFY_MAX_SHADES - 2; id++) snap.shades[snap.shadeCount++] = shade(id, "S"); + TEST_ASSERT_EQUAL_UINT8(255, nextShadeId(snap)); +} +static void test_group_options_lists_the_shades_not_in_it() { + snap.shades[snap.shadeCount++] = shade(7, "Spare"); + const std::string expected = groupWithLinked().substr(0, groupWithLinked().length() - 1) + + ",\"availShades\":[" + shadeRefJson(7) + "]}"; + TEST_ASSERT_EQUAL_STRING(expected.c_str(), document(snapshot_doc_t::groupOptions, 6).c_str()); +} +static void test_group_options_of_a_group_gone_by_the_snapshot() { + TEST_ASSERT_EQUAL_STRING("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}", + document(snapshot_doc_t::groupOptions, 9).c_str()); +} + /********************************************************************* * Slots ********************************************************************/ @@ -288,6 +329,14 @@ int main(int argc, char **argv) { RUN_TEST(test_a_single_item_gone_by_the_snapshot_is_an_error); RUN_TEST(test_an_item_too_big_for_the_unit_is_left_out); RUN_TEST(test_a_left_out_item_does_not_leave_a_stray_comma); + RUN_TEST(test_repeaters_list); + RUN_TEST(test_next_room_fills_the_lowest_gap); + RUN_TEST(test_next_shade); + RUN_TEST(test_next_address_skips_one_already_used); + RUN_TEST(test_next_group); + RUN_TEST(test_no_room_for_another_shade); + RUN_TEST(test_group_options_lists_the_shades_not_in_it); + RUN_TEST(test_group_options_of_a_group_gone_by_the_snapshot); RUN_TEST(test_two_reads_at_once_and_no_more); RUN_TEST(test_a_read_is_filled_then_freed); RUN_TEST(test_a_read_gone_before_its_fill_is_not_filled); From 4c5bc01fe5f2ecd987d7c9eaf8d5e467f467712a Mon Sep 17 00:00:00 2001 From: cjkas Date: Fri, 16 Oct 2026 10:40:44 +0200 Subject: [PATCH 3/7] refactor: send shade and group changes once per loop() pass The p_* setters and emitState() no longer publish or emit straight away: they set SHADE_CHANGED_* / GROUP_CHANGED_* bits on the shade or group. At the end of every loop() pass SomfyShadeController::sendChanges() publishes the MQTT topics whose bits are set and sends one shadeState or groupState, then clears the bits. emitState(evt) with an event name and emitState(num) for one client still go out at once. publishShadeTopics() (ShadeChanges.cpp, tested natively) writes the topics from a shade snapshot; publishState() on connect uses it too. That fixes MQTT position and target, a constant 50 since c94819e, and direction, a constant 0 on connect. position and tiltPosition are now retained when they change, as they were on connect, and a group whose flags change sends one groupState instead of two. somfy_flags_t moves to ShadeTypes.h. 8 new native tests, 327 in all. Flash: esp32devdbg +196 bytes, esp32dev +228 bytes. Co-Authored-By: Claude Opus 5.5 --- docs/LOOP_QUEUE_PLAN.md | 17 +++- platformio.ini | 2 +- src/ShadeChanges.cpp | 28 ++++++ src/ShadeChanges.h | 44 ++++++++++ src/ShadeTypes.h | 11 +++ src/Somfy.h | 32 ++++--- src/SomfyController.ino | 4 + src/SomfyGroup.cpp | 2 +- src/SomfyShade.cpp | 28 +++--- src/outputs/SomfyEvents.cpp | 22 +++++ src/outputs/SomfyMqtt.cpp | 38 ++++---- test/test_shadechanges/test_shadechanges.cpp | 91 ++++++++++++++++++++ 12 files changed, 268 insertions(+), 51 deletions(-) create mode 100644 src/ShadeChanges.cpp create mode 100644 src/ShadeChanges.h create mode 100644 test/test_shadechanges/test_shadechanges.cpp diff --git a/docs/LOOP_QUEUE_PLAN.md b/docs/LOOP_QUEUE_PLAN.md index d69dc0d..8ae17c2 100644 --- a/docs/LOOP_QUEUE_PLAN.md +++ b/docs/LOOP_QUEUE_PLAN.md @@ -1,6 +1,6 @@ # Plan: domain modules behind the LoopQueue -**Status:** steps 0–3 built (PRs #39, #40, #41 and #42); steps 4 and 5 planned. +**Status:** steps 0–4 built (PRs #39 to #43); step 5 planned. **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 @@ -158,6 +158,16 @@ Flash: `esp32devdbg` 1,547,381 → 1,547,821 bytes, `esp32dev` 1,398,337 → 1,4 Flash: `esp32devdbg` 1,547,821 → 1,536,337 bytes, `esp32dev` 1,400,401 → 1,393,221 bytes. The 19 handler bodies became one path. +### Step 4: what changed in behaviour +- **MQTT gets the shade's real `position` and `target`.** Both were a constant 50 since `c94819e`, and `direction` was a constant 0 when MQTT connected. +- **Each pass sends a changed shade or group once.** The `p_*` setters and `emitState()` only mark the shade (`SHADE_CHANGED_*` bits in `ShadeChanges.h`). At the end of each `loop()` pass, `SomfyShadeController::sendChanges()` publishes the changed MQTT topics and sends one `shadeState` or `groupState`. + - A group whose flags change now sends one `groupState`, not two. + - `emitState(evt)` with an event name (`shadeAdded`, ...) and `emitState(num)` for one client still go out at once. +- **`position` and `tiltPosition` are retained by the broker** when they change, as they already were when MQTT connected. A client that subscribes later gets the current position. +- **Topic values come from one tested place.** `publishShadeTopics()` writes the topics from a shade snapshot, and `publishState()` (all topics, on connect) uses it too. + +8 new native tests, 327 in all. Flash: `esp32devdbg` 1,536,337 → 1,536,533 bytes, `esp32dev` 1,393,221 → 1,393,449 bytes. + ### 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`. @@ -174,7 +184,6 @@ NVS stays at `0x9000`, so the rolling codes survive. The file system partition s ## Found during the review, not part of this plan Still 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)). - `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 Home Assistant integration sends no `apikey` header on shade, group and tilt commands. @@ -187,3 +196,7 @@ Fixed by step 3: - `/linkRemote` and `/unlinkRemote` sent a 500 and then a second response. - The web UI's `procRoomRemoved` kept the removed room and dropped the others, and `deleteShade` never closed its prompt (`prompt.remove;`). - The web UI called `/repeaters`, which didn't exist. + +Fixed by step 4: +- MQTT `position`/`target` were published as a constant 50, and `direction` as 0 on connect. +- A group whose flags changed sent `groupState` twice. diff --git a/platformio.ini b/platformio.ini index d829029..02f9bde 100644 --- a/platformio.ini +++ b/platformio.ini @@ -109,7 +109,7 @@ lib_deps = platform = native test_framework = unity test_build_src = yes -build_src_filter = -<*> + + + + + + + + + + + + +build_src_filter = -<*> + + + + + + + + + + + + + build_flags = -std=gnu++17 -Isrc diff --git a/src/ShadeChanges.cpp b/src/ShadeChanges.cpp new file mode 100644 index 0000000..4e65f7f --- /dev/null +++ b/src/ShadeChanges.cpp @@ -0,0 +1,28 @@ +#include "ShadeChanges.h" + +static bool flag(const shade_snapshot_t &shade, somfy_flags_t f) { return (shade.flags & static_cast(f)) != 0; } + +void publishShadeTopics(uint16_t changed, const shade_snapshot_t &shade, topic_sink_t sink, void *ctx) { + // Where the shade is and is going are kept by the broker so a client that + // connects later gets them; the rest are passing news. + if(changed & SHADE_CHANGED_POSITION) sink("position", shade.position, true, ctx); + if(changed & SHADE_CHANGED_DIRECTION) sink("direction", shade.direction, true, ctx); + if(changed & SHADE_CHANGED_TARGET) sink("target", shade.target, true, ctx); + if(changed & SHADE_CHANGED_ROLLING_CODE) sink("lastRollingCode", shade.lastRollingCode, false, ctx); + if(changed & SHADE_CHANGED_MY_POS) sink("mypos", shade.myPos, true, ctx); + if(changed & SHADE_CHANGED_MY_TILT_POS) sink("myTiltPos", shade.myTiltPos, true, ctx); + if(changed & SHADE_CHANGED_TILT_DIRECTION) sink("tiltDirection", shade.tiltDirection, true, ctx); + if(changed & SHADE_CHANGED_TILT_POSITION) sink("tiltPosition", shade.tiltPosition, true, ctx); + if(changed & SHADE_CHANGED_TILT_TARGET) sink("tiltTarget", shade.tiltTarget, true, ctx); + if(changed & SHADE_CHANGED_SUN_FLAG) sink("sunFlag", flag(shade, somfy_flags_t::SunFlag), false, ctx); + if(changed & SHADE_CHANGED_SUNNY) sink("sunny", flag(shade, somfy_flags_t::Sunny), false, ctx); + if(changed & SHADE_CHANGED_WINDY) sink("windy", flag(shade, somfy_flags_t::Windy), false, ctx); +} +uint16_t shadeStateTopics(const shade_snapshot_t &shade) { + uint16_t topics = SHADE_CHANGED_POSITION | SHADE_CHANGED_DIRECTION | SHADE_CHANGED_TARGET | SHADE_CHANGED_ROLLING_CODE | + SHADE_CHANGED_MY_POS | SHADE_CHANGED_MY_TILT_POS | SHADE_CHANGED_WINDY; + if(shade.tiltType != static_cast(tilt_types::none)) + topics |= SHADE_CHANGED_TILT_DIRECTION | SHADE_CHANGED_TILT_POSITION | SHADE_CHANGED_TILT_TARGET; + if(shade.sunSensor) topics |= SHADE_CHANGED_SUN_FLAG | SHADE_CHANGED_SUNNY; + return topics; +} diff --git a/src/ShadeChanges.h b/src/ShadeChanges.h new file mode 100644 index 0000000..9adacd7 --- /dev/null +++ b/src/ShadeChanges.h @@ -0,0 +1,44 @@ +#ifndef SHADE_CHANGES_H +#define SHADE_CHANGES_H +#include +#include "ModelSnapshot.h" + +// What changed about a shade since the change fan-out last sent it out, as +// bits. The shade's setters and emitState() set them; once per loop() pass the +// fan-out sends one shadeState for the shade, publishes the MQTT topics whose +// bits are set, and clears them. Kept free of the shade model and MQTT so it +// can be unit tested on the PC (pio test -e native). +enum : uint16_t { + SHADE_CHANGED_POSITION = 0x0001, + SHADE_CHANGED_TILT_POSITION = 0x0002, + SHADE_CHANGED_DIRECTION = 0x0004, + SHADE_CHANGED_TILT_DIRECTION = 0x0008, + SHADE_CHANGED_TARGET = 0x0010, + SHADE_CHANGED_TILT_TARGET = 0x0020, + SHADE_CHANGED_MY_POS = 0x0040, + SHADE_CHANGED_MY_TILT_POS = 0x0080, + SHADE_CHANGED_ROLLING_CODE = 0x0100, + SHADE_CHANGED_SUN_FLAG = 0x0200, + SHADE_CHANGED_SUNNY = 0x0400, + SHADE_CHANGED_WINDY = 0x0800, + // Something only the socket's shadeState carries changed. + SHADE_CHANGED_STATE = 0x1000 +}; +// The bits that have an MQTT topic. +#define SHADE_CHANGED_TOPICS 0x0FFF +// The same for a group: its direction topic, and groupState for the socket. +enum : uint8_t { + GROUP_CHANGED_DIRECTION = 0x01, + GROUP_CHANGED_STATE = 0x02 +}; + +// Publishes one MQTT topic: its name under shades//, its value and whether +// the broker keeps it. +typedef void (*topic_sink_t)(const char *topic, int32_t value, bool retain, void *ctx); +// Publishes the topics of the changed bits with the shade's values, positions +// as clients see them. +void publishShadeTopics(uint16_t changed, const shade_snapshot_t &shade, topic_sink_t sink, void *ctx); +// The topics a shade publishes in full when MQTT connects: the tilt ones only +// with a tilt, the sun ones only with a sun sensor. +uint16_t shadeStateTopics(const shade_snapshot_t &shade); +#endif diff --git a/src/ShadeTypes.h b/src/ShadeTypes.h index bdbe7aa..7e2921d 100644 --- a/src/ShadeTypes.h +++ b/src/ShadeTypes.h @@ -35,6 +35,17 @@ enum class shade_types : byte { cgate1 = 0x0F, rgate1 = 0x10 }; +// The bits of a shade's or group's flags. +enum class somfy_flags_t : byte { + SunFlag = 0x01, + SunSensor = 0x02, + DemoMode = 0x04, + Light = 0x08, + Windy = 0x10, + Sunny = 0x20, + Lighted = 0x40, + SimMy = 0x80 +}; enum class tilt_types : byte { none = 0x00, tiltmotor = 0x01, diff --git a/src/Somfy.h b/src/Somfy.h index 1d1670c..6417efc 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -10,18 +10,9 @@ #include "ShadeTypes.h" #include "ShadeMotion.h" #include "ShadeSensors.h" +#include "ShadeChanges.h" #include "Transceiver.h" -enum class somfy_flags_t : byte { - SunFlag = 0x01, - SunSensor = 0x02, - DemoMode = 0x04, - Light = 0x08, - Windy = 0x10, - Sunny = 0x20, - Lighted = 0x40, - SimMy = 0x80 -}; enum class gpio_flags_t : byte { LowLevelTrigger = 0x01 }; @@ -180,8 +171,14 @@ class SomfyShade : public SomfyRemote { bool sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0); bool linkRemote(uint32_t remoteAddress, uint16_t rollingCode = 0); bool unlinkRemote(uint32_t remoteAddress); - void emitState(const char *evt = "shadeState"); + // Marks the shade as changed; the change fan-out sends shadeState once per + // loop() pass. + void emitState(); + // Sends evt about the shade to every client now. + void emitState(const char *evt); void emitState(uint8_t num, const char *evt = "shadeState"); + // What changed since the fan-out last sent the shade (SHADE_CHANGED_* bits). + uint16_t changed = 0; void emitCommand(somfy_commands cmd, const char *source, uint32_t sourceAddress, const char *evt = "shadeCommand"); void emitCommand(uint8_t num, somfy_commands cmd, const char *source, uint32_t sourceAddress, const char *evt = "shadeCommand"); // False when the radio refused a command it sent. @@ -193,6 +190,8 @@ class SomfyShade : public SomfyRemote { static void unpublish(uint8_t id); static void unpublish(uint8_t id, const char *topic); void publishState(); + // Publishes the MQTT topics of the SHADE_CHANGED_* bits in changed. + void publishTopics(uint16_t changed); void commit(); void commitShadePosition(); void commitTiltPosition(); @@ -250,8 +249,14 @@ class SomfyGroup : public SomfyRemote { static void unpublish(uint8_t id, const char *topic); void publishState(); void updateFlags(); - void emitState(const char *evt = "groupState"); + // Marks the group as changed; the change fan-out sends groupState once per + // loop() pass. + void emitState(); + // Sends evt about the group to every client now. + void emitState(const char *evt); void emitState(uint8_t num, const char *evt = "groupState"); + // What changed since the fan-out last sent the group (GROUP_CHANGED_* bits). + uint8_t changed = 0; bool sendCommand(somfy_commands cmd); bool sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0); int8_t p_direction(int8_t dir); @@ -307,6 +312,9 @@ class SomfyShadeController { void markTxPending(uint32_t remoteAddress, uint32_t ticket); void processTxEvent(const somfy_tx_event_t &evt); void emitState(uint8_t num = 255); + // The change fan-out: sends every shade and group that changed since the + // last pass, its changed MQTT topics and one state event. loop() only. + void sendChanges(); void publish(); void processWaitingFrame(); void commit(); diff --git a/src/SomfyController.ino b/src/SomfyController.ino index 5cb9b40..cb2fdf9 100644 --- a/src/SomfyController.ino +++ b/src/SomfyController.ino @@ -158,6 +158,10 @@ void loop() { loopQueue.drain(); logIfSlow(TAG, "Loop queue", timing.lap(), LOOP_SLOW_MS); esp_task_wdt_reset(); + // What changed in this pass goes out once, to MQTT and the sockets. + somfy.sendChanges(); + logIfSlow(TAG, "Loop changes", timing.lap(), LOOP_SLOW_MS); + esp_task_wdt_reset(); if(net.connected() || net.softAPOpened) { if(!rebootDelay.reboot && net.connected() && !net.softAPOpened) { git.loop(); diff --git a/src/SomfyGroup.cpp b/src/SomfyGroup.cpp index f09b09a..d3cfba4 100644 --- a/src/SomfyGroup.cpp +++ b/src/SomfyGroup.cpp @@ -43,7 +43,7 @@ int8_t SomfyGroup::p_direction(int8_t dir) { int8_t old = this->direction; if(old != dir) { this->direction = dir; - this->publish("direction", this->direction); + this->changed |= GROUP_CHANGED_DIRECTION; } return old; } diff --git a/src/SomfyShade.cpp b/src/SomfyShade.cpp index 3e1607b..f19e587 100644 --- a/src/SomfyShade.cpp +++ b/src/SomfyShade.cpp @@ -298,22 +298,23 @@ void SomfyShade::checkMovement() { } -// State Setters +// State Setters. Each marks what clients see as changed when it does change; +// the change fan-out publishes it once per loop() pass. float SomfyShade::p_currentPos(float pos) { float old = this->currentPos; this->currentPos = pos; - if(floor(old) != floor(pos)) this->publish("position", (uint8_t)50); + if(floor(old) != floor(pos)) this->changed |= SHADE_CHANGED_POSITION; return old; } float SomfyShade::p_currentTiltPos(float pos) { float old = this->currentTiltPos; this->currentTiltPos = pos; - if(floor(old) != floor(pos)) this->publish("tiltPosition", this->transformPosition(static_cast(floor(this->currentTiltPos)))); + if(floor(old) != floor(pos)) this->changed |= SHADE_CHANGED_TILT_POSITION; return old; } uint16_t SomfyShade::p_lastRollingCode(uint16_t code) { uint16_t old = SomfyRemote::p_lastRollingCode(code); - if(old != code) this->publish("lastRollingCode", code); + if(old != code) this->changed |= SHADE_CHANGED_ROLLING_CODE; return old; } bool SomfyShade::p_flag(somfy_flags_t flag, bool val) { @@ -326,24 +327,24 @@ bool SomfyShade::p_flag(somfy_flags_t flag, bool val) { } bool SomfyShade::p_sunFlag(bool val) { bool old = this->p_flag(somfy_flags_t::SunFlag, val); - if(old != val) this->publish("sunFlag", static_cast(val)); + if(old != val) this->changed |= SHADE_CHANGED_SUN_FLAG; return old; } bool SomfyShade::p_windy(bool val) { bool old = this->p_flag(somfy_flags_t::Windy, val); - if(old != val) this->publish("windy", static_cast(val)); + if(old != val) this->changed |= SHADE_CHANGED_WINDY; return old; } bool SomfyShade::p_sunny(bool val) { bool old = this->p_flag(somfy_flags_t::Sunny, val); - if(old != val) this->publish("sunny", static_cast(val)); + if(old != val) this->changed |= SHADE_CHANGED_SUNNY; return old; } int8_t SomfyShade::p_direction(int8_t dir) { int8_t old = this->direction; if(old != dir) { this->direction = dir; - this->publish("direction", this->direction, true); + this->changed |= SHADE_CHANGED_DIRECTION; } return old; } @@ -351,7 +352,7 @@ int8_t SomfyShade::p_tiltDirection(int8_t dir) { int8_t old = this->tiltDirection; if(old != dir) { this->tiltDirection = dir; - this->publish("tiltDirection", this->tiltDirection, true); + this->changed |= SHADE_CHANGED_TILT_DIRECTION; } return old; } @@ -360,7 +361,7 @@ float SomfyShade::p_target(float target) { if(old != target) { this->target = target; if(this->transformPosition(old) != this->transformPosition(target)) - this->publish("target", this->transformPosition(this->target), true); + this->changed |= SHADE_CHANGED_TARGET; } return old; } @@ -369,17 +370,16 @@ float SomfyShade::p_tiltTarget(float target) { if(old != target) { this->tiltTarget = target; if(this->transformPosition(old) != this->transformPosition(target)) - this->publish("tiltTarget", this->transformPosition(this->tiltTarget), true); + this->changed |= SHADE_CHANGED_TILT_TARGET; } return old; } float SomfyShade::p_myPos(float pos) { float old = this->myPos; if(old != pos) { - //if(this->transformPosition(pos) == 0) Serial.println("MyPos = %.2f", pos); this->myPos = pos; if(this->transformPosition(old) != this->transformPosition(pos)) - this->publish("mypos", this->transformPosition(this->myPos), true); + this->changed |= SHADE_CHANGED_MY_POS; } return old; } @@ -388,7 +388,7 @@ float SomfyShade::p_myTiltPos(float pos) { if(old != pos) { this->myTiltPos = pos; if(this->transformPosition(old) != this->transformPosition(pos)) - this->publish("myTiltPos", this->transformPosition(this->myTiltPos), true); + this->changed |= SHADE_CHANGED_MY_TILT_POS; } return old; } diff --git a/src/outputs/SomfyEvents.cpp b/src/outputs/SomfyEvents.cpp index 16917ad..116f196 100644 --- a/src/outputs/SomfyEvents.cpp +++ b/src/outputs/SomfyEvents.cpp @@ -8,6 +8,7 @@ extern SomfyShadeController somfy; extern SocketEmitter sockEmit; extern MQTTClass mqtt; +void SomfyShade::emitState() { this->changed |= SHADE_CHANGED_STATE; } void SomfyShade::emitState(const char *evt) { this->emitState(255, evt); } void SomfyShade::emitState(uint8_t num, const char *evt) { JsonSockEvent *json = sockEmit.beginEmit(evt); @@ -65,6 +66,7 @@ void SomfyRoom::emitState(uint8_t num, const char *evt) { sockEmit.endEmit(num); this->publish(); } +void SomfyGroup::emitState() { this->changed |= GROUP_CHANGED_STATE; } void SomfyGroup::emitState(const char *evt) { this->emitState(255, evt); } void SomfyGroup::emitState(uint8_t num, const char *evt) { uint8_t flags = 0; @@ -102,6 +104,26 @@ void emitRequestResult(uint32_t requestId, const edit_result_t &result) { json->endObject(); sockEmit.endEmit(); } +// The change fan-out. Everything that changed about a shade or group in this +// loop() pass goes out once: its MQTT topics, then one state event. +void SomfyShadeController::sendChanges() { + for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { + SomfyShade &shade = this->shades[i]; + const uint16_t changed = shade.changed; + shade.changed = 0; + if(!changed || shade.getShadeId() == 255) continue; + if(changed & SHADE_CHANGED_TOPICS) shade.publishTopics(changed); + shade.emitState(255, "shadeState"); + } + for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { + SomfyGroup &group = this->groups[i]; + const uint8_t changed = group.changed; + group.changed = 0; + if(!changed || group.getGroupId() == 255) continue; + if(changed & GROUP_CHANGED_DIRECTION) group.publish("direction", group.direction); + group.emitState(255, "groupState"); + } +} void SomfyShadeController::emitState(uint8_t num) { for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { SomfyShade *shade = &this->shades[i]; diff --git a/src/outputs/SomfyMqtt.cpp b/src/outputs/SomfyMqtt.cpp index 4b07edf..7fe7cf3 100644 --- a/src/outputs/SomfyMqtt.cpp +++ b/src/outputs/SomfyMqtt.cpp @@ -1,6 +1,7 @@ #include "Utils.h" #include "ConfigSettings.h" #include "Somfy.h" +#include "SomfySnapshot.h" #include "Sockets.h" #include "entryPoints/MQTT.h" @@ -31,28 +32,23 @@ void SomfyRoom::unpublish() { mqtt.unpublish(topic); } } +// Publishes one topic of a shade: shades//. +static void publishShadeTopic(const char *topic, int32_t value, bool retain, void *ctx) { + SomfyShade *shade = static_cast(ctx); + if(value < 0) shade->publish(topic, static_cast(value), retain); + else shade->publish(topic, static_cast(value), retain); +} +void SomfyShade::publishTopics(uint16_t changed) { + if(!mqtt.connected()) return; + shade_snapshot_t s; + snapshotShade(*this, s); + publishShadeTopics(changed, s, publishShadeTopic, this); +} void SomfyShade::publishState() { - if(mqtt.connected()) { - this->publish("position", (uint8_t)50, true); - this->publish("direction", (int8_t)0, true); - this->publish("target", (uint8_t)50, true); - this->publish("lastRollingCode", this->lastRollingCode); - this->publish("mypos", this->transformPosition(this->myPos), true); - this->publish("myTiltPos", this->transformPosition(this->myTiltPos), true); - if(this->tiltType != tilt_types::none) { - this->publish("tiltDirection", this->tiltDirection, true); - this->publish("tiltPosition", this->transformPosition(this->currentTiltPos), true); - this->publish("tiltTarget", this->transformPosition(this->tiltTarget), true); - } - const uint8_t sunFlag = !!(this->flags & static_cast(somfy_flags_t::SunFlag)); - const uint8_t isSunny = !!(this->flags & static_cast(somfy_flags_t::Sunny)); - const uint8_t isWindy = !!(this->flags & static_cast(somfy_flags_t::Windy)); - if(this->hasSunSensor()) { - this->publish("sunFlag", sunFlag); - this->publish("sunny", isSunny); - } - this->publish("windy", isWindy); - } + if(!mqtt.connected()) return; + shade_snapshot_t s; + snapshotShade(*this, s); + publishShadeTopics(shadeStateTopics(s), s, publishShadeTopic, this); } void SomfyShade::publishDisco() { if(!mqtt.connected() || !settings.MQTT.pubDisco) return; diff --git a/test/test_shadechanges/test_shadechanges.cpp b/test/test_shadechanges/test_shadechanges.cpp new file mode 100644 index 0000000..9b15f48 --- /dev/null +++ b/test/test_shadechanges/test_shadechanges.cpp @@ -0,0 +1,91 @@ +#include +#include +#include "ShadeChanges.h" + +static std::string published; +static void record(const char *topic, int32_t value, bool retain, void *ctx) { + (void)ctx; + if(!published.empty()) published += " "; + published += std::string(topic) + "=" + std::to_string(value) + (retain ? "R" : ""); +} +static std::string topics(uint16_t changed, const shade_snapshot_t &shade) { + published.clear(); + publishShadeTopics(changed, shade, record, nullptr); + return published; +} +// A shade as the snapshot shows it to clients. +static shade_snapshot_t shade() { + shade_snapshot_t s{}; + s.shadeId = 4; + s.position = 30; + s.target = 70; + s.direction = 1; + s.myPos = -1; + s.myTiltPos = -1; + s.tiltType = static_cast(tilt_types::none); + s.tiltPosition = 40; + s.tiltTarget = 45; + s.tiltDirection = -1; + s.lastRollingCode = 1234; + return s; +} + +void setUp() {} +void tearDown() {} + +static void test_nothing_changed_publishes_nothing() { + TEST_ASSERT_EQUAL_STRING("", topics(0, shade()).c_str()); + TEST_ASSERT_EQUAL_STRING("", topics(SHADE_CHANGED_STATE, shade()).c_str()); +} +static void test_position_and_target_are_the_shades_own() { + // Both used to go out as a constant 50. + TEST_ASSERT_EQUAL_STRING("position=30R", topics(SHADE_CHANGED_POSITION, shade()).c_str()); + TEST_ASSERT_EQUAL_STRING("target=70R", topics(SHADE_CHANGED_TARGET, shade()).c_str()); +} +static void test_only_the_changed_topics_go_out() { + TEST_ASSERT_EQUAL_STRING("direction=1R lastRollingCode=1234", + topics(SHADE_CHANGED_DIRECTION | SHADE_CHANGED_ROLLING_CODE, shade()).c_str()); +} +static void test_an_unset_favorite_is_minus_1() { + TEST_ASSERT_EQUAL_STRING("mypos=-1R myTiltPos=-1R", topics(SHADE_CHANGED_MY_POS | SHADE_CHANGED_MY_TILT_POS, shade()).c_str()); +} +static void test_tilt_topics() { + TEST_ASSERT_EQUAL_STRING("tiltDirection=-1R tiltPosition=40R tiltTarget=45R", + topics(SHADE_CHANGED_TILT_DIRECTION | SHADE_CHANGED_TILT_POSITION | SHADE_CHANGED_TILT_TARGET, shade()).c_str()); +} +static void test_sun_and_wind_are_0_or_1() { + shade_snapshot_t s = shade(); + s.flags = static_cast(somfy_flags_t::SunFlag) | static_cast(somfy_flags_t::Windy); + TEST_ASSERT_EQUAL_STRING("sunFlag=1 sunny=0 windy=1", + topics(SHADE_CHANGED_SUN_FLAG | SHADE_CHANGED_SUNNY | SHADE_CHANGED_WINDY, s).c_str()); +} +static void test_full_state_of_a_plain_shade() { + // Direction used to go out as a constant 0 when MQTT connected. + TEST_ASSERT_EQUAL_STRING("position=30R direction=1R target=70R lastRollingCode=1234 mypos=-1R myTiltPos=-1R windy=0", + topics(shadeStateTopics(shade()), shade()).c_str()); +} +static void test_full_state_adds_tilt_and_sun_when_the_shade_has_them() { + shade_snapshot_t s = shade(); + s.tiltType = static_cast(tilt_types::integrated); + s.sunSensor = true; + const uint16_t state = shadeStateTopics(s); + TEST_ASSERT_TRUE(state & SHADE_CHANGED_TILT_POSITION); + TEST_ASSERT_TRUE(state & SHADE_CHANGED_SUNNY); + TEST_ASSERT_FALSE(state & SHADE_CHANGED_STATE); + TEST_ASSERT_EQUAL_UINT16(SHADE_CHANGED_TOPICS, state); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_nothing_changed_publishes_nothing); + RUN_TEST(test_position_and_target_are_the_shades_own); + RUN_TEST(test_only_the_changed_topics_go_out); + RUN_TEST(test_an_unset_favorite_is_minus_1); + RUN_TEST(test_tilt_topics); + RUN_TEST(test_sun_and_wind_are_0_or_1); + RUN_TEST(test_full_state_of_a_plain_shade); + RUN_TEST(test_full_state_adds_tilt_and_sun_when_the_shade_has_them); + return UNITY_END(); +} From a0ad3017791f2073d846b49c3598792fd12d2e30 Mon Sep 17 00:00:00 2001 From: cjkas Date: Fri, 16 Oct 2026 13:47:56 +0200 Subject: [PATCH 4/7] refactor: split the transports into the modules GLOSSARY.md names Code moves unchanged; only files and names change. - entryPoints/WebUiRoutes.cpp: registerWebUiRoutes(), the port 80 routes and the UI's files, with the helpers only they use. - entryPoints/ApiRoutes.cpp: registerApiRoutes(), the port 8081 routes. - entryPoints/Web.cpp keeps the servers' lifecycle, auth and the handlers both route sets call; entryPoints/WebRoutes.h is what the three share. - entryPoints/MQTT.* -> MqttConnection.*, class MQTTClass -> MqttConnection. - outputs/SomfyMqtt.cpp -> outputs/MqttPublisher.cpp. - outputs/Sockets.* -> outputs/SocketEmitter.*. The subscribe and unsubscribe lists (the latter repeated groups/+/direction/set) become MqttCommands' MQTT_COMMAND_TOPICS, and a native test checks the parser takes every topic in it. GLOSSARY.md gains MqttConnection and Change fan-out. 328 native tests. Flash: esp32devdbg +3,112 bytes (the routes are a second ArduinoJson file), esp32dev -2,336 bytes. Co-Authored-By: Claude Opus 5.5 --- GLOSSARY.md | 7 + docs/KNOWN_ISSUES.md | 4 +- docs/LOOP_QUEUE_PLAN.md | 18 +- src/ESPNetwork.cpp | 6 +- src/GitOTA.cpp | 2 +- .../MQTT.cpp => MqttConnection.cpp} | 75 +- src/{entryPoints/MQTT.h => MqttConnection.h} | 6 +- src/SomfyController.ino | 6 +- src/Transceiver.cpp | 2 +- src/entryPoints/ApiRoutes.cpp | 52 + src/entryPoints/MqttCommands.cpp | 17 + src/entryPoints/MqttCommands.h | 5 + src/entryPoints/Web.cpp | 1016 +---------------- src/entryPoints/WebRoutes.h | 45 + src/entryPoints/WebUiRoutes.cpp | 967 ++++++++++++++++ .../{SomfyMqtt.cpp => MqttPublisher.cpp} | 6 +- .../{Sockets.cpp => SocketEmitter.cpp} | 2 +- src/outputs/{Sockets.h => SocketEmitter.h} | 0 src/outputs/SomfyEvents.cpp | 6 +- test/test_mqttcommands/test_mqttcommands.cpp | 10 + 20 files changed, 1186 insertions(+), 1066 deletions(-) rename src/{entryPoints/MQTT.cpp => MqttConnection.cpp} (70%) rename src/{entryPoints/MQTT.h => MqttConnection.h} (93%) create mode 100644 src/entryPoints/ApiRoutes.cpp create mode 100644 src/entryPoints/WebRoutes.h create mode 100644 src/entryPoints/WebUiRoutes.cpp rename src/outputs/{SomfyMqtt.cpp => MqttPublisher.cpp} (99%) rename src/outputs/{Sockets.cpp => SocketEmitter.cpp} (99%) rename src/outputs/{Sockets.h => SocketEmitter.h} (100%) diff --git a/GLOSSARY.md b/GLOSSARY.md index d82189f..1a82b28 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -86,6 +86,13 @@ _Avoid_: MQTTController 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 +**MqttConnection** +The link to the MQTT broker that MqttCommands and MqttPublisher share: connecting, the root topic and the subscriptions. +_Avoid_: MQTTClass + +**Change fan-out** +What sends everything that changed about a shade or group in one pass of `loop()` once: its changed MQTT topics, then one state event. + ## Domain modules **Domain module** diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index abb19f4..db0e42a 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -49,7 +49,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/outputs/Sockets.cpp](../src/outputs/Sockets.cpp). +**Location:** [`JsonSockEvent` in src/WResp.cpp](../src/WResp.cpp), buffer `g_response[MAX_SOCK_RESPONSE]` (2048 bytes) in [src/outputs/SocketEmitter.cpp](../src/outputs/SocketEmitter.cpp). ### Summary @@ -83,7 +83,7 @@ Unlike the `/controller` HTTP crash, this path does **not** abort — there is n 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. +Every socket event is formatted into the one static buffer `g_response` in [src/outputs/SocketEmitter.cpp](../src/outputs/SocketEmitter.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. diff --git a/docs/LOOP_QUEUE_PLAN.md b/docs/LOOP_QUEUE_PLAN.md index 8ae17c2..c3518a4 100644 --- a/docs/LOOP_QUEUE_PLAN.md +++ b/docs/LOOP_QUEUE_PLAN.md @@ -1,6 +1,6 @@ # Plan: domain modules behind the LoopQueue -**Status:** steps 0–4 built (PRs #39 to #43); step 5 planned. +**Status:** all steps built (PRs #39 to #44). **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 @@ -168,6 +168,22 @@ Flash: `esp32devdbg` 1,547,821 → 1,536,337 bytes, `esp32dev` 1,400,401 → 1,3 8 new native tests, 327 in all. Flash: `esp32devdbg` 1,536,337 → 1,536,533 bytes, `esp32dev` 1,393,221 → 1,393,449 bytes. +### Step 5: the file split +Code moved unchanged; only files and names changed. + +| File | Holds | +|---|---| +| `entryPoints/WebUiRoutes.cpp` | `registerWebUiRoutes()`: the port 80 routes and the UI's files, with the helpers only they use | +| `entryPoints/ApiRoutes.cpp` | `registerApiRoutes()`: the port 8081 routes | +| `entryPoints/Web.cpp` | the web servers' lifecycle, auth, and the handlers both route sets call | +| `entryPoints/WebRoutes.h` | what those three share | +| `entryPoints/MqttCommands.*` | + `MQTT_COMMAND_TOPICS`, the topics the connection subscribes to | +| `MqttConnection.*` | was `entryPoints/MQTT.*`; class `MQTTClass` → `MqttConnection` | +| `outputs/MqttPublisher.cpp` | was `outputs/SomfyMqtt.cpp` | +| `outputs/SocketEmitter.*` | was `outputs/Sockets.*` | + +The subscribe and unsubscribe lists, which repeated `groups/+/direction/set`, became the one `MQTT_COMMAND_TOPICS` list, and a native test checks that the parser takes every topic in it. Flash: `esp32devdbg` 1,536,533 → 1,539,645 bytes (+3.1 KB, since the routes are a second file that uses ArduinoJson, as SOMFY_SPLIT_PLAN.md warned), `esp32dev` 1,393,449 → 1,391,113 bytes (−2.3 KB, where `-Os` inlines less). + ### 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`. diff --git a/src/ESPNetwork.cpp b/src/ESPNetwork.cpp index 9bad48e..9925420 100644 --- a/src/ESPNetwork.cpp +++ b/src/ESPNetwork.cpp @@ -14,17 +14,17 @@ static inline void safe_wdt_reset() { #include "ConfigSettings.h" #include "ESPNetwork.h" #include "entryPoints/Web.h" -#include "outputs/Sockets.h" +#include "outputs/SocketEmitter.h" #include "Utils.h" #include "SSDP.h" -#include "entryPoints/MQTT.h" +#include "MqttConnection.h" static const char *TAG = "Network"; extern ConfigSettings settings; extern Web webServer; extern SocketEmitter sockEmit; -extern MQTTClass mqtt; +extern MqttConnection mqtt; extern rebootDelay_t rebootDelay; extern ESPNetwork net; extern SomfyShadeController somfy; diff --git a/src/GitOTA.cpp b/src/GitOTA.cpp index e0913e0..9b13d9c 100644 --- a/src/GitOTA.cpp +++ b/src/GitOTA.cpp @@ -8,7 +8,7 @@ #include "ConfigSettings.h" #include "GitOTA.h" #include "Utils.h" -#include "outputs/Sockets.h" +#include "outputs/SocketEmitter.h" #include "Somfy.h" #include "entryPoints/Web.h" #include "WResp.h" diff --git a/src/entryPoints/MQTT.cpp b/src/MqttConnection.cpp similarity index 70% rename from src/entryPoints/MQTT.cpp rename to src/MqttConnection.cpp index 6cbaf79..cb34f6b 100644 --- a/src/entryPoints/MQTT.cpp +++ b/src/MqttConnection.cpp @@ -4,8 +4,8 @@ #include #include "esp_log.h" #include "ConfigSettings.h" -#include "MQTT.h" -#include "MqttCommands.h" +#include "MqttConnection.h" +#include "entryPoints/MqttCommands.h" #include "Somfy.h" #include "LoopQueue.h" #include "ESPNetwork.h" @@ -26,21 +26,21 @@ extern rebootDelay_t rebootDelay; extern LoopQueue loopQueue; -bool MQTTClass::begin() { +bool MqttConnection::begin() { this->suspended = false; return true; } -bool MQTTClass::end() { +bool MqttConnection::end() { this->suspended = true; this->disconnect(); return true; } -void MQTTClass::reset() { +void MqttConnection::reset() { this->disconnect(); this->lastConnect = 0; this->connect(); } -bool MQTTClass::loop() { +bool MqttConnection::loop() { if(settings.MQTT.enabled && !rebootDelay.reboot && !this->suspended && !mqttClient.connected()) { esp_task_wdt_reset(); if(!this->connected() && net.connected()) this->connect(); @@ -49,7 +49,7 @@ bool MQTTClass::loop() { if(settings.MQTT.enabled) mqttClient.loop(); return true; } -void MQTTClass::receive(const char *topic, byte*payload, uint32_t length) { +void MqttConnection::receive(const char *topic, byte*payload, uint32_t length) { esp_task_wdt_reset(); // Make sure we do not reboot here. ESP_LOGD(TAG, "MQTT Topic:%s payload:%.*s", topic, (int)length, (const char *)payload); // This already runs on loop(), but going through the loop queue keeps MQTT @@ -61,7 +61,7 @@ void MQTTClass::receive(const char *topic, byte*payload, uint32_t length) { } loopQueue.submit(item); } -bool MQTTClass::connect() { +bool MqttConnection::connect() { esp_task_wdt_reset(); // Make sure we do not reboot here. if(mqttClient.connected()) { if(!settings.MQTT.enabled || this->suspended) @@ -88,21 +88,8 @@ bool MQTTClass::connect() { this->publish("serverId", settings.serverId, true); this->publish("mac", net.mac.c_str()); somfy.publish(); - this->subscribe("shades/+/target/set"); - this->subscribe("shades/+/tiltTarget/set"); - this->subscribe("shades/+/direction/set"); - this->subscribe("shades/+/mypos/set"); - this->subscribe("shades/+/myTiltPos/set"); - this->subscribe("shades/+/sunFlag/set"); - this->subscribe("shades/+/sunny/set"); - this->subscribe("shades/+/windy/set"); - this->subscribe("shades/+/position/set"); - this->subscribe("shades/+/tiltPosition/set"); - this->subscribe("groups/+/direction/set"); - this->subscribe("groups/+/sunFlag/set"); - this->subscribe("groups/+/sunny/set"); - this->subscribe("groups/+/windy/set"); - mqttClient.setCallback(MQTTClass::receive); + for(const char *topic : MQTT_COMMAND_TOPICS) this->subscribe(topic); + mqttClient.setCallback(MqttConnection::receive); ESP_LOGI(TAG, "MQTT Startup Completed"); esp_task_wdt_reset(); this->lastConnect = millis(); @@ -119,28 +106,14 @@ bool MQTTClass::connect() { } return true; } -bool MQTTClass::disconnect() { +bool MqttConnection::disconnect() { if(mqttClient.connected()) { - this->unsubscribe("shades/+/target/set"); - this->unsubscribe("shades/+/direction/set"); - this->unsubscribe("shades/+/tiltTarget/set"); - this->unsubscribe("shades/+/mypos/set"); - this->unsubscribe("shades/+/myTiltPos/set"); - this->unsubscribe("shades/+/sunFlag/set"); - this->unsubscribe("groups/+/direction/set"); - this->unsubscribe("shades/+/sunny/set"); - this->unsubscribe("shades/+/windy/set"); - this->unsubscribe("shades/+/position/set"); - this->unsubscribe("shades/+/tiltPosition/set"); - this->unsubscribe("groups/+/direction/set"); - this->unsubscribe("groups/+/sunFlag/set"); - this->unsubscribe("groups/+/sunny/set"); - this->unsubscribe("groups/+/windy/set"); + for(const char *topic : MQTT_COMMAND_TOPICS) this->unsubscribe(topic); mqttClient.disconnect(); } return true; } -bool MQTTClass::unsubscribe(const char *topic) { +bool MqttConnection::unsubscribe(const char *topic) { if(mqttClient.connected()) { char top[128]; if(strlen(settings.MQTT.rootTopic) > 0) @@ -152,7 +125,7 @@ bool MQTTClass::unsubscribe(const char *topic) { } return true; } -bool MQTTClass::subscribe(const char *topic) { +bool MqttConnection::subscribe(const char *topic) { if(mqttClient.connected()) { esp_task_wdt_reset(); // Make sure we do not reboot here. char top[128]; @@ -165,7 +138,7 @@ bool MQTTClass::subscribe(const char *topic) { } return true; } -bool MQTTClass::publish(const char *topic, const char *payload, bool retain) { +bool MqttConnection::publish(const char *topic, const char *payload, bool retain) { if(mqttClient.connected()) { char top[128]; if(strlen(settings.MQTT.rootTopic) > 0) @@ -178,11 +151,11 @@ bool MQTTClass::publish(const char *topic, const char *payload, bool retain) { } return false; } -bool MQTTClass::publish(const char *topic, uint32_t val, bool retain) { +bool MqttConnection::publish(const char *topic, uint32_t val, bool retain) { snprintf(g_content, sizeof(g_content), "%u", val); return this->publish(topic, g_content, retain); } -bool MQTTClass::unpublish(const char *topic) { +bool MqttConnection::unpublish(const char *topic) { if(mqttClient.connected()) { char top[128]; if(strlen(settings.MQTT.rootTopic) > 0) @@ -199,7 +172,7 @@ bool MQTTClass::unpublish(const char *topic) { // mqttClient.endPublish(); } -bool MQTTClass::publishBuffer(const char *topic, uint8_t *data, uint16_t len, bool retain) { +bool MqttConnection::publishBuffer(const char *topic, uint8_t *data, uint16_t len, bool retain) { size_t res; uint16_t offset = 0; uint16_t to_write = len; @@ -216,28 +189,28 @@ bool MQTTClass::publishBuffer(const char *topic, uint8_t *data, uint16_t len, bo mqttClient.endPublish(); return true; } -bool MQTTClass::publishDisco(const char *topic, JsonObject &obj, bool retain) { +bool MqttConnection::publishDisco(const char *topic, JsonObject &obj, bool retain) { serializeJson(obj, g_content, sizeof(g_content)); this->publishBuffer(topic, (uint8_t *)g_content, strlen(g_content), retain); return true; } -bool MQTTClass::publish(const char *topic, int8_t val, bool retain) { +bool MqttConnection::publish(const char *topic, int8_t val, bool retain) { snprintf(g_content, sizeof(g_content), "%d", val); return this->publish(topic, g_content, retain); } -bool MQTTClass::publish(const char *topic, uint8_t val, bool retain) { +bool MqttConnection::publish(const char *topic, uint8_t val, bool retain) { snprintf(g_content, sizeof(g_content), "%u", val); return this->publish(topic, g_content, retain); } -bool MQTTClass::publish(const char *topic, uint16_t val, bool retain) { +bool MqttConnection::publish(const char *topic, uint16_t val, bool retain) { snprintf(g_content, sizeof(g_content), "%u", val); return this->publish(topic, g_content, retain); } -bool MQTTClass::publish(const char *topic, bool val, bool retain) { +bool MqttConnection::publish(const char *topic, bool val, bool retain) { snprintf(g_content, sizeof(g_content), "%s", val ? "true" : "false"); return this->publish(topic, g_content, retain); } -bool MQTTClass::connected() { +bool MqttConnection::connected() { if(settings.MQTT.enabled) return mqttClient.connected(); return false; } diff --git a/src/entryPoints/MQTT.h b/src/MqttConnection.h similarity index 93% rename from src/entryPoints/MQTT.h rename to src/MqttConnection.h index fbc4839..4b27420 100644 --- a/src/entryPoints/MQTT.h +++ b/src/MqttConnection.h @@ -1,9 +1,9 @@ -#ifndef MQTT_H -#define MQTT_H +#ifndef MQTT_CONNECTION_H +#define MQTT_CONNECTION_H #include #include #include -class MQTTClass { +class MqttConnection { public: uint64_t lastConnect = 0; bool suspended = false; diff --git a/src/SomfyController.ino b/src/SomfyController.ino index cb2fdf9..718d602 100644 --- a/src/SomfyController.ino +++ b/src/SomfyController.ino @@ -5,10 +5,10 @@ #include "ConfigSettings.h" #include "ESPNetwork.h" #include "entryPoints/Web.h" -#include "outputs/Sockets.h" +#include "outputs/SocketEmitter.h" #include "Utils.h" #include "Somfy.h" -#include "entryPoints/MQTT.h" +#include "MqttConnection.h" #include "LoopQueue.h" #include "ShadeRequests.h" #include "SomfyShadeOps.h" @@ -35,7 +35,7 @@ SocketEmitter sockEmit; ESPNetwork net; rebootDelay_t rebootDelay; SomfyShadeController somfy; -MQTTClass mqtt; +MqttConnection mqtt; GitUpdater git; SomfyShadeOps shadeOps; ShadeRequests shadeRequests(shadeOps); diff --git a/src/Transceiver.cpp b/src/Transceiver.cpp index 9179a91..a01bdd6 100644 --- a/src/Transceiver.cpp +++ b/src/Transceiver.cpp @@ -10,7 +10,7 @@ #include "Utils.h" #include "ConfigSettings.h" #include "Somfy.h" -#include "outputs/Sockets.h" +#include "outputs/SocketEmitter.h" static const char *TAG = "Somfy"; diff --git a/src/entryPoints/ApiRoutes.cpp b/src/entryPoints/ApiRoutes.cpp new file mode 100644 index 0000000..3350ea0 --- /dev/null +++ b/src/entryPoints/ApiRoutes.cpp @@ -0,0 +1,52 @@ +#include +#include +#include "Web.h" +#include "WebRoutes.h" + +// ApiRoutes: the routes integrations such as Home Assistant call on port 8081. +// The handlers are the ones the config web UI calls too (Web.cpp). + +extern Web webServer; + +void registerApiRoutes(AsyncWebServer &server) { + // GET endpoints + server.on("/discovery", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleDiscovery(r); }); + server.on("/rooms", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetRooms(r); }); + server.on("/shades", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetShades(r); }); + server.on("/groups", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetGroups(r); }); + server.on("/controller", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleController(r); }); + server.on("/room", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleRoom(r); }); + server.on("/shade", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleShade(r); }); + server.on("/group", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleGroup(r); }); + server.on("/downloadFirmware", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleDownloadFirmware(r); }); + server.on("/backup", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleBackup(r); }); + server.on("/reboot", WebRequestMethodComposite(HTTP_POST) | HTTP_PUT, [](AsyncWebServerRequest *r) { webServer.handleReboot(r); }); + // JSON body endpoints + server.addHandler(new AsyncCallbackJsonWebHandler("/shadeCommand", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleShadeCommand(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/groupCommand", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleGroupCommand(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/tiltCommand", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleTiltCommand(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/repeatCommand", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleRepeatCommand(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/setPositions", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleSetPositions(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/setSensor", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleSetSensor(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/login", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleLogin(r, j); })); + // GET fallback for command endpoints (query params) + server.on("/shadeCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleShadeCommand(r, v); }); + server.on("/groupCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleGroupCommand(r, v); }); + server.on("/tiltCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleTiltCommand(r, v); }); + server.on("/repeatCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleRepeatCommand(r, v); }); + server.on("/setPositions", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleSetPositions(r, v); }); + server.on("/setSensor", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleSetSensor(r, v); }); + server.on("/login", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleLogin(r, v); }); + // OPTIONS preflight + not found + server.onNotFound([](AsyncWebServerRequest *r) { + if(r->method() == HTTP_OPTIONS) { r->send(200); return; } + webServer.handleNotFound(r); + }); +} diff --git a/src/entryPoints/MqttCommands.cpp b/src/entryPoints/MqttCommands.cpp index 89d0a1b..528d8ce 100644 --- a/src/entryPoints/MqttCommands.cpp +++ b/src/entryPoints/MqttCommands.cpp @@ -2,6 +2,23 @@ #include #include "MqttCommands.h" +const char *const MQTT_COMMAND_TOPICS[MQTT_COMMAND_TOPIC_COUNT] = { + "shades/+/target/set", + "shades/+/tiltTarget/set", + "shades/+/direction/set", + "shades/+/mypos/set", + "shades/+/myTiltPos/set", + "shades/+/sunFlag/set", + "shades/+/sunny/set", + "shades/+/windy/set", + "shades/+/position/set", + "shades/+/tiltPosition/set", + "groups/+/direction/set", + "groups/+/sunFlag/set", + "groups/+/sunny/set", + "groups/+/windy/set" +}; + namespace { struct segment_t { const char *s = ""; diff --git a/src/entryPoints/MqttCommands.h b/src/entryPoints/MqttCommands.h index 1d1df1b..e4797f3 100644 --- a/src/entryPoints/MqttCommands.h +++ b/src/entryPoints/MqttCommands.h @@ -11,4 +11,9 @@ // Turns a message on a command topic into a shade request. False for a topic // or value it doesn't take, which is dropped. bool parseMqttCommand(const char *topic, const uint8_t *payload, size_t length, shade_request_t &req); + +// The command topics under the root topic, + standing for any id. The MQTT +// connection subscribes to these. +#define MQTT_COMMAND_TOPIC_COUNT 14 +extern const char *const MQTT_COMMAND_TOPICS[MQTT_COMMAND_TOPIC_COUNT]; #endif diff --git a/src/entryPoints/Web.cpp b/src/entryPoints/Web.cpp index 62d6621..14b72d3 100644 --- a/src/entryPoints/Web.cpp +++ b/src/entryPoints/Web.cpp @@ -15,7 +15,8 @@ #include "SomfySnapshot.h" #include "SomfyEditor.h" #include "Web.h" -#include "MQTT.h" +#include "WebRoutes.h" +#include "MqttConnection.h" #include "GitOTA.h" #include "ESPNetwork.h" #include "Timing.h" @@ -29,15 +30,13 @@ extern SSDPClass SSDP; extern rebootDelay_t rebootDelay; extern SomfyShadeController somfy; extern Web webServer; -extern MQTTClass mqtt; +extern MqttConnection mqtt; extern GitUpdater git; extern ESPNetwork net; extern LoopQueue loopQueue; extern SnapshotSlots snapshotSlots; -//#define WEB_MAX_RESPONSE 34768 -#define WEB_MAX_RESPONSE 4096 -static char g_async_content[WEB_MAX_RESPONSE]; +char g_async_content[WEB_MAX_RESPONSE]; // General responses @@ -45,75 +44,31 @@ static const char _response_404[] = "404: Service Not Found"; // Encodings -static const char _encoding_text[] = "text/plain"; -static const char _encoding_html[] = "text/html"; -static const char _encoding_json[] = "application/json"; +const char _encoding_text[] = "text/plain"; +const char _encoding_json[] = "application/json"; static const char *TAG = "Web"; -static void sendQueueFull(AsyncWebServerRequest *request); -static void sendRadioUnavailable(AsyncWebServerRequest *request); // Hands an item to loop(), which owns the shades. When it can't be the // request is answered with the reason and false is returned. -static bool queueLoopItem(AsyncWebServerRequest *request, const loop_item_t &item) { +bool queueLoopItem(AsyncWebServerRequest *request, const loop_item_t &item) { // Nothing can go on air during a frequency scan so tell the client now rather // than dropping the request once it reaches the radio. if(loopItemUsesRadio(item) && somfy.transceiver.txSuspended()) { sendRadioUnavailable(request); return false; } if(!loopQueue.submit(item)) { sendQueueFull(request); return false; } return true; } -static bool queueShadeRequest(AsyncWebServerRequest *request, const shade_request_t &req) { +bool queueShadeRequest(AsyncWebServerRequest *request, const shade_request_t &req) { loop_item_t item; item.request = req; return queueLoopItem(request, item); } -static bool queueSetMyPosition(AsyncWebServerRequest *request, SomfyShade *shade, int8_t pos, int8_t tilt) { - shade_request_t req; - req.type = shade_request_type_t::set_favorite; - req.id = shade->getShadeId(); - req.position = pos; - req.tiltPosition = tilt; - return queueShadeRequest(request, req); -} -// loop() clears paired and saves it once the Prog is queued. The shade -// belongs to loop() so it isn't changed here. -static bool queueUnpairShade(AsyncWebServerRequest *request, SomfyShade *shade) { - shade_request_t req; - req.type = shade_request_type_t::unpair; - req.id = shade->getShadeId(); - return queueShadeRequest(request, req); -} -// Sending a command reads and commits the rolling code on loop(), so a new -// code is set there too. -static bool queueSetRollingCode(AsyncWebServerRequest *request, SomfyShade *shade, uint16_t rollingCode) { - shade_request_t req; - req.type = shade_request_type_t::set_rolling_code; - req.id = shade->getShadeId(); - req.rollingCode = rollingCode; - return queueShadeRequest(request, req); -} -// A restore's options travel through the loop queue as RESTORE_* bits. -static uint8_t restoreParts(const restore_options_t &opts) { - return (opts.settings ? RESTORE_SETTINGS : 0) | (opts.shades ? RESTORE_SHADES : 0) | - (opts.network ? RESTORE_NETWORK : 0) | (opts.transceiver ? RESTORE_TRANSCEIVER : 0) | - (opts.repeaters ? RESTORE_REPEATERS : 0) | (opts.mqtt ? RESTORE_MQTT : 0); -} -// The radio and MQTT are shut down once an upload starts and only a reboot -// brings them back. The completion handler schedules it but never runs if the -// client goes away mid-upload, so make sure the device reboots then too. -static void onUploadDisconnect() { - if(webServer.uploadSuccess || rebootDelay.reboot) return; - ESP_LOGW(TAG, "Upload abandoned, rebooting"); - if(Update.isRunning()) Update.abort(); - rebootDelay.reboot = true; - rebootDelay.rebootTime = millis() + 500; -} // Sent when a command can't be handed to the main loop. -static void sendQueueFull(AsyncWebServerRequest *request) { +void sendQueueFull(AsyncWebServerRequest *request) { request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Command queue full\"}")); } // Sent when the radio can't take a frame (suspended or queue full). -static void sendRadioUnavailable(AsyncWebServerRequest *request) { +void sendRadioUnavailable(AsyncWebServerRequest *request) { request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Radio TX unavailable\"}")); } @@ -121,19 +76,6 @@ AsyncWebServer asyncServer(80); AsyncWebServer asyncApiServer(8081); void Web::startup() { ESP_LOGI(TAG, "Launching web server..."); - - asyncServer.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) { - AsyncJsonResponse *response = new AsyncJsonResponse(); - JsonObject root = response->getRoot().to(); - root["type"] = static_cast(settings.Security.type); - root["permissions"] = settings.Security.permissions; - root["serverId"] = settings.serverId; - root["version"] = settings.fwVersion.name; - root["model"] = "ESPSomfyRTS"; - root["hostname"] = settings.hostname; - response->setLength(); - request->send(response); - }); asyncApiServer.begin(); ESP_LOGI(TAG, "Async API server started on port 8081"); } @@ -196,11 +138,11 @@ bool Web::createAPIToken(const IPAddress ipAddress, char *token) { // Async API Handlers // ===================================================== // Helper: get a query param as String, or empty if missing -static String asyncParam(AsyncWebServerRequest *request, const char *name) { +String asyncParam(AsyncWebServerRequest *request, const char *name) { if(request->hasParam(name)) return request->getParam(name)->value(); return String(); } -static bool asyncHasParam(AsyncWebServerRequest *request, const char *name) { +bool asyncHasParam(AsyncWebServerRequest *request, const char *name) { return request->hasParam(name); } @@ -209,7 +151,7 @@ static bool asyncHasParam(AsyncWebServerRequest *request, const char *name) { // replies) copy one item into the ModelSnapshot types and write it the way the // read routes do. The read routes themselves write from a ModelSnapshot that // loop() fills (sendSnapshot). -static void serializeShadeRef(SomfyShade *shade, JsonFormatter &json) { +void serializeShadeRef(SomfyShade *shade, JsonFormatter &json) { shade_snapshot_t s; snapshotShade(*shade, s); writeShadeRef(json, s); @@ -217,14 +159,14 @@ static void serializeShadeRef(SomfyShade *shade, JsonFormatter &json) { // paired and lastRollingCode are passed in for a response that reports the // change the main loop is about to make. An unpair is held until it goes on // air (holdLoopItem), so the answer comes true. -static void serializeShade(SomfyShade *shade, JsonFormatter &json, bool paired, uint16_t lastRollingCode) { +void serializeShade(SomfyShade *shade, JsonFormatter &json, bool paired, uint16_t lastRollingCode) { shade_snapshot_t s; snapshotShade(*shade, s); s.paired = paired; s.lastRollingCode = lastRollingCode; writeShade(json, s); } -static void serializeShade(SomfyShade *shade, JsonFormatter &json) { serializeShade(shade, json, shade->paired, shade->lastRollingCode); } +void serializeShade(SomfyShade *shade, JsonFormatter &json) { serializeShade(shade, json, shade->paired, shade->lastRollingCode); } static void serializeGroupRef(SomfyGroup *group, JsonFormatter &json) { group_snapshot_t s; snapshotGroup(*group, s); @@ -236,7 +178,7 @@ static void serializeGroup(SomfyGroup *group, JsonFormatter &json) { shade_snapshot_t linked; writeGroup(json, s, liveShade, &linked); } -static void serializeTransceiverConfig(JsonFormatter &json) { +void serializeTransceiverConfig(JsonFormatter &json) { auto &cfg = somfy.transceiver.config; json.addElem("type", cfg.type); json.addElem("TXPin", cfg.TXPin); @@ -254,14 +196,14 @@ static void serializeTransceiverConfig(JsonFormatter &json) { json.addElem("noiseDetection", cfg.noiseDetection); json.addElem("radioInit", cfg.radioInit); } -static void serializeAppVersion(JsonFormatter &json, appver_t &ver) { +void serializeAppVersion(JsonFormatter &json, appver_t &ver) { json.addElem("name", ver.name); json.addElem("major", ver.major); json.addElem("minor", ver.minor); json.addElem("build", ver.build); json.addElem("suffix", ver.suffix); } -static void serializeGitVersion(JsonFormatter &json) { +void serializeGitVersion(JsonFormatter &json) { json.addElem("available", git.updateAvailable); json.addElem("status", git.status); json.addElem("error", (int32_t)git.error); @@ -278,7 +220,7 @@ static void serializeGitVersion(JsonFormatter &json) { serializeAppVersion(json, git.latest); json.endObject(); } -static void serializeGitRelease(GitRelease *rel, JsonFormatter &json) { +void serializeGitRelease(GitRelease *rel, JsonFormatter &json) { Timestamp ts; char buff[20]; sprintf(buff, "%llu", rel->id); @@ -350,7 +292,7 @@ struct SnapshotRead { // The response is gone, whether it finished or the client went away. ~SnapshotRead() { snapshotSlots.release(this->slot); } }; -static void sendSnapshot(AsyncWebServerRequest *request, snapshot_doc_t doc, uint8_t id = 0, snapshot_header_t header = nullptr) { +void sendSnapshot(AsyncWebServerRequest *request, snapshot_doc_t doc, uint8_t id, snapshot_header_t header) { const int8_t slot = snapshotSlots.acquire(); if(slot < 0) { request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Busy, try the read again\"}")); @@ -376,58 +318,6 @@ static void sendSnapshot(AsyncWebServerRequest *request, snapshot_doc_t doc, uin request->send(response); } -// Hands an edit of the shade model to loop() (docs/adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md). -// The reply only says it was accepted; the result reaches every client as a -// requestResult event carrying the same requestId once loop() has applied it. -static void queueModelEdit(AsyncWebServerRequest *request, model_edit_type_t type, JsonVariantConst body) { - if(!beginModelEdit()) { - request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Busy, try the change again\"}")); - return; - } - model_edit_t *edit = new (std::nothrow) model_edit_t(); - if(!edit) { - cancelModelEdit(nullptr); - sendQueueFull(request); - return; - } - edit->type = type; - edit->requestId = nextEditRequestId(); - edit->doc.set(body); - const uint32_t requestId = edit->requestId; - loop_item_t item; - item.type = loop_item_type_t::model_edit; - item.edit = edit; - if(edit->doc.overflowed() || !loopQueue.submit(item)) { - cancelModelEdit(edit); - sendQueueFull(request); - return; - } - char reply[64]; - snprintf(reply, sizeof(reply), "{\"status\":\"accepted\",\"requestId\":%lu}", (unsigned long)requestId); - request->send(200, _encoding_json, reply); -} -// The JSON (PUT/POST) form of an edit route. Bodies over 4 KB are refused with -// 413 before they reach the heap. -static AsyncCallbackJsonWebHandler *editHandler(const char *uri, model_edit_type_t type) { - auto *handler = new AsyncCallbackJsonWebHandler(uri, [type](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No object supplied.\"}")); return; } - queueModelEdit(request, type, json); - }); - handler->setMaxContentLength(4096); - return handler; -} -// The GET form of an edit route: its query parameters become the request. -static void queueModelEditFromParams(AsyncWebServerRequest *request, model_edit_type_t type, std::initializer_list names) { - JsonDocument body; - for(const char *name : names) { - if(!asyncHasParam(request, name)) continue; - const String value = asyncParam(request, name); - if(strcmp(name, "paired") == 0) body[name] = toBoolean(value.c_str(), false); - else body[name] = strtoul(value.c_str(), nullptr, 10); - } - queueModelEdit(request, type, body.as()); -} - // -- Async handler implementations -- void Web::handleDiscovery(AsyncWebServerRequest *request) { if(request->method() == HTTP_POST || request->method() == HTTP_GET) { @@ -907,869 +797,7 @@ void Web::begin() { DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*"); DefaultHeaders::Instance().addHeader("Access-Control-Allow-Methods", "PUT,POST,GET,OPTIONS"); DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "*"); - // GET endpoints - asyncApiServer.on("/discovery", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleDiscovery(r); }); - asyncApiServer.on("/rooms", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetRooms(r); }); - asyncApiServer.on("/shades", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetShades(r); }); - asyncApiServer.on("/groups", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetGroups(r); }); - asyncApiServer.on("/controller", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleController(r); }); - asyncApiServer.on("/room", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleRoom(r); }); - asyncApiServer.on("/shade", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleShade(r); }); - asyncApiServer.on("/group", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleGroup(r); }); - asyncApiServer.on("/downloadFirmware", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleDownloadFirmware(r); }); - asyncApiServer.on("/backup", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleBackup(r); }); - asyncApiServer.on("/reboot", WebRequestMethodComposite(HTTP_POST) | HTTP_PUT, [](AsyncWebServerRequest *r) { webServer.handleReboot(r); }); - // JSON body endpoints - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/shadeCommand", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleShadeCommand(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/groupCommand", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleGroupCommand(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/tiltCommand", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleTiltCommand(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/repeatCommand", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleRepeatCommand(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/setPositions", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleSetPositions(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/setSensor", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleSetSensor(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/login", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleLogin(r, j); })); - // GET fallback for command endpoints (query params) - asyncApiServer.on("/shadeCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleShadeCommand(r, v); }); - asyncApiServer.on("/groupCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleGroupCommand(r, v); }); - asyncApiServer.on("/tiltCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleTiltCommand(r, v); }); - asyncApiServer.on("/repeatCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleRepeatCommand(r, v); }); - asyncApiServer.on("/setPositions", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleSetPositions(r, v); }); - asyncApiServer.on("/setSensor", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleSetSensor(r, v); }); - asyncApiServer.on("/login", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleLogin(r, v); }); - // OPTIONS preflight + not found - asyncApiServer.onNotFound([](AsyncWebServerRequest *r) { - if(r->method() == HTTP_OPTIONS) { r->send(200); return; } - webServer.handleNotFound(r); - }); - - // Web Interface - - // Web Interface - // Command endpoints - delegate to async handler methods - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/tiltCommand", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleTiltCommand(request, json); })); - asyncServer.on("/tiltCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleTiltCommand(request, v); }); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/repeatCommand", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleRepeatCommand(request, json); })); - asyncServer.on("/repeatCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleRepeatCommand(request, v); }); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/shadeCommand", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleShadeCommand(request, json); })); - asyncServer.on("/shadeCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleShadeCommand(request, v); }); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/groupCommand", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleGroupCommand(request, json); })); - asyncServer.on("/groupCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleGroupCommand(request, v); }); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setPositions", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleSetPositions(request, json); })); - asyncServer.on("/setPositions", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleSetPositions(request, v); }); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setSensor", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleSetSensor(request, json); })); - asyncServer.on("/setSensor", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleSetSensor(request, v); }); - - asyncServer.on("/upnp.xml", HTTP_GET, [](AsyncWebServerRequest *request) { - AsyncResponseStream *response = request->beginResponseStream("text/xml"); - SSDP.schema(*response); - request->send(response); - }); - - // / and /loginContext are already handled by serveStatic and startup() - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/login", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleLogin(request, json); })); - asyncServer.on("/login", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleLogin(request, v); }); - - asyncServer.on("/shades.cfg", HTTP_GET, [](AsyncWebServerRequest *request) { - request->send(LittleFS, "/shades.cfg", _encoding_text); - }); - asyncServer.on("/shades.tmp", HTTP_GET, [](AsyncWebServerRequest *request) { - request->send(LittleFS, "/shades.tmp", _encoding_text); - }); - - asyncServer.on("/getReleases", HTTP_GET, [](AsyncWebServerRequest *request) { - GitRepo repo; - repo.getReleases(); - git.setCurrentRelease(repo); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - // Inline GitRepo::toJSON - resp.beginObject("fwVersion"); - serializeAppVersion(resp, settings.fwVersion); - resp.endObject(); - resp.beginObject("appVersion"); - serializeAppVersion(resp, settings.appVersion); - resp.endObject(); - resp.beginArray("releases"); - for(uint8_t i = 0; i < GIT_MAX_RELEASES + 1; i++) { - if(repo.releases[i].id == 0) continue; - resp.beginObject(); - serializeGitRelease(&repo.releases[i], resp); - resp.endObject(); - } - resp.endArray(); - resp.endObject(); - resp.endResponse(); - }); - - asyncServer.on("/downloadFirmware", HTTP_GET, [](AsyncWebServerRequest *request) { webServer.handleDownloadFirmware(request); }); - - asyncServer.on("/cancelFirmware", HTTP_GET, [](AsyncWebServerRequest *request) { - if(!git.lockFS) { - git.status = GIT_UPDATE_CANCELLING; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeGitVersion(resp); - resp.endObject(); - resp.endResponse(); - git.cancelled = true; - } - else { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Cannot cancel during filesystem update.\"}")); - } - }); - - asyncServer.on("/backup", HTTP_GET, [](AsyncWebServerRequest *request) { webServer.handleBackup(request); }); - - asyncServer.on("/restore", HTTP_POST, - [](AsyncWebServerRequest *request) { - if(webServer.uploadSuccess) { - restore_options_t opts; - if(asyncHasParam(request, "data")) { - String dataStr = asyncParam(request, "data"); - ESP_LOGD(TAG, "%s", dataStr.c_str()); - JsonDocument doc; - DeserializationError err = deserializeJson(doc, dataStr); - if(err) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - else { - JsonObject obj = doc.as(); - opts.fromJSON(obj); - } - } - else { - ESP_LOGD(TAG, "No restore options sent. Using defaults..."); - opts.shades = true; - } - // The main loop restores, since it owns the shades and their rolling - // codes, and then reboots. - loop_item_t item; - item.type = loop_item_type_t::restore_shades; - item.restoreParts = restoreParts(opts); - if(!queueLoopItem(request, item)) return; - request->send(200, _encoding_json, "{\"status\":\"Success\",\"desc\":\"Restoring Shade settings\"}"); - } - }, - [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { - esp_task_wdt_reset(); - if(index == 0) { - webServer.uploadSuccess = false; - ESP_LOGD(TAG, "Restore: %s", filename.c_str()); - File fup = LittleFS.open("/shades.tmp", "w"); - fup.close(); - } - if(len > 0) { - File fup = LittleFS.open("/shades.tmp", "a"); - fup.write(data, len); - fup.close(); - } - if(final) { - webServer.uploadSuccess = true; - } - }); - - // Static file routes removed - handled by serveStatic in startup() - - asyncServer.on("/controller", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleController(request); }); - asyncServer.on("/rooms", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetRooms(request); }); - asyncServer.on("/shades", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetShades(request); }); - asyncServer.on("/groups", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetGroups(request); }); - asyncServer.on("/room", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleRoom(request); }); - asyncServer.on("/shade", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleShade(request); }); - asyncServer.on("/group", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGroup(request); }); - - // What a new room, shade or group would get, from a snapshot. - asyncServer.on("/getNextRoom", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextRoom); }); - asyncServer.on("/getNextShade", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextShade); }); - asyncServer.on("/getNextGroup", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextGroup); }); - asyncServer.on("/groupOptions", HTTP_GET, [](AsyncWebServerRequest *request) { - if(!asyncHasParam(request, "groupId")) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"You must supply a valid group id.\"}")); - return; - } - const int groupId = atoi(asyncParam(request, "groupId").c_str()); - // As for /group, existence is checked on the live model. - if(somfy.getGroupById(groupId)) sendSnapshot(request, snapshot_doc_t::groupOptions, groupId); - else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}")); - }); - // Adding and saving rooms, shades and groups. Edits of the shade model: - // loop() applies them and sends requestResult. - asyncServer.addHandler(editHandler("/addRoom", model_edit_type_t::add_room)); - asyncServer.addHandler(editHandler("/addShade", model_edit_type_t::add_shade)); - asyncServer.addHandler(editHandler("/addGroup", model_edit_type_t::add_group)); - asyncServer.addHandler(editHandler("/saveRoom", model_edit_type_t::save_room)); - asyncServer.addHandler(editHandler("/saveShade", model_edit_type_t::save_shade)); - asyncServer.addHandler(editHandler("/saveGroup", model_edit_type_t::save_group)); - - - // setMyPosition - supports both GET with query params and POST/PUT with JSON body - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setMyPosition", - [](AsyncWebServerRequest *request, JsonVariant &json) { - uint8_t shadeId = 255; - int8_t pos = -1; - int8_t tilt = -1; - if(!json.isNull()) { - JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; - else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade id was supplied.\"}")); return; } - if(!obj["pos"].isNull()) pos = obj["pos"].as(); - if(!obj["tilt"].isNull()) tilt = obj["tilt"].as(); - } - SomfyShade* shade = somfy.getShadeById(shadeId); - if(shade) { - if(pos >= 0 && pos <= 100) - if(!queueSetMyPosition(request, shade, pos, requestTarget(tilt))) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShadeRef(shade, resp); - resp.endObject(); - resp.endResponse(); - } - else { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); - } - })); - asyncServer.on("/setMyPosition", HTTP_GET, [](AsyncWebServerRequest *request) { - uint8_t shadeId = 255; - int8_t pos = -1; - int8_t tilt = -1; - if(asyncHasParam(request, "shadeId")) { - shadeId = atoi(asyncParam(request, "shadeId").c_str()); - if(asyncHasParam(request, "pos")) pos = atoi(asyncParam(request, "pos").c_str()); - if(asyncHasParam(request, "tilt")) tilt = atoi(asyncParam(request, "tilt").c_str()); - } - else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; } - SomfyShade* shade = somfy.getShadeById(shadeId); - if(shade) { - if(pos >= 0 && pos <= 100) - if(!queueSetMyPosition(request, shade, pos, requestTarget(tilt))) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShadeRef(shade, resp); - resp.endObject(); - resp.endResponse(); - } - else { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); - } - }); - - // setRollingCode - supports both query params and JSON body - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setRollingCode", - [](AsyncWebServerRequest *request, JsonVariant &json) { - uint8_t shadeId = 255; - uint16_t rollingCode = 0; - if(!json.isNull()) { - JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; - if(!obj["rollingCode"].isNull()) rollingCode = obj["rollingCode"]; - } - SomfyShade* shade = nullptr; - if(shadeId != 255) shade = somfy.getShadeById(shadeId); - if(!shade) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to set rolling code\"}")); - } - else { - if(!queueSetRollingCode(request, shade, rollingCode)) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp, shade->paired, rollingCode); - resp.endObject(); - resp.endResponse(); - } - })); - asyncServer.on("/setRollingCode", HTTP_GET, [](AsyncWebServerRequest *request) { - uint8_t shadeId = 255; - uint16_t rollingCode = 0; - if(asyncHasParam(request, "shadeId")) { - shadeId = atoi(asyncParam(request, "shadeId").c_str()); - rollingCode = atoi(asyncParam(request, "rollingCode").c_str()); - } - SomfyShade* shade = nullptr; - if(shadeId != 255) shade = somfy.getShadeById(shadeId); - if(!shade) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to set rolling code\"}")); - } - else { - if(!queueSetRollingCode(request, shade, rollingCode)) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp, shade->paired, rollingCode); - resp.endObject(); - resp.endResponse(); - } - }); - - asyncServer.addHandler(editHandler("/setPaired", model_edit_type_t::set_paired)); - asyncServer.on("/setPaired", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::set_paired, { "shadeId", "paired" }); - }); - - // unpairShade - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/unpairShade", - [](AsyncWebServerRequest *request, JsonVariant &json) { - uint8_t shadeId = 255; - if(!json.isNull()) { - JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; - } - SomfyShade* shade = nullptr; - if(shadeId != 255) shade = somfy.getShadeById(shadeId); - if(!shade) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); - } - else { - if(!queueUnpairShade(request, shade)) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp, false, shade->lastRollingCode); - resp.endObject(); - resp.endResponse(); - } - })); - asyncServer.on("/unpairShade", HTTP_GET, [](AsyncWebServerRequest *request) { - uint8_t shadeId = 255; - if(asyncHasParam(request, "shadeId")) - shadeId = atoi(asyncParam(request, "shadeId").c_str()); - SomfyShade* shade = nullptr; - if(shadeId != 255) shade = somfy.getShadeById(shadeId); - if(!shade) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); - } - else { - if(!queueUnpairShade(request, shade)) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp, false, shade->lastRollingCode); - resp.endObject(); - resp.endResponse(); - } - }); - - // Linking repeaters, remotes and group members, and deleting. Edits of the - // shade model: loop() applies them and sends requestResult. - asyncServer.addHandler(editHandler("/linkRepeater", model_edit_type_t::link_repeater)); - asyncServer.on("/linkRepeater", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::link_repeater, { "address" }); - }); - asyncServer.addHandler(editHandler("/unlinkRepeater", model_edit_type_t::unlink_repeater)); - asyncServer.on("/unlinkRepeater", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::unlink_repeater, { "address" }); - }); - asyncServer.addHandler(editHandler("/linkRemote", model_edit_type_t::link_remote)); - asyncServer.addHandler(editHandler("/unlinkRemote", model_edit_type_t::unlink_remote)); - asyncServer.addHandler(editHandler("/linkToGroup", model_edit_type_t::link_to_group)); - asyncServer.addHandler(editHandler("/unlinkFromGroup", model_edit_type_t::unlink_from_group)); - asyncServer.addHandler(editHandler("/deleteRoom", model_edit_type_t::delete_room)); - asyncServer.on("/deleteRoom", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::delete_room, { "roomId" }); - }); - asyncServer.addHandler(editHandler("/deleteShade", model_edit_type_t::delete_shade)); - asyncServer.on("/deleteShade", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::delete_shade, { "shadeId" }); - }); - asyncServer.addHandler(editHandler("/deleteGroup", model_edit_type_t::delete_group)); - asyncServer.on("/deleteGroup", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::delete_group, { "groupId" }); - }); - // The repeaters, from a snapshot like the other reads. - asyncServer.on("/repeaters", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::repeaters); }); - - - // updateFirmware - file upload - asyncServer.on("/updateFirmware", HTTP_POST, - [](AsyncWebServerRequest *request) { - if(Update.hasError()) - request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating firmware: \"}"); - 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(index == 0) { - webServer.uploadSuccess = false; - ESP_LOGI(TAG, "Update: %s", filename.c_str()); - if(!Update.begin(UPDATE_SIZE_UNKNOWN)) { - ESP_LOGE(TAG, "Update begin failed"); - } - else { - somfy.transceiver.end(); - mqtt.end(); - request->onDisconnect(onUploadDisconnect); - } - } - if(len > 0) { - if(Update.write(data, len) != len) { - ESP_LOGE(TAG, "Upload of %s aborted invalid size %d", filename.c_str(), len); - Update.abort(); - } - } - if(final) { - if(Update.end(true)) { - ESP_LOGI(TAG, "Update Success: %u Rebooting...", index + len); - webServer.uploadSuccess = true; - } - else { - ESP_LOGE(TAG, "Update end failed"); - } - } - esp_task_wdt_reset(); - }); - - // updateShadeConfig - file upload - asyncServer.on("/updateShadeConfig", HTTP_POST, - [](AsyncWebServerRequest *request) { - if(git.lockFS) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Filesystem update in progress\"}")); - return; - } - request->send(200, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Updating Shade Config: \"}"); - }, - [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { - if(index == 0) { - ESP_LOGI(TAG, "Update: shades.cfg"); - File fup = LittleFS.open("/shades.tmp", "w"); - fup.close(); - } - if(len > 0) { - File fup = LittleFS.open("/shades.tmp", "a"); - fup.write(data, len); - fup.close(); - } - if(final) { - // The main loop loads it, since it owns the shades. - loop_item_t item; - item.type = loop_item_type_t::load_shades; - loopQueue.submit(item); - } - }); - - // updateApplication - file upload - asyncServer.on("/updateApplication", HTTP_POST, - [](AsyncWebServerRequest *request) { - if(Update.hasError()) - request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating application: \"}"); - else - request->send(200, _encoding_json, "{\"status\":\"SUCCESS\",\"desc\":\"Successfully updated application\"}"); - rebootDelay.reboot = true; - rebootDelay.rebootTime = millis() + 500; - }, - [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { - if(index == 0) { - webServer.uploadSuccess = false; - ESP_LOGI(TAG, "Update: %s", filename.c_str()); - if(!Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS)) { - ESP_LOGE(TAG, "Update begin failed"); - } - else { - somfy.transceiver.end(); - mqtt.end(); - request->onDisconnect(onUploadDisconnect); - } - } - if(len > 0) { - if(Update.write(data, len) != len) { - ESP_LOGE(TAG, "Upload of %s aborted invalid size %d", filename.c_str(), len); - Update.abort(); - } - } - if(final) { - if(Update.end(true)) { - webServer.uploadSuccess = true; - ESP_LOGI(TAG, "Update Success: %u Rebooting...", index + len); - somfy.commit(); - } - else { - somfy.commit(); - ESP_LOGE(TAG, "Update end failed"); - } - } - esp_task_wdt_reset(); - }); - - asyncServer.on("/scanaps", HTTP_GET, [](AsyncWebServerRequest *request) { - esp_task_wdt_reset(); - esp_task_wdt_delete(NULL); - if(net.softAPOpened) WiFi.disconnect(false); - int n = WiFi.scanNetworks(false, true); - esp_task_wdt_add(NULL); - ESP_LOGI(TAG, "Scanned %d networks", n); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - resp.beginObject("connected"); - resp.addElem("name", settings.WIFI.ssid); - resp.addElem("passphrase", settings.WIFI.passphrase); - resp.addElem("strength", (int32_t)WiFi.RSSI()); - resp.addElem("channel", (int32_t)WiFi.channel()); - resp.endObject(); - resp.beginArray("accessPoints"); - for(int i = 0; i < n; ++i) { - if(WiFi.SSID(i).length() == 0 || WiFi.RSSI(i) < -95) continue; - resp.beginObject(); - resp.addElem("name", WiFi.SSID(i).c_str()); - resp.addElem("channel", (int32_t)WiFi.channel(i)); - resp.addElem("strength", (int32_t)WiFi.RSSI(i)); - resp.addElem("macAddress", WiFi.BSSIDstr(i).c_str()); - resp.endObject(); - } - resp.endArray(); - resp.endObject(); - resp.endResponse(); - }); - - asyncServer.on("/reboot", WebRequestMethodComposite(HTTP_POST) | HTTP_PUT, [](AsyncWebServerRequest *request) { webServer.handleReboot(request); }); - - // saveSecurity - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/saveSecurity", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(400, _encoding_html, "Error parsing JSON body"); - return; - } - JsonObject obj = json.as(); - settings.Security.fromJSON(obj); - settings.Security.save(); - char token[65]; - webServer.createAPIToken(request->client()->remoteIP(), token); - JsonDocument sdoc; - JsonObject sobj = sdoc.to(); - settings.Security.toJSON(sobj); - sobj["apiKey"] = token; - serializeJson(sdoc, g_async_content, sizeof(g_async_content)); - request->send(200, _encoding_json, g_async_content); - })); - - // getSecurity - asyncServer.on("/getSecurity", HTTP_GET, [](AsyncWebServerRequest *request) { - JsonDocument doc; - JsonObject obj = doc.to(); - settings.Security.toJSON(obj); - serializeJson(doc, g_async_content, sizeof(g_async_content)); - request->send(200, _encoding_json, g_async_content); - }); - - // saveRadio - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/saveRadio", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(400, _encoding_html, "Error parsing JSON body"); - return; - } - JsonObject obj = json.as(); - somfy.transceiver.fromJSON(obj); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - resp.beginObject("config"); - serializeTransceiverConfig(resp); - resp.endObject(); - resp.endObject(); - resp.endResponse(); - })); - - // getRadio - asyncServer.on("/getRadio", HTTP_GET, [](AsyncWebServerRequest *request) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeTransceiverConfig(resp); - resp.endObject(); - resp.endResponse(); - }); - - // sendRemoteCommand - supports GET with query params and POST/PUT with JSON body - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/sendRemoteCommand", - [](AsyncWebServerRequest *request, JsonVariant &json) { - somfy_frame_t frame; - uint8_t repeats = 0; - if(!json.isNull()) { - JsonObject obj = json.as(); - String scmd; - if(!obj["address"].isNull()) frame.remoteAddress = obj["address"]; - if(!obj["command"].isNull()) scmd = obj["command"].as(); - if(!obj["repeats"].isNull()) repeats = obj["repeats"]; - if(!obj["rcode"].isNull()) frame.rollingCode = obj["rcode"]; - if(!obj["encKey"].isNull()) frame.encKey = obj["encKey"]; - frame.cmd = translateSomfyCommand(scmd.c_str()); - } - if(frame.remoteAddress > 0 && frame.rollingCode > 0) { - if(somfy.sendFrame(frame, repeats)) - request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); - else - sendRadioUnavailable(request); - } - else - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}")); - })); - asyncServer.on("/sendRemoteCommand", HTTP_GET, [](AsyncWebServerRequest *request) { - somfy_frame_t frame; - uint8_t repeats = 0; - if(asyncHasParam(request, "address")) { - frame.remoteAddress = atoi(asyncParam(request, "address").c_str()); - if(asyncHasParam(request, "encKey")) frame.encKey = atoi(asyncParam(request, "encKey").c_str()); - if(asyncHasParam(request, "command")) frame.cmd = translateSomfyCommand(asyncParam(request, "command")); - if(asyncHasParam(request, "rcode")) frame.rollingCode = atoi(asyncParam(request, "rcode").c_str()); - if(asyncHasParam(request, "repeats")) repeats = atoi(asyncParam(request, "repeats").c_str()); - } - if(frame.remoteAddress > 0 && frame.rollingCode > 0) { - if(somfy.sendFrame(frame, repeats)) - request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); - else - sendRadioUnavailable(request); - } - else - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}")); - }); - - // setgeneral - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setgeneral", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - JsonObject obj = json.as(); - if(!obj["hostname"].isNull() || !obj["ssdpBroadcast"].isNull() || !obj["checkForUpdate"].isNull()) { - bool checkForUpdate = settings.checkForUpdate; - settings.fromJSON(obj); - settings.save(); - if(settings.checkForUpdate != checkForUpdate) git.emitUpdateCheck(); - if(!obj["hostname"].isNull()) net.updateHostname(); - } - if(!obj["ntpServer"].isNull() || !obj["ntpServer"].isNull()) { - settings.NTP.fromJSON(obj); - settings.NTP.save(); - } - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set General Settings\"}"); - })); - - // setNetwork - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setNetwork", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(400, _encoding_html, "Error parsing JSON body"); - return; - } - JsonObject obj = json.as(); - bool reboot = false; - if(!obj["connType"].isNull() && obj["connType"].as() != static_cast(settings.connType)) { - settings.connType = static_cast(obj["connType"].as()); - settings.save(); - reboot = true; - } - if(!obj["wifi"].isNull()) { - JsonObject objWifi = obj["wifi"]; - if(settings.connType == conn_types_t::wifi) { - if(!objWifi["ssid"].isNull() && objWifi["ssid"].as().compareTo(settings.WIFI.ssid) != 0) { - if(WiFi.softAPgetStationNum() == 0) reboot = true; - } - if(!objWifi["passphrase"].isNull() && objWifi["passphrase"].as().compareTo(settings.WIFI.passphrase) != 0) { - if(WiFi.softAPgetStationNum() == 0) reboot = true; - } - } - settings.WIFI.fromJSON(objWifi); - settings.WIFI.save(); - } - if(!obj["ethernet"].isNull()) { - JsonObject objEth = obj["ethernet"]; - if(settings.connType == conn_types_t::ethernet || settings.connType == conn_types_t::ethernetpref) - reboot = true; -#ifndef CONFIG_IDF_TARGET_ESP32C6 - settings.Ethernet.fromJSON(objEth); - settings.Ethernet.save(); -#endif - } - if(reboot) { - ESP_LOGI(TAG, "Rebooting ESP for new Network settings..."); - rebootDelay.reboot = true; - rebootDelay.rebootTime = millis() + 1000; - } - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set Network Settings\"}"); - })); - - // setIP - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setIP", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - ESP_LOGD(TAG, "Setting IP..."); - JsonObject obj = json.as(); - settings.IP.fromJSON(obj); - settings.IP.save(); - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set Network Settings\"}"); - })); - - // connectwifi - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/connectwifi", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - JsonObject obj = json.as(); - ESP_LOGD(TAG, "Settings WIFI connection..."); - String ssid = ""; - String passphrase = ""; - if(!obj["ssid"].isNull()) ssid = obj["ssid"].as(); - if(!obj["passphrase"].isNull()) passphrase = obj["passphrase"].as(); - bool reboot = false; - if(ssid.compareTo(settings.WIFI.ssid) != 0) reboot = true; - if(passphrase.compareTo(settings.WIFI.passphrase) != 0) reboot = true; - if(!settings.WIFI.ssidExists(ssid.c_str()) && ssid.length() > 0) { - request->send(400, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"WiFi Network Does not exist\"}"); - } - else { - SETCHARPROP(settings.WIFI.ssid, ssid.c_str(), sizeof(settings.WIFI.ssid)); - SETCHARPROP(settings.WIFI.passphrase, passphrase.c_str(), sizeof(settings.WIFI.passphrase)); - settings.WIFI.save(); - settings.WIFI.print(); - request->send(201, _encoding_json, "{\"status\":\"OK\",\"desc\":\"Successfully set server connection\"}"); - if(reboot) { - ESP_LOGI(TAG, "Rebooting ESP for new WiFi settings..."); - rebootDelay.reboot = true; - rebootDelay.rebootTime = millis() + 1000; - } - } - })); - - // modulesettings - asyncServer.on("/modulesettings", HTTP_GET, [](AsyncWebServerRequest *request) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - resp.addElem("fwVersion", settings.fwVersion.name); - resp.addElem("ssdpBroadcast", settings.ssdpBroadcast); - resp.addElem("hostname", settings.hostname); - resp.addElem("connType", static_cast(settings.connType)); - resp.addElem("chipModel", settings.chipModel); - resp.addElem("checkForUpdate", settings.checkForUpdate); - resp.addElem("ntpServer", settings.NTP.ntpServer); - resp.addElem("posixZone", settings.NTP.posixZone); - resp.endObject(); - resp.endResponse(); - }); - - // networksettings - asyncServer.on("/networksettings", HTTP_GET, [](AsyncWebServerRequest *request) { - JsonDocument doc; - JsonObject obj = doc.to(); - settings.toJSON(obj); - obj["fwVersion"] = settings.fwVersion.name; - JsonObject eth = obj.createNestedObject("ethernet"); -#ifndef CONFIG_IDF_TARGET_ESP32C6 - settings.Ethernet.toJSON(eth); -#endif - JsonObject wifi = obj.createNestedObject("wifi"); - settings.WIFI.toJSON(wifi); - JsonObject ip = obj.createNestedObject("ip"); - settings.IP.toJSON(ip); - serializeJson(doc, g_async_content, sizeof(g_async_content)); - request->send(200, _encoding_json, g_async_content); - }); - - // connectmqtt - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/connectmqtt", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - JsonObject obj = json.as(); - ESP_LOGD(TAG, "Saving MQTT"); - mqtt.disconnect(); - settings.MQTT.fromJSON(obj); - settings.MQTT.save(); - JsonDocument sdoc; - JsonObject sobj = sdoc.to(); - settings.MQTT.toJSON(sobj); - serializeJson(sdoc, g_async_content, sizeof(g_async_content)); - request->send(200, _encoding_json, g_async_content); - })); - - // mqttsettings - asyncServer.on("/mqttsettings", HTTP_GET, [](AsyncWebServerRequest *request) { - JsonDocument doc; - JsonObject obj = doc.to(); - settings.MQTT.toJSON(obj); - serializeJson(doc, g_async_content, sizeof(g_async_content)); - request->send(200, _encoding_json, g_async_content); - }); - - // Sort orders: the ids in the order the client lists them. - asyncServer.addHandler(editHandler("/roomSortOrder", model_edit_type_t::room_order)); - asyncServer.addHandler(editHandler("/shadeSortOrder", model_edit_type_t::shade_order)); - asyncServer.addHandler(editHandler("/groupSortOrder", model_edit_type_t::group_order)); - - asyncServer.on("/beginFrequencyScan", HTTP_GET, [](AsyncWebServerRequest *request) { - somfy.transceiver.beginFrequencyScan(); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeTransceiverConfig(resp); - resp.endObject(); - resp.endResponse(); - }); - - asyncServer.on("/endFrequencyScan", HTTP_GET, [](AsyncWebServerRequest *request) { - somfy.transceiver.endFrequencyScan(); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeTransceiverConfig(resp); - resp.endObject(); - resp.endResponse(); - }); - - asyncServer.on("/recoverFilesystem", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { - if(git.status == GIT_UPDATING) - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Filesystem is updating. Please wait!!!\"}"); - else if(git.status != GIT_STATUS_READY) - request->send(200, "application/json", "{\"status\":\"ERROR\",\"desc\":\"Cannot recover file system at this time.\"}"); - else { - git.recoverFilesystem(); - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Recovering filesystem from github please wait!!!\"}"); - } - }); - - asyncServer.onNotFound([](AsyncWebServerRequest *r) { - if(r->method() == HTTP_OPTIONS) { r->send(200); return; } - webServer.handleNotFound(r); - }); - - // serveStatic MUST be registered AFTER all route handlers so it doesn't shadow them - asyncServer.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); - + registerApiRoutes(asyncApiServer); + registerWebUiRoutes(asyncServer); asyncServer.begin(); } diff --git a/src/entryPoints/WebRoutes.h b/src/entryPoints/WebRoutes.h new file mode 100644 index 0000000..58bd4ef --- /dev/null +++ b/src/entryPoints/WebRoutes.h @@ -0,0 +1,45 @@ +#ifndef WEB_ROUTES_H +#define WEB_ROUTES_H +#include +#include "Somfy.h" +#include "LoopQueue.h" +#include "ModelSnapshot.h" +#include "GitOTA.h" + +// What WebUiRoutes, ApiRoutes and the handlers both of them call (Web.cpp) +// share. Internal to the web servers. + +// Registers the routes the config web UI calls on port 80, and its files. +void registerWebUiRoutes(AsyncWebServer &server); +// Registers the routes integrations such as Home Assistant call on port 8081. +void registerApiRoutes(AsyncWebServer &server); + +#define WEB_MAX_RESPONSE 4096 +// Where replies are formatted before they are sent. async_tcp only. +extern char g_async_content[WEB_MAX_RESPONSE]; +extern const char _encoding_text[]; +extern const char _encoding_json[]; + +// Sent when a request can't be handed to loop(). +void sendQueueFull(AsyncWebServerRequest *request); +// Sent when the radio can't take a frame (suspended or queue full). +void sendRadioUnavailable(AsyncWebServerRequest *request); +// Hands an item to loop(), which owns the shades. When it can't be the +// request is answered with the reason and false is returned. +bool queueLoopItem(AsyncWebServerRequest *request, const loop_item_t &item); +bool queueShadeRequest(AsyncWebServerRequest *request, const shade_request_t &req); +// A query parameter, or an empty string when it is missing. +String asyncParam(AsyncWebServerRequest *request, const char *name); +bool asyncHasParam(AsyncWebServerRequest *request, const char *name); +// Serves a read from a ModelSnapshot that loop() fills. +void sendSnapshot(AsyncWebServerRequest *request, snapshot_doc_t doc, uint8_t id = 0, snapshot_header_t header = nullptr); + +// One item of a reply, copied from the live shade model. +void serializeShadeRef(SomfyShade *shade, JsonFormatter &json); +void serializeShade(SomfyShade *shade, JsonFormatter &json); +void serializeShade(SomfyShade *shade, JsonFormatter &json, bool paired, uint16_t lastRollingCode); +void serializeTransceiverConfig(JsonFormatter &json); +void serializeAppVersion(JsonFormatter &json, appver_t &ver); +void serializeGitVersion(JsonFormatter &json); +void serializeGitRelease(GitRelease *rel, JsonFormatter &json); +#endif diff --git a/src/entryPoints/WebUiRoutes.cpp b/src/entryPoints/WebUiRoutes.cpp new file mode 100644 index 0000000..e46176b --- /dev/null +++ b/src/entryPoints/WebUiRoutes.cpp @@ -0,0 +1,967 @@ +#include +#include +#include +#include +#include "esp_log.h" +#include "ConfigSettings.h" +#include "ConfigFile.h" +#include "Utils.h" +#include "SSDP.h" +#include "Somfy.h" +#include "WResp.h" +#include "LoopQueue.h" +#include "ModelSnapshot.h" +#include "SomfyEditor.h" +#include "Web.h" +#include "WebRoutes.h" +#include "MqttConnection.h" +#include "GitOTA.h" +#include "ESPNetwork.h" +#include +#include +#include + +// WebUiRoutes: the routes the config web UI calls on port 80, and the UI's own +// files. + +extern ConfigSettings settings; +extern SSDPClass SSDP; +extern rebootDelay_t rebootDelay; +extern SomfyShadeController somfy; +extern Web webServer; +extern MqttConnection mqtt; +extern GitUpdater git; +extern ESPNetwork net; +extern LoopQueue loopQueue; + +static const char *TAG = "Web"; +static const char _encoding_html[] = "text/html"; + +static bool queueSetMyPosition(AsyncWebServerRequest *request, SomfyShade *shade, int8_t pos, int8_t tilt) { + shade_request_t req; + req.type = shade_request_type_t::set_favorite; + req.id = shade->getShadeId(); + req.position = pos; + req.tiltPosition = tilt; + return queueShadeRequest(request, req); +} +// loop() clears paired and saves it once the Prog is queued. The shade +// belongs to loop() so it isn't changed here. +static bool queueUnpairShade(AsyncWebServerRequest *request, SomfyShade *shade) { + shade_request_t req; + req.type = shade_request_type_t::unpair; + req.id = shade->getShadeId(); + return queueShadeRequest(request, req); +} +// Sending a command reads and commits the rolling code on loop(), so a new +// code is set there too. +static bool queueSetRollingCode(AsyncWebServerRequest *request, SomfyShade *shade, uint16_t rollingCode) { + shade_request_t req; + req.type = shade_request_type_t::set_rolling_code; + req.id = shade->getShadeId(); + req.rollingCode = rollingCode; + return queueShadeRequest(request, req); +} +// A restore's options travel through the loop queue as RESTORE_* bits. +static uint8_t restoreParts(const restore_options_t &opts) { + return (opts.settings ? RESTORE_SETTINGS : 0) | (opts.shades ? RESTORE_SHADES : 0) | + (opts.network ? RESTORE_NETWORK : 0) | (opts.transceiver ? RESTORE_TRANSCEIVER : 0) | + (opts.repeaters ? RESTORE_REPEATERS : 0) | (opts.mqtt ? RESTORE_MQTT : 0); +} +// The radio and MQTT are shut down once an upload starts and only a reboot +// brings them back. The completion handler schedules it but never runs if the +// client goes away mid-upload, so make sure the device reboots then too. +static void onUploadDisconnect() { + if(webServer.uploadSuccess || rebootDelay.reboot) return; + ESP_LOGW(TAG, "Upload abandoned, rebooting"); + if(Update.isRunning()) Update.abort(); + rebootDelay.reboot = true; + rebootDelay.rebootTime = millis() + 500; +} +// Hands an edit of the shade model to loop() (docs/adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md). +// The reply only says it was accepted; the result reaches every client as a +// requestResult event carrying the same requestId once loop() has applied it. +static void queueModelEdit(AsyncWebServerRequest *request, model_edit_type_t type, JsonVariantConst body) { + if(!beginModelEdit()) { + request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Busy, try the change again\"}")); + return; + } + model_edit_t *edit = new (std::nothrow) model_edit_t(); + if(!edit) { + cancelModelEdit(nullptr); + sendQueueFull(request); + return; + } + edit->type = type; + edit->requestId = nextEditRequestId(); + edit->doc.set(body); + const uint32_t requestId = edit->requestId; + loop_item_t item; + item.type = loop_item_type_t::model_edit; + item.edit = edit; + if(edit->doc.overflowed() || !loopQueue.submit(item)) { + cancelModelEdit(edit); + sendQueueFull(request); + return; + } + char reply[64]; + snprintf(reply, sizeof(reply), "{\"status\":\"accepted\",\"requestId\":%lu}", (unsigned long)requestId); + request->send(200, _encoding_json, reply); +} +// The JSON (PUT/POST) form of an edit route. Bodies over 4 KB are refused with +// 413 before they reach the heap. +static AsyncCallbackJsonWebHandler *editHandler(const char *uri, model_edit_type_t type) { + auto *handler = new AsyncCallbackJsonWebHandler(uri, [type](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No object supplied.\"}")); return; } + queueModelEdit(request, type, json); + }); + handler->setMaxContentLength(4096); + return handler; +} +// The GET form of an edit route: its query parameters become the request. +static void queueModelEditFromParams(AsyncWebServerRequest *request, model_edit_type_t type, std::initializer_list names) { + JsonDocument body; + for(const char *name : names) { + if(!asyncHasParam(request, name)) continue; + const String value = asyncParam(request, name); + if(strcmp(name, "paired") == 0) body[name] = toBoolean(value.c_str(), false); + else body[name] = strtoul(value.c_str(), nullptr, 10); + } + queueModelEdit(request, type, body.as()); +} + +void registerWebUiRoutes(AsyncWebServer &server) { + server.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) { + AsyncJsonResponse *response = new AsyncJsonResponse(); + JsonObject root = response->getRoot().to(); + root["type"] = static_cast(settings.Security.type); + root["permissions"] = settings.Security.permissions; + root["serverId"] = settings.serverId; + root["version"] = settings.fwVersion.name; + root["model"] = "ESPSomfyRTS"; + root["hostname"] = settings.hostname; + response->setLength(); + request->send(response); + }); + // Web Interface + // Command endpoints - delegate to async handler methods + server.addHandler(new AsyncCallbackJsonWebHandler("/tiltCommand", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleTiltCommand(request, json); })); + server.on("/tiltCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleTiltCommand(request, v); }); + + server.addHandler(new AsyncCallbackJsonWebHandler("/repeatCommand", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleRepeatCommand(request, json); })); + server.on("/repeatCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleRepeatCommand(request, v); }); + + server.addHandler(new AsyncCallbackJsonWebHandler("/shadeCommand", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleShadeCommand(request, json); })); + server.on("/shadeCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleShadeCommand(request, v); }); + + server.addHandler(new AsyncCallbackJsonWebHandler("/groupCommand", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleGroupCommand(request, json); })); + server.on("/groupCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleGroupCommand(request, v); }); + + server.addHandler(new AsyncCallbackJsonWebHandler("/setPositions", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleSetPositions(request, json); })); + server.on("/setPositions", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleSetPositions(request, v); }); + + server.addHandler(new AsyncCallbackJsonWebHandler("/setSensor", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleSetSensor(request, json); })); + server.on("/setSensor", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleSetSensor(request, v); }); + + server.on("/upnp.xml", HTTP_GET, [](AsyncWebServerRequest *request) { + AsyncResponseStream *response = request->beginResponseStream("text/xml"); + SSDP.schema(*response); + request->send(response); + }); + + // / and /loginContext are already handled by serveStatic and startup() + + server.addHandler(new AsyncCallbackJsonWebHandler("/login", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleLogin(request, json); })); + server.on("/login", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleLogin(request, v); }); + + server.on("/shades.cfg", HTTP_GET, [](AsyncWebServerRequest *request) { + request->send(LittleFS, "/shades.cfg", _encoding_text); + }); + server.on("/shades.tmp", HTTP_GET, [](AsyncWebServerRequest *request) { + request->send(LittleFS, "/shades.tmp", _encoding_text); + }); + + server.on("/getReleases", HTTP_GET, [](AsyncWebServerRequest *request) { + GitRepo repo; + repo.getReleases(); + git.setCurrentRelease(repo); + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + // Inline GitRepo::toJSON + resp.beginObject("fwVersion"); + serializeAppVersion(resp, settings.fwVersion); + resp.endObject(); + resp.beginObject("appVersion"); + serializeAppVersion(resp, settings.appVersion); + resp.endObject(); + resp.beginArray("releases"); + for(uint8_t i = 0; i < GIT_MAX_RELEASES + 1; i++) { + if(repo.releases[i].id == 0) continue; + resp.beginObject(); + serializeGitRelease(&repo.releases[i], resp); + resp.endObject(); + } + resp.endArray(); + resp.endObject(); + resp.endResponse(); + }); + + server.on("/downloadFirmware", HTTP_GET, [](AsyncWebServerRequest *request) { webServer.handleDownloadFirmware(request); }); + + server.on("/cancelFirmware", HTTP_GET, [](AsyncWebServerRequest *request) { + if(!git.lockFS) { + git.status = GIT_UPDATE_CANCELLING; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeGitVersion(resp); + resp.endObject(); + resp.endResponse(); + git.cancelled = true; + } + else { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Cannot cancel during filesystem update.\"}")); + } + }); + + server.on("/backup", HTTP_GET, [](AsyncWebServerRequest *request) { webServer.handleBackup(request); }); + + server.on("/restore", HTTP_POST, + [](AsyncWebServerRequest *request) { + if(webServer.uploadSuccess) { + restore_options_t opts; + if(asyncHasParam(request, "data")) { + String dataStr = asyncParam(request, "data"); + ESP_LOGD(TAG, "%s", dataStr.c_str()); + JsonDocument doc; + DeserializationError err = deserializeJson(doc, dataStr); + if(err) { + request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); + return; + } + else { + JsonObject obj = doc.as(); + opts.fromJSON(obj); + } + } + else { + ESP_LOGD(TAG, "No restore options sent. Using defaults..."); + opts.shades = true; + } + // The main loop restores, since it owns the shades and their rolling + // codes, and then reboots. + loop_item_t item; + item.type = loop_item_type_t::restore_shades; + item.restoreParts = restoreParts(opts); + if(!queueLoopItem(request, item)) return; + request->send(200, _encoding_json, "{\"status\":\"Success\",\"desc\":\"Restoring Shade settings\"}"); + } + }, + [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { + esp_task_wdt_reset(); + if(index == 0) { + webServer.uploadSuccess = false; + ESP_LOGD(TAG, "Restore: %s", filename.c_str()); + File fup = LittleFS.open("/shades.tmp", "w"); + fup.close(); + } + if(len > 0) { + File fup = LittleFS.open("/shades.tmp", "a"); + fup.write(data, len); + fup.close(); + } + if(final) { + webServer.uploadSuccess = true; + } + }); + + // Static file routes removed - handled by serveStatic in startup() + + server.on("/controller", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleController(request); }); + server.on("/rooms", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetRooms(request); }); + server.on("/shades", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetShades(request); }); + server.on("/groups", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetGroups(request); }); + server.on("/room", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleRoom(request); }); + server.on("/shade", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleShade(request); }); + server.on("/group", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGroup(request); }); + + // What a new room, shade or group would get, from a snapshot. + server.on("/getNextRoom", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextRoom); }); + server.on("/getNextShade", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextShade); }); + server.on("/getNextGroup", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextGroup); }); + server.on("/groupOptions", HTTP_GET, [](AsyncWebServerRequest *request) { + if(!asyncHasParam(request, "groupId")) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"You must supply a valid group id.\"}")); + return; + } + const int groupId = atoi(asyncParam(request, "groupId").c_str()); + // As for /group, existence is checked on the live model. + if(somfy.getGroupById(groupId)) sendSnapshot(request, snapshot_doc_t::groupOptions, groupId); + else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}")); + }); + // Adding and saving rooms, shades and groups. Edits of the shade model: + // loop() applies them and sends requestResult. + server.addHandler(editHandler("/addRoom", model_edit_type_t::add_room)); + server.addHandler(editHandler("/addShade", model_edit_type_t::add_shade)); + server.addHandler(editHandler("/addGroup", model_edit_type_t::add_group)); + server.addHandler(editHandler("/saveRoom", model_edit_type_t::save_room)); + server.addHandler(editHandler("/saveShade", model_edit_type_t::save_shade)); + server.addHandler(editHandler("/saveGroup", model_edit_type_t::save_group)); + + + // setMyPosition - supports both GET with query params and POST/PUT with JSON body + server.addHandler(new AsyncCallbackJsonWebHandler("/setMyPosition", + [](AsyncWebServerRequest *request, JsonVariant &json) { + uint8_t shadeId = 255; + int8_t pos = -1; + int8_t tilt = -1; + if(!json.isNull()) { + JsonObject obj = json.as(); + if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; + else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade id was supplied.\"}")); return; } + if(!obj["pos"].isNull()) pos = obj["pos"].as(); + if(!obj["tilt"].isNull()) tilt = obj["tilt"].as(); + } + SomfyShade* shade = somfy.getShadeById(shadeId); + if(shade) { + if(pos >= 0 && pos <= 100) + if(!queueSetMyPosition(request, shade, pos, requestTarget(tilt))) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShadeRef(shade, resp); + resp.endObject(); + resp.endResponse(); + } + else { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); + } + })); + server.on("/setMyPosition", HTTP_GET, [](AsyncWebServerRequest *request) { + uint8_t shadeId = 255; + int8_t pos = -1; + int8_t tilt = -1; + if(asyncHasParam(request, "shadeId")) { + shadeId = atoi(asyncParam(request, "shadeId").c_str()); + if(asyncHasParam(request, "pos")) pos = atoi(asyncParam(request, "pos").c_str()); + if(asyncHasParam(request, "tilt")) tilt = atoi(asyncParam(request, "tilt").c_str()); + } + else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; } + SomfyShade* shade = somfy.getShadeById(shadeId); + if(shade) { + if(pos >= 0 && pos <= 100) + if(!queueSetMyPosition(request, shade, pos, requestTarget(tilt))) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShadeRef(shade, resp); + resp.endObject(); + resp.endResponse(); + } + else { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); + } + }); + + // setRollingCode - supports both query params and JSON body + server.addHandler(new AsyncCallbackJsonWebHandler("/setRollingCode", + [](AsyncWebServerRequest *request, JsonVariant &json) { + uint8_t shadeId = 255; + uint16_t rollingCode = 0; + if(!json.isNull()) { + JsonObject obj = json.as(); + if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; + if(!obj["rollingCode"].isNull()) rollingCode = obj["rollingCode"]; + } + SomfyShade* shade = nullptr; + if(shadeId != 255) shade = somfy.getShadeById(shadeId); + if(!shade) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to set rolling code\"}")); + } + else { + if(!queueSetRollingCode(request, shade, rollingCode)) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShade(shade, resp, shade->paired, rollingCode); + resp.endObject(); + resp.endResponse(); + } + })); + server.on("/setRollingCode", HTTP_GET, [](AsyncWebServerRequest *request) { + uint8_t shadeId = 255; + uint16_t rollingCode = 0; + if(asyncHasParam(request, "shadeId")) { + shadeId = atoi(asyncParam(request, "shadeId").c_str()); + rollingCode = atoi(asyncParam(request, "rollingCode").c_str()); + } + SomfyShade* shade = nullptr; + if(shadeId != 255) shade = somfy.getShadeById(shadeId); + if(!shade) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to set rolling code\"}")); + } + else { + if(!queueSetRollingCode(request, shade, rollingCode)) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShade(shade, resp, shade->paired, rollingCode); + resp.endObject(); + resp.endResponse(); + } + }); + + server.addHandler(editHandler("/setPaired", model_edit_type_t::set_paired)); + server.on("/setPaired", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::set_paired, { "shadeId", "paired" }); + }); + + // unpairShade + server.addHandler(new AsyncCallbackJsonWebHandler("/unpairShade", + [](AsyncWebServerRequest *request, JsonVariant &json) { + uint8_t shadeId = 255; + if(!json.isNull()) { + JsonObject obj = json.as(); + if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; + } + SomfyShade* shade = nullptr; + if(shadeId != 255) shade = somfy.getShadeById(shadeId); + if(!shade) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); + } + else { + if(!queueUnpairShade(request, shade)) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShade(shade, resp, false, shade->lastRollingCode); + resp.endObject(); + resp.endResponse(); + } + })); + server.on("/unpairShade", HTTP_GET, [](AsyncWebServerRequest *request) { + uint8_t shadeId = 255; + if(asyncHasParam(request, "shadeId")) + shadeId = atoi(asyncParam(request, "shadeId").c_str()); + SomfyShade* shade = nullptr; + if(shadeId != 255) shade = somfy.getShadeById(shadeId); + if(!shade) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); + } + else { + if(!queueUnpairShade(request, shade)) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShade(shade, resp, false, shade->lastRollingCode); + resp.endObject(); + resp.endResponse(); + } + }); + + // Linking repeaters, remotes and group members, and deleting. Edits of the + // shade model: loop() applies them and sends requestResult. + server.addHandler(editHandler("/linkRepeater", model_edit_type_t::link_repeater)); + server.on("/linkRepeater", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::link_repeater, { "address" }); + }); + server.addHandler(editHandler("/unlinkRepeater", model_edit_type_t::unlink_repeater)); + server.on("/unlinkRepeater", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::unlink_repeater, { "address" }); + }); + server.addHandler(editHandler("/linkRemote", model_edit_type_t::link_remote)); + server.addHandler(editHandler("/unlinkRemote", model_edit_type_t::unlink_remote)); + server.addHandler(editHandler("/linkToGroup", model_edit_type_t::link_to_group)); + server.addHandler(editHandler("/unlinkFromGroup", model_edit_type_t::unlink_from_group)); + server.addHandler(editHandler("/deleteRoom", model_edit_type_t::delete_room)); + server.on("/deleteRoom", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::delete_room, { "roomId" }); + }); + server.addHandler(editHandler("/deleteShade", model_edit_type_t::delete_shade)); + server.on("/deleteShade", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::delete_shade, { "shadeId" }); + }); + server.addHandler(editHandler("/deleteGroup", model_edit_type_t::delete_group)); + server.on("/deleteGroup", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::delete_group, { "groupId" }); + }); + // The repeaters, from a snapshot like the other reads. + server.on("/repeaters", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::repeaters); }); + + + // updateFirmware - file upload + server.on("/updateFirmware", HTTP_POST, + [](AsyncWebServerRequest *request) { + if(Update.hasError()) + request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating firmware: \"}"); + 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(index == 0) { + webServer.uploadSuccess = false; + ESP_LOGI(TAG, "Update: %s", filename.c_str()); + if(!Update.begin(UPDATE_SIZE_UNKNOWN)) { + ESP_LOGE(TAG, "Update begin failed"); + } + else { + somfy.transceiver.end(); + mqtt.end(); + request->onDisconnect(onUploadDisconnect); + } + } + if(len > 0) { + if(Update.write(data, len) != len) { + ESP_LOGE(TAG, "Upload of %s aborted invalid size %d", filename.c_str(), len); + Update.abort(); + } + } + if(final) { + if(Update.end(true)) { + ESP_LOGI(TAG, "Update Success: %u Rebooting...", index + len); + webServer.uploadSuccess = true; + } + else { + ESP_LOGE(TAG, "Update end failed"); + } + } + esp_task_wdt_reset(); + }); + + // updateShadeConfig - file upload + server.on("/updateShadeConfig", HTTP_POST, + [](AsyncWebServerRequest *request) { + if(git.lockFS) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Filesystem update in progress\"}")); + return; + } + request->send(200, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Updating Shade Config: \"}"); + }, + [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { + if(index == 0) { + ESP_LOGI(TAG, "Update: shades.cfg"); + File fup = LittleFS.open("/shades.tmp", "w"); + fup.close(); + } + if(len > 0) { + File fup = LittleFS.open("/shades.tmp", "a"); + fup.write(data, len); + fup.close(); + } + if(final) { + // The main loop loads it, since it owns the shades. + loop_item_t item; + item.type = loop_item_type_t::load_shades; + loopQueue.submit(item); + } + }); + + // updateApplication - file upload + server.on("/updateApplication", HTTP_POST, + [](AsyncWebServerRequest *request) { + if(Update.hasError()) + request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating application: \"}"); + else + request->send(200, _encoding_json, "{\"status\":\"SUCCESS\",\"desc\":\"Successfully updated application\"}"); + rebootDelay.reboot = true; + rebootDelay.rebootTime = millis() + 500; + }, + [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { + if(index == 0) { + webServer.uploadSuccess = false; + ESP_LOGI(TAG, "Update: %s", filename.c_str()); + if(!Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS)) { + ESP_LOGE(TAG, "Update begin failed"); + } + else { + somfy.transceiver.end(); + mqtt.end(); + request->onDisconnect(onUploadDisconnect); + } + } + if(len > 0) { + if(Update.write(data, len) != len) { + ESP_LOGE(TAG, "Upload of %s aborted invalid size %d", filename.c_str(), len); + Update.abort(); + } + } + if(final) { + if(Update.end(true)) { + webServer.uploadSuccess = true; + ESP_LOGI(TAG, "Update Success: %u Rebooting...", index + len); + somfy.commit(); + } + else { + somfy.commit(); + ESP_LOGE(TAG, "Update end failed"); + } + } + esp_task_wdt_reset(); + }); + + server.on("/scanaps", HTTP_GET, [](AsyncWebServerRequest *request) { + esp_task_wdt_reset(); + esp_task_wdt_delete(NULL); + if(net.softAPOpened) WiFi.disconnect(false); + int n = WiFi.scanNetworks(false, true); + esp_task_wdt_add(NULL); + ESP_LOGI(TAG, "Scanned %d networks", n); + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + resp.beginObject("connected"); + resp.addElem("name", settings.WIFI.ssid); + resp.addElem("passphrase", settings.WIFI.passphrase); + resp.addElem("strength", (int32_t)WiFi.RSSI()); + resp.addElem("channel", (int32_t)WiFi.channel()); + resp.endObject(); + resp.beginArray("accessPoints"); + for(int i = 0; i < n; ++i) { + if(WiFi.SSID(i).length() == 0 || WiFi.RSSI(i) < -95) continue; + resp.beginObject(); + resp.addElem("name", WiFi.SSID(i).c_str()); + resp.addElem("channel", (int32_t)WiFi.channel(i)); + resp.addElem("strength", (int32_t)WiFi.RSSI(i)); + resp.addElem("macAddress", WiFi.BSSIDstr(i).c_str()); + resp.endObject(); + } + resp.endArray(); + resp.endObject(); + resp.endResponse(); + }); + + server.on("/reboot", WebRequestMethodComposite(HTTP_POST) | HTTP_PUT, [](AsyncWebServerRequest *request) { webServer.handleReboot(request); }); + + // saveSecurity + server.addHandler(new AsyncCallbackJsonWebHandler("/saveSecurity", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(400, _encoding_html, "Error parsing JSON body"); + return; + } + JsonObject obj = json.as(); + settings.Security.fromJSON(obj); + settings.Security.save(); + char token[65]; + webServer.createAPIToken(request->client()->remoteIP(), token); + JsonDocument sdoc; + JsonObject sobj = sdoc.to(); + settings.Security.toJSON(sobj); + sobj["apiKey"] = token; + serializeJson(sdoc, g_async_content, sizeof(g_async_content)); + request->send(200, _encoding_json, g_async_content); + })); + + // getSecurity + server.on("/getSecurity", HTTP_GET, [](AsyncWebServerRequest *request) { + JsonDocument doc; + JsonObject obj = doc.to(); + settings.Security.toJSON(obj); + serializeJson(doc, g_async_content, sizeof(g_async_content)); + request->send(200, _encoding_json, g_async_content); + }); + + // saveRadio + server.addHandler(new AsyncCallbackJsonWebHandler("/saveRadio", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(400, _encoding_html, "Error parsing JSON body"); + return; + } + JsonObject obj = json.as(); + somfy.transceiver.fromJSON(obj); + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + resp.beginObject("config"); + serializeTransceiverConfig(resp); + resp.endObject(); + resp.endObject(); + resp.endResponse(); + })); + + // getRadio + server.on("/getRadio", HTTP_GET, [](AsyncWebServerRequest *request) { + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeTransceiverConfig(resp); + resp.endObject(); + resp.endResponse(); + }); + + // sendRemoteCommand - supports GET with query params and POST/PUT with JSON body + server.addHandler(new AsyncCallbackJsonWebHandler("/sendRemoteCommand", + [](AsyncWebServerRequest *request, JsonVariant &json) { + somfy_frame_t frame; + uint8_t repeats = 0; + if(!json.isNull()) { + JsonObject obj = json.as(); + String scmd; + if(!obj["address"].isNull()) frame.remoteAddress = obj["address"]; + if(!obj["command"].isNull()) scmd = obj["command"].as(); + if(!obj["repeats"].isNull()) repeats = obj["repeats"]; + if(!obj["rcode"].isNull()) frame.rollingCode = obj["rcode"]; + if(!obj["encKey"].isNull()) frame.encKey = obj["encKey"]; + frame.cmd = translateSomfyCommand(scmd.c_str()); + } + if(frame.remoteAddress > 0 && frame.rollingCode > 0) { + if(somfy.sendFrame(frame, repeats)) + request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); + else + sendRadioUnavailable(request); + } + else + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}")); + })); + server.on("/sendRemoteCommand", HTTP_GET, [](AsyncWebServerRequest *request) { + somfy_frame_t frame; + uint8_t repeats = 0; + if(asyncHasParam(request, "address")) { + frame.remoteAddress = atoi(asyncParam(request, "address").c_str()); + if(asyncHasParam(request, "encKey")) frame.encKey = atoi(asyncParam(request, "encKey").c_str()); + if(asyncHasParam(request, "command")) frame.cmd = translateSomfyCommand(asyncParam(request, "command")); + if(asyncHasParam(request, "rcode")) frame.rollingCode = atoi(asyncParam(request, "rcode").c_str()); + if(asyncHasParam(request, "repeats")) repeats = atoi(asyncParam(request, "repeats").c_str()); + } + if(frame.remoteAddress > 0 && frame.rollingCode > 0) { + if(somfy.sendFrame(frame, repeats)) + request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); + else + sendRadioUnavailable(request); + } + else + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}")); + }); + + // setgeneral + server.addHandler(new AsyncCallbackJsonWebHandler("/setgeneral", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); + return; + } + JsonObject obj = json.as(); + if(!obj["hostname"].isNull() || !obj["ssdpBroadcast"].isNull() || !obj["checkForUpdate"].isNull()) { + bool checkForUpdate = settings.checkForUpdate; + settings.fromJSON(obj); + settings.save(); + if(settings.checkForUpdate != checkForUpdate) git.emitUpdateCheck(); + if(!obj["hostname"].isNull()) net.updateHostname(); + } + if(!obj["ntpServer"].isNull() || !obj["ntpServer"].isNull()) { + settings.NTP.fromJSON(obj); + settings.NTP.save(); + } + request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set General Settings\"}"); + })); + + // setNetwork + server.addHandler(new AsyncCallbackJsonWebHandler("/setNetwork", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(400, _encoding_html, "Error parsing JSON body"); + return; + } + JsonObject obj = json.as(); + bool reboot = false; + if(!obj["connType"].isNull() && obj["connType"].as() != static_cast(settings.connType)) { + settings.connType = static_cast(obj["connType"].as()); + settings.save(); + reboot = true; + } + if(!obj["wifi"].isNull()) { + JsonObject objWifi = obj["wifi"]; + if(settings.connType == conn_types_t::wifi) { + if(!objWifi["ssid"].isNull() && objWifi["ssid"].as().compareTo(settings.WIFI.ssid) != 0) { + if(WiFi.softAPgetStationNum() == 0) reboot = true; + } + if(!objWifi["passphrase"].isNull() && objWifi["passphrase"].as().compareTo(settings.WIFI.passphrase) != 0) { + if(WiFi.softAPgetStationNum() == 0) reboot = true; + } + } + settings.WIFI.fromJSON(objWifi); + settings.WIFI.save(); + } + if(!obj["ethernet"].isNull()) { + JsonObject objEth = obj["ethernet"]; + if(settings.connType == conn_types_t::ethernet || settings.connType == conn_types_t::ethernetpref) + reboot = true; +#ifndef CONFIG_IDF_TARGET_ESP32C6 + settings.Ethernet.fromJSON(objEth); + settings.Ethernet.save(); +#endif + } + if(reboot) { + ESP_LOGI(TAG, "Rebooting ESP for new Network settings..."); + rebootDelay.reboot = true; + rebootDelay.rebootTime = millis() + 1000; + } + request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set Network Settings\"}"); + })); + + // setIP + server.addHandler(new AsyncCallbackJsonWebHandler("/setIP", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); + return; + } + ESP_LOGD(TAG, "Setting IP..."); + JsonObject obj = json.as(); + settings.IP.fromJSON(obj); + settings.IP.save(); + request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set Network Settings\"}"); + })); + + // connectwifi + server.addHandler(new AsyncCallbackJsonWebHandler("/connectwifi", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); + return; + } + JsonObject obj = json.as(); + ESP_LOGD(TAG, "Settings WIFI connection..."); + String ssid = ""; + String passphrase = ""; + if(!obj["ssid"].isNull()) ssid = obj["ssid"].as(); + if(!obj["passphrase"].isNull()) passphrase = obj["passphrase"].as(); + bool reboot = false; + if(ssid.compareTo(settings.WIFI.ssid) != 0) reboot = true; + if(passphrase.compareTo(settings.WIFI.passphrase) != 0) reboot = true; + if(!settings.WIFI.ssidExists(ssid.c_str()) && ssid.length() > 0) { + request->send(400, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"WiFi Network Does not exist\"}"); + } + else { + SETCHARPROP(settings.WIFI.ssid, ssid.c_str(), sizeof(settings.WIFI.ssid)); + SETCHARPROP(settings.WIFI.passphrase, passphrase.c_str(), sizeof(settings.WIFI.passphrase)); + settings.WIFI.save(); + settings.WIFI.print(); + request->send(201, _encoding_json, "{\"status\":\"OK\",\"desc\":\"Successfully set server connection\"}"); + if(reboot) { + ESP_LOGI(TAG, "Rebooting ESP for new WiFi settings..."); + rebootDelay.reboot = true; + rebootDelay.rebootTime = millis() + 1000; + } + } + })); + + // modulesettings + server.on("/modulesettings", HTTP_GET, [](AsyncWebServerRequest *request) { + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + resp.addElem("fwVersion", settings.fwVersion.name); + resp.addElem("ssdpBroadcast", settings.ssdpBroadcast); + resp.addElem("hostname", settings.hostname); + resp.addElem("connType", static_cast(settings.connType)); + resp.addElem("chipModel", settings.chipModel); + resp.addElem("checkForUpdate", settings.checkForUpdate); + resp.addElem("ntpServer", settings.NTP.ntpServer); + resp.addElem("posixZone", settings.NTP.posixZone); + resp.endObject(); + resp.endResponse(); + }); + + // networksettings + server.on("/networksettings", HTTP_GET, [](AsyncWebServerRequest *request) { + JsonDocument doc; + JsonObject obj = doc.to(); + settings.toJSON(obj); + obj["fwVersion"] = settings.fwVersion.name; + JsonObject eth = obj.createNestedObject("ethernet"); +#ifndef CONFIG_IDF_TARGET_ESP32C6 + settings.Ethernet.toJSON(eth); +#endif + JsonObject wifi = obj.createNestedObject("wifi"); + settings.WIFI.toJSON(wifi); + JsonObject ip = obj.createNestedObject("ip"); + settings.IP.toJSON(ip); + serializeJson(doc, g_async_content, sizeof(g_async_content)); + request->send(200, _encoding_json, g_async_content); + }); + + // connectmqtt + server.addHandler(new AsyncCallbackJsonWebHandler("/connectmqtt", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); + return; + } + JsonObject obj = json.as(); + ESP_LOGD(TAG, "Saving MQTT"); + mqtt.disconnect(); + settings.MQTT.fromJSON(obj); + settings.MQTT.save(); + JsonDocument sdoc; + JsonObject sobj = sdoc.to(); + settings.MQTT.toJSON(sobj); + serializeJson(sdoc, g_async_content, sizeof(g_async_content)); + request->send(200, _encoding_json, g_async_content); + })); + + // mqttsettings + server.on("/mqttsettings", HTTP_GET, [](AsyncWebServerRequest *request) { + JsonDocument doc; + JsonObject obj = doc.to(); + settings.MQTT.toJSON(obj); + serializeJson(doc, g_async_content, sizeof(g_async_content)); + request->send(200, _encoding_json, g_async_content); + }); + + // Sort orders: the ids in the order the client lists them. + server.addHandler(editHandler("/roomSortOrder", model_edit_type_t::room_order)); + server.addHandler(editHandler("/shadeSortOrder", model_edit_type_t::shade_order)); + server.addHandler(editHandler("/groupSortOrder", model_edit_type_t::group_order)); + + server.on("/beginFrequencyScan", HTTP_GET, [](AsyncWebServerRequest *request) { + somfy.transceiver.beginFrequencyScan(); + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeTransceiverConfig(resp); + resp.endObject(); + resp.endResponse(); + }); + + server.on("/endFrequencyScan", HTTP_GET, [](AsyncWebServerRequest *request) { + somfy.transceiver.endFrequencyScan(); + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeTransceiverConfig(resp); + resp.endObject(); + resp.endResponse(); + }); + + server.on("/recoverFilesystem", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { + if(git.status == GIT_UPDATING) + request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Filesystem is updating. Please wait!!!\"}"); + else if(git.status != GIT_STATUS_READY) + request->send(200, "application/json", "{\"status\":\"ERROR\",\"desc\":\"Cannot recover file system at this time.\"}"); + else { + git.recoverFilesystem(); + request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Recovering filesystem from github please wait!!!\"}"); + } + }); + + server.onNotFound([](AsyncWebServerRequest *r) { + if(r->method() == HTTP_OPTIONS) { r->send(200); return; } + webServer.handleNotFound(r); + }); + + // serveStatic MUST be registered AFTER all route handlers so it doesn't shadow them + server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); + +} diff --git a/src/outputs/SomfyMqtt.cpp b/src/outputs/MqttPublisher.cpp similarity index 99% rename from src/outputs/SomfyMqtt.cpp rename to src/outputs/MqttPublisher.cpp index 7fe7cf3..7191c02 100644 --- a/src/outputs/SomfyMqtt.cpp +++ b/src/outputs/MqttPublisher.cpp @@ -2,13 +2,13 @@ #include "ConfigSettings.h" #include "Somfy.h" #include "SomfySnapshot.h" -#include "Sockets.h" -#include "entryPoints/MQTT.h" +#include "SocketEmitter.h" +#include "MqttConnection.h" extern SomfyShadeController somfy; extern SocketEmitter sockEmit; extern ConfigSettings settings; -extern MQTTClass mqtt; +extern MqttConnection mqtt; void SomfyRoom::publish() { if(mqtt.connected()) { diff --git a/src/outputs/Sockets.cpp b/src/outputs/SocketEmitter.cpp similarity index 99% rename from src/outputs/Sockets.cpp rename to src/outputs/SocketEmitter.cpp index 3e887e1..334633f 100644 --- a/src/outputs/Sockets.cpp +++ b/src/outputs/SocketEmitter.cpp @@ -2,7 +2,7 @@ #include #include #include "esp_log.h" -#include "Sockets.h" +#include "SocketEmitter.h" #include "ConfigSettings.h" #include "Somfy.h" #include "ESPNetwork.h" diff --git a/src/outputs/Sockets.h b/src/outputs/SocketEmitter.h similarity index 100% rename from src/outputs/Sockets.h rename to src/outputs/SocketEmitter.h diff --git a/src/outputs/SomfyEvents.cpp b/src/outputs/SomfyEvents.cpp index 116f196..2f9e28d 100644 --- a/src/outputs/SomfyEvents.cpp +++ b/src/outputs/SomfyEvents.cpp @@ -1,12 +1,12 @@ #include #include "Somfy.h" #include "SomfyEditor.h" -#include "Sockets.h" -#include "entryPoints/MQTT.h" +#include "SocketEmitter.h" +#include "MqttConnection.h" extern SomfyShadeController somfy; extern SocketEmitter sockEmit; -extern MQTTClass mqtt; +extern MqttConnection mqtt; void SomfyShade::emitState() { this->changed |= SHADE_CHANGED_STATE; } void SomfyShade::emitState(const char *evt) { this->emitState(255, evt); } diff --git a/test/test_mqttcommands/test_mqttcommands.cpp b/test/test_mqttcommands/test_mqttcommands.cpp index aa5f913..00066e9 100644 --- a/test/test_mqttcommands/test_mqttcommands.cpp +++ b/test/test_mqttcommands/test_mqttcommands.cpp @@ -1,5 +1,6 @@ #include #include +#include #include "entryPoints/MqttCommands.h" static shade_request_t req; @@ -137,6 +138,14 @@ static void test_group_sunny_and_windy() { TEST_ASSERT_TRUE(parse("espsomfy/groups/2/windy/set", "1")); TEST_ASSERT_EQUAL_INT8(1, req.windy); } +static void test_every_subscribed_topic_is_understood() { + // What the connection subscribes to and what the parser takes can't drift. + for(const char *pattern : MQTT_COMMAND_TOPICS) { + std::string topic = std::string("espsomfy/") + pattern; + topic.replace(topic.find('+'), 1, "3"); + TEST_ASSERT_TRUE_MESSAGE(parse(topic.c_str(), "1"), topic.c_str()); + } +} static void test_groups_only_take_their_commands() { TEST_ASSERT_FALSE(parse("espsomfy/groups/2/target/set", "30")); TEST_ASSERT_FALSE(parse("espsomfy/groups/2/mypos/set", "30")); @@ -161,5 +170,6 @@ int main(int argc, char **argv) { RUN_TEST(test_group_direction_and_sun_flag); RUN_TEST(test_group_sunny_and_windy); RUN_TEST(test_groups_only_take_their_commands); + RUN_TEST(test_every_subscribed_topic_is_understood); return UNITY_END(); } From 220af1fafbe95e5eebd0e7c82909c772e8028343 Mon Sep 17 00:00:00 2001 From: cjkas Date: Fri, 16 Oct 2026 14:03:24 +0200 Subject: [PATCH 5/7] docs: plan deepening the shade behaviour Records the design state for candidate #1 of the architecture review so a new session can pick it up: the drifted Up/Down/My copies in SomfyShade.cpp, the echo path through SomfyRemote::sendCommandAfter, what steps 1-5 already did, round 1 of the design questions with the recommended answers, and the topics waiting for round 2. Co-Authored-By: Claude Opus 5.5 --- docs/SHADE_BEHAVIOUR_PLAN.md | 110 +++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/SHADE_BEHAVIOUR_PLAN.md diff --git a/docs/SHADE_BEHAVIOUR_PLAN.md b/docs/SHADE_BEHAVIOUR_PLAN.md new file mode 100644 index 0000000..67b9018 --- /dev/null +++ b/docs/SHADE_BEHAVIOUR_PLAN.md @@ -0,0 +1,110 @@ +# Plan: deepen the shade behaviour + +**Status:** design in progress. Round 1 of the design questions is open (below); nothing is built. +**Written:** 2026-10-16, on top of `refactor/split-transports` (PR #44, the top of the LoopQueue stack #40–#44, which is not yet merged or run on a device). Line numbers refer to that branch. +**Start here:** read [GLOSSARY.md](../GLOSSARY.md), [ADR-0002](adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md) and [LOOP_QUEUE_PLAN.md](LOOP_QUEUE_PLAN.md) first. Their decisions are not reopened here. + +## Why + +This is candidate #1 of the architecture review, the one ranked first. The LoopQueue plan (Q7 there) deliberately did it second, so that the bugs in front of the shade could be fixed first. Steps 1–5 rebuilt everything around the shade. The shade's own behaviour is still about 1,000 lines in [src/SomfyShade.cpp](../src/SomfyShade.cpp). + +### The same rule exists in several drifted copies + +What a shade does for Up, Down or My is written out separately in each place it can be triggered from: + +| Function | Triggered by | +|---|---| +| `processFrame` | a frame received from a remote, and the shade's own echoed frame (see below) | +| `processWaitingFrame` | a remote button held (long press, tilt) | +| `processInternalCommand` | a group command reaching its member shades | +| `sendCommand`, `sendTiltCommand`, `moveToTarget`, `moveToTiltTarget`, `setMyPosition`, `moveToMyPosition` | commands from the web, MQTT and Home Assistant, through ShadeRequests and `SomfyShadeOps` | +| `checkMovement` | every `loop()` pass: simulated travel, arriving at the target, sending the stop, storing the favorite | +| `processTxEvent` | the radio reporting a job started, finished, aborted or dropped | + +Known drift: +- **Up on a tilt-only shade.** `sendCommand` and `processInternalCommand` set target 100, position 100 and tilt target 0. `processFrame` sets only the tilt target. +- **My while a tilt-only shade moves.** `processInternalCommand` sets the lift target to 100; `processFrame` leaves the lift alone. +- **The four "arrived at target" blocks in `checkMovement`** aren't symmetric. The tilt blocks clear `settingTiltPos`; the lift blocks never clear `settingPos`, which may send an extra stop on a later arrival. This is on the "still open" list in LOOP_QUEUE_PLAN.md, not confirmed on a device. Their stop conditions also differ by hand-written literals. +- **`setMyPosition`** has three near-copy branches (tilt-only, tilt, none). + +### None of it is tested + +The native tests can't build it. It reads `millis()`, sends through the radio, and uses the global `somfy`. Only the helpers it calls are tested: `travelPosition`, `stepTargets`, `ShadeSensors`, `TxHold`/`ShadeTxState` and `requestedFavoriteTilt` (`ShadeMotion`, `ShadeSensors`). The fixes just before this work (set-favorite tilt, rolling codes on the main loop) were in this code. + +## What the shade code depends on today + +- **Time:** `millis()` throughout `checkMovement`, `processFrame` and the senders. +- **Radio:** every send goes through `SomfyRemote::sendCommandAfter` ([src/SomfyRemote.cpp](../src/SomfyRemote.cpp), around lines 61–108). It: + 1. takes the next rolling code from NVS; + 2. encodes the frame and submits it with `somfy.sendFrame()`, getting a ticket, or 0 when the radio refuses; + 3. commits the rolling code; + 4. sets `lastFrame`; + 5. calls `somfy.processFrame(lastFrame, internal = true)`; + 6. calls `somfy.markTxPending()` for commands that start movement. +- **The echo (step 5 above).** `somfy.processFrame()` runs `processFrame` on every shade, the sender included. So part of what a command does to the sending shade happens in `sendCommand`, and part in its own `processFrame(internal)` on the echoed frame. The same echo moves any other shade that has the sender's address as a linked remote. GPIO-driven shades (`GP_Remote`/`GP_Relay`) skip the radio but still echo. +- **Groups.** A group command sends the group's frame, then calls `processInternalCommand` on each member shade ([src/SomfyGroup.cpp](../src/SomfyGroup.cpp)). +- **The global `somfy`:** `isDirty`/`commit()` for saving positions and the favorite, `updateGroupFlags()` after sensor and sun-flag changes, `transceiver.config` for bit length and protocol, and `groups` in `isInGroup()`. +- **GPIO:** `triggerGPIOs`, `setGPIOs` and `usesPin` for dry contacts and GPIO motors. +- **Outputs:** only `changed` bits since step 4. The change fan-out publishes them (`ShadeChanges.h`, `SomfyShadeController::sendChanges()`). + +## What makes it easier now + +- **Callers:** ShadeRequests reaches the shade only through the `ShadeOps` seam ([src/ShadeRequests.h](../src/ShadeRequests.h)). The real implementation is `SomfyShadeOps`, and a recording fake exists in `test/test_shaderequests`. Once the behaviour module exists, only `SomfyShadeOps` changes. +- **Outputs:** the shade only marks what changed (step 4). +- **Concurrency:** only `loop()` touches shades (ADR-0002), so the module is single-threaded code. +- **Snapshots:** `shade_snapshot_t` and `snapshotShade()` already describe a shade as clients see it, which is useful for test assertions. + +## The goal + +One module holding the shade's behaviour behind a small interface: +- **Inputs:** a received frame, a command, a group command, a radio event, and a tick, each with the current time. +- **Sending:** goes out through a seam that returns a ticket or a refusal. On the device that is the radio path; in tests, a fake that records what was sent. +- **Inside:** one rule per command and tilt type. +- **Tested** natively, by scenarios such as: *an integrated-tilt shade at 0%; a remote sends Down; at 1.5 s it is still tilting; at 4 s it is lifting at about 25%; reaching the 40% target sends a stop; the radio drops the stop and the shade runs on to 100%.* + +## Design questions + +The decisions are the user's. Each question comes with a recommended answer, and they are asked in rounds, as in the LoopQueue plan. Settled answers move to "Decisions". + +### Round 1 (open) + +**Q1. What moves into the module.** +- **Into the module:** everything that decides where the shade is and where it is going: `processFrame`, `processWaitingFrame`, `processInternalCommand`, `sendCommand`, `sendTiltCommand`, `moveToTarget`, `moveToTiltTarget`, `setMyPosition`, `moveToMyPosition`, `checkMovement` and `processTxEvent`. Their state goes too: positions, targets, directions, start times, the `setting*` flags, the favorite, `ShadeSensors` and `ShadeTxState`. +- **Stays in `SomfyShade`:** identity and settings (id, name, remotes, types, times), persistence, GPIO, JSON and rolling codes. + +Recommended: yes. + +**Q2. Three phases: pin, move, reconcile.** +1. **Pin:** native scenario tests of today's behaviour, including where the copies disagree, each labelled "today: …". Every later behaviour change then shows up as a test change. +2. **Move:** the code goes into the module unchanged, and the pinning tests stay green. Today's code can't be built natively, so phases 1 and 2 happen together: the seams go in while the tests are written against the moved, unchanged code. +3. **Reconcile:** the copies are made to agree, one decision per commit, each checked on real motors. + +Recommended: yes. + +**Q3. Time is passed in.** Every input carries `now` (`onFrame(frame, now)`, `tick(now)`, ...), as `ShadeSensors::check(…, now)` and `TxHold` already do. The alternative is a clock interface the module asks. Recommended: pass it in. + +**Q4. One behaviour object per shade, settings as plain data.** Each `SomfyShade` holds one module object with the motion state. Its settings come in as a plain struct: shade type, tilt type, up/down/tilt times, step size, `simMy`, `flipCommands`. The alternative is functions with no state of their own, over a state struct the caller owns. Recommended: an object per shade. + +### Round 2 (waits for round 1) + +- **The echo.** Should the module apply its own commands directly, instead of reacting to its own echoed frame? This is the biggest decision; it decides what the radio seam looks like, and whether linked shades still learn about the command from the echo. +- **The radio seam.** What it takes (command, repeats, step size, delay, priority) and returns (ticket or refused). Rolling codes, NVS and frame encoding stay behind it in `SomfyRemote`. GPIO-driven shades sit behind the same seam. +- **Linked remotes and groups.** How a frame from a linked remote and a group command reach the module, and whether `processInternalCommand` stays a separate input. +- **The global `somfy`.** Where `isDirty`/`commit()` go (a change bit the caller acts on?), `updateGroupFlags()` (the controller after the pass?), `transceiver.config` (part of the settings struct?), and `isInGroup()` (given by the caller?). + +### Later rounds + +- The names, which go into GLOSSARY.md (for example **ShadeBehaviour**, **MotorLink**). Check for clashes with `ShadeMotion`, `ShadeOps`, `ShadeRequests` and `ShadeChanges`. +- The order of the steps and the PR split. +- What the reconcile phase decides first: Up/Down/My on tilt-only shades, `settingPos` on arrival. + +## Constraints + +- **[AGENTS.md](../AGENTS.md):** every change keeps `pio test -e native` green and adds or updates tests. +- **Flash:** check every commit. `esp32dev` (release, with OTA) has about 180 KB free. The debug build uses one 3 MB slot once #39 is in its history. Every file that uses ArduinoJson costs flash, so keep the new module free of it. +- **`ShadeMotion`, `ShadeSensors`, `ShadeTxState` and `stepTargets`** are deep and tested. Reuse them; don't re-derive them. +- **Vocabulary:** module, interface, seam, adapter, depth (the `/codebase-design` skill) and the domain terms in GLOSSARY.md. + +## Decisions + +None yet. From fdf1a47ff862357fc51e720aa3669c442890945e Mon Sep 17 00:00:00 2001 From: cjkas Date: Fri, 16 Oct 2026 13:47:56 +0200 Subject: [PATCH 6/7] refactor: split the transports into the modules GLOSSARY.md names Code moves unchanged; only files and names change. - entryPoints/WebUiRoutes.cpp: registerWebUiRoutes(), the port 80 routes and the UI's files, with the helpers only they use. - entryPoints/ApiRoutes.cpp: registerApiRoutes(), the port 8081 routes. - entryPoints/Web.cpp keeps the servers' lifecycle, auth and the handlers both route sets call; entryPoints/WebRoutes.h is what the three share. - entryPoints/MQTT.* -> MqttConnection.*, class MQTTClass -> MqttConnection. - outputs/SomfyMqtt.cpp -> outputs/MqttPublisher.cpp. - outputs/Sockets.* -> outputs/SocketEmitter.*. The subscribe and unsubscribe lists (the latter repeated groups/+/direction/set) become MqttCommands' MQTT_COMMAND_TOPICS, and a native test checks the parser takes every topic in it. GLOSSARY.md gains MqttConnection and Change fan-out. 328 native tests. Flash: esp32devdbg +3,112 bytes (the routes are a second ArduinoJson file), esp32dev -2,336 bytes. Co-Authored-By: Claude Opus 5.5 --- GLOSSARY.md | 7 + docs/KNOWN_ISSUES.md | 4 +- docs/LOOP_QUEUE_PLAN.md | 18 +- src/ESPNetwork.cpp | 6 +- src/GitOTA.cpp | 2 +- .../MQTT.cpp => MqttConnection.cpp} | 75 +- src/{entryPoints/MQTT.h => MqttConnection.h} | 6 +- src/SomfyController.ino | 6 +- src/Transceiver.cpp | 2 +- src/entryPoints/ApiRoutes.cpp | 52 + src/entryPoints/MqttCommands.cpp | 17 + src/entryPoints/MqttCommands.h | 5 + src/entryPoints/Web.cpp | 1016 +---------------- src/entryPoints/WebRoutes.h | 45 + src/entryPoints/WebUiRoutes.cpp | 967 ++++++++++++++++ .../{SomfyMqtt.cpp => MqttPublisher.cpp} | 6 +- .../{Sockets.cpp => SocketEmitter.cpp} | 2 +- src/outputs/{Sockets.h => SocketEmitter.h} | 0 src/outputs/SomfyEvents.cpp | 6 +- test/test_mqttcommands/test_mqttcommands.cpp | 10 + 20 files changed, 1186 insertions(+), 1066 deletions(-) rename src/{entryPoints/MQTT.cpp => MqttConnection.cpp} (70%) rename src/{entryPoints/MQTT.h => MqttConnection.h} (93%) create mode 100644 src/entryPoints/ApiRoutes.cpp create mode 100644 src/entryPoints/WebRoutes.h create mode 100644 src/entryPoints/WebUiRoutes.cpp rename src/outputs/{SomfyMqtt.cpp => MqttPublisher.cpp} (99%) rename src/outputs/{Sockets.cpp => SocketEmitter.cpp} (99%) rename src/outputs/{Sockets.h => SocketEmitter.h} (100%) diff --git a/GLOSSARY.md b/GLOSSARY.md index d82189f..1a82b28 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -86,6 +86,13 @@ _Avoid_: MQTTController 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 +**MqttConnection** +The link to the MQTT broker that MqttCommands and MqttPublisher share: connecting, the root topic and the subscriptions. +_Avoid_: MQTTClass + +**Change fan-out** +What sends everything that changed about a shade or group in one pass of `loop()` once: its changed MQTT topics, then one state event. + ## Domain modules **Domain module** diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index abb19f4..db0e42a 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -49,7 +49,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/outputs/Sockets.cpp](../src/outputs/Sockets.cpp). +**Location:** [`JsonSockEvent` in src/WResp.cpp](../src/WResp.cpp), buffer `g_response[MAX_SOCK_RESPONSE]` (2048 bytes) in [src/outputs/SocketEmitter.cpp](../src/outputs/SocketEmitter.cpp). ### Summary @@ -83,7 +83,7 @@ Unlike the `/controller` HTTP crash, this path does **not** abort — there is n 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. +Every socket event is formatted into the one static buffer `g_response` in [src/outputs/SocketEmitter.cpp](../src/outputs/SocketEmitter.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. diff --git a/docs/LOOP_QUEUE_PLAN.md b/docs/LOOP_QUEUE_PLAN.md index 8ae17c2..c3518a4 100644 --- a/docs/LOOP_QUEUE_PLAN.md +++ b/docs/LOOP_QUEUE_PLAN.md @@ -1,6 +1,6 @@ # Plan: domain modules behind the LoopQueue -**Status:** steps 0–4 built (PRs #39 to #43); step 5 planned. +**Status:** all steps built (PRs #39 to #44). **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 @@ -168,6 +168,22 @@ Flash: `esp32devdbg` 1,547,821 → 1,536,337 bytes, `esp32dev` 1,400,401 → 1,3 8 new native tests, 327 in all. Flash: `esp32devdbg` 1,536,337 → 1,536,533 bytes, `esp32dev` 1,393,221 → 1,393,449 bytes. +### Step 5: the file split +Code moved unchanged; only files and names changed. + +| File | Holds | +|---|---| +| `entryPoints/WebUiRoutes.cpp` | `registerWebUiRoutes()`: the port 80 routes and the UI's files, with the helpers only they use | +| `entryPoints/ApiRoutes.cpp` | `registerApiRoutes()`: the port 8081 routes | +| `entryPoints/Web.cpp` | the web servers' lifecycle, auth, and the handlers both route sets call | +| `entryPoints/WebRoutes.h` | what those three share | +| `entryPoints/MqttCommands.*` | + `MQTT_COMMAND_TOPICS`, the topics the connection subscribes to | +| `MqttConnection.*` | was `entryPoints/MQTT.*`; class `MQTTClass` → `MqttConnection` | +| `outputs/MqttPublisher.cpp` | was `outputs/SomfyMqtt.cpp` | +| `outputs/SocketEmitter.*` | was `outputs/Sockets.*` | + +The subscribe and unsubscribe lists, which repeated `groups/+/direction/set`, became the one `MQTT_COMMAND_TOPICS` list, and a native test checks that the parser takes every topic in it. Flash: `esp32devdbg` 1,536,533 → 1,539,645 bytes (+3.1 KB, since the routes are a second file that uses ArduinoJson, as SOMFY_SPLIT_PLAN.md warned), `esp32dev` 1,393,449 → 1,391,113 bytes (−2.3 KB, where `-Os` inlines less). + ### 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`. diff --git a/src/ESPNetwork.cpp b/src/ESPNetwork.cpp index 9bad48e..9925420 100644 --- a/src/ESPNetwork.cpp +++ b/src/ESPNetwork.cpp @@ -14,17 +14,17 @@ static inline void safe_wdt_reset() { #include "ConfigSettings.h" #include "ESPNetwork.h" #include "entryPoints/Web.h" -#include "outputs/Sockets.h" +#include "outputs/SocketEmitter.h" #include "Utils.h" #include "SSDP.h" -#include "entryPoints/MQTT.h" +#include "MqttConnection.h" static const char *TAG = "Network"; extern ConfigSettings settings; extern Web webServer; extern SocketEmitter sockEmit; -extern MQTTClass mqtt; +extern MqttConnection mqtt; extern rebootDelay_t rebootDelay; extern ESPNetwork net; extern SomfyShadeController somfy; diff --git a/src/GitOTA.cpp b/src/GitOTA.cpp index e0913e0..9b13d9c 100644 --- a/src/GitOTA.cpp +++ b/src/GitOTA.cpp @@ -8,7 +8,7 @@ #include "ConfigSettings.h" #include "GitOTA.h" #include "Utils.h" -#include "outputs/Sockets.h" +#include "outputs/SocketEmitter.h" #include "Somfy.h" #include "entryPoints/Web.h" #include "WResp.h" diff --git a/src/entryPoints/MQTT.cpp b/src/MqttConnection.cpp similarity index 70% rename from src/entryPoints/MQTT.cpp rename to src/MqttConnection.cpp index 6cbaf79..cb34f6b 100644 --- a/src/entryPoints/MQTT.cpp +++ b/src/MqttConnection.cpp @@ -4,8 +4,8 @@ #include #include "esp_log.h" #include "ConfigSettings.h" -#include "MQTT.h" -#include "MqttCommands.h" +#include "MqttConnection.h" +#include "entryPoints/MqttCommands.h" #include "Somfy.h" #include "LoopQueue.h" #include "ESPNetwork.h" @@ -26,21 +26,21 @@ extern rebootDelay_t rebootDelay; extern LoopQueue loopQueue; -bool MQTTClass::begin() { +bool MqttConnection::begin() { this->suspended = false; return true; } -bool MQTTClass::end() { +bool MqttConnection::end() { this->suspended = true; this->disconnect(); return true; } -void MQTTClass::reset() { +void MqttConnection::reset() { this->disconnect(); this->lastConnect = 0; this->connect(); } -bool MQTTClass::loop() { +bool MqttConnection::loop() { if(settings.MQTT.enabled && !rebootDelay.reboot && !this->suspended && !mqttClient.connected()) { esp_task_wdt_reset(); if(!this->connected() && net.connected()) this->connect(); @@ -49,7 +49,7 @@ bool MQTTClass::loop() { if(settings.MQTT.enabled) mqttClient.loop(); return true; } -void MQTTClass::receive(const char *topic, byte*payload, uint32_t length) { +void MqttConnection::receive(const char *topic, byte*payload, uint32_t length) { esp_task_wdt_reset(); // Make sure we do not reboot here. ESP_LOGD(TAG, "MQTT Topic:%s payload:%.*s", topic, (int)length, (const char *)payload); // This already runs on loop(), but going through the loop queue keeps MQTT @@ -61,7 +61,7 @@ void MQTTClass::receive(const char *topic, byte*payload, uint32_t length) { } loopQueue.submit(item); } -bool MQTTClass::connect() { +bool MqttConnection::connect() { esp_task_wdt_reset(); // Make sure we do not reboot here. if(mqttClient.connected()) { if(!settings.MQTT.enabled || this->suspended) @@ -88,21 +88,8 @@ bool MQTTClass::connect() { this->publish("serverId", settings.serverId, true); this->publish("mac", net.mac.c_str()); somfy.publish(); - this->subscribe("shades/+/target/set"); - this->subscribe("shades/+/tiltTarget/set"); - this->subscribe("shades/+/direction/set"); - this->subscribe("shades/+/mypos/set"); - this->subscribe("shades/+/myTiltPos/set"); - this->subscribe("shades/+/sunFlag/set"); - this->subscribe("shades/+/sunny/set"); - this->subscribe("shades/+/windy/set"); - this->subscribe("shades/+/position/set"); - this->subscribe("shades/+/tiltPosition/set"); - this->subscribe("groups/+/direction/set"); - this->subscribe("groups/+/sunFlag/set"); - this->subscribe("groups/+/sunny/set"); - this->subscribe("groups/+/windy/set"); - mqttClient.setCallback(MQTTClass::receive); + for(const char *topic : MQTT_COMMAND_TOPICS) this->subscribe(topic); + mqttClient.setCallback(MqttConnection::receive); ESP_LOGI(TAG, "MQTT Startup Completed"); esp_task_wdt_reset(); this->lastConnect = millis(); @@ -119,28 +106,14 @@ bool MQTTClass::connect() { } return true; } -bool MQTTClass::disconnect() { +bool MqttConnection::disconnect() { if(mqttClient.connected()) { - this->unsubscribe("shades/+/target/set"); - this->unsubscribe("shades/+/direction/set"); - this->unsubscribe("shades/+/tiltTarget/set"); - this->unsubscribe("shades/+/mypos/set"); - this->unsubscribe("shades/+/myTiltPos/set"); - this->unsubscribe("shades/+/sunFlag/set"); - this->unsubscribe("groups/+/direction/set"); - this->unsubscribe("shades/+/sunny/set"); - this->unsubscribe("shades/+/windy/set"); - this->unsubscribe("shades/+/position/set"); - this->unsubscribe("shades/+/tiltPosition/set"); - this->unsubscribe("groups/+/direction/set"); - this->unsubscribe("groups/+/sunFlag/set"); - this->unsubscribe("groups/+/sunny/set"); - this->unsubscribe("groups/+/windy/set"); + for(const char *topic : MQTT_COMMAND_TOPICS) this->unsubscribe(topic); mqttClient.disconnect(); } return true; } -bool MQTTClass::unsubscribe(const char *topic) { +bool MqttConnection::unsubscribe(const char *topic) { if(mqttClient.connected()) { char top[128]; if(strlen(settings.MQTT.rootTopic) > 0) @@ -152,7 +125,7 @@ bool MQTTClass::unsubscribe(const char *topic) { } return true; } -bool MQTTClass::subscribe(const char *topic) { +bool MqttConnection::subscribe(const char *topic) { if(mqttClient.connected()) { esp_task_wdt_reset(); // Make sure we do not reboot here. char top[128]; @@ -165,7 +138,7 @@ bool MQTTClass::subscribe(const char *topic) { } return true; } -bool MQTTClass::publish(const char *topic, const char *payload, bool retain) { +bool MqttConnection::publish(const char *topic, const char *payload, bool retain) { if(mqttClient.connected()) { char top[128]; if(strlen(settings.MQTT.rootTopic) > 0) @@ -178,11 +151,11 @@ bool MQTTClass::publish(const char *topic, const char *payload, bool retain) { } return false; } -bool MQTTClass::publish(const char *topic, uint32_t val, bool retain) { +bool MqttConnection::publish(const char *topic, uint32_t val, bool retain) { snprintf(g_content, sizeof(g_content), "%u", val); return this->publish(topic, g_content, retain); } -bool MQTTClass::unpublish(const char *topic) { +bool MqttConnection::unpublish(const char *topic) { if(mqttClient.connected()) { char top[128]; if(strlen(settings.MQTT.rootTopic) > 0) @@ -199,7 +172,7 @@ bool MQTTClass::unpublish(const char *topic) { // mqttClient.endPublish(); } -bool MQTTClass::publishBuffer(const char *topic, uint8_t *data, uint16_t len, bool retain) { +bool MqttConnection::publishBuffer(const char *topic, uint8_t *data, uint16_t len, bool retain) { size_t res; uint16_t offset = 0; uint16_t to_write = len; @@ -216,28 +189,28 @@ bool MQTTClass::publishBuffer(const char *topic, uint8_t *data, uint16_t len, bo mqttClient.endPublish(); return true; } -bool MQTTClass::publishDisco(const char *topic, JsonObject &obj, bool retain) { +bool MqttConnection::publishDisco(const char *topic, JsonObject &obj, bool retain) { serializeJson(obj, g_content, sizeof(g_content)); this->publishBuffer(topic, (uint8_t *)g_content, strlen(g_content), retain); return true; } -bool MQTTClass::publish(const char *topic, int8_t val, bool retain) { +bool MqttConnection::publish(const char *topic, int8_t val, bool retain) { snprintf(g_content, sizeof(g_content), "%d", val); return this->publish(topic, g_content, retain); } -bool MQTTClass::publish(const char *topic, uint8_t val, bool retain) { +bool MqttConnection::publish(const char *topic, uint8_t val, bool retain) { snprintf(g_content, sizeof(g_content), "%u", val); return this->publish(topic, g_content, retain); } -bool MQTTClass::publish(const char *topic, uint16_t val, bool retain) { +bool MqttConnection::publish(const char *topic, uint16_t val, bool retain) { snprintf(g_content, sizeof(g_content), "%u", val); return this->publish(topic, g_content, retain); } -bool MQTTClass::publish(const char *topic, bool val, bool retain) { +bool MqttConnection::publish(const char *topic, bool val, bool retain) { snprintf(g_content, sizeof(g_content), "%s", val ? "true" : "false"); return this->publish(topic, g_content, retain); } -bool MQTTClass::connected() { +bool MqttConnection::connected() { if(settings.MQTT.enabled) return mqttClient.connected(); return false; } diff --git a/src/entryPoints/MQTT.h b/src/MqttConnection.h similarity index 93% rename from src/entryPoints/MQTT.h rename to src/MqttConnection.h index fbc4839..4b27420 100644 --- a/src/entryPoints/MQTT.h +++ b/src/MqttConnection.h @@ -1,9 +1,9 @@ -#ifndef MQTT_H -#define MQTT_H +#ifndef MQTT_CONNECTION_H +#define MQTT_CONNECTION_H #include #include #include -class MQTTClass { +class MqttConnection { public: uint64_t lastConnect = 0; bool suspended = false; diff --git a/src/SomfyController.ino b/src/SomfyController.ino index cb2fdf9..718d602 100644 --- a/src/SomfyController.ino +++ b/src/SomfyController.ino @@ -5,10 +5,10 @@ #include "ConfigSettings.h" #include "ESPNetwork.h" #include "entryPoints/Web.h" -#include "outputs/Sockets.h" +#include "outputs/SocketEmitter.h" #include "Utils.h" #include "Somfy.h" -#include "entryPoints/MQTT.h" +#include "MqttConnection.h" #include "LoopQueue.h" #include "ShadeRequests.h" #include "SomfyShadeOps.h" @@ -35,7 +35,7 @@ SocketEmitter sockEmit; ESPNetwork net; rebootDelay_t rebootDelay; SomfyShadeController somfy; -MQTTClass mqtt; +MqttConnection mqtt; GitUpdater git; SomfyShadeOps shadeOps; ShadeRequests shadeRequests(shadeOps); diff --git a/src/Transceiver.cpp b/src/Transceiver.cpp index 9179a91..a01bdd6 100644 --- a/src/Transceiver.cpp +++ b/src/Transceiver.cpp @@ -10,7 +10,7 @@ #include "Utils.h" #include "ConfigSettings.h" #include "Somfy.h" -#include "outputs/Sockets.h" +#include "outputs/SocketEmitter.h" static const char *TAG = "Somfy"; diff --git a/src/entryPoints/ApiRoutes.cpp b/src/entryPoints/ApiRoutes.cpp new file mode 100644 index 0000000..3350ea0 --- /dev/null +++ b/src/entryPoints/ApiRoutes.cpp @@ -0,0 +1,52 @@ +#include +#include +#include "Web.h" +#include "WebRoutes.h" + +// ApiRoutes: the routes integrations such as Home Assistant call on port 8081. +// The handlers are the ones the config web UI calls too (Web.cpp). + +extern Web webServer; + +void registerApiRoutes(AsyncWebServer &server) { + // GET endpoints + server.on("/discovery", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleDiscovery(r); }); + server.on("/rooms", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetRooms(r); }); + server.on("/shades", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetShades(r); }); + server.on("/groups", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetGroups(r); }); + server.on("/controller", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleController(r); }); + server.on("/room", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleRoom(r); }); + server.on("/shade", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleShade(r); }); + server.on("/group", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleGroup(r); }); + server.on("/downloadFirmware", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleDownloadFirmware(r); }); + server.on("/backup", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleBackup(r); }); + server.on("/reboot", WebRequestMethodComposite(HTTP_POST) | HTTP_PUT, [](AsyncWebServerRequest *r) { webServer.handleReboot(r); }); + // JSON body endpoints + server.addHandler(new AsyncCallbackJsonWebHandler("/shadeCommand", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleShadeCommand(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/groupCommand", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleGroupCommand(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/tiltCommand", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleTiltCommand(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/repeatCommand", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleRepeatCommand(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/setPositions", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleSetPositions(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/setSensor", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleSetSensor(r, j); })); + server.addHandler(new AsyncCallbackJsonWebHandler("/login", + [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleLogin(r, j); })); + // GET fallback for command endpoints (query params) + server.on("/shadeCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleShadeCommand(r, v); }); + server.on("/groupCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleGroupCommand(r, v); }); + server.on("/tiltCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleTiltCommand(r, v); }); + server.on("/repeatCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleRepeatCommand(r, v); }); + server.on("/setPositions", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleSetPositions(r, v); }); + server.on("/setSensor", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleSetSensor(r, v); }); + server.on("/login", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleLogin(r, v); }); + // OPTIONS preflight + not found + server.onNotFound([](AsyncWebServerRequest *r) { + if(r->method() == HTTP_OPTIONS) { r->send(200); return; } + webServer.handleNotFound(r); + }); +} diff --git a/src/entryPoints/MqttCommands.cpp b/src/entryPoints/MqttCommands.cpp index 89d0a1b..528d8ce 100644 --- a/src/entryPoints/MqttCommands.cpp +++ b/src/entryPoints/MqttCommands.cpp @@ -2,6 +2,23 @@ #include #include "MqttCommands.h" +const char *const MQTT_COMMAND_TOPICS[MQTT_COMMAND_TOPIC_COUNT] = { + "shades/+/target/set", + "shades/+/tiltTarget/set", + "shades/+/direction/set", + "shades/+/mypos/set", + "shades/+/myTiltPos/set", + "shades/+/sunFlag/set", + "shades/+/sunny/set", + "shades/+/windy/set", + "shades/+/position/set", + "shades/+/tiltPosition/set", + "groups/+/direction/set", + "groups/+/sunFlag/set", + "groups/+/sunny/set", + "groups/+/windy/set" +}; + namespace { struct segment_t { const char *s = ""; diff --git a/src/entryPoints/MqttCommands.h b/src/entryPoints/MqttCommands.h index 1d1df1b..e4797f3 100644 --- a/src/entryPoints/MqttCommands.h +++ b/src/entryPoints/MqttCommands.h @@ -11,4 +11,9 @@ // Turns a message on a command topic into a shade request. False for a topic // or value it doesn't take, which is dropped. bool parseMqttCommand(const char *topic, const uint8_t *payload, size_t length, shade_request_t &req); + +// The command topics under the root topic, + standing for any id. The MQTT +// connection subscribes to these. +#define MQTT_COMMAND_TOPIC_COUNT 14 +extern const char *const MQTT_COMMAND_TOPICS[MQTT_COMMAND_TOPIC_COUNT]; #endif diff --git a/src/entryPoints/Web.cpp b/src/entryPoints/Web.cpp index 62d6621..14b72d3 100644 --- a/src/entryPoints/Web.cpp +++ b/src/entryPoints/Web.cpp @@ -15,7 +15,8 @@ #include "SomfySnapshot.h" #include "SomfyEditor.h" #include "Web.h" -#include "MQTT.h" +#include "WebRoutes.h" +#include "MqttConnection.h" #include "GitOTA.h" #include "ESPNetwork.h" #include "Timing.h" @@ -29,15 +30,13 @@ extern SSDPClass SSDP; extern rebootDelay_t rebootDelay; extern SomfyShadeController somfy; extern Web webServer; -extern MQTTClass mqtt; +extern MqttConnection mqtt; extern GitUpdater git; extern ESPNetwork net; extern LoopQueue loopQueue; extern SnapshotSlots snapshotSlots; -//#define WEB_MAX_RESPONSE 34768 -#define WEB_MAX_RESPONSE 4096 -static char g_async_content[WEB_MAX_RESPONSE]; +char g_async_content[WEB_MAX_RESPONSE]; // General responses @@ -45,75 +44,31 @@ static const char _response_404[] = "404: Service Not Found"; // Encodings -static const char _encoding_text[] = "text/plain"; -static const char _encoding_html[] = "text/html"; -static const char _encoding_json[] = "application/json"; +const char _encoding_text[] = "text/plain"; +const char _encoding_json[] = "application/json"; static const char *TAG = "Web"; -static void sendQueueFull(AsyncWebServerRequest *request); -static void sendRadioUnavailable(AsyncWebServerRequest *request); // Hands an item to loop(), which owns the shades. When it can't be the // request is answered with the reason and false is returned. -static bool queueLoopItem(AsyncWebServerRequest *request, const loop_item_t &item) { +bool queueLoopItem(AsyncWebServerRequest *request, const loop_item_t &item) { // Nothing can go on air during a frequency scan so tell the client now rather // than dropping the request once it reaches the radio. if(loopItemUsesRadio(item) && somfy.transceiver.txSuspended()) { sendRadioUnavailable(request); return false; } if(!loopQueue.submit(item)) { sendQueueFull(request); return false; } return true; } -static bool queueShadeRequest(AsyncWebServerRequest *request, const shade_request_t &req) { +bool queueShadeRequest(AsyncWebServerRequest *request, const shade_request_t &req) { loop_item_t item; item.request = req; return queueLoopItem(request, item); } -static bool queueSetMyPosition(AsyncWebServerRequest *request, SomfyShade *shade, int8_t pos, int8_t tilt) { - shade_request_t req; - req.type = shade_request_type_t::set_favorite; - req.id = shade->getShadeId(); - req.position = pos; - req.tiltPosition = tilt; - return queueShadeRequest(request, req); -} -// loop() clears paired and saves it once the Prog is queued. The shade -// belongs to loop() so it isn't changed here. -static bool queueUnpairShade(AsyncWebServerRequest *request, SomfyShade *shade) { - shade_request_t req; - req.type = shade_request_type_t::unpair; - req.id = shade->getShadeId(); - return queueShadeRequest(request, req); -} -// Sending a command reads and commits the rolling code on loop(), so a new -// code is set there too. -static bool queueSetRollingCode(AsyncWebServerRequest *request, SomfyShade *shade, uint16_t rollingCode) { - shade_request_t req; - req.type = shade_request_type_t::set_rolling_code; - req.id = shade->getShadeId(); - req.rollingCode = rollingCode; - return queueShadeRequest(request, req); -} -// A restore's options travel through the loop queue as RESTORE_* bits. -static uint8_t restoreParts(const restore_options_t &opts) { - return (opts.settings ? RESTORE_SETTINGS : 0) | (opts.shades ? RESTORE_SHADES : 0) | - (opts.network ? RESTORE_NETWORK : 0) | (opts.transceiver ? RESTORE_TRANSCEIVER : 0) | - (opts.repeaters ? RESTORE_REPEATERS : 0) | (opts.mqtt ? RESTORE_MQTT : 0); -} -// The radio and MQTT are shut down once an upload starts and only a reboot -// brings them back. The completion handler schedules it but never runs if the -// client goes away mid-upload, so make sure the device reboots then too. -static void onUploadDisconnect() { - if(webServer.uploadSuccess || rebootDelay.reboot) return; - ESP_LOGW(TAG, "Upload abandoned, rebooting"); - if(Update.isRunning()) Update.abort(); - rebootDelay.reboot = true; - rebootDelay.rebootTime = millis() + 500; -} // Sent when a command can't be handed to the main loop. -static void sendQueueFull(AsyncWebServerRequest *request) { +void sendQueueFull(AsyncWebServerRequest *request) { request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Command queue full\"}")); } // Sent when the radio can't take a frame (suspended or queue full). -static void sendRadioUnavailable(AsyncWebServerRequest *request) { +void sendRadioUnavailable(AsyncWebServerRequest *request) { request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Radio TX unavailable\"}")); } @@ -121,19 +76,6 @@ AsyncWebServer asyncServer(80); AsyncWebServer asyncApiServer(8081); void Web::startup() { ESP_LOGI(TAG, "Launching web server..."); - - asyncServer.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) { - AsyncJsonResponse *response = new AsyncJsonResponse(); - JsonObject root = response->getRoot().to(); - root["type"] = static_cast(settings.Security.type); - root["permissions"] = settings.Security.permissions; - root["serverId"] = settings.serverId; - root["version"] = settings.fwVersion.name; - root["model"] = "ESPSomfyRTS"; - root["hostname"] = settings.hostname; - response->setLength(); - request->send(response); - }); asyncApiServer.begin(); ESP_LOGI(TAG, "Async API server started on port 8081"); } @@ -196,11 +138,11 @@ bool Web::createAPIToken(const IPAddress ipAddress, char *token) { // Async API Handlers // ===================================================== // Helper: get a query param as String, or empty if missing -static String asyncParam(AsyncWebServerRequest *request, const char *name) { +String asyncParam(AsyncWebServerRequest *request, const char *name) { if(request->hasParam(name)) return request->getParam(name)->value(); return String(); } -static bool asyncHasParam(AsyncWebServerRequest *request, const char *name) { +bool asyncHasParam(AsyncWebServerRequest *request, const char *name) { return request->hasParam(name); } @@ -209,7 +151,7 @@ static bool asyncHasParam(AsyncWebServerRequest *request, const char *name) { // replies) copy one item into the ModelSnapshot types and write it the way the // read routes do. The read routes themselves write from a ModelSnapshot that // loop() fills (sendSnapshot). -static void serializeShadeRef(SomfyShade *shade, JsonFormatter &json) { +void serializeShadeRef(SomfyShade *shade, JsonFormatter &json) { shade_snapshot_t s; snapshotShade(*shade, s); writeShadeRef(json, s); @@ -217,14 +159,14 @@ static void serializeShadeRef(SomfyShade *shade, JsonFormatter &json) { // paired and lastRollingCode are passed in for a response that reports the // change the main loop is about to make. An unpair is held until it goes on // air (holdLoopItem), so the answer comes true. -static void serializeShade(SomfyShade *shade, JsonFormatter &json, bool paired, uint16_t lastRollingCode) { +void serializeShade(SomfyShade *shade, JsonFormatter &json, bool paired, uint16_t lastRollingCode) { shade_snapshot_t s; snapshotShade(*shade, s); s.paired = paired; s.lastRollingCode = lastRollingCode; writeShade(json, s); } -static void serializeShade(SomfyShade *shade, JsonFormatter &json) { serializeShade(shade, json, shade->paired, shade->lastRollingCode); } +void serializeShade(SomfyShade *shade, JsonFormatter &json) { serializeShade(shade, json, shade->paired, shade->lastRollingCode); } static void serializeGroupRef(SomfyGroup *group, JsonFormatter &json) { group_snapshot_t s; snapshotGroup(*group, s); @@ -236,7 +178,7 @@ static void serializeGroup(SomfyGroup *group, JsonFormatter &json) { shade_snapshot_t linked; writeGroup(json, s, liveShade, &linked); } -static void serializeTransceiverConfig(JsonFormatter &json) { +void serializeTransceiverConfig(JsonFormatter &json) { auto &cfg = somfy.transceiver.config; json.addElem("type", cfg.type); json.addElem("TXPin", cfg.TXPin); @@ -254,14 +196,14 @@ static void serializeTransceiverConfig(JsonFormatter &json) { json.addElem("noiseDetection", cfg.noiseDetection); json.addElem("radioInit", cfg.radioInit); } -static void serializeAppVersion(JsonFormatter &json, appver_t &ver) { +void serializeAppVersion(JsonFormatter &json, appver_t &ver) { json.addElem("name", ver.name); json.addElem("major", ver.major); json.addElem("minor", ver.minor); json.addElem("build", ver.build); json.addElem("suffix", ver.suffix); } -static void serializeGitVersion(JsonFormatter &json) { +void serializeGitVersion(JsonFormatter &json) { json.addElem("available", git.updateAvailable); json.addElem("status", git.status); json.addElem("error", (int32_t)git.error); @@ -278,7 +220,7 @@ static void serializeGitVersion(JsonFormatter &json) { serializeAppVersion(json, git.latest); json.endObject(); } -static void serializeGitRelease(GitRelease *rel, JsonFormatter &json) { +void serializeGitRelease(GitRelease *rel, JsonFormatter &json) { Timestamp ts; char buff[20]; sprintf(buff, "%llu", rel->id); @@ -350,7 +292,7 @@ struct SnapshotRead { // The response is gone, whether it finished or the client went away. ~SnapshotRead() { snapshotSlots.release(this->slot); } }; -static void sendSnapshot(AsyncWebServerRequest *request, snapshot_doc_t doc, uint8_t id = 0, snapshot_header_t header = nullptr) { +void sendSnapshot(AsyncWebServerRequest *request, snapshot_doc_t doc, uint8_t id, snapshot_header_t header) { const int8_t slot = snapshotSlots.acquire(); if(slot < 0) { request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Busy, try the read again\"}")); @@ -376,58 +318,6 @@ static void sendSnapshot(AsyncWebServerRequest *request, snapshot_doc_t doc, uin request->send(response); } -// Hands an edit of the shade model to loop() (docs/adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md). -// The reply only says it was accepted; the result reaches every client as a -// requestResult event carrying the same requestId once loop() has applied it. -static void queueModelEdit(AsyncWebServerRequest *request, model_edit_type_t type, JsonVariantConst body) { - if(!beginModelEdit()) { - request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Busy, try the change again\"}")); - return; - } - model_edit_t *edit = new (std::nothrow) model_edit_t(); - if(!edit) { - cancelModelEdit(nullptr); - sendQueueFull(request); - return; - } - edit->type = type; - edit->requestId = nextEditRequestId(); - edit->doc.set(body); - const uint32_t requestId = edit->requestId; - loop_item_t item; - item.type = loop_item_type_t::model_edit; - item.edit = edit; - if(edit->doc.overflowed() || !loopQueue.submit(item)) { - cancelModelEdit(edit); - sendQueueFull(request); - return; - } - char reply[64]; - snprintf(reply, sizeof(reply), "{\"status\":\"accepted\",\"requestId\":%lu}", (unsigned long)requestId); - request->send(200, _encoding_json, reply); -} -// The JSON (PUT/POST) form of an edit route. Bodies over 4 KB are refused with -// 413 before they reach the heap. -static AsyncCallbackJsonWebHandler *editHandler(const char *uri, model_edit_type_t type) { - auto *handler = new AsyncCallbackJsonWebHandler(uri, [type](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No object supplied.\"}")); return; } - queueModelEdit(request, type, json); - }); - handler->setMaxContentLength(4096); - return handler; -} -// The GET form of an edit route: its query parameters become the request. -static void queueModelEditFromParams(AsyncWebServerRequest *request, model_edit_type_t type, std::initializer_list names) { - JsonDocument body; - for(const char *name : names) { - if(!asyncHasParam(request, name)) continue; - const String value = asyncParam(request, name); - if(strcmp(name, "paired") == 0) body[name] = toBoolean(value.c_str(), false); - else body[name] = strtoul(value.c_str(), nullptr, 10); - } - queueModelEdit(request, type, body.as()); -} - // -- Async handler implementations -- void Web::handleDiscovery(AsyncWebServerRequest *request) { if(request->method() == HTTP_POST || request->method() == HTTP_GET) { @@ -907,869 +797,7 @@ void Web::begin() { DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*"); DefaultHeaders::Instance().addHeader("Access-Control-Allow-Methods", "PUT,POST,GET,OPTIONS"); DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "*"); - // GET endpoints - asyncApiServer.on("/discovery", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleDiscovery(r); }); - asyncApiServer.on("/rooms", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetRooms(r); }); - asyncApiServer.on("/shades", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetShades(r); }); - asyncApiServer.on("/groups", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleGetGroups(r); }); - asyncApiServer.on("/controller", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleController(r); }); - asyncApiServer.on("/room", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleRoom(r); }); - asyncApiServer.on("/shade", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleShade(r); }); - asyncApiServer.on("/group", HTTP_GET, [](AsyncWebServerRequest *r) { webServer.handleGroup(r); }); - asyncApiServer.on("/downloadFirmware", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleDownloadFirmware(r); }); - asyncApiServer.on("/backup", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *r) { webServer.handleBackup(r); }); - asyncApiServer.on("/reboot", WebRequestMethodComposite(HTTP_POST) | HTTP_PUT, [](AsyncWebServerRequest *r) { webServer.handleReboot(r); }); - // JSON body endpoints - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/shadeCommand", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleShadeCommand(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/groupCommand", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleGroupCommand(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/tiltCommand", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleTiltCommand(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/repeatCommand", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleRepeatCommand(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/setPositions", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleSetPositions(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/setSensor", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleSetSensor(r, j); })); - asyncApiServer.addHandler(new AsyncCallbackJsonWebHandler("/login", - [](AsyncWebServerRequest *r, JsonVariant &j) { webServer.handleLogin(r, j); })); - // GET fallback for command endpoints (query params) - asyncApiServer.on("/shadeCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleShadeCommand(r, v); }); - asyncApiServer.on("/groupCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleGroupCommand(r, v); }); - asyncApiServer.on("/tiltCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleTiltCommand(r, v); }); - asyncApiServer.on("/repeatCommand", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleRepeatCommand(r, v); }); - asyncApiServer.on("/setPositions", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleSetPositions(r, v); }); - asyncApiServer.on("/setSensor", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleSetSensor(r, v); }); - asyncApiServer.on("/login", HTTP_GET, [](AsyncWebServerRequest *r) { JsonVariant v; webServer.handleLogin(r, v); }); - // OPTIONS preflight + not found - asyncApiServer.onNotFound([](AsyncWebServerRequest *r) { - if(r->method() == HTTP_OPTIONS) { r->send(200); return; } - webServer.handleNotFound(r); - }); - - // Web Interface - - // Web Interface - // Command endpoints - delegate to async handler methods - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/tiltCommand", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleTiltCommand(request, json); })); - asyncServer.on("/tiltCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleTiltCommand(request, v); }); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/repeatCommand", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleRepeatCommand(request, json); })); - asyncServer.on("/repeatCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleRepeatCommand(request, v); }); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/shadeCommand", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleShadeCommand(request, json); })); - asyncServer.on("/shadeCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleShadeCommand(request, v); }); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/groupCommand", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleGroupCommand(request, json); })); - asyncServer.on("/groupCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleGroupCommand(request, v); }); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setPositions", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleSetPositions(request, json); })); - asyncServer.on("/setPositions", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleSetPositions(request, v); }); - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setSensor", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleSetSensor(request, json); })); - asyncServer.on("/setSensor", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleSetSensor(request, v); }); - - asyncServer.on("/upnp.xml", HTTP_GET, [](AsyncWebServerRequest *request) { - AsyncResponseStream *response = request->beginResponseStream("text/xml"); - SSDP.schema(*response); - request->send(response); - }); - - // / and /loginContext are already handled by serveStatic and startup() - - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/login", - [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleLogin(request, json); })); - asyncServer.on("/login", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleLogin(request, v); }); - - asyncServer.on("/shades.cfg", HTTP_GET, [](AsyncWebServerRequest *request) { - request->send(LittleFS, "/shades.cfg", _encoding_text); - }); - asyncServer.on("/shades.tmp", HTTP_GET, [](AsyncWebServerRequest *request) { - request->send(LittleFS, "/shades.tmp", _encoding_text); - }); - - asyncServer.on("/getReleases", HTTP_GET, [](AsyncWebServerRequest *request) { - GitRepo repo; - repo.getReleases(); - git.setCurrentRelease(repo); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - // Inline GitRepo::toJSON - resp.beginObject("fwVersion"); - serializeAppVersion(resp, settings.fwVersion); - resp.endObject(); - resp.beginObject("appVersion"); - serializeAppVersion(resp, settings.appVersion); - resp.endObject(); - resp.beginArray("releases"); - for(uint8_t i = 0; i < GIT_MAX_RELEASES + 1; i++) { - if(repo.releases[i].id == 0) continue; - resp.beginObject(); - serializeGitRelease(&repo.releases[i], resp); - resp.endObject(); - } - resp.endArray(); - resp.endObject(); - resp.endResponse(); - }); - - asyncServer.on("/downloadFirmware", HTTP_GET, [](AsyncWebServerRequest *request) { webServer.handleDownloadFirmware(request); }); - - asyncServer.on("/cancelFirmware", HTTP_GET, [](AsyncWebServerRequest *request) { - if(!git.lockFS) { - git.status = GIT_UPDATE_CANCELLING; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeGitVersion(resp); - resp.endObject(); - resp.endResponse(); - git.cancelled = true; - } - else { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Cannot cancel during filesystem update.\"}")); - } - }); - - asyncServer.on("/backup", HTTP_GET, [](AsyncWebServerRequest *request) { webServer.handleBackup(request); }); - - asyncServer.on("/restore", HTTP_POST, - [](AsyncWebServerRequest *request) { - if(webServer.uploadSuccess) { - restore_options_t opts; - if(asyncHasParam(request, "data")) { - String dataStr = asyncParam(request, "data"); - ESP_LOGD(TAG, "%s", dataStr.c_str()); - JsonDocument doc; - DeserializationError err = deserializeJson(doc, dataStr); - if(err) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - else { - JsonObject obj = doc.as(); - opts.fromJSON(obj); - } - } - else { - ESP_LOGD(TAG, "No restore options sent. Using defaults..."); - opts.shades = true; - } - // The main loop restores, since it owns the shades and their rolling - // codes, and then reboots. - loop_item_t item; - item.type = loop_item_type_t::restore_shades; - item.restoreParts = restoreParts(opts); - if(!queueLoopItem(request, item)) return; - request->send(200, _encoding_json, "{\"status\":\"Success\",\"desc\":\"Restoring Shade settings\"}"); - } - }, - [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { - esp_task_wdt_reset(); - if(index == 0) { - webServer.uploadSuccess = false; - ESP_LOGD(TAG, "Restore: %s", filename.c_str()); - File fup = LittleFS.open("/shades.tmp", "w"); - fup.close(); - } - if(len > 0) { - File fup = LittleFS.open("/shades.tmp", "a"); - fup.write(data, len); - fup.close(); - } - if(final) { - webServer.uploadSuccess = true; - } - }); - - // Static file routes removed - handled by serveStatic in startup() - - asyncServer.on("/controller", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleController(request); }); - asyncServer.on("/rooms", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetRooms(request); }); - asyncServer.on("/shades", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetShades(request); }); - asyncServer.on("/groups", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetGroups(request); }); - asyncServer.on("/room", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleRoom(request); }); - asyncServer.on("/shade", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleShade(request); }); - asyncServer.on("/group", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGroup(request); }); - - // What a new room, shade or group would get, from a snapshot. - asyncServer.on("/getNextRoom", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextRoom); }); - asyncServer.on("/getNextShade", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextShade); }); - asyncServer.on("/getNextGroup", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextGroup); }); - asyncServer.on("/groupOptions", HTTP_GET, [](AsyncWebServerRequest *request) { - if(!asyncHasParam(request, "groupId")) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"You must supply a valid group id.\"}")); - return; - } - const int groupId = atoi(asyncParam(request, "groupId").c_str()); - // As for /group, existence is checked on the live model. - if(somfy.getGroupById(groupId)) sendSnapshot(request, snapshot_doc_t::groupOptions, groupId); - else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}")); - }); - // Adding and saving rooms, shades and groups. Edits of the shade model: - // loop() applies them and sends requestResult. - asyncServer.addHandler(editHandler("/addRoom", model_edit_type_t::add_room)); - asyncServer.addHandler(editHandler("/addShade", model_edit_type_t::add_shade)); - asyncServer.addHandler(editHandler("/addGroup", model_edit_type_t::add_group)); - asyncServer.addHandler(editHandler("/saveRoom", model_edit_type_t::save_room)); - asyncServer.addHandler(editHandler("/saveShade", model_edit_type_t::save_shade)); - asyncServer.addHandler(editHandler("/saveGroup", model_edit_type_t::save_group)); - - - // setMyPosition - supports both GET with query params and POST/PUT with JSON body - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setMyPosition", - [](AsyncWebServerRequest *request, JsonVariant &json) { - uint8_t shadeId = 255; - int8_t pos = -1; - int8_t tilt = -1; - if(!json.isNull()) { - JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; - else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade id was supplied.\"}")); return; } - if(!obj["pos"].isNull()) pos = obj["pos"].as(); - if(!obj["tilt"].isNull()) tilt = obj["tilt"].as(); - } - SomfyShade* shade = somfy.getShadeById(shadeId); - if(shade) { - if(pos >= 0 && pos <= 100) - if(!queueSetMyPosition(request, shade, pos, requestTarget(tilt))) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShadeRef(shade, resp); - resp.endObject(); - resp.endResponse(); - } - else { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); - } - })); - asyncServer.on("/setMyPosition", HTTP_GET, [](AsyncWebServerRequest *request) { - uint8_t shadeId = 255; - int8_t pos = -1; - int8_t tilt = -1; - if(asyncHasParam(request, "shadeId")) { - shadeId = atoi(asyncParam(request, "shadeId").c_str()); - if(asyncHasParam(request, "pos")) pos = atoi(asyncParam(request, "pos").c_str()); - if(asyncHasParam(request, "tilt")) tilt = atoi(asyncParam(request, "tilt").c_str()); - } - else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; } - SomfyShade* shade = somfy.getShadeById(shadeId); - if(shade) { - if(pos >= 0 && pos <= 100) - if(!queueSetMyPosition(request, shade, pos, requestTarget(tilt))) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShadeRef(shade, resp); - resp.endObject(); - resp.endResponse(); - } - else { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); - } - }); - - // setRollingCode - supports both query params and JSON body - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setRollingCode", - [](AsyncWebServerRequest *request, JsonVariant &json) { - uint8_t shadeId = 255; - uint16_t rollingCode = 0; - if(!json.isNull()) { - JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; - if(!obj["rollingCode"].isNull()) rollingCode = obj["rollingCode"]; - } - SomfyShade* shade = nullptr; - if(shadeId != 255) shade = somfy.getShadeById(shadeId); - if(!shade) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to set rolling code\"}")); - } - else { - if(!queueSetRollingCode(request, shade, rollingCode)) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp, shade->paired, rollingCode); - resp.endObject(); - resp.endResponse(); - } - })); - asyncServer.on("/setRollingCode", HTTP_GET, [](AsyncWebServerRequest *request) { - uint8_t shadeId = 255; - uint16_t rollingCode = 0; - if(asyncHasParam(request, "shadeId")) { - shadeId = atoi(asyncParam(request, "shadeId").c_str()); - rollingCode = atoi(asyncParam(request, "rollingCode").c_str()); - } - SomfyShade* shade = nullptr; - if(shadeId != 255) shade = somfy.getShadeById(shadeId); - if(!shade) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to set rolling code\"}")); - } - else { - if(!queueSetRollingCode(request, shade, rollingCode)) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp, shade->paired, rollingCode); - resp.endObject(); - resp.endResponse(); - } - }); - - asyncServer.addHandler(editHandler("/setPaired", model_edit_type_t::set_paired)); - asyncServer.on("/setPaired", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::set_paired, { "shadeId", "paired" }); - }); - - // unpairShade - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/unpairShade", - [](AsyncWebServerRequest *request, JsonVariant &json) { - uint8_t shadeId = 255; - if(!json.isNull()) { - JsonObject obj = json.as(); - if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; - } - SomfyShade* shade = nullptr; - if(shadeId != 255) shade = somfy.getShadeById(shadeId); - if(!shade) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); - } - else { - if(!queueUnpairShade(request, shade)) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp, false, shade->lastRollingCode); - resp.endObject(); - resp.endResponse(); - } - })); - asyncServer.on("/unpairShade", HTTP_GET, [](AsyncWebServerRequest *request) { - uint8_t shadeId = 255; - if(asyncHasParam(request, "shadeId")) - shadeId = atoi(asyncParam(request, "shadeId").c_str()); - SomfyShade* shade = nullptr; - if(shadeId != 255) shade = somfy.getShadeById(shadeId); - if(!shade) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); - } - else { - if(!queueUnpairShade(request, shade)) return; - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeShade(shade, resp, false, shade->lastRollingCode); - resp.endObject(); - resp.endResponse(); - } - }); - - // Linking repeaters, remotes and group members, and deleting. Edits of the - // shade model: loop() applies them and sends requestResult. - asyncServer.addHandler(editHandler("/linkRepeater", model_edit_type_t::link_repeater)); - asyncServer.on("/linkRepeater", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::link_repeater, { "address" }); - }); - asyncServer.addHandler(editHandler("/unlinkRepeater", model_edit_type_t::unlink_repeater)); - asyncServer.on("/unlinkRepeater", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::unlink_repeater, { "address" }); - }); - asyncServer.addHandler(editHandler("/linkRemote", model_edit_type_t::link_remote)); - asyncServer.addHandler(editHandler("/unlinkRemote", model_edit_type_t::unlink_remote)); - asyncServer.addHandler(editHandler("/linkToGroup", model_edit_type_t::link_to_group)); - asyncServer.addHandler(editHandler("/unlinkFromGroup", model_edit_type_t::unlink_from_group)); - asyncServer.addHandler(editHandler("/deleteRoom", model_edit_type_t::delete_room)); - asyncServer.on("/deleteRoom", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::delete_room, { "roomId" }); - }); - asyncServer.addHandler(editHandler("/deleteShade", model_edit_type_t::delete_shade)); - asyncServer.on("/deleteShade", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::delete_shade, { "shadeId" }); - }); - asyncServer.addHandler(editHandler("/deleteGroup", model_edit_type_t::delete_group)); - asyncServer.on("/deleteGroup", HTTP_GET, [](AsyncWebServerRequest *request) { - queueModelEditFromParams(request, model_edit_type_t::delete_group, { "groupId" }); - }); - // The repeaters, from a snapshot like the other reads. - asyncServer.on("/repeaters", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::repeaters); }); - - - // updateFirmware - file upload - asyncServer.on("/updateFirmware", HTTP_POST, - [](AsyncWebServerRequest *request) { - if(Update.hasError()) - request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating firmware: \"}"); - 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(index == 0) { - webServer.uploadSuccess = false; - ESP_LOGI(TAG, "Update: %s", filename.c_str()); - if(!Update.begin(UPDATE_SIZE_UNKNOWN)) { - ESP_LOGE(TAG, "Update begin failed"); - } - else { - somfy.transceiver.end(); - mqtt.end(); - request->onDisconnect(onUploadDisconnect); - } - } - if(len > 0) { - if(Update.write(data, len) != len) { - ESP_LOGE(TAG, "Upload of %s aborted invalid size %d", filename.c_str(), len); - Update.abort(); - } - } - if(final) { - if(Update.end(true)) { - ESP_LOGI(TAG, "Update Success: %u Rebooting...", index + len); - webServer.uploadSuccess = true; - } - else { - ESP_LOGE(TAG, "Update end failed"); - } - } - esp_task_wdt_reset(); - }); - - // updateShadeConfig - file upload - asyncServer.on("/updateShadeConfig", HTTP_POST, - [](AsyncWebServerRequest *request) { - if(git.lockFS) { - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Filesystem update in progress\"}")); - return; - } - request->send(200, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Updating Shade Config: \"}"); - }, - [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { - if(index == 0) { - ESP_LOGI(TAG, "Update: shades.cfg"); - File fup = LittleFS.open("/shades.tmp", "w"); - fup.close(); - } - if(len > 0) { - File fup = LittleFS.open("/shades.tmp", "a"); - fup.write(data, len); - fup.close(); - } - if(final) { - // The main loop loads it, since it owns the shades. - loop_item_t item; - item.type = loop_item_type_t::load_shades; - loopQueue.submit(item); - } - }); - - // updateApplication - file upload - asyncServer.on("/updateApplication", HTTP_POST, - [](AsyncWebServerRequest *request) { - if(Update.hasError()) - request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating application: \"}"); - else - request->send(200, _encoding_json, "{\"status\":\"SUCCESS\",\"desc\":\"Successfully updated application\"}"); - rebootDelay.reboot = true; - rebootDelay.rebootTime = millis() + 500; - }, - [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { - if(index == 0) { - webServer.uploadSuccess = false; - ESP_LOGI(TAG, "Update: %s", filename.c_str()); - if(!Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS)) { - ESP_LOGE(TAG, "Update begin failed"); - } - else { - somfy.transceiver.end(); - mqtt.end(); - request->onDisconnect(onUploadDisconnect); - } - } - if(len > 0) { - if(Update.write(data, len) != len) { - ESP_LOGE(TAG, "Upload of %s aborted invalid size %d", filename.c_str(), len); - Update.abort(); - } - } - if(final) { - if(Update.end(true)) { - webServer.uploadSuccess = true; - ESP_LOGI(TAG, "Update Success: %u Rebooting...", index + len); - somfy.commit(); - } - else { - somfy.commit(); - ESP_LOGE(TAG, "Update end failed"); - } - } - esp_task_wdt_reset(); - }); - - asyncServer.on("/scanaps", HTTP_GET, [](AsyncWebServerRequest *request) { - esp_task_wdt_reset(); - esp_task_wdt_delete(NULL); - if(net.softAPOpened) WiFi.disconnect(false); - int n = WiFi.scanNetworks(false, true); - esp_task_wdt_add(NULL); - ESP_LOGI(TAG, "Scanned %d networks", n); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - resp.beginObject("connected"); - resp.addElem("name", settings.WIFI.ssid); - resp.addElem("passphrase", settings.WIFI.passphrase); - resp.addElem("strength", (int32_t)WiFi.RSSI()); - resp.addElem("channel", (int32_t)WiFi.channel()); - resp.endObject(); - resp.beginArray("accessPoints"); - for(int i = 0; i < n; ++i) { - if(WiFi.SSID(i).length() == 0 || WiFi.RSSI(i) < -95) continue; - resp.beginObject(); - resp.addElem("name", WiFi.SSID(i).c_str()); - resp.addElem("channel", (int32_t)WiFi.channel(i)); - resp.addElem("strength", (int32_t)WiFi.RSSI(i)); - resp.addElem("macAddress", WiFi.BSSIDstr(i).c_str()); - resp.endObject(); - } - resp.endArray(); - resp.endObject(); - resp.endResponse(); - }); - - asyncServer.on("/reboot", WebRequestMethodComposite(HTTP_POST) | HTTP_PUT, [](AsyncWebServerRequest *request) { webServer.handleReboot(request); }); - - // saveSecurity - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/saveSecurity", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(400, _encoding_html, "Error parsing JSON body"); - return; - } - JsonObject obj = json.as(); - settings.Security.fromJSON(obj); - settings.Security.save(); - char token[65]; - webServer.createAPIToken(request->client()->remoteIP(), token); - JsonDocument sdoc; - JsonObject sobj = sdoc.to(); - settings.Security.toJSON(sobj); - sobj["apiKey"] = token; - serializeJson(sdoc, g_async_content, sizeof(g_async_content)); - request->send(200, _encoding_json, g_async_content); - })); - - // getSecurity - asyncServer.on("/getSecurity", HTTP_GET, [](AsyncWebServerRequest *request) { - JsonDocument doc; - JsonObject obj = doc.to(); - settings.Security.toJSON(obj); - serializeJson(doc, g_async_content, sizeof(g_async_content)); - request->send(200, _encoding_json, g_async_content); - }); - - // saveRadio - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/saveRadio", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(400, _encoding_html, "Error parsing JSON body"); - return; - } - JsonObject obj = json.as(); - somfy.transceiver.fromJSON(obj); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - resp.beginObject("config"); - serializeTransceiverConfig(resp); - resp.endObject(); - resp.endObject(); - resp.endResponse(); - })); - - // getRadio - asyncServer.on("/getRadio", HTTP_GET, [](AsyncWebServerRequest *request) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeTransceiverConfig(resp); - resp.endObject(); - resp.endResponse(); - }); - - // sendRemoteCommand - supports GET with query params and POST/PUT with JSON body - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/sendRemoteCommand", - [](AsyncWebServerRequest *request, JsonVariant &json) { - somfy_frame_t frame; - uint8_t repeats = 0; - if(!json.isNull()) { - JsonObject obj = json.as(); - String scmd; - if(!obj["address"].isNull()) frame.remoteAddress = obj["address"]; - if(!obj["command"].isNull()) scmd = obj["command"].as(); - if(!obj["repeats"].isNull()) repeats = obj["repeats"]; - if(!obj["rcode"].isNull()) frame.rollingCode = obj["rcode"]; - if(!obj["encKey"].isNull()) frame.encKey = obj["encKey"]; - frame.cmd = translateSomfyCommand(scmd.c_str()); - } - if(frame.remoteAddress > 0 && frame.rollingCode > 0) { - if(somfy.sendFrame(frame, repeats)) - request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); - else - sendRadioUnavailable(request); - } - else - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}")); - })); - asyncServer.on("/sendRemoteCommand", HTTP_GET, [](AsyncWebServerRequest *request) { - somfy_frame_t frame; - uint8_t repeats = 0; - if(asyncHasParam(request, "address")) { - frame.remoteAddress = atoi(asyncParam(request, "address").c_str()); - if(asyncHasParam(request, "encKey")) frame.encKey = atoi(asyncParam(request, "encKey").c_str()); - if(asyncHasParam(request, "command")) frame.cmd = translateSomfyCommand(asyncParam(request, "command")); - if(asyncHasParam(request, "rcode")) frame.rollingCode = atoi(asyncParam(request, "rcode").c_str()); - if(asyncHasParam(request, "repeats")) repeats = atoi(asyncParam(request, "repeats").c_str()); - } - if(frame.remoteAddress > 0 && frame.rollingCode > 0) { - if(somfy.sendFrame(frame, repeats)) - request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); - else - sendRadioUnavailable(request); - } - else - request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}")); - }); - - // setgeneral - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setgeneral", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - JsonObject obj = json.as(); - if(!obj["hostname"].isNull() || !obj["ssdpBroadcast"].isNull() || !obj["checkForUpdate"].isNull()) { - bool checkForUpdate = settings.checkForUpdate; - settings.fromJSON(obj); - settings.save(); - if(settings.checkForUpdate != checkForUpdate) git.emitUpdateCheck(); - if(!obj["hostname"].isNull()) net.updateHostname(); - } - if(!obj["ntpServer"].isNull() || !obj["ntpServer"].isNull()) { - settings.NTP.fromJSON(obj); - settings.NTP.save(); - } - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set General Settings\"}"); - })); - - // setNetwork - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setNetwork", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(400, _encoding_html, "Error parsing JSON body"); - return; - } - JsonObject obj = json.as(); - bool reboot = false; - if(!obj["connType"].isNull() && obj["connType"].as() != static_cast(settings.connType)) { - settings.connType = static_cast(obj["connType"].as()); - settings.save(); - reboot = true; - } - if(!obj["wifi"].isNull()) { - JsonObject objWifi = obj["wifi"]; - if(settings.connType == conn_types_t::wifi) { - if(!objWifi["ssid"].isNull() && objWifi["ssid"].as().compareTo(settings.WIFI.ssid) != 0) { - if(WiFi.softAPgetStationNum() == 0) reboot = true; - } - if(!objWifi["passphrase"].isNull() && objWifi["passphrase"].as().compareTo(settings.WIFI.passphrase) != 0) { - if(WiFi.softAPgetStationNum() == 0) reboot = true; - } - } - settings.WIFI.fromJSON(objWifi); - settings.WIFI.save(); - } - if(!obj["ethernet"].isNull()) { - JsonObject objEth = obj["ethernet"]; - if(settings.connType == conn_types_t::ethernet || settings.connType == conn_types_t::ethernetpref) - reboot = true; -#ifndef CONFIG_IDF_TARGET_ESP32C6 - settings.Ethernet.fromJSON(objEth); - settings.Ethernet.save(); -#endif - } - if(reboot) { - ESP_LOGI(TAG, "Rebooting ESP for new Network settings..."); - rebootDelay.reboot = true; - rebootDelay.rebootTime = millis() + 1000; - } - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set Network Settings\"}"); - })); - - // setIP - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/setIP", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - ESP_LOGD(TAG, "Setting IP..."); - JsonObject obj = json.as(); - settings.IP.fromJSON(obj); - settings.IP.save(); - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set Network Settings\"}"); - })); - - // connectwifi - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/connectwifi", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - JsonObject obj = json.as(); - ESP_LOGD(TAG, "Settings WIFI connection..."); - String ssid = ""; - String passphrase = ""; - if(!obj["ssid"].isNull()) ssid = obj["ssid"].as(); - if(!obj["passphrase"].isNull()) passphrase = obj["passphrase"].as(); - bool reboot = false; - if(ssid.compareTo(settings.WIFI.ssid) != 0) reboot = true; - if(passphrase.compareTo(settings.WIFI.passphrase) != 0) reboot = true; - if(!settings.WIFI.ssidExists(ssid.c_str()) && ssid.length() > 0) { - request->send(400, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"WiFi Network Does not exist\"}"); - } - else { - SETCHARPROP(settings.WIFI.ssid, ssid.c_str(), sizeof(settings.WIFI.ssid)); - SETCHARPROP(settings.WIFI.passphrase, passphrase.c_str(), sizeof(settings.WIFI.passphrase)); - settings.WIFI.save(); - settings.WIFI.print(); - request->send(201, _encoding_json, "{\"status\":\"OK\",\"desc\":\"Successfully set server connection\"}"); - if(reboot) { - ESP_LOGI(TAG, "Rebooting ESP for new WiFi settings..."); - rebootDelay.reboot = true; - rebootDelay.rebootTime = millis() + 1000; - } - } - })); - - // modulesettings - asyncServer.on("/modulesettings", HTTP_GET, [](AsyncWebServerRequest *request) { - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - resp.addElem("fwVersion", settings.fwVersion.name); - resp.addElem("ssdpBroadcast", settings.ssdpBroadcast); - resp.addElem("hostname", settings.hostname); - resp.addElem("connType", static_cast(settings.connType)); - resp.addElem("chipModel", settings.chipModel); - resp.addElem("checkForUpdate", settings.checkForUpdate); - resp.addElem("ntpServer", settings.NTP.ntpServer); - resp.addElem("posixZone", settings.NTP.posixZone); - resp.endObject(); - resp.endResponse(); - }); - - // networksettings - asyncServer.on("/networksettings", HTTP_GET, [](AsyncWebServerRequest *request) { - JsonDocument doc; - JsonObject obj = doc.to(); - settings.toJSON(obj); - obj["fwVersion"] = settings.fwVersion.name; - JsonObject eth = obj.createNestedObject("ethernet"); -#ifndef CONFIG_IDF_TARGET_ESP32C6 - settings.Ethernet.toJSON(eth); -#endif - JsonObject wifi = obj.createNestedObject("wifi"); - settings.WIFI.toJSON(wifi); - JsonObject ip = obj.createNestedObject("ip"); - settings.IP.toJSON(ip); - serializeJson(doc, g_async_content, sizeof(g_async_content)); - request->send(200, _encoding_json, g_async_content); - }); - - // connectmqtt - asyncServer.addHandler(new AsyncCallbackJsonWebHandler("/connectmqtt", - [](AsyncWebServerRequest *request, JsonVariant &json) { - if(json.isNull()) { - request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); - return; - } - JsonObject obj = json.as(); - ESP_LOGD(TAG, "Saving MQTT"); - mqtt.disconnect(); - settings.MQTT.fromJSON(obj); - settings.MQTT.save(); - JsonDocument sdoc; - JsonObject sobj = sdoc.to(); - settings.MQTT.toJSON(sobj); - serializeJson(sdoc, g_async_content, sizeof(g_async_content)); - request->send(200, _encoding_json, g_async_content); - })); - - // mqttsettings - asyncServer.on("/mqttsettings", HTTP_GET, [](AsyncWebServerRequest *request) { - JsonDocument doc; - JsonObject obj = doc.to(); - settings.MQTT.toJSON(obj); - serializeJson(doc, g_async_content, sizeof(g_async_content)); - request->send(200, _encoding_json, g_async_content); - }); - - // Sort orders: the ids in the order the client lists them. - asyncServer.addHandler(editHandler("/roomSortOrder", model_edit_type_t::room_order)); - asyncServer.addHandler(editHandler("/shadeSortOrder", model_edit_type_t::shade_order)); - asyncServer.addHandler(editHandler("/groupSortOrder", model_edit_type_t::group_order)); - - asyncServer.on("/beginFrequencyScan", HTTP_GET, [](AsyncWebServerRequest *request) { - somfy.transceiver.beginFrequencyScan(); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeTransceiverConfig(resp); - resp.endObject(); - resp.endResponse(); - }); - - asyncServer.on("/endFrequencyScan", HTTP_GET, [](AsyncWebServerRequest *request) { - somfy.transceiver.endFrequencyScan(); - AsyncJsonResp resp; - resp.beginResponse(request, g_async_content, sizeof(g_async_content)); - resp.beginObject(); - serializeTransceiverConfig(resp); - resp.endObject(); - resp.endResponse(); - }); - - asyncServer.on("/recoverFilesystem", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { - if(git.status == GIT_UPDATING) - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Filesystem is updating. Please wait!!!\"}"); - else if(git.status != GIT_STATUS_READY) - request->send(200, "application/json", "{\"status\":\"ERROR\",\"desc\":\"Cannot recover file system at this time.\"}"); - else { - git.recoverFilesystem(); - request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Recovering filesystem from github please wait!!!\"}"); - } - }); - - asyncServer.onNotFound([](AsyncWebServerRequest *r) { - if(r->method() == HTTP_OPTIONS) { r->send(200); return; } - webServer.handleNotFound(r); - }); - - // serveStatic MUST be registered AFTER all route handlers so it doesn't shadow them - asyncServer.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); - + registerApiRoutes(asyncApiServer); + registerWebUiRoutes(asyncServer); asyncServer.begin(); } diff --git a/src/entryPoints/WebRoutes.h b/src/entryPoints/WebRoutes.h new file mode 100644 index 0000000..58bd4ef --- /dev/null +++ b/src/entryPoints/WebRoutes.h @@ -0,0 +1,45 @@ +#ifndef WEB_ROUTES_H +#define WEB_ROUTES_H +#include +#include "Somfy.h" +#include "LoopQueue.h" +#include "ModelSnapshot.h" +#include "GitOTA.h" + +// What WebUiRoutes, ApiRoutes and the handlers both of them call (Web.cpp) +// share. Internal to the web servers. + +// Registers the routes the config web UI calls on port 80, and its files. +void registerWebUiRoutes(AsyncWebServer &server); +// Registers the routes integrations such as Home Assistant call on port 8081. +void registerApiRoutes(AsyncWebServer &server); + +#define WEB_MAX_RESPONSE 4096 +// Where replies are formatted before they are sent. async_tcp only. +extern char g_async_content[WEB_MAX_RESPONSE]; +extern const char _encoding_text[]; +extern const char _encoding_json[]; + +// Sent when a request can't be handed to loop(). +void sendQueueFull(AsyncWebServerRequest *request); +// Sent when the radio can't take a frame (suspended or queue full). +void sendRadioUnavailable(AsyncWebServerRequest *request); +// Hands an item to loop(), which owns the shades. When it can't be the +// request is answered with the reason and false is returned. +bool queueLoopItem(AsyncWebServerRequest *request, const loop_item_t &item); +bool queueShadeRequest(AsyncWebServerRequest *request, const shade_request_t &req); +// A query parameter, or an empty string when it is missing. +String asyncParam(AsyncWebServerRequest *request, const char *name); +bool asyncHasParam(AsyncWebServerRequest *request, const char *name); +// Serves a read from a ModelSnapshot that loop() fills. +void sendSnapshot(AsyncWebServerRequest *request, snapshot_doc_t doc, uint8_t id = 0, snapshot_header_t header = nullptr); + +// One item of a reply, copied from the live shade model. +void serializeShadeRef(SomfyShade *shade, JsonFormatter &json); +void serializeShade(SomfyShade *shade, JsonFormatter &json); +void serializeShade(SomfyShade *shade, JsonFormatter &json, bool paired, uint16_t lastRollingCode); +void serializeTransceiverConfig(JsonFormatter &json); +void serializeAppVersion(JsonFormatter &json, appver_t &ver); +void serializeGitVersion(JsonFormatter &json); +void serializeGitRelease(GitRelease *rel, JsonFormatter &json); +#endif diff --git a/src/entryPoints/WebUiRoutes.cpp b/src/entryPoints/WebUiRoutes.cpp new file mode 100644 index 0000000..e46176b --- /dev/null +++ b/src/entryPoints/WebUiRoutes.cpp @@ -0,0 +1,967 @@ +#include +#include +#include +#include +#include "esp_log.h" +#include "ConfigSettings.h" +#include "ConfigFile.h" +#include "Utils.h" +#include "SSDP.h" +#include "Somfy.h" +#include "WResp.h" +#include "LoopQueue.h" +#include "ModelSnapshot.h" +#include "SomfyEditor.h" +#include "Web.h" +#include "WebRoutes.h" +#include "MqttConnection.h" +#include "GitOTA.h" +#include "ESPNetwork.h" +#include +#include +#include + +// WebUiRoutes: the routes the config web UI calls on port 80, and the UI's own +// files. + +extern ConfigSettings settings; +extern SSDPClass SSDP; +extern rebootDelay_t rebootDelay; +extern SomfyShadeController somfy; +extern Web webServer; +extern MqttConnection mqtt; +extern GitUpdater git; +extern ESPNetwork net; +extern LoopQueue loopQueue; + +static const char *TAG = "Web"; +static const char _encoding_html[] = "text/html"; + +static bool queueSetMyPosition(AsyncWebServerRequest *request, SomfyShade *shade, int8_t pos, int8_t tilt) { + shade_request_t req; + req.type = shade_request_type_t::set_favorite; + req.id = shade->getShadeId(); + req.position = pos; + req.tiltPosition = tilt; + return queueShadeRequest(request, req); +} +// loop() clears paired and saves it once the Prog is queued. The shade +// belongs to loop() so it isn't changed here. +static bool queueUnpairShade(AsyncWebServerRequest *request, SomfyShade *shade) { + shade_request_t req; + req.type = shade_request_type_t::unpair; + req.id = shade->getShadeId(); + return queueShadeRequest(request, req); +} +// Sending a command reads and commits the rolling code on loop(), so a new +// code is set there too. +static bool queueSetRollingCode(AsyncWebServerRequest *request, SomfyShade *shade, uint16_t rollingCode) { + shade_request_t req; + req.type = shade_request_type_t::set_rolling_code; + req.id = shade->getShadeId(); + req.rollingCode = rollingCode; + return queueShadeRequest(request, req); +} +// A restore's options travel through the loop queue as RESTORE_* bits. +static uint8_t restoreParts(const restore_options_t &opts) { + return (opts.settings ? RESTORE_SETTINGS : 0) | (opts.shades ? RESTORE_SHADES : 0) | + (opts.network ? RESTORE_NETWORK : 0) | (opts.transceiver ? RESTORE_TRANSCEIVER : 0) | + (opts.repeaters ? RESTORE_REPEATERS : 0) | (opts.mqtt ? RESTORE_MQTT : 0); +} +// The radio and MQTT are shut down once an upload starts and only a reboot +// brings them back. The completion handler schedules it but never runs if the +// client goes away mid-upload, so make sure the device reboots then too. +static void onUploadDisconnect() { + if(webServer.uploadSuccess || rebootDelay.reboot) return; + ESP_LOGW(TAG, "Upload abandoned, rebooting"); + if(Update.isRunning()) Update.abort(); + rebootDelay.reboot = true; + rebootDelay.rebootTime = millis() + 500; +} +// Hands an edit of the shade model to loop() (docs/adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md). +// The reply only says it was accepted; the result reaches every client as a +// requestResult event carrying the same requestId once loop() has applied it. +static void queueModelEdit(AsyncWebServerRequest *request, model_edit_type_t type, JsonVariantConst body) { + if(!beginModelEdit()) { + request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Busy, try the change again\"}")); + return; + } + model_edit_t *edit = new (std::nothrow) model_edit_t(); + if(!edit) { + cancelModelEdit(nullptr); + sendQueueFull(request); + return; + } + edit->type = type; + edit->requestId = nextEditRequestId(); + edit->doc.set(body); + const uint32_t requestId = edit->requestId; + loop_item_t item; + item.type = loop_item_type_t::model_edit; + item.edit = edit; + if(edit->doc.overflowed() || !loopQueue.submit(item)) { + cancelModelEdit(edit); + sendQueueFull(request); + return; + } + char reply[64]; + snprintf(reply, sizeof(reply), "{\"status\":\"accepted\",\"requestId\":%lu}", (unsigned long)requestId); + request->send(200, _encoding_json, reply); +} +// The JSON (PUT/POST) form of an edit route. Bodies over 4 KB are refused with +// 413 before they reach the heap. +static AsyncCallbackJsonWebHandler *editHandler(const char *uri, model_edit_type_t type) { + auto *handler = new AsyncCallbackJsonWebHandler(uri, [type](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No object supplied.\"}")); return; } + queueModelEdit(request, type, json); + }); + handler->setMaxContentLength(4096); + return handler; +} +// The GET form of an edit route: its query parameters become the request. +static void queueModelEditFromParams(AsyncWebServerRequest *request, model_edit_type_t type, std::initializer_list names) { + JsonDocument body; + for(const char *name : names) { + if(!asyncHasParam(request, name)) continue; + const String value = asyncParam(request, name); + if(strcmp(name, "paired") == 0) body[name] = toBoolean(value.c_str(), false); + else body[name] = strtoul(value.c_str(), nullptr, 10); + } + queueModelEdit(request, type, body.as()); +} + +void registerWebUiRoutes(AsyncWebServer &server) { + server.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) { + AsyncJsonResponse *response = new AsyncJsonResponse(); + JsonObject root = response->getRoot().to(); + root["type"] = static_cast(settings.Security.type); + root["permissions"] = settings.Security.permissions; + root["serverId"] = settings.serverId; + root["version"] = settings.fwVersion.name; + root["model"] = "ESPSomfyRTS"; + root["hostname"] = settings.hostname; + response->setLength(); + request->send(response); + }); + // Web Interface + // Command endpoints - delegate to async handler methods + server.addHandler(new AsyncCallbackJsonWebHandler("/tiltCommand", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleTiltCommand(request, json); })); + server.on("/tiltCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleTiltCommand(request, v); }); + + server.addHandler(new AsyncCallbackJsonWebHandler("/repeatCommand", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleRepeatCommand(request, json); })); + server.on("/repeatCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleRepeatCommand(request, v); }); + + server.addHandler(new AsyncCallbackJsonWebHandler("/shadeCommand", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleShadeCommand(request, json); })); + server.on("/shadeCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleShadeCommand(request, v); }); + + server.addHandler(new AsyncCallbackJsonWebHandler("/groupCommand", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleGroupCommand(request, json); })); + server.on("/groupCommand", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleGroupCommand(request, v); }); + + server.addHandler(new AsyncCallbackJsonWebHandler("/setPositions", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleSetPositions(request, json); })); + server.on("/setPositions", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleSetPositions(request, v); }); + + server.addHandler(new AsyncCallbackJsonWebHandler("/setSensor", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleSetSensor(request, json); })); + server.on("/setSensor", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleSetSensor(request, v); }); + + server.on("/upnp.xml", HTTP_GET, [](AsyncWebServerRequest *request) { + AsyncResponseStream *response = request->beginResponseStream("text/xml"); + SSDP.schema(*response); + request->send(response); + }); + + // / and /loginContext are already handled by serveStatic and startup() + + server.addHandler(new AsyncCallbackJsonWebHandler("/login", + [](AsyncWebServerRequest *request, JsonVariant &json) { webServer.handleLogin(request, json); })); + server.on("/login", HTTP_GET, [](AsyncWebServerRequest *request) { JsonVariant v; webServer.handleLogin(request, v); }); + + server.on("/shades.cfg", HTTP_GET, [](AsyncWebServerRequest *request) { + request->send(LittleFS, "/shades.cfg", _encoding_text); + }); + server.on("/shades.tmp", HTTP_GET, [](AsyncWebServerRequest *request) { + request->send(LittleFS, "/shades.tmp", _encoding_text); + }); + + server.on("/getReleases", HTTP_GET, [](AsyncWebServerRequest *request) { + GitRepo repo; + repo.getReleases(); + git.setCurrentRelease(repo); + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + // Inline GitRepo::toJSON + resp.beginObject("fwVersion"); + serializeAppVersion(resp, settings.fwVersion); + resp.endObject(); + resp.beginObject("appVersion"); + serializeAppVersion(resp, settings.appVersion); + resp.endObject(); + resp.beginArray("releases"); + for(uint8_t i = 0; i < GIT_MAX_RELEASES + 1; i++) { + if(repo.releases[i].id == 0) continue; + resp.beginObject(); + serializeGitRelease(&repo.releases[i], resp); + resp.endObject(); + } + resp.endArray(); + resp.endObject(); + resp.endResponse(); + }); + + server.on("/downloadFirmware", HTTP_GET, [](AsyncWebServerRequest *request) { webServer.handleDownloadFirmware(request); }); + + server.on("/cancelFirmware", HTTP_GET, [](AsyncWebServerRequest *request) { + if(!git.lockFS) { + git.status = GIT_UPDATE_CANCELLING; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeGitVersion(resp); + resp.endObject(); + resp.endResponse(); + git.cancelled = true; + } + else { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Cannot cancel during filesystem update.\"}")); + } + }); + + server.on("/backup", HTTP_GET, [](AsyncWebServerRequest *request) { webServer.handleBackup(request); }); + + server.on("/restore", HTTP_POST, + [](AsyncWebServerRequest *request) { + if(webServer.uploadSuccess) { + restore_options_t opts; + if(asyncHasParam(request, "data")) { + String dataStr = asyncParam(request, "data"); + ESP_LOGD(TAG, "%s", dataStr.c_str()); + JsonDocument doc; + DeserializationError err = deserializeJson(doc, dataStr); + if(err) { + request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); + return; + } + else { + JsonObject obj = doc.as(); + opts.fromJSON(obj); + } + } + else { + ESP_LOGD(TAG, "No restore options sent. Using defaults..."); + opts.shades = true; + } + // The main loop restores, since it owns the shades and their rolling + // codes, and then reboots. + loop_item_t item; + item.type = loop_item_type_t::restore_shades; + item.restoreParts = restoreParts(opts); + if(!queueLoopItem(request, item)) return; + request->send(200, _encoding_json, "{\"status\":\"Success\",\"desc\":\"Restoring Shade settings\"}"); + } + }, + [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { + esp_task_wdt_reset(); + if(index == 0) { + webServer.uploadSuccess = false; + ESP_LOGD(TAG, "Restore: %s", filename.c_str()); + File fup = LittleFS.open("/shades.tmp", "w"); + fup.close(); + } + if(len > 0) { + File fup = LittleFS.open("/shades.tmp", "a"); + fup.write(data, len); + fup.close(); + } + if(final) { + webServer.uploadSuccess = true; + } + }); + + // Static file routes removed - handled by serveStatic in startup() + + server.on("/controller", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleController(request); }); + server.on("/rooms", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetRooms(request); }); + server.on("/shades", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetShades(request); }); + server.on("/groups", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGetGroups(request); }); + server.on("/room", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleRoom(request); }); + server.on("/shade", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleShade(request); }); + server.on("/group", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { webServer.handleGroup(request); }); + + // What a new room, shade or group would get, from a snapshot. + server.on("/getNextRoom", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextRoom); }); + server.on("/getNextShade", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextShade); }); + server.on("/getNextGroup", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::nextGroup); }); + server.on("/groupOptions", HTTP_GET, [](AsyncWebServerRequest *request) { + if(!asyncHasParam(request, "groupId")) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"You must supply a valid group id.\"}")); + return; + } + const int groupId = atoi(asyncParam(request, "groupId").c_str()); + // As for /group, existence is checked on the live model. + if(somfy.getGroupById(groupId)) sendSnapshot(request, snapshot_doc_t::groupOptions, groupId); + else request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Group Id not found.\"}")); + }); + // Adding and saving rooms, shades and groups. Edits of the shade model: + // loop() applies them and sends requestResult. + server.addHandler(editHandler("/addRoom", model_edit_type_t::add_room)); + server.addHandler(editHandler("/addShade", model_edit_type_t::add_shade)); + server.addHandler(editHandler("/addGroup", model_edit_type_t::add_group)); + server.addHandler(editHandler("/saveRoom", model_edit_type_t::save_room)); + server.addHandler(editHandler("/saveShade", model_edit_type_t::save_shade)); + server.addHandler(editHandler("/saveGroup", model_edit_type_t::save_group)); + + + // setMyPosition - supports both GET with query params and POST/PUT with JSON body + server.addHandler(new AsyncCallbackJsonWebHandler("/setMyPosition", + [](AsyncWebServerRequest *request, JsonVariant &json) { + uint8_t shadeId = 255; + int8_t pos = -1; + int8_t tilt = -1; + if(!json.isNull()) { + JsonObject obj = json.as(); + if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; + else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade id was supplied.\"}")); return; } + if(!obj["pos"].isNull()) pos = obj["pos"].as(); + if(!obj["tilt"].isNull()) tilt = obj["tilt"].as(); + } + SomfyShade* shade = somfy.getShadeById(shadeId); + if(shade) { + if(pos >= 0 && pos <= 100) + if(!queueSetMyPosition(request, shade, pos, requestTarget(tilt))) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShadeRef(shade, resp); + resp.endObject(); + resp.endResponse(); + } + else { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); + } + })); + server.on("/setMyPosition", HTTP_GET, [](AsyncWebServerRequest *request) { + uint8_t shadeId = 255; + int8_t pos = -1; + int8_t tilt = -1; + if(asyncHasParam(request, "shadeId")) { + shadeId = atoi(asyncParam(request, "shadeId").c_str()); + if(asyncHasParam(request, "pos")) pos = atoi(asyncParam(request, "pos").c_str()); + if(asyncHasParam(request, "tilt")) tilt = atoi(asyncParam(request, "tilt").c_str()); + } + else { request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No shade object supplied.\"}")); return; } + SomfyShade* shade = somfy.getShadeById(shadeId); + if(shade) { + if(pos >= 0 && pos <= 100) + if(!queueSetMyPosition(request, shade, pos, requestTarget(tilt))) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShadeRef(shade, resp); + resp.endObject(); + resp.endResponse(); + } + else { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade with the specified id not found.\"}")); + } + }); + + // setRollingCode - supports both query params and JSON body + server.addHandler(new AsyncCallbackJsonWebHandler("/setRollingCode", + [](AsyncWebServerRequest *request, JsonVariant &json) { + uint8_t shadeId = 255; + uint16_t rollingCode = 0; + if(!json.isNull()) { + JsonObject obj = json.as(); + if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; + if(!obj["rollingCode"].isNull()) rollingCode = obj["rollingCode"]; + } + SomfyShade* shade = nullptr; + if(shadeId != 255) shade = somfy.getShadeById(shadeId); + if(!shade) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to set rolling code\"}")); + } + else { + if(!queueSetRollingCode(request, shade, rollingCode)) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShade(shade, resp, shade->paired, rollingCode); + resp.endObject(); + resp.endResponse(); + } + })); + server.on("/setRollingCode", HTTP_GET, [](AsyncWebServerRequest *request) { + uint8_t shadeId = 255; + uint16_t rollingCode = 0; + if(asyncHasParam(request, "shadeId")) { + shadeId = atoi(asyncParam(request, "shadeId").c_str()); + rollingCode = atoi(asyncParam(request, "rollingCode").c_str()); + } + SomfyShade* shade = nullptr; + if(shadeId != 255) shade = somfy.getShadeById(shadeId); + if(!shade) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to set rolling code\"}")); + } + else { + if(!queueSetRollingCode(request, shade, rollingCode)) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShade(shade, resp, shade->paired, rollingCode); + resp.endObject(); + resp.endResponse(); + } + }); + + server.addHandler(editHandler("/setPaired", model_edit_type_t::set_paired)); + server.on("/setPaired", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::set_paired, { "shadeId", "paired" }); + }); + + // unpairShade + server.addHandler(new AsyncCallbackJsonWebHandler("/unpairShade", + [](AsyncWebServerRequest *request, JsonVariant &json) { + uint8_t shadeId = 255; + if(!json.isNull()) { + JsonObject obj = json.as(); + if(!obj["shadeId"].isNull()) shadeId = obj["shadeId"]; + } + SomfyShade* shade = nullptr; + if(shadeId != 255) shade = somfy.getShadeById(shadeId); + if(!shade) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); + } + else { + if(!queueUnpairShade(request, shade)) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShade(shade, resp, false, shade->lastRollingCode); + resp.endObject(); + resp.endResponse(); + } + })); + server.on("/unpairShade", HTTP_GET, [](AsyncWebServerRequest *request) { + uint8_t shadeId = 255; + if(asyncHasParam(request, "shadeId")) + shadeId = atoi(asyncParam(request, "shadeId").c_str()); + SomfyShade* shade = nullptr; + if(shadeId != 255) shade = somfy.getShadeById(shadeId); + if(!shade) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Shade not found to unpair\"}")); + } + else { + if(!queueUnpairShade(request, shade)) return; + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeShade(shade, resp, false, shade->lastRollingCode); + resp.endObject(); + resp.endResponse(); + } + }); + + // Linking repeaters, remotes and group members, and deleting. Edits of the + // shade model: loop() applies them and sends requestResult. + server.addHandler(editHandler("/linkRepeater", model_edit_type_t::link_repeater)); + server.on("/linkRepeater", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::link_repeater, { "address" }); + }); + server.addHandler(editHandler("/unlinkRepeater", model_edit_type_t::unlink_repeater)); + server.on("/unlinkRepeater", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::unlink_repeater, { "address" }); + }); + server.addHandler(editHandler("/linkRemote", model_edit_type_t::link_remote)); + server.addHandler(editHandler("/unlinkRemote", model_edit_type_t::unlink_remote)); + server.addHandler(editHandler("/linkToGroup", model_edit_type_t::link_to_group)); + server.addHandler(editHandler("/unlinkFromGroup", model_edit_type_t::unlink_from_group)); + server.addHandler(editHandler("/deleteRoom", model_edit_type_t::delete_room)); + server.on("/deleteRoom", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::delete_room, { "roomId" }); + }); + server.addHandler(editHandler("/deleteShade", model_edit_type_t::delete_shade)); + server.on("/deleteShade", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::delete_shade, { "shadeId" }); + }); + server.addHandler(editHandler("/deleteGroup", model_edit_type_t::delete_group)); + server.on("/deleteGroup", HTTP_GET, [](AsyncWebServerRequest *request) { + queueModelEditFromParams(request, model_edit_type_t::delete_group, { "groupId" }); + }); + // The repeaters, from a snapshot like the other reads. + server.on("/repeaters", HTTP_GET, [](AsyncWebServerRequest *request) { sendSnapshot(request, snapshot_doc_t::repeaters); }); + + + // updateFirmware - file upload + server.on("/updateFirmware", HTTP_POST, + [](AsyncWebServerRequest *request) { + if(Update.hasError()) + request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating firmware: \"}"); + 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(index == 0) { + webServer.uploadSuccess = false; + ESP_LOGI(TAG, "Update: %s", filename.c_str()); + if(!Update.begin(UPDATE_SIZE_UNKNOWN)) { + ESP_LOGE(TAG, "Update begin failed"); + } + else { + somfy.transceiver.end(); + mqtt.end(); + request->onDisconnect(onUploadDisconnect); + } + } + if(len > 0) { + if(Update.write(data, len) != len) { + ESP_LOGE(TAG, "Upload of %s aborted invalid size %d", filename.c_str(), len); + Update.abort(); + } + } + if(final) { + if(Update.end(true)) { + ESP_LOGI(TAG, "Update Success: %u Rebooting...", index + len); + webServer.uploadSuccess = true; + } + else { + ESP_LOGE(TAG, "Update end failed"); + } + } + esp_task_wdt_reset(); + }); + + // updateShadeConfig - file upload + server.on("/updateShadeConfig", HTTP_POST, + [](AsyncWebServerRequest *request) { + if(git.lockFS) { + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Filesystem update in progress\"}")); + return; + } + request->send(200, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Updating Shade Config: \"}"); + }, + [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { + if(index == 0) { + ESP_LOGI(TAG, "Update: shades.cfg"); + File fup = LittleFS.open("/shades.tmp", "w"); + fup.close(); + } + if(len > 0) { + File fup = LittleFS.open("/shades.tmp", "a"); + fup.write(data, len); + fup.close(); + } + if(final) { + // The main loop loads it, since it owns the shades. + loop_item_t item; + item.type = loop_item_type_t::load_shades; + loopQueue.submit(item); + } + }); + + // updateApplication - file upload + server.on("/updateApplication", HTTP_POST, + [](AsyncWebServerRequest *request) { + if(Update.hasError()) + request->send(500, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"Error updating application: \"}"); + else + request->send(200, _encoding_json, "{\"status\":\"SUCCESS\",\"desc\":\"Successfully updated application\"}"); + rebootDelay.reboot = true; + rebootDelay.rebootTime = millis() + 500; + }, + [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { + if(index == 0) { + webServer.uploadSuccess = false; + ESP_LOGI(TAG, "Update: %s", filename.c_str()); + if(!Update.begin(UPDATE_SIZE_UNKNOWN, U_SPIFFS)) { + ESP_LOGE(TAG, "Update begin failed"); + } + else { + somfy.transceiver.end(); + mqtt.end(); + request->onDisconnect(onUploadDisconnect); + } + } + if(len > 0) { + if(Update.write(data, len) != len) { + ESP_LOGE(TAG, "Upload of %s aborted invalid size %d", filename.c_str(), len); + Update.abort(); + } + } + if(final) { + if(Update.end(true)) { + webServer.uploadSuccess = true; + ESP_LOGI(TAG, "Update Success: %u Rebooting...", index + len); + somfy.commit(); + } + else { + somfy.commit(); + ESP_LOGE(TAG, "Update end failed"); + } + } + esp_task_wdt_reset(); + }); + + server.on("/scanaps", HTTP_GET, [](AsyncWebServerRequest *request) { + esp_task_wdt_reset(); + esp_task_wdt_delete(NULL); + if(net.softAPOpened) WiFi.disconnect(false); + int n = WiFi.scanNetworks(false, true); + esp_task_wdt_add(NULL); + ESP_LOGI(TAG, "Scanned %d networks", n); + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + resp.beginObject("connected"); + resp.addElem("name", settings.WIFI.ssid); + resp.addElem("passphrase", settings.WIFI.passphrase); + resp.addElem("strength", (int32_t)WiFi.RSSI()); + resp.addElem("channel", (int32_t)WiFi.channel()); + resp.endObject(); + resp.beginArray("accessPoints"); + for(int i = 0; i < n; ++i) { + if(WiFi.SSID(i).length() == 0 || WiFi.RSSI(i) < -95) continue; + resp.beginObject(); + resp.addElem("name", WiFi.SSID(i).c_str()); + resp.addElem("channel", (int32_t)WiFi.channel(i)); + resp.addElem("strength", (int32_t)WiFi.RSSI(i)); + resp.addElem("macAddress", WiFi.BSSIDstr(i).c_str()); + resp.endObject(); + } + resp.endArray(); + resp.endObject(); + resp.endResponse(); + }); + + server.on("/reboot", WebRequestMethodComposite(HTTP_POST) | HTTP_PUT, [](AsyncWebServerRequest *request) { webServer.handleReboot(request); }); + + // saveSecurity + server.addHandler(new AsyncCallbackJsonWebHandler("/saveSecurity", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(400, _encoding_html, "Error parsing JSON body"); + return; + } + JsonObject obj = json.as(); + settings.Security.fromJSON(obj); + settings.Security.save(); + char token[65]; + webServer.createAPIToken(request->client()->remoteIP(), token); + JsonDocument sdoc; + JsonObject sobj = sdoc.to(); + settings.Security.toJSON(sobj); + sobj["apiKey"] = token; + serializeJson(sdoc, g_async_content, sizeof(g_async_content)); + request->send(200, _encoding_json, g_async_content); + })); + + // getSecurity + server.on("/getSecurity", HTTP_GET, [](AsyncWebServerRequest *request) { + JsonDocument doc; + JsonObject obj = doc.to(); + settings.Security.toJSON(obj); + serializeJson(doc, g_async_content, sizeof(g_async_content)); + request->send(200, _encoding_json, g_async_content); + }); + + // saveRadio + server.addHandler(new AsyncCallbackJsonWebHandler("/saveRadio", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(400, _encoding_html, "Error parsing JSON body"); + return; + } + JsonObject obj = json.as(); + somfy.transceiver.fromJSON(obj); + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + resp.beginObject("config"); + serializeTransceiverConfig(resp); + resp.endObject(); + resp.endObject(); + resp.endResponse(); + })); + + // getRadio + server.on("/getRadio", HTTP_GET, [](AsyncWebServerRequest *request) { + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeTransceiverConfig(resp); + resp.endObject(); + resp.endResponse(); + }); + + // sendRemoteCommand - supports GET with query params and POST/PUT with JSON body + server.addHandler(new AsyncCallbackJsonWebHandler("/sendRemoteCommand", + [](AsyncWebServerRequest *request, JsonVariant &json) { + somfy_frame_t frame; + uint8_t repeats = 0; + if(!json.isNull()) { + JsonObject obj = json.as(); + String scmd; + if(!obj["address"].isNull()) frame.remoteAddress = obj["address"]; + if(!obj["command"].isNull()) scmd = obj["command"].as(); + if(!obj["repeats"].isNull()) repeats = obj["repeats"]; + if(!obj["rcode"].isNull()) frame.rollingCode = obj["rcode"]; + if(!obj["encKey"].isNull()) frame.encKey = obj["encKey"]; + frame.cmd = translateSomfyCommand(scmd.c_str()); + } + if(frame.remoteAddress > 0 && frame.rollingCode > 0) { + if(somfy.sendFrame(frame, repeats)) + request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); + else + sendRadioUnavailable(request); + } + else + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}")); + })); + server.on("/sendRemoteCommand", HTTP_GET, [](AsyncWebServerRequest *request) { + somfy_frame_t frame; + uint8_t repeats = 0; + if(asyncHasParam(request, "address")) { + frame.remoteAddress = atoi(asyncParam(request, "address").c_str()); + if(asyncHasParam(request, "encKey")) frame.encKey = atoi(asyncParam(request, "encKey").c_str()); + if(asyncHasParam(request, "command")) frame.cmd = translateSomfyCommand(asyncParam(request, "command")); + if(asyncHasParam(request, "rcode")) frame.rollingCode = atoi(asyncParam(request, "rcode").c_str()); + if(asyncHasParam(request, "repeats")) repeats = atoi(asyncParam(request, "repeats").c_str()); + } + if(frame.remoteAddress > 0 && frame.rollingCode > 0) { + if(somfy.sendFrame(frame, repeats)) + request->send(200, _encoding_json, F("{\"status\":\"SUCCESS\",\"desc\":\"Command Sent\"}")); + else + sendRadioUnavailable(request); + } + else + request->send(500, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"No address or rolling code provided\"}")); + }); + + // setgeneral + server.addHandler(new AsyncCallbackJsonWebHandler("/setgeneral", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); + return; + } + JsonObject obj = json.as(); + if(!obj["hostname"].isNull() || !obj["ssdpBroadcast"].isNull() || !obj["checkForUpdate"].isNull()) { + bool checkForUpdate = settings.checkForUpdate; + settings.fromJSON(obj); + settings.save(); + if(settings.checkForUpdate != checkForUpdate) git.emitUpdateCheck(); + if(!obj["hostname"].isNull()) net.updateHostname(); + } + if(!obj["ntpServer"].isNull() || !obj["ntpServer"].isNull()) { + settings.NTP.fromJSON(obj); + settings.NTP.save(); + } + request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set General Settings\"}"); + })); + + // setNetwork + server.addHandler(new AsyncCallbackJsonWebHandler("/setNetwork", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(400, _encoding_html, "Error parsing JSON body"); + return; + } + JsonObject obj = json.as(); + bool reboot = false; + if(!obj["connType"].isNull() && obj["connType"].as() != static_cast(settings.connType)) { + settings.connType = static_cast(obj["connType"].as()); + settings.save(); + reboot = true; + } + if(!obj["wifi"].isNull()) { + JsonObject objWifi = obj["wifi"]; + if(settings.connType == conn_types_t::wifi) { + if(!objWifi["ssid"].isNull() && objWifi["ssid"].as().compareTo(settings.WIFI.ssid) != 0) { + if(WiFi.softAPgetStationNum() == 0) reboot = true; + } + if(!objWifi["passphrase"].isNull() && objWifi["passphrase"].as().compareTo(settings.WIFI.passphrase) != 0) { + if(WiFi.softAPgetStationNum() == 0) reboot = true; + } + } + settings.WIFI.fromJSON(objWifi); + settings.WIFI.save(); + } + if(!obj["ethernet"].isNull()) { + JsonObject objEth = obj["ethernet"]; + if(settings.connType == conn_types_t::ethernet || settings.connType == conn_types_t::ethernetpref) + reboot = true; +#ifndef CONFIG_IDF_TARGET_ESP32C6 + settings.Ethernet.fromJSON(objEth); + settings.Ethernet.save(); +#endif + } + if(reboot) { + ESP_LOGI(TAG, "Rebooting ESP for new Network settings..."); + rebootDelay.reboot = true; + rebootDelay.rebootTime = millis() + 1000; + } + request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set Network Settings\"}"); + })); + + // setIP + server.addHandler(new AsyncCallbackJsonWebHandler("/setIP", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); + return; + } + ESP_LOGD(TAG, "Setting IP..."); + JsonObject obj = json.as(); + settings.IP.fromJSON(obj); + settings.IP.save(); + request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Successfully set Network Settings\"}"); + })); + + // connectwifi + server.addHandler(new AsyncCallbackJsonWebHandler("/connectwifi", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); + return; + } + JsonObject obj = json.as(); + ESP_LOGD(TAG, "Settings WIFI connection..."); + String ssid = ""; + String passphrase = ""; + if(!obj["ssid"].isNull()) ssid = obj["ssid"].as(); + if(!obj["passphrase"].isNull()) passphrase = obj["passphrase"].as(); + bool reboot = false; + if(ssid.compareTo(settings.WIFI.ssid) != 0) reboot = true; + if(passphrase.compareTo(settings.WIFI.passphrase) != 0) reboot = true; + if(!settings.WIFI.ssidExists(ssid.c_str()) && ssid.length() > 0) { + request->send(400, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"WiFi Network Does not exist\"}"); + } + else { + SETCHARPROP(settings.WIFI.ssid, ssid.c_str(), sizeof(settings.WIFI.ssid)); + SETCHARPROP(settings.WIFI.passphrase, passphrase.c_str(), sizeof(settings.WIFI.passphrase)); + settings.WIFI.save(); + settings.WIFI.print(); + request->send(201, _encoding_json, "{\"status\":\"OK\",\"desc\":\"Successfully set server connection\"}"); + if(reboot) { + ESP_LOGI(TAG, "Rebooting ESP for new WiFi settings..."); + rebootDelay.reboot = true; + rebootDelay.rebootTime = millis() + 1000; + } + } + })); + + // modulesettings + server.on("/modulesettings", HTTP_GET, [](AsyncWebServerRequest *request) { + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + resp.addElem("fwVersion", settings.fwVersion.name); + resp.addElem("ssdpBroadcast", settings.ssdpBroadcast); + resp.addElem("hostname", settings.hostname); + resp.addElem("connType", static_cast(settings.connType)); + resp.addElem("chipModel", settings.chipModel); + resp.addElem("checkForUpdate", settings.checkForUpdate); + resp.addElem("ntpServer", settings.NTP.ntpServer); + resp.addElem("posixZone", settings.NTP.posixZone); + resp.endObject(); + resp.endResponse(); + }); + + // networksettings + server.on("/networksettings", HTTP_GET, [](AsyncWebServerRequest *request) { + JsonDocument doc; + JsonObject obj = doc.to(); + settings.toJSON(obj); + obj["fwVersion"] = settings.fwVersion.name; + JsonObject eth = obj.createNestedObject("ethernet"); +#ifndef CONFIG_IDF_TARGET_ESP32C6 + settings.Ethernet.toJSON(eth); +#endif + JsonObject wifi = obj.createNestedObject("wifi"); + settings.WIFI.toJSON(wifi); + JsonObject ip = obj.createNestedObject("ip"); + settings.IP.toJSON(ip); + serializeJson(doc, g_async_content, sizeof(g_async_content)); + request->send(200, _encoding_json, g_async_content); + }); + + // connectmqtt + server.addHandler(new AsyncCallbackJsonWebHandler("/connectmqtt", + [](AsyncWebServerRequest *request, JsonVariant &json) { + if(json.isNull()) { + request->send(500, "application/json", "{\"status\":\"ERROR\",\"desc\":\"JSON parse error\"}"); + return; + } + JsonObject obj = json.as(); + ESP_LOGD(TAG, "Saving MQTT"); + mqtt.disconnect(); + settings.MQTT.fromJSON(obj); + settings.MQTT.save(); + JsonDocument sdoc; + JsonObject sobj = sdoc.to(); + settings.MQTT.toJSON(sobj); + serializeJson(sdoc, g_async_content, sizeof(g_async_content)); + request->send(200, _encoding_json, g_async_content); + })); + + // mqttsettings + server.on("/mqttsettings", HTTP_GET, [](AsyncWebServerRequest *request) { + JsonDocument doc; + JsonObject obj = doc.to(); + settings.MQTT.toJSON(obj); + serializeJson(doc, g_async_content, sizeof(g_async_content)); + request->send(200, _encoding_json, g_async_content); + }); + + // Sort orders: the ids in the order the client lists them. + server.addHandler(editHandler("/roomSortOrder", model_edit_type_t::room_order)); + server.addHandler(editHandler("/shadeSortOrder", model_edit_type_t::shade_order)); + server.addHandler(editHandler("/groupSortOrder", model_edit_type_t::group_order)); + + server.on("/beginFrequencyScan", HTTP_GET, [](AsyncWebServerRequest *request) { + somfy.transceiver.beginFrequencyScan(); + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeTransceiverConfig(resp); + resp.endObject(); + resp.endResponse(); + }); + + server.on("/endFrequencyScan", HTTP_GET, [](AsyncWebServerRequest *request) { + somfy.transceiver.endFrequencyScan(); + AsyncJsonResp resp; + resp.beginResponse(request, g_async_content, sizeof(g_async_content)); + resp.beginObject(); + serializeTransceiverConfig(resp); + resp.endObject(); + resp.endResponse(); + }); + + server.on("/recoverFilesystem", WebRequestMethodComposite(HTTP_GET) | HTTP_POST, [](AsyncWebServerRequest *request) { + if(git.status == GIT_UPDATING) + request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Filesystem is updating. Please wait!!!\"}"); + else if(git.status != GIT_STATUS_READY) + request->send(200, "application/json", "{\"status\":\"ERROR\",\"desc\":\"Cannot recover file system at this time.\"}"); + else { + git.recoverFilesystem(); + request->send(200, "application/json", "{\"status\":\"OK\",\"desc\":\"Recovering filesystem from github please wait!!!\"}"); + } + }); + + server.onNotFound([](AsyncWebServerRequest *r) { + if(r->method() == HTTP_OPTIONS) { r->send(200); return; } + webServer.handleNotFound(r); + }); + + // serveStatic MUST be registered AFTER all route handlers so it doesn't shadow them + server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); + +} diff --git a/src/outputs/SomfyMqtt.cpp b/src/outputs/MqttPublisher.cpp similarity index 99% rename from src/outputs/SomfyMqtt.cpp rename to src/outputs/MqttPublisher.cpp index 7fe7cf3..7191c02 100644 --- a/src/outputs/SomfyMqtt.cpp +++ b/src/outputs/MqttPublisher.cpp @@ -2,13 +2,13 @@ #include "ConfigSettings.h" #include "Somfy.h" #include "SomfySnapshot.h" -#include "Sockets.h" -#include "entryPoints/MQTT.h" +#include "SocketEmitter.h" +#include "MqttConnection.h" extern SomfyShadeController somfy; extern SocketEmitter sockEmit; extern ConfigSettings settings; -extern MQTTClass mqtt; +extern MqttConnection mqtt; void SomfyRoom::publish() { if(mqtt.connected()) { diff --git a/src/outputs/Sockets.cpp b/src/outputs/SocketEmitter.cpp similarity index 99% rename from src/outputs/Sockets.cpp rename to src/outputs/SocketEmitter.cpp index 3e887e1..334633f 100644 --- a/src/outputs/Sockets.cpp +++ b/src/outputs/SocketEmitter.cpp @@ -2,7 +2,7 @@ #include #include #include "esp_log.h" -#include "Sockets.h" +#include "SocketEmitter.h" #include "ConfigSettings.h" #include "Somfy.h" #include "ESPNetwork.h" diff --git a/src/outputs/Sockets.h b/src/outputs/SocketEmitter.h similarity index 100% rename from src/outputs/Sockets.h rename to src/outputs/SocketEmitter.h diff --git a/src/outputs/SomfyEvents.cpp b/src/outputs/SomfyEvents.cpp index 116f196..2f9e28d 100644 --- a/src/outputs/SomfyEvents.cpp +++ b/src/outputs/SomfyEvents.cpp @@ -1,12 +1,12 @@ #include #include "Somfy.h" #include "SomfyEditor.h" -#include "Sockets.h" -#include "entryPoints/MQTT.h" +#include "SocketEmitter.h" +#include "MqttConnection.h" extern SomfyShadeController somfy; extern SocketEmitter sockEmit; -extern MQTTClass mqtt; +extern MqttConnection mqtt; void SomfyShade::emitState() { this->changed |= SHADE_CHANGED_STATE; } void SomfyShade::emitState(const char *evt) { this->emitState(255, evt); } diff --git a/test/test_mqttcommands/test_mqttcommands.cpp b/test/test_mqttcommands/test_mqttcommands.cpp index aa5f913..00066e9 100644 --- a/test/test_mqttcommands/test_mqttcommands.cpp +++ b/test/test_mqttcommands/test_mqttcommands.cpp @@ -1,5 +1,6 @@ #include #include +#include #include "entryPoints/MqttCommands.h" static shade_request_t req; @@ -137,6 +138,14 @@ static void test_group_sunny_and_windy() { TEST_ASSERT_TRUE(parse("espsomfy/groups/2/windy/set", "1")); TEST_ASSERT_EQUAL_INT8(1, req.windy); } +static void test_every_subscribed_topic_is_understood() { + // What the connection subscribes to and what the parser takes can't drift. + for(const char *pattern : MQTT_COMMAND_TOPICS) { + std::string topic = std::string("espsomfy/") + pattern; + topic.replace(topic.find('+'), 1, "3"); + TEST_ASSERT_TRUE_MESSAGE(parse(topic.c_str(), "1"), topic.c_str()); + } +} static void test_groups_only_take_their_commands() { TEST_ASSERT_FALSE(parse("espsomfy/groups/2/target/set", "30")); TEST_ASSERT_FALSE(parse("espsomfy/groups/2/mypos/set", "30")); @@ -161,5 +170,6 @@ int main(int argc, char **argv) { RUN_TEST(test_group_direction_and_sun_flag); RUN_TEST(test_group_sunny_and_windy); RUN_TEST(test_groups_only_take_their_commands); + RUN_TEST(test_every_subscribed_topic_is_understood); return UNITY_END(); } From 9fdf8c8783772402d301a41feecfc1978a9c8be9 Mon Sep 17 00:00:00 2001 From: cjkas Date: Fri, 16 Oct 2026 14:03:24 +0200 Subject: [PATCH 7/7] docs: plan deepening the shade behaviour Records the design state for candidate #1 of the architecture review so a new session can pick it up: the drifted Up/Down/My copies in SomfyShade.cpp, the echo path through SomfyRemote::sendCommandAfter, what steps 1-5 already did, round 1 of the design questions with the recommended answers, and the topics waiting for round 2. Co-Authored-By: Claude Opus 5.5 --- docs/SHADE_BEHAVIOUR_PLAN.md | 110 +++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/SHADE_BEHAVIOUR_PLAN.md diff --git a/docs/SHADE_BEHAVIOUR_PLAN.md b/docs/SHADE_BEHAVIOUR_PLAN.md new file mode 100644 index 0000000..67b9018 --- /dev/null +++ b/docs/SHADE_BEHAVIOUR_PLAN.md @@ -0,0 +1,110 @@ +# Plan: deepen the shade behaviour + +**Status:** design in progress. Round 1 of the design questions is open (below); nothing is built. +**Written:** 2026-10-16, on top of `refactor/split-transports` (PR #44, the top of the LoopQueue stack #40–#44, which is not yet merged or run on a device). Line numbers refer to that branch. +**Start here:** read [GLOSSARY.md](../GLOSSARY.md), [ADR-0002](adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md) and [LOOP_QUEUE_PLAN.md](LOOP_QUEUE_PLAN.md) first. Their decisions are not reopened here. + +## Why + +This is candidate #1 of the architecture review, the one ranked first. The LoopQueue plan (Q7 there) deliberately did it second, so that the bugs in front of the shade could be fixed first. Steps 1–5 rebuilt everything around the shade. The shade's own behaviour is still about 1,000 lines in [src/SomfyShade.cpp](../src/SomfyShade.cpp). + +### The same rule exists in several drifted copies + +What a shade does for Up, Down or My is written out separately in each place it can be triggered from: + +| Function | Triggered by | +|---|---| +| `processFrame` | a frame received from a remote, and the shade's own echoed frame (see below) | +| `processWaitingFrame` | a remote button held (long press, tilt) | +| `processInternalCommand` | a group command reaching its member shades | +| `sendCommand`, `sendTiltCommand`, `moveToTarget`, `moveToTiltTarget`, `setMyPosition`, `moveToMyPosition` | commands from the web, MQTT and Home Assistant, through ShadeRequests and `SomfyShadeOps` | +| `checkMovement` | every `loop()` pass: simulated travel, arriving at the target, sending the stop, storing the favorite | +| `processTxEvent` | the radio reporting a job started, finished, aborted or dropped | + +Known drift: +- **Up on a tilt-only shade.** `sendCommand` and `processInternalCommand` set target 100, position 100 and tilt target 0. `processFrame` sets only the tilt target. +- **My while a tilt-only shade moves.** `processInternalCommand` sets the lift target to 100; `processFrame` leaves the lift alone. +- **The four "arrived at target" blocks in `checkMovement`** aren't symmetric. The tilt blocks clear `settingTiltPos`; the lift blocks never clear `settingPos`, which may send an extra stop on a later arrival. This is on the "still open" list in LOOP_QUEUE_PLAN.md, not confirmed on a device. Their stop conditions also differ by hand-written literals. +- **`setMyPosition`** has three near-copy branches (tilt-only, tilt, none). + +### None of it is tested + +The native tests can't build it. It reads `millis()`, sends through the radio, and uses the global `somfy`. Only the helpers it calls are tested: `travelPosition`, `stepTargets`, `ShadeSensors`, `TxHold`/`ShadeTxState` and `requestedFavoriteTilt` (`ShadeMotion`, `ShadeSensors`). The fixes just before this work (set-favorite tilt, rolling codes on the main loop) were in this code. + +## What the shade code depends on today + +- **Time:** `millis()` throughout `checkMovement`, `processFrame` and the senders. +- **Radio:** every send goes through `SomfyRemote::sendCommandAfter` ([src/SomfyRemote.cpp](../src/SomfyRemote.cpp), around lines 61–108). It: + 1. takes the next rolling code from NVS; + 2. encodes the frame and submits it with `somfy.sendFrame()`, getting a ticket, or 0 when the radio refuses; + 3. commits the rolling code; + 4. sets `lastFrame`; + 5. calls `somfy.processFrame(lastFrame, internal = true)`; + 6. calls `somfy.markTxPending()` for commands that start movement. +- **The echo (step 5 above).** `somfy.processFrame()` runs `processFrame` on every shade, the sender included. So part of what a command does to the sending shade happens in `sendCommand`, and part in its own `processFrame(internal)` on the echoed frame. The same echo moves any other shade that has the sender's address as a linked remote. GPIO-driven shades (`GP_Remote`/`GP_Relay`) skip the radio but still echo. +- **Groups.** A group command sends the group's frame, then calls `processInternalCommand` on each member shade ([src/SomfyGroup.cpp](../src/SomfyGroup.cpp)). +- **The global `somfy`:** `isDirty`/`commit()` for saving positions and the favorite, `updateGroupFlags()` after sensor and sun-flag changes, `transceiver.config` for bit length and protocol, and `groups` in `isInGroup()`. +- **GPIO:** `triggerGPIOs`, `setGPIOs` and `usesPin` for dry contacts and GPIO motors. +- **Outputs:** only `changed` bits since step 4. The change fan-out publishes them (`ShadeChanges.h`, `SomfyShadeController::sendChanges()`). + +## What makes it easier now + +- **Callers:** ShadeRequests reaches the shade only through the `ShadeOps` seam ([src/ShadeRequests.h](../src/ShadeRequests.h)). The real implementation is `SomfyShadeOps`, and a recording fake exists in `test/test_shaderequests`. Once the behaviour module exists, only `SomfyShadeOps` changes. +- **Outputs:** the shade only marks what changed (step 4). +- **Concurrency:** only `loop()` touches shades (ADR-0002), so the module is single-threaded code. +- **Snapshots:** `shade_snapshot_t` and `snapshotShade()` already describe a shade as clients see it, which is useful for test assertions. + +## The goal + +One module holding the shade's behaviour behind a small interface: +- **Inputs:** a received frame, a command, a group command, a radio event, and a tick, each with the current time. +- **Sending:** goes out through a seam that returns a ticket or a refusal. On the device that is the radio path; in tests, a fake that records what was sent. +- **Inside:** one rule per command and tilt type. +- **Tested** natively, by scenarios such as: *an integrated-tilt shade at 0%; a remote sends Down; at 1.5 s it is still tilting; at 4 s it is lifting at about 25%; reaching the 40% target sends a stop; the radio drops the stop and the shade runs on to 100%.* + +## Design questions + +The decisions are the user's. Each question comes with a recommended answer, and they are asked in rounds, as in the LoopQueue plan. Settled answers move to "Decisions". + +### Round 1 (open) + +**Q1. What moves into the module.** +- **Into the module:** everything that decides where the shade is and where it is going: `processFrame`, `processWaitingFrame`, `processInternalCommand`, `sendCommand`, `sendTiltCommand`, `moveToTarget`, `moveToTiltTarget`, `setMyPosition`, `moveToMyPosition`, `checkMovement` and `processTxEvent`. Their state goes too: positions, targets, directions, start times, the `setting*` flags, the favorite, `ShadeSensors` and `ShadeTxState`. +- **Stays in `SomfyShade`:** identity and settings (id, name, remotes, types, times), persistence, GPIO, JSON and rolling codes. + +Recommended: yes. + +**Q2. Three phases: pin, move, reconcile.** +1. **Pin:** native scenario tests of today's behaviour, including where the copies disagree, each labelled "today: …". Every later behaviour change then shows up as a test change. +2. **Move:** the code goes into the module unchanged, and the pinning tests stay green. Today's code can't be built natively, so phases 1 and 2 happen together: the seams go in while the tests are written against the moved, unchanged code. +3. **Reconcile:** the copies are made to agree, one decision per commit, each checked on real motors. + +Recommended: yes. + +**Q3. Time is passed in.** Every input carries `now` (`onFrame(frame, now)`, `tick(now)`, ...), as `ShadeSensors::check(…, now)` and `TxHold` already do. The alternative is a clock interface the module asks. Recommended: pass it in. + +**Q4. One behaviour object per shade, settings as plain data.** Each `SomfyShade` holds one module object with the motion state. Its settings come in as a plain struct: shade type, tilt type, up/down/tilt times, step size, `simMy`, `flipCommands`. The alternative is functions with no state of their own, over a state struct the caller owns. Recommended: an object per shade. + +### Round 2 (waits for round 1) + +- **The echo.** Should the module apply its own commands directly, instead of reacting to its own echoed frame? This is the biggest decision; it decides what the radio seam looks like, and whether linked shades still learn about the command from the echo. +- **The radio seam.** What it takes (command, repeats, step size, delay, priority) and returns (ticket or refused). Rolling codes, NVS and frame encoding stay behind it in `SomfyRemote`. GPIO-driven shades sit behind the same seam. +- **Linked remotes and groups.** How a frame from a linked remote and a group command reach the module, and whether `processInternalCommand` stays a separate input. +- **The global `somfy`.** Where `isDirty`/`commit()` go (a change bit the caller acts on?), `updateGroupFlags()` (the controller after the pass?), `transceiver.config` (part of the settings struct?), and `isInGroup()` (given by the caller?). + +### Later rounds + +- The names, which go into GLOSSARY.md (for example **ShadeBehaviour**, **MotorLink**). Check for clashes with `ShadeMotion`, `ShadeOps`, `ShadeRequests` and `ShadeChanges`. +- The order of the steps and the PR split. +- What the reconcile phase decides first: Up/Down/My on tilt-only shades, `settingPos` on arrival. + +## Constraints + +- **[AGENTS.md](../AGENTS.md):** every change keeps `pio test -e native` green and adds or updates tests. +- **Flash:** check every commit. `esp32dev` (release, with OTA) has about 180 KB free. The debug build uses one 3 MB slot once #39 is in its history. Every file that uses ArduinoJson costs flash, so keep the new module free of it. +- **`ShadeMotion`, `ShadeSensors`, `ShadeTxState` and `stepTargets`** are deep and tested. Reuse them; don't re-derive them. +- **Vocabulary:** module, interface, seam, adapter, depth (the `/codebase-design` skill) and the domain terms in GLOSSARY.md. + +## Decisions + +None yet.