refactor: apply shade model edits on loop() through ModelEditor

The 19 routes that changed rooms, shades, groups and repeaters on the web
server task (add, save, delete, link and unlink, setPaired, the sort
orders) now copy their request into a model_edit_t, queue it on the
LoopQueue and reply {status:accepted,requestId:N}. loop() applies it
(SomfyJson.cpp, next to fromJSON) and broadcasts requestResult {requestId,
ok, id, code, desc}. One edit at a time; a second gets 503, a body over
4 KB gets 413.

ModelEditor.h/.cpp holds the rules, tested natively: the lowest free id,
the next sort order, a free remote address, adding to and removing from
the group-member and repeater lists, and the GPIO pin-conflict check. They
replace the getNext*Id/getMax*Order copies, the remote-address search,
the list code and the inline pin checks. The pin check now uses the
protocol being saved and checks every pin against Ethernet, not only up.

/getNextRoom, /getNextShade, /getNextGroup, /groupOptions and a new
/repeaters (the UI already called it) are ModelSnapshot reads.

The web UI's editJSON() sends an edit, waits for its requestResult, then
reads the item back from /shade, /group, /room or /repeaters, so the
callbacks that used the old reply bodies are unchanged. No result within
5 s offers a reload; reads and edits told Busy are retried. It now also
refreshes its lists on shadeAdded/shadeRemoved/groupAdded/groupRemoved
from other clients, and procRoomRemoved keeps the right rooms.

Also fixed: a shade that fails validation is no longer added; linked
repeaters and sort orders are saved; linking reports a full list.

27 new native tests, 319 in all. Flash: esp32devdbg -11,484 bytes,
esp32dev -7,180 bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-16 10:26:45 +02:00
parent bde8a1efd1
commit 72b37e5f8d
20 changed files with 1035 additions and 918 deletions

78
src/ModelEditor.cpp Normal file
View file

@ -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<uint8_t>(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<size_t>(-1);
}
}
return candidate;
}
template<typename T>
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<typename T>
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<uint8_t>(list, size, 0); }
bool addToList(uint8_t *list, size_t size, uint8_t item) { return addTo<uint8_t>(list, size, item); }
bool addToList(uint32_t *list, size_t size, uint32_t item) { return addTo<uint32_t>(list, size, item); }
bool removeFromList(uint8_t *list, size_t size, uint8_t item) { return removeFrom<uint8_t>(list, size, item); }
bool removeFromList(uint32_t *list, size_t size, uint32_t item) { return removeFrom<uint32_t>(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 &ethernet, 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;
}