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);