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 808e5a7..9954e42 100644 --- a/platformio.ini +++ b/platformio.ini @@ -113,7 +113,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);