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 77703a9855
commit e70e499d75
20 changed files with 1035 additions and 918 deletions

View file

@ -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;
}
}
}

View file

@ -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.

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

77
src/ModelEditor.h Normal file
View file

@ -0,0 +1,77 @@
#ifndef MODEL_EDITOR_H
#define MODEL_EDITOR_H
#include <stddef.h>
#include <stdint.h>
#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 &ethernet, const pin_users_t &otherShades);
#endif

View file

@ -3,6 +3,7 @@
#include <string.h>
#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;

View file

@ -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.

View file

@ -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

View file

@ -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];

29
src/SomfyEditor.h Normal file
View file

@ -0,0 +1,29 @@
#ifndef SOMFY_EDITOR_H
#define SOMFY_EDITOR_H
#include <ArduinoJson.h>
#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

View file

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

View file

@ -1,6 +1,9 @@
#include "esp_log.h"
#include "ConfigSettings.h"
#include "Somfy.h"
#include <atomic>
#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<shade_types>(obj["shadeType"].as<uint8_t>());
type = static_cast<shade_types>(obj["shadeType"].as<uint8_t>());
}
}
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<uint8_t>() : this->gpioUp;
uint8_t downPin = !obj["gpioDown"].isNull() ? obj["gpioDown"].as<uint8_t>() : this->gpioDown;
uint8_t myPin = !obj["gpioMy"].isNull() ? obj["gpioMy"].as<uint8_t>() : 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<radio_proto>(obj["proto"].as<uint8_t>()) : this->proto;
const uint8_t upPin = !obj["gpioUp"].isNull() ? obj["gpioUp"].as<uint8_t>() : this->gpioUp;
const uint8_t downPin = !obj["gpioDown"].isNull() ? obj["gpioDown"].as<uint8_t>() : this->gpioDown;
const uint8_t myPin = !obj["gpioMy"].isNull() ? obj["gpioMy"].as<uint8_t>() : 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<SomfyShade *>(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<bool> editBusy(false);
static std::atomic<uint32_t> 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<uint8_t>(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<typename Find>
static edit_result_t applyOrder(JsonDocument &doc, Find find) {
if(!doc.is<JsonArray>()) return edit_result_t::error("JSON parse error");
int8_t order = 0;
for(JsonVariant v : doc.as<JsonArray>()) {
auto *item = find(v.as<uint8_t>());
if(item) item->sortOrder = order++;
}
somfy.commit();
return done();
}
edit_result_t applyModelEdit(model_edit_t &edit) {
JsonObject obj = edit.doc.as<JsonObject>();
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<uint32_t>() : (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.");
}

View file

@ -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<uint8_t>(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++)

View file

@ -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<const char *> 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<JsonVariantConst>());
}
// -- 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<uint8_t>(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<uint8_t>(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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonObject>();
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<JsonArray>();
uint8_t order = 0;
for(JsonVariant v : arr) {
uint8_t roomId = v.as<uint8_t>();
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<JsonArray>();
uint8_t order = 0;
for(JsonVariant v : arr) {
uint8_t shadeId = v.as<uint8_t>();
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<JsonArray>();
uint8_t order = 0;
for(JsonVariant v : arr) {
uint8_t groupId = v.as<uint8_t>();
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();

View file

@ -1,5 +1,6 @@
#include <esp_task_wdt.h>
#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];