mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 13:52:15 +01:00
fix: edit ops review findings
From the review of 7723711:
- The assembler is locked: a client closed for a full queue is closed
by the task that was sending, loop(), whose disconnect dropped the
buffer while async_tcp could be copying into it. A whole message is
taken out of it, so a disconnect can't free it while it is read.
- loop() expires a message stalled for 10 s, freeing its buffer and
telling its client, instead of holding it until another client came.
A binary message is refused before it can touch another's buffer.
- Each edit takes only the fields its route reads; one it would ignore
is refused by name. linkRemote's rolling code goes in under the
route's name, so applyModelEdit is unchanged again.
- An edit refused for an id that doesn't exist replies not found
(edit_result_t::missing). A reply that doesn't fit its buffer goes
as an internal error, so the op still gets one.
- The TYPE table says what becomes of each op (proto_op_t), which the
socket switches on. The reply address is one proto_reply_to_t. The
routes and the ops start and queue an edit the same way
(newModelEdit, submitModelEdit). The full-list texts and the offered
remote address rule live in ModelEditor.
- e2e: the sort order test skips a list that it would renumber.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
77237111ea
commit
8fa087aa89
23 changed files with 486 additions and 257 deletions
|
|
@ -9,7 +9,7 @@ Why the protocol looks like this: [ADR 0005](adr/0005-one-websocket-protocol.md)
|
|||
- `ws://host/ws` on port 80. Under Secured the upgrade needs `?apikey=<token>` from `/login`; without a valid one it is refused with 401 ([ADR 0006](adr/0006-two-security-modes-eleven-http-routes.md)).
|
||||
- Optional `?schema=<hash>`: the client already has the schema with that hash and doesn't need it again.
|
||||
- At most 5 clients. A sixth is closed as soon as it connects.
|
||||
- Text messages of at most 4 KB. A message may be sent as several frames, and a frame larger than one TCP segment (about 1.4 KB) reaches the device in pieces anyway; the device puts it back together. It does that for one message at a time across all clients: while it is, another client's long message is answered with busy, and the client sends it again. A message that takes over 10 s to arrive may lose its place to another. These refusals have no `id`, since the message couldn't be read. A binary encoding, if one comes, would be asked for at the upgrade (for example `?enc=bin`) and use binary frames.
|
||||
- Text messages of at most 4 KB. A message may be sent as several frames, and a frame larger than one TCP segment (about 1.4 KB) reaches the device in pieces anyway; the device puts it back together. It does that for one message at a time across all clients: while it is, another client's long message is answered with busy, and the client sends it again. A message that hasn't arrived in full within 10 s is given up, and its client is told with a bad request. These refusals have no `id`, since the message couldn't be read. A binary encoding, if one comes, would be asked for at the upgrade (for example `?enc=bin`) and use binary frames.
|
||||
|
||||
## Envelope
|
||||
|
||||
|
|
@ -107,9 +107,9 @@ Every op carries an `id` and gets one reply. The ops do what the HTTP routes of
|
|||
Each edit op is the edit route of the same name: `addRoom`, `addShade`, `addGroup`, `saveRoom`, `saveShade`, `saveGroup`, `deleteRoom`, `deleteShade`, `deleteGroup`, `linkRemote`, `unlinkRemote`, `linkToGroup`, `unlinkFromGroup`, `linkRepeater`, `unlinkRepeater`, `setPaired`, `roomSortOrder`, `shadeSortOrder`, `groupSortOrder`.
|
||||
|
||||
- The payload holds what the route's request does, under the dictionary's keys: a room's, shade's or group's fields as in the sync, and its id to save, delete or link it. `linkRemote` takes `remoteAddress` and `lastRollingCode`; `linkRepeater` takes `remoteAddress`. A sort order takes `ids`, the ids in their new order.
|
||||
- As with the commands, only the fields that were sent are applied. A key that isn't in the dictionary, or a value of the wrong kind, is a bad request before anything is queued.
|
||||
- As with the commands, only the fields that were sent are applied. A key that isn't in the dictionary, a field the edit doesn't set (a save doesn't set `paired` or `linkedRemotes`, for one), or a value of the wrong kind, is a bad request before anything is queued.
|
||||
- One edit runs at a time, whichever client or route sent it: while one is waiting, another gets busy.
|
||||
- The reply comes once the edit is applied and saved. An add's reply carries the new item's id (`roomId`, `shadeId` or `groupId`). A refused edit is a bad request with the route's reason in `desc`, and for a shade whose settings don't add up, the reason's number in `validation`.
|
||||
- The reply comes once the edit is applied and saved. An add's reply carries the new item's id (`roomId`, `shadeId` or `groupId`). A refused edit has the route's reason in `desc`: not found for an id that doesn't exist, otherwise a bad request, with the reason's number in `validation` for a shade whose settings don't add up.
|
||||
- The edit's events follow: the whole item for an add or save, `*Removed` for a delete, the new order for a sort order, `repeaters` for a repeater.
|
||||
|
||||
## Reads
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ void LoopQueue::drain() {
|
|||
const shade_request_result_t result = shadeRequests.run(item.request);
|
||||
if(result == shade_request_result_t::radio_refused)
|
||||
ESP_LOGW(TAG, "Radio didn't take shade request %u", static_cast<uint8_t>(item.request.type));
|
||||
if(item.replyClient) protoSocket.replyRequest(item.replyClient, item.replyId, item.request, result);
|
||||
if(item.replyTo.client) protoSocket.replyRequest(item.replyTo, item.request, result);
|
||||
break;
|
||||
}
|
||||
case loop_item_type_t::restore_shades: {
|
||||
|
|
@ -85,7 +85,7 @@ void LoopQueue::drain() {
|
|||
runModelEdit(item.edit);
|
||||
break;
|
||||
case loop_item_type_t::proto_read:
|
||||
protoSocket.replyRead(item.replyClient, item.replyId, item.readOp, item.readId);
|
||||
protoSocket.replyRead(item.replyTo, item.readOp, item.groupId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
#define LOOP_QUEUE_H
|
||||
#include <stdint.h>
|
||||
#include "ShadeRequests.h"
|
||||
#include "ProtoFrame.h"
|
||||
|
||||
// The one queue through which other tasks hand work to loop(), which alone
|
||||
// changes the shade model (docs/adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md).
|
||||
|
|
@ -34,12 +35,11 @@ struct loop_item_t {
|
|||
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
|
||||
// shade_request and proto_read from a protocol 2 client: loop() replies to
|
||||
// this socket client with replyId once it has run. 0 for no reply.
|
||||
uint32_t replyClient = 0;
|
||||
uint32_t replyId = 0;
|
||||
// shade_request and proto_read from a protocol 2 client: loop() replies
|
||||
// once it has run.
|
||||
proto_reply_to_t replyTo;
|
||||
uint8_t readOp = 0; // proto_read: the op's type
|
||||
uint8_t readId = 0; // proto_read: the group, for groupOptions
|
||||
uint8_t groupId = 0; // proto_read: groupOptions' group
|
||||
};
|
||||
|
||||
// Whether the item sends anything on air.
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ uint32_t freeRemoteAddress(uint32_t candidate, const uint32_t *used, size_t coun
|
|||
}
|
||||
return candidate;
|
||||
}
|
||||
uint32_t offeredRemoteAddress(uint32_t startingAddress, uint8_t id, const uint32_t *used, size_t count) {
|
||||
return freeRemoteAddress(startingAddress + id, used, count);
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
static bool addTo(T *list, size_t size, T item) {
|
||||
|
|
|
|||
|
|
@ -26,9 +26,10 @@ enum class model_edit_type_t : uint8_t {
|
|||
// What an edit did, sent to the clients as requestResult. id is the room,
|
||||
// shade or group it added or changed, 0 when none. shadeId is the shade a
|
||||
// group gained or lost. code is the validation error of a shade that wasn't
|
||||
// saved, 0 otherwise.
|
||||
// saved, 0 otherwise. notFound: it was refused for an id that doesn't exist.
|
||||
struct edit_result_t {
|
||||
bool ok = true;
|
||||
bool notFound = false;
|
||||
uint8_t id = 0;
|
||||
uint8_t shadeId = 0;
|
||||
int8_t code = 0;
|
||||
|
|
@ -40,7 +41,17 @@ struct edit_result_t {
|
|||
r.code = code;
|
||||
return r;
|
||||
}
|
||||
static edit_result_t missing(const char *desc) {
|
||||
edit_result_t r = error(desc);
|
||||
r.notFound = true;
|
||||
return r;
|
||||
}
|
||||
};
|
||||
// Why an add, or the read that offers one, is refused when every slot of its
|
||||
// kind is taken.
|
||||
#define EDIT_ROOMS_FULL "Maximum number of rooms exceeded."
|
||||
#define EDIT_SHADES_FULL "Maximum number of shades exceeded."
|
||||
#define EDIT_GROUPS_FULL "Maximum number of groups exceeded."
|
||||
|
||||
// The lists an edit can put in a new order.
|
||||
enum class sort_list_t : uint8_t { none, rooms, shades, groups };
|
||||
|
|
@ -64,6 +75,9 @@ 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);
|
||||
// The remote address a new shade or group with that id is offered: the
|
||||
// starting address plus its id, or the next free one after it.
|
||||
uint32_t offeredRemoteAddress(uint32_t startingAddress, uint8_t id, 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
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ uint32_t nextRemoteAddress(const model_snapshot_t &snap, uint8_t id) {
|
|||
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);
|
||||
return offeredRemoteAddress(snap.startingAddress, id, used, count);
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
|
|
|
|||
|
|
@ -2,41 +2,43 @@
|
|||
#include <string.h>
|
||||
#include "ProtoAssembly.h"
|
||||
|
||||
void MessageAssembler::free() {
|
||||
::free(this->buf);
|
||||
void MessageAssembler::release() {
|
||||
free(this->buf);
|
||||
this->buf = nullptr;
|
||||
this->len = 0;
|
||||
this->owner = 0;
|
||||
}
|
||||
void MessageAssembler::drop(uint32_t clientId) {
|
||||
if(this->owner == clientId) this->free();
|
||||
if(this->stalled == clientId) this->stalled = 0;
|
||||
if(this->owner == clientId) this->release();
|
||||
}
|
||||
uint32_t MessageAssembler::expire(uint32_t now) {
|
||||
if(!this->owner || now - this->startedAt < PROTO_ASSEMBLY_TIMEOUT_MS) return 0;
|
||||
const uint32_t stalled = this->owner;
|
||||
this->release();
|
||||
return stalled;
|
||||
}
|
||||
char *MessageAssembler::take(size_t &len) {
|
||||
char *message = this->buf;
|
||||
len = this->len;
|
||||
this->buf = nullptr;
|
||||
this->release();
|
||||
return message;
|
||||
}
|
||||
assembly_t MessageAssembler::add(const proto_piece_t &piece, uint32_t now) {
|
||||
if(piece.first) {
|
||||
if(this->stalled == piece.clientId) this->stalled = 0;
|
||||
if(this->owner && this->owner != piece.clientId) {
|
||||
if(now - this->startedAt < PROTO_ASSEMBLY_TIMEOUT_MS) return assembly_t::busy;
|
||||
// A message that stalled gives the buffer up after a while.
|
||||
this->stalled = this->owner;
|
||||
}
|
||||
this->free();
|
||||
// Refused without taking the buffer from anyone.
|
||||
if(!piece.text) return assembly_t::notText;
|
||||
if(this->owner && this->owner != piece.clientId) return assembly_t::busy;
|
||||
this->release();
|
||||
this->buf = static_cast<char *>(malloc(this->limit + 1));
|
||||
if(!this->buf) return assembly_t::noMemory;
|
||||
this->owner = piece.clientId;
|
||||
this->startedAt = now;
|
||||
}
|
||||
else if(this->owner != piece.clientId) {
|
||||
if(this->stalled == piece.clientId) {
|
||||
this->stalled = 0;
|
||||
return assembly_t::timedOut;
|
||||
}
|
||||
// The rest of a message that was already answered.
|
||||
return assembly_t::ignored;
|
||||
}
|
||||
else if(this->owner != piece.clientId) return assembly_t::ignored;
|
||||
if(this->len + piece.len > this->limit) {
|
||||
this->free();
|
||||
this->release();
|
||||
return assembly_t::tooLarge;
|
||||
}
|
||||
memcpy(this->buf + this->len, piece.data, piece.len);
|
||||
|
|
|
|||
|
|
@ -6,13 +6,12 @@
|
|||
// Putting a protocol 2 message back together from the pieces the web server
|
||||
// hands over: a frame larger than a TCP segment, or a message sent as several
|
||||
// frames (docs/PROTOCOL.md). One message at a time across every client, in
|
||||
// one buffer taken when its first piece arrives and freed when it is whole, so
|
||||
// nothing is held while no long message is on its way. async_tcp only. Kept
|
||||
// free of the web server so it can be unit tested on the PC
|
||||
// (pio test -e native).
|
||||
// one buffer taken when its first piece arrives and given up when it is whole,
|
||||
// refused or stalled, so nothing is held while no long message is on its way.
|
||||
// Not thread safe: the caller locks it. Kept free of the web server so it can
|
||||
// be unit tested on the PC (pio test -e native).
|
||||
|
||||
// How long a message may take to arrive in full before another client's may
|
||||
// take the buffer.
|
||||
// How long a message may take to arrive in full.
|
||||
#define PROTO_ASSEMBLY_TIMEOUT_MS 10000
|
||||
|
||||
// One piece of a message.
|
||||
|
|
@ -26,36 +25,35 @@ struct proto_piece_t {
|
|||
};
|
||||
enum class assembly_t : uint8_t {
|
||||
more, // wait for the next piece
|
||||
complete, // the message is whole: text() and length(), then done()
|
||||
complete, // the message is whole: take() it
|
||||
ignored, // a piece of a message that was already answered
|
||||
busy, // another client's message holds the buffer
|
||||
tooLarge, // over the limit: the rest of it is ignored
|
||||
notText, // a binary message: the rest of it is ignored
|
||||
noMemory, // the buffer couldn't be had: the rest of it is ignored
|
||||
timedOut // it stalled and another client's took the buffer
|
||||
noMemory // the buffer couldn't be had: the rest of it is ignored
|
||||
};
|
||||
class MessageAssembler {
|
||||
public:
|
||||
explicit MessageAssembler(size_t limit) : limit(limit) {}
|
||||
~MessageAssembler() { this->free(); }
|
||||
// Each reply-worthy result (busy, tooLarge, notText, noMemory, timedOut)
|
||||
// comes once per message, from the piece that caused it.
|
||||
~MessageAssembler() { this->release(); }
|
||||
// Each refusal (busy, tooLarge, notText, noMemory) comes once per message,
|
||||
// from the piece that caused it, so the client gets one reply.
|
||||
assembly_t add(const proto_piece_t &piece, uint32_t now);
|
||||
// The whole message, terminated, until done().
|
||||
const char *text() const { return this->buf; }
|
||||
size_t length() const { return this->len; }
|
||||
void done() { this->free(); }
|
||||
// The complete message, terminated, for the caller to free(): the
|
||||
// assembler holds nothing after it.
|
||||
char *take(size_t &len);
|
||||
// The client has gone: its part of a message goes with it.
|
||||
void drop(uint32_t clientId);
|
||||
// Gives up a message that has taken too long, returning its client, which
|
||||
// is owed a reply; 0 when none has. The rest of it is ignored.
|
||||
uint32_t expire(uint32_t now);
|
||||
bool holding() const { return this->owner != 0; }
|
||||
private:
|
||||
size_t limit;
|
||||
uint32_t owner = 0;
|
||||
uint32_t startedAt = 0;
|
||||
// The client whose stalled message lost the buffer, still to be told.
|
||||
uint32_t stalled = 0;
|
||||
char *buf = nullptr;
|
||||
size_t len = 0;
|
||||
void free();
|
||||
void release();
|
||||
};
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "ProtoEdits.h"
|
||||
|
||||
struct edit_op_t {
|
||||
|
|
@ -34,15 +35,65 @@ bool protoEditType(uint8_t type, model_edit_type_t &out) {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
bool protoIsReadOp(uint8_t type) {
|
||||
switch(static_cast<proto_type_t>(type)) {
|
||||
case proto_type_t::getNextRoom:
|
||||
case proto_type_t::getNextShade:
|
||||
case proto_type_t::getNextGroup:
|
||||
case proto_type_t::groupOptions:
|
||||
return true;
|
||||
default:
|
||||
bool protoIsReadOp(uint8_t type) { return protoOpKind(type) == proto_op_t::read; }
|
||||
|
||||
// The fields each edit's route reads, so a field it would ignore is refused
|
||||
// rather than dropped without a word.
|
||||
static const proto_field_t ROOM_FIELDS[] = {proto_field_t::name, proto_field_t::sortOrder};
|
||||
static const proto_field_t SHADE_FIELDS[] = {
|
||||
proto_field_t::name, proto_field_t::roomId, proto_field_t::remoteAddress, proto_field_t::shadeType,
|
||||
proto_field_t::tiltType, proto_field_t::bitLength, proto_field_t::proto, proto_field_t::upTime,
|
||||
proto_field_t::downTime, proto_field_t::tiltTime, proto_field_t::stepSize, proto_field_t::flipCommands,
|
||||
proto_field_t::flipPosition, proto_field_t::sunSensor, proto_field_t::light, proto_field_t::simMy,
|
||||
proto_field_t::repeats, proto_field_t::gpioUp, proto_field_t::gpioDown, proto_field_t::gpioMy,
|
||||
proto_field_t::gpioLLTrigger
|
||||
};
|
||||
static const proto_field_t GROUP_FIELDS[] = {
|
||||
proto_field_t::name, proto_field_t::roomId, proto_field_t::remoteAddress, proto_field_t::bitLength,
|
||||
proto_field_t::proto, proto_field_t::flipCommands, proto_field_t::sunSensor, proto_field_t::repeats
|
||||
};
|
||||
template<size_t N>
|
||||
static bool in(proto_field_t field, const proto_field_t (&fields)[N]) {
|
||||
for(proto_field_t f : fields)
|
||||
if(f == field) return true;
|
||||
return false;
|
||||
}
|
||||
static bool takes(model_edit_type_t type, proto_field_t f) {
|
||||
switch(type) {
|
||||
case model_edit_type_t::add_room: return in(f, ROOM_FIELDS);
|
||||
case model_edit_type_t::save_room: return f == proto_field_t::roomId || in(f, ROOM_FIELDS);
|
||||
case model_edit_type_t::add_shade: return in(f, SHADE_FIELDS);
|
||||
case model_edit_type_t::save_shade: return f == proto_field_t::shadeId || in(f, SHADE_FIELDS);
|
||||
case model_edit_type_t::add_group: return in(f, GROUP_FIELDS);
|
||||
case model_edit_type_t::save_group: return f == proto_field_t::groupId || in(f, GROUP_FIELDS);
|
||||
case model_edit_type_t::delete_room: return f == proto_field_t::roomId;
|
||||
case model_edit_type_t::delete_shade: return f == proto_field_t::shadeId;
|
||||
case model_edit_type_t::delete_group: return f == proto_field_t::groupId;
|
||||
case model_edit_type_t::link_remote:
|
||||
return f == proto_field_t::shadeId || f == proto_field_t::remoteAddress || f == proto_field_t::lastRollingCode;
|
||||
case model_edit_type_t::unlink_remote: return f == proto_field_t::shadeId || f == proto_field_t::remoteAddress;
|
||||
case model_edit_type_t::link_to_group:
|
||||
case model_edit_type_t::unlink_from_group: return f == proto_field_t::groupId || f == proto_field_t::shadeId;
|
||||
case model_edit_type_t::link_repeater:
|
||||
case model_edit_type_t::unlink_repeater: return f == proto_field_t::remoteAddress;
|
||||
case model_edit_type_t::set_paired: return f == proto_field_t::shadeId || f == proto_field_t::paired;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
// The name a field has in the edit route's request. Only linkRemote's rolling
|
||||
// code is named apart from the dictionary.
|
||||
static const char *routeName(model_edit_type_t type, proto_field_t f) {
|
||||
if(type == model_edit_type_t::link_remote && f == proto_field_t::lastRollingCode) return "rollingCode";
|
||||
return protoField(f).name;
|
||||
}
|
||||
static const char *kindName(char kind) {
|
||||
switch(kind) {
|
||||
case 'b': return "boolean (1 or 0)";
|
||||
case 'i': return "whole number";
|
||||
case 'n': return "number";
|
||||
case 's': return "string";
|
||||
case 'l': return "list";
|
||||
default: return "object";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,9 +117,6 @@ static bool expandValue(const proto_field_info_t &f, JsonVariantConst v, JsonVar
|
|||
case 's':
|
||||
if(v.is<const char *>()) return out.set(v);
|
||||
return false;
|
||||
case 'l':
|
||||
if(v.is<JsonArrayConst>()) return out.set(v);
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
|
@ -80,6 +128,11 @@ const char *expandEditPayload(model_edit_type_t type, JsonObjectConst d, JsonDoc
|
|||
case model_edit_type_t::shade_order:
|
||||
case model_edit_type_t::group_order: {
|
||||
// The route takes the ids on their own, in their new order.
|
||||
for(JsonPairConst kv : d) {
|
||||
if(strcmp(kv.key().c_str(), protoField(proto_field_t::ids).key) == 0) continue;
|
||||
snprintf(why, sizeof(why), "A sort order takes only ids");
|
||||
return why;
|
||||
}
|
||||
JsonVariantConst ids = d[protoField(proto_field_t::ids).key];
|
||||
if(!ids.is<JsonArrayConst>()) return "ids is missing";
|
||||
for(JsonVariantConst id : ids.as<JsonArrayConst>())
|
||||
|
|
@ -92,15 +145,18 @@ const char *expandEditPayload(model_edit_type_t type, JsonObjectConst d, JsonDoc
|
|||
}
|
||||
JsonObject obj = doc.to<JsonObject>();
|
||||
for(JsonPairConst kv : d) {
|
||||
const proto_field_info_t *f = protoFieldByKey(kv.key().c_str());
|
||||
if(!f) {
|
||||
proto_field_t field;
|
||||
if(!protoFieldOfKey(kv.key().c_str(), field)) {
|
||||
snprintf(why, sizeof(why), "Unknown field %s", kv.key().c_str());
|
||||
return why;
|
||||
}
|
||||
if(!expandValue(*f, kv.value(), obj[f->name].to<JsonVariant>())) {
|
||||
snprintf(why, sizeof(why), "%s is not a %s", f->name,
|
||||
f->kind == 'b' ? "boolean (1 or 0)" : f->kind == 'i' ? "whole number" : f->kind == 'n' ? "number" :
|
||||
f->kind == 's' ? "string" : "list");
|
||||
const proto_field_info_t &f = protoField(field);
|
||||
if(!takes(type, field)) {
|
||||
snprintf(why, sizeof(why), "%s can't be set by this edit", f.name);
|
||||
return why;
|
||||
}
|
||||
if(!expandValue(f, kv.value(), obj[routeName(type, field)].to<JsonVariant>())) {
|
||||
snprintf(why, sizeof(why), "%s is not a %s", f.name, kindName(f.kind));
|
||||
return why;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,13 @@
|
|||
// nothing of the shade model (docs/PROTOCOL.md). Kept free of the shade model
|
||||
// and the web server so it can be unit tested on the PC (pio test -e native).
|
||||
|
||||
// Who an op's reply goes to when loop() sends it: the socket client and the
|
||||
// op's id. A client of 0 wants no reply.
|
||||
struct proto_reply_to_t {
|
||||
uint32_t client = 0;
|
||||
uint32_t id = 0;
|
||||
};
|
||||
|
||||
// A frame: {"v":2,"t":<type>,"id":<id>,"d":{ ... }}. id 0 leaves it out.
|
||||
void beginFrame(JsonFormatter &json, proto_type_t type, uint32_t id = 0);
|
||||
void endFrame(JsonFormatter &json);
|
||||
|
|
|
|||
|
|
@ -103,9 +103,7 @@ bool protoCommand(const char *name, somfy_commands &cmd) {
|
|||
return false;
|
||||
}
|
||||
|
||||
bool protoIsShadeOp(uint8_t type) {
|
||||
return type >= static_cast<uint8_t>(proto_type_t::shadeCommand) && type <= static_cast<uint8_t>(proto_type_t::setRollingCode);
|
||||
}
|
||||
bool protoIsShadeOp(uint8_t type) { return protoOpKind(type) == proto_op_t::shadeRequest; }
|
||||
|
||||
// The repeat count and step size a command may carry.
|
||||
static void readRepeat(OpReader &r, shade_request_t &req) {
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@
|
|||
#define PROTO_FIELD_ROW(name, key, kind, def) {#name, key, kind, def},
|
||||
static const proto_field_info_t FIELDS[] = { PROTO_FIELDS(PROTO_FIELD_ROW) };
|
||||
#undef PROTO_FIELD_ROW
|
||||
// Errors and feeds have no sender, so it is left empty and starts as 0.
|
||||
#define PROTO_NAMED_ROW(name, number, ...) {number, #name, PROTO_SENDER(__VA_ARGS__, 0)},
|
||||
#define PROTO_SENDER(sender, ...) sender
|
||||
static const proto_named_t TYPES[] = { PROTO_TYPES(PROTO_NAMED_ROW) };
|
||||
#define PROTO_TYPE_ROW(name, number, sender, op) {number, #name, sender, op},
|
||||
static const proto_named_t TYPES[] = { PROTO_TYPES(PROTO_TYPE_ROW) };
|
||||
#undef PROTO_TYPE_ROW
|
||||
// Errors and feeds have no sender or op.
|
||||
#define PROTO_NAMED_ROW(name, number) {number, #name, 0, 0},
|
||||
static const proto_named_t ERRORS[] = { PROTO_ERRORS(PROTO_NAMED_ROW) };
|
||||
static const proto_named_t FEEDS[] = { PROTO_FEEDS(PROTO_NAMED_ROW) };
|
||||
#undef PROTO_SENDER
|
||||
#undef PROTO_NAMED_ROW
|
||||
static_assert(sizeof(FIELDS) / sizeof(FIELDS[0]) == static_cast<size_t>(proto_field_t::count), "a field without a row");
|
||||
|
||||
|
|
@ -21,6 +21,12 @@ const proto_field_info_t *protoFieldByKey(const char *key) {
|
|||
if(strcmp(f.key, key) == 0) return &f;
|
||||
return nullptr;
|
||||
}
|
||||
bool protoFieldOfKey(const char *key, proto_field_t &out) {
|
||||
const proto_field_info_t *f = protoFieldByKey(key);
|
||||
if(!f) return false;
|
||||
out = static_cast<proto_field_t>(f - FIELDS);
|
||||
return true;
|
||||
}
|
||||
uint8_t protoTypeCount() { return sizeof(TYPES) / sizeof(TYPES[0]); }
|
||||
const proto_named_t &protoTypeAt(uint8_t index) { return TYPES[index]; }
|
||||
uint8_t protoErrorCount() { return sizeof(ERRORS) / sizeof(ERRORS[0]); }
|
||||
|
|
@ -32,6 +38,11 @@ const char *protoTypeName(uint8_t number) {
|
|||
if(t.number == number) return t.name;
|
||||
return nullptr;
|
||||
}
|
||||
proto_op_t protoOpKind(uint8_t type) {
|
||||
for(const proto_named_t &t : TYPES)
|
||||
if(t.number == type) return static_cast<proto_op_t>(t.op);
|
||||
return proto_op_t::none;
|
||||
}
|
||||
bool protoClientMaySend(uint8_t type) {
|
||||
for(const proto_named_t &t : TYPES)
|
||||
if(t.number == type) return t.sender == 'c' || t.sender == 'b';
|
||||
|
|
|
|||
|
|
@ -15,66 +15,67 @@
|
|||
|
||||
// Message types: 1-15 control, 16-127 client ops, 128-255 device events. A
|
||||
// number is never reused; a retired type stays reserved.
|
||||
// TYPE(name, number, sender): sender is 'c' client, 'd' device, 'b' both.
|
||||
// TYPE(name, number, sender, op): sender is 'c' client, 'd' device, 'b' both;
|
||||
// op is what becomes of a client op (proto_op_t), '-' for the rest.
|
||||
#define PROTO_TYPES(TYPE) \
|
||||
TYPE(hello, 1, 'd') \
|
||||
TYPE(reply, 2, 'd') \
|
||||
TYPE(next, 3, 'c') \
|
||||
TYPE(sync, 4, 'b') \
|
||||
TYPE(syncEnd, 5, 'd') \
|
||||
TYPE(subscribe, 6, 'c') \
|
||||
TYPE(unsubscribe, 7, 'c') \
|
||||
TYPE(shadeCommand, 16, 'c') \
|
||||
TYPE(groupCommand, 17, 'c') \
|
||||
TYPE(tiltCommand, 18, 'c') \
|
||||
TYPE(repeatCommand, 19, 'c') \
|
||||
TYPE(setPositions, 20, 'c') \
|
||||
TYPE(setSensor, 21, 'c') \
|
||||
TYPE(setMyPosition, 22, 'c') \
|
||||
TYPE(unpairShade, 23, 'c') \
|
||||
TYPE(setRollingCode, 24, 'c') \
|
||||
TYPE(sendRemoteCommand, 25, 'c') \
|
||||
TYPE(addRoom, 26, 'c') \
|
||||
TYPE(addShade, 27, 'c') \
|
||||
TYPE(addGroup, 28, 'c') \
|
||||
TYPE(saveRoom, 29, 'c') \
|
||||
TYPE(saveShade, 30, 'c') \
|
||||
TYPE(saveGroup, 31, 'c') \
|
||||
TYPE(deleteRoom, 32, 'c') \
|
||||
TYPE(deleteShade, 33, 'c') \
|
||||
TYPE(deleteGroup, 34, 'c') \
|
||||
TYPE(linkRemote, 35, 'c') \
|
||||
TYPE(unlinkRemote, 36, 'c') \
|
||||
TYPE(linkToGroup, 37, 'c') \
|
||||
TYPE(unlinkFromGroup, 38, 'c') \
|
||||
TYPE(linkRepeater, 39, 'c') \
|
||||
TYPE(unlinkRepeater, 40, 'c') \
|
||||
TYPE(setPaired, 41, 'c') \
|
||||
TYPE(roomSortOrder, 42, 'c') \
|
||||
TYPE(shadeSortOrder, 43, 'c') \
|
||||
TYPE(groupSortOrder, 44, 'c') \
|
||||
TYPE(getNextRoom, 45, 'c') \
|
||||
TYPE(getNextShade, 46, 'c') \
|
||||
TYPE(getNextGroup, 47, 'c') \
|
||||
TYPE(groupOptions, 48, 'c') \
|
||||
TYPE(shadeState, 128, 'd') \
|
||||
TYPE(shade, 129, 'd') \
|
||||
TYPE(shadeRemoved, 130, 'd') \
|
||||
TYPE(group, 131, 'd') \
|
||||
TYPE(groupState, 132, 'd') \
|
||||
TYPE(groupRemoved, 133, 'd') \
|
||||
TYPE(room, 134, 'd') \
|
||||
TYPE(roomRemoved, 135, 'd') \
|
||||
TYPE(roomOrder, 136, 'd') \
|
||||
TYPE(shadeOrder, 137, 'd') \
|
||||
TYPE(groupOrder, 138, 'd') \
|
||||
TYPE(shadeCommanded, 139, 'd') \
|
||||
TYPE(memory, 140, 'd') \
|
||||
TYPE(wifi, 141, 'd') \
|
||||
TYPE(ethernet, 142, 'd') \
|
||||
TYPE(frame, 143, 'd') \
|
||||
TYPE(frequencyScan, 144, 'd') \
|
||||
TYPE(repeaters, 145, 'd')
|
||||
TYPE(hello, 1, 'd', '-') \
|
||||
TYPE(reply, 2, 'd', '-') \
|
||||
TYPE(next, 3, 'c', '-') \
|
||||
TYPE(sync, 4, 'b', '-') \
|
||||
TYPE(syncEnd, 5, 'd', '-') \
|
||||
TYPE(subscribe, 6, 'c', '-') \
|
||||
TYPE(unsubscribe, 7, 'c', '-') \
|
||||
TYPE(shadeCommand, 16, 'c', 'q') \
|
||||
TYPE(groupCommand, 17, 'c', 'q') \
|
||||
TYPE(tiltCommand, 18, 'c', 'q') \
|
||||
TYPE(repeatCommand, 19, 'c', 'q') \
|
||||
TYPE(setPositions, 20, 'c', 'q') \
|
||||
TYPE(setSensor, 21, 'c', 'q') \
|
||||
TYPE(setMyPosition, 22, 'c', 'q') \
|
||||
TYPE(unpairShade, 23, 'c', 'q') \
|
||||
TYPE(setRollingCode, 24, 'c', 'q') \
|
||||
TYPE(sendRemoteCommand, 25, 'c', 'f') \
|
||||
TYPE(addRoom, 26, 'c', 'e') \
|
||||
TYPE(addShade, 27, 'c', 'e') \
|
||||
TYPE(addGroup, 28, 'c', 'e') \
|
||||
TYPE(saveRoom, 29, 'c', 'e') \
|
||||
TYPE(saveShade, 30, 'c', 'e') \
|
||||
TYPE(saveGroup, 31, 'c', 'e') \
|
||||
TYPE(deleteRoom, 32, 'c', 'e') \
|
||||
TYPE(deleteShade, 33, 'c', 'e') \
|
||||
TYPE(deleteGroup, 34, 'c', 'e') \
|
||||
TYPE(linkRemote, 35, 'c', 'e') \
|
||||
TYPE(unlinkRemote, 36, 'c', 'e') \
|
||||
TYPE(linkToGroup, 37, 'c', 'e') \
|
||||
TYPE(unlinkFromGroup, 38, 'c', 'e') \
|
||||
TYPE(linkRepeater, 39, 'c', 'e') \
|
||||
TYPE(unlinkRepeater, 40, 'c', 'e') \
|
||||
TYPE(setPaired, 41, 'c', 'e') \
|
||||
TYPE(roomSortOrder, 42, 'c', 'e') \
|
||||
TYPE(shadeSortOrder, 43, 'c', 'e') \
|
||||
TYPE(groupSortOrder, 44, 'c', 'e') \
|
||||
TYPE(getNextRoom, 45, 'c', 'r') \
|
||||
TYPE(getNextShade, 46, 'c', 'r') \
|
||||
TYPE(getNextGroup, 47, 'c', 'r') \
|
||||
TYPE(groupOptions, 48, 'c', 'r') \
|
||||
TYPE(shadeState, 128, 'd', '-') \
|
||||
TYPE(shade, 129, 'd', '-') \
|
||||
TYPE(shadeRemoved, 130, 'd', '-') \
|
||||
TYPE(group, 131, 'd', '-') \
|
||||
TYPE(groupState, 132, 'd', '-') \
|
||||
TYPE(groupRemoved, 133, 'd', '-') \
|
||||
TYPE(room, 134, 'd', '-') \
|
||||
TYPE(roomRemoved, 135, 'd', '-') \
|
||||
TYPE(roomOrder, 136, 'd', '-') \
|
||||
TYPE(shadeOrder, 137, 'd', '-') \
|
||||
TYPE(groupOrder, 138, 'd', '-') \
|
||||
TYPE(shadeCommanded, 139, 'd', '-') \
|
||||
TYPE(memory, 140, 'd', '-') \
|
||||
TYPE(wifi, 141, 'd', '-') \
|
||||
TYPE(ethernet, 142, 'd', '-') \
|
||||
TYPE(frame, 143, 'd', '-') \
|
||||
TYPE(frequencyScan, 144, 'd', '-') \
|
||||
TYPE(repeaters, 145, 'd', '-')
|
||||
|
||||
// Fields, in one dictionary for every type. FIELD(name, key, kind, default):
|
||||
// kind is b bool (sent as 1/0), i integer, n number with a fraction, s string,
|
||||
|
|
@ -222,9 +223,20 @@ struct proto_named_t {
|
|||
uint8_t number;
|
||||
const char *name;
|
||||
char sender; // types only
|
||||
char op; // types only
|
||||
};
|
||||
// What becomes of a client op, from the TYPE table.
|
||||
enum class proto_op_t : char {
|
||||
none = '-', // not an op
|
||||
shadeRequest = 'q', // a command: a shade request loop() runs
|
||||
remoteFrame = 'f', // sendRemoteCommand: a frame that goes to the radio at once
|
||||
edit = 'e', // an edit loop() applies
|
||||
read = 'r' // a read loop() answers from the shade model
|
||||
};
|
||||
const proto_field_info_t &protoField(proto_field_t field);
|
||||
const proto_field_info_t *protoFieldByKey(const char *key);
|
||||
// The field with that key; false when there is none.
|
||||
bool protoFieldOfKey(const char *key, proto_field_t &out);
|
||||
// The tables as the schema lists them, in declaration order.
|
||||
uint8_t protoTypeCount();
|
||||
const proto_named_t &protoTypeAt(uint8_t index);
|
||||
|
|
@ -235,6 +247,7 @@ const proto_named_t &protoFeedAt(uint8_t index);
|
|||
const char *protoTypeName(uint8_t number);
|
||||
// What a client may send: control messages it sends and the ops.
|
||||
bool protoClientMaySend(uint8_t type);
|
||||
proto_op_t protoOpKind(uint8_t type);
|
||||
// Hex FNV-1a of every row of the schema: 8 characters and a terminator.
|
||||
const char *protoSchemaHash();
|
||||
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ uint32_t SomfyShadeController::getNextRemoteAddress(uint8_t id) {
|
|||
if(this->shades[i].getShadeId() != 255) used[count++] = this->shades[i].getRemoteAddress();
|
||||
for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++)
|
||||
if(this->groups[i].getGroupId() != 255) used[count++] = this->groups[i].getRemoteAddress();
|
||||
return freeRemoteAddress(this->startingAddress + id, used, count);
|
||||
return offeredRemoteAddress(this->startingAddress, id, used, count);
|
||||
}
|
||||
uint8_t SomfyShadeController::getNextRoomId() {
|
||||
uint8_t ids[SOMFY_MAX_ROOMS];
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
#define SOMFY_EDITOR_H
|
||||
#include <ArduinoJson.h>
|
||||
#include "ModelEditor.h"
|
||||
#include "ProtoFrame.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
|
||||
|
|
@ -9,13 +10,11 @@
|
|||
|
||||
// One edit on its way to loop(): the edit route and a copy of its request.
|
||||
// From an edit route its result goes to every client as requestResult; from a
|
||||
// protocol 2 edit op, as the reply to replyId for the socket client
|
||||
// replyClient.
|
||||
// protocol 2 edit op, as its reply.
|
||||
struct model_edit_t {
|
||||
model_edit_type_t type = model_edit_type_t::add_room;
|
||||
uint32_t requestId = 0;
|
||||
uint32_t replyClient = 0;
|
||||
uint32_t replyId = 0;
|
||||
proto_reply_to_t replyTo;
|
||||
JsonDocument doc;
|
||||
};
|
||||
|
||||
|
|
@ -25,6 +24,12 @@ bool beginModelEdit();
|
|||
uint32_t nextEditRequestId();
|
||||
// Gives up an edit that was begun but never queued. Any task.
|
||||
void cancelModelEdit(model_edit_t *edit);
|
||||
// Begins an edit of that type: nullptr when one is already waiting or running
|
||||
// (busy), or there was no memory for it. Any task.
|
||||
model_edit_t *newModelEdit(model_edit_type_t type, bool &busy);
|
||||
// Hands a begun edit to loop(); false when the queue is full, and it is given
|
||||
// up. Any task.
|
||||
bool submitModelEdit(model_edit_t *edit);
|
||||
// Applies the edit, saves it, tells how it went and frees it. loop() only.
|
||||
void runModelEdit(model_edit_t *edit);
|
||||
// Applies the edit to the shade model and saves it. loop() only.
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@
|
|||
#include "SomfyEditor.h"
|
||||
#include "ShadeChanges.h"
|
||||
#include "outputs/ProtoSocket.h"
|
||||
#include "LoopQueue.h"
|
||||
|
||||
static const char *TAG = "Somfy";
|
||||
|
||||
extern SomfyShadeController somfy;
|
||||
extern ConfigSettings settings;
|
||||
extern LoopQueue loopQueue;
|
||||
|
||||
int8_t SomfyShade::validateJSON(JsonObject &obj) {
|
||||
int8_t ret = 0;
|
||||
|
|
@ -228,6 +230,25 @@ void cancelModelEdit(model_edit_t *edit) {
|
|||
delete edit;
|
||||
editBusy.store(false);
|
||||
}
|
||||
model_edit_t *newModelEdit(model_edit_type_t type, bool &busy) {
|
||||
busy = !beginModelEdit();
|
||||
if(busy) return nullptr;
|
||||
model_edit_t *edit = new (std::nothrow) model_edit_t();
|
||||
if(!edit) {
|
||||
cancelModelEdit(nullptr);
|
||||
return nullptr;
|
||||
}
|
||||
edit->type = type;
|
||||
return edit;
|
||||
}
|
||||
bool submitModelEdit(model_edit_t *edit) {
|
||||
loop_item_t item;
|
||||
item.type = loop_item_type_t::model_edit;
|
||||
item.edit = edit;
|
||||
if(!edit->doc.overflowed() && loopQueue.submit(item)) return true;
|
||||
cancelModelEdit(edit);
|
||||
return false;
|
||||
}
|
||||
// What protocol 2 clients get after an edit (editChanges). A shade or group
|
||||
// goes out whole from the change fan-out, by its config bit, so it is sent
|
||||
// once with whatever else changed; the rest goes out now.
|
||||
|
|
@ -246,7 +267,7 @@ 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);
|
||||
else markEdited(*edit, result);
|
||||
if(edit->replyClient) protoSocket.replyEdit(edit->replyClient, edit->replyId, edit->type, result);
|
||||
if(edit->replyTo.client) protoSocket.replyEdit(edit->replyTo, edit->type, result);
|
||||
else emitRequestResult(edit->requestId, result);
|
||||
cancelModelEdit(edit);
|
||||
}
|
||||
|
|
@ -273,7 +294,7 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
|
|||
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.");
|
||||
if(!room) return edit_result_t::error(EDIT_ROOMS_FULL);
|
||||
room->fromJSON(obj);
|
||||
room->save();
|
||||
room->emitState("roomAdded");
|
||||
|
|
@ -281,7 +302,7 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
|
|||
}
|
||||
case model_edit_type_t::add_shade: {
|
||||
SomfyShade *shade = somfy.addShade();
|
||||
if(!shade) return edit_result_t::error("Maximum number of shades exceeded.");
|
||||
if(!shade) return edit_result_t::error(EDIT_SHADES_FULL);
|
||||
const int8_t err = shade->fromJSON(obj);
|
||||
if(err != 0) {
|
||||
// Not added after all: free the slot it took.
|
||||
|
|
@ -294,7 +315,7 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
|
|||
}
|
||||
case model_edit_type_t::add_group: {
|
||||
SomfyGroup *group = somfy.addGroup();
|
||||
if(!group) return edit_result_t::error("Maximum number of groups exceeded.");
|
||||
if(!group) return edit_result_t::error(EDIT_GROUPS_FULL);
|
||||
group->fromJSON(obj);
|
||||
group->save();
|
||||
group->emitState("groupAdded");
|
||||
|
|
@ -303,7 +324,7 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
|
|||
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.");
|
||||
if(!room) return edit_result_t::missing("Room Id not found.");
|
||||
room->fromJSON(obj);
|
||||
room->save();
|
||||
room->emitState();
|
||||
|
|
@ -312,7 +333,7 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
|
|||
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.");
|
||||
if(!shade) return edit_result_t::missing("Shade Id not found.");
|
||||
const int8_t err = shade->fromJSON(obj);
|
||||
if(err != 0) return edit_result_t::error("Data Error.", err);
|
||||
shade->save();
|
||||
|
|
@ -322,7 +343,7 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
|
|||
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.");
|
||||
if(!group) return edit_result_t::missing("Group Id not found.");
|
||||
group->fromJSON(obj);
|
||||
group->save();
|
||||
group->emitState();
|
||||
|
|
@ -331,7 +352,7 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
|
|||
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.");
|
||||
if(!somfy.getRoomById(roomId)) return edit_result_t::missing("Room with the specified id not found.");
|
||||
somfy.deleteRoom(roomId);
|
||||
return done(roomId);
|
||||
}
|
||||
|
|
@ -339,7 +360,7 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
|
|||
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) return edit_result_t::missing("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);
|
||||
|
|
@ -347,7 +368,7 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
|
|||
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.");
|
||||
if(!somfy.getGroupById(groupId)) return edit_result_t::missing("Group with the specified id not found.");
|
||||
somfy.deleteGroup(groupId);
|
||||
return done(groupId);
|
||||
}
|
||||
|
|
@ -355,13 +376,11 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
|
|||
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(!shade) return edit_result_t::missing("Shade Id not found.");
|
||||
if(obj["remoteAddress"].isNull()) return edit_result_t::error("Remote address not provided.");
|
||||
const uint32_t address = obj["remoteAddress"];
|
||||
// The route's name, or protocol 2's.
|
||||
const uint16_t rollingCode = obj["rollingCode"] | (obj["lastRollingCode"] | (uint16_t)0);
|
||||
if(edit.type == model_edit_type_t::unlink_remote) shade->unlinkRemote(address);
|
||||
else if(!shade->linkRemote(address, rollingCode))
|
||||
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());
|
||||
}
|
||||
|
|
@ -372,8 +391,8 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
|
|||
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(!group) return edit_result_t::missing("Group id not found.");
|
||||
if(!somfy.getShadeById(shadeId)) return edit_result_t::missing("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();
|
||||
|
|
@ -393,7 +412,7 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
|
|||
}
|
||||
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");
|
||||
if(!shade) return edit_result_t::missing("Shade not found to pair");
|
||||
shade->paired = obj["paired"] | false;
|
||||
shade->save();
|
||||
shade->emitState();
|
||||
|
|
|
|||
|
|
@ -81,25 +81,17 @@ static void onUploadDisconnect() {
|
|||
// 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();
|
||||
bool busy;
|
||||
model_edit_t *edit = newModelEdit(type, busy);
|
||||
if(!edit) {
|
||||
cancelModelEdit(nullptr);
|
||||
sendQueueFull(request);
|
||||
if(busy) request->send(503, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"Busy, try the change again\"}"));
|
||||
else 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);
|
||||
if(!submitModelEdit(edit)) {
|
||||
sendQueueFull(request);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <algorithm>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include "esp_log.h"
|
||||
#include "ProtoSocket.h"
|
||||
#include "ProtoSync.h"
|
||||
|
|
@ -40,8 +42,16 @@ static_assert(MAX_SOCK_RESPONSE >= PROTO_MAX_FRAME, "protocol 2 frames share the
|
|||
static proto_slot_t slots[PROTO_MAX_CLIENTS];
|
||||
// What loop() formats events and syncEnd with, into the socket event buffer.
|
||||
static BufferedJsonFormatter eventJson;
|
||||
// Messages that arrive in pieces. async_tcp only.
|
||||
// Messages that arrive in pieces. async_tcp adds to it, and a disconnect can
|
||||
// drop from it on another task (a client closed for a full queue is closed by
|
||||
// whoever was sending), so it is only touched under its lock, and never while
|
||||
// replying.
|
||||
static MessageAssembler assembler(PROTO_MAX_CLIENT_FRAME);
|
||||
static SemaphoreHandle_t assemblerMutex = nullptr;
|
||||
struct AssemblerLock {
|
||||
AssemblerLock() { xSemaphoreTake(assemblerMutex, portMAX_DELAY); }
|
||||
~AssemblerLock() { xSemaphoreGive(assemblerMutex); }
|
||||
};
|
||||
|
||||
/*********************************************************************
|
||||
* The shade model as a sync source
|
||||
|
|
@ -163,8 +173,7 @@ static void queueShadeOp(AsyncWebSocketClient *client, const proto_message_t &re
|
|||
replyError(client, req.id, proto_error_t::radioUnavailable, "The radio is paused");
|
||||
return;
|
||||
}
|
||||
item.replyClient = client->id();
|
||||
item.replyId = req.id;
|
||||
item.replyTo = {client->id(), req.id};
|
||||
if(!loopQueue.submit(item)) replyError(client, req.id, proto_error_t::busy, "Command queue full");
|
||||
}
|
||||
// A frame as a remote that isn't one of the shades. It touches nothing of the
|
||||
|
|
@ -182,32 +191,21 @@ static void sendRemoteCommand(AsyncWebSocketClient *client, const proto_message_
|
|||
}
|
||||
// An edit op: loop() applies it as its edit route does, and replies.
|
||||
static void queueEditOp(AsyncWebSocketClient *client, const proto_message_t &msg, model_edit_type_t type, JsonObjectConst d) {
|
||||
if(!beginModelEdit()) {
|
||||
replyError(client, msg.id, proto_error_t::busy, "Another edit is running, send it again");
|
||||
return;
|
||||
}
|
||||
model_edit_t *edit = new (std::nothrow) model_edit_t();
|
||||
bool busy;
|
||||
model_edit_t *edit = newModelEdit(type, busy);
|
||||
if(!edit) {
|
||||
cancelModelEdit(nullptr);
|
||||
replyError(client, msg.id, proto_error_t::internal, "Out of memory");
|
||||
if(busy) replyError(client, msg.id, proto_error_t::busy, "Another edit is running, send it again");
|
||||
else replyError(client, msg.id, proto_error_t::internal, "Out of memory");
|
||||
return;
|
||||
}
|
||||
const char *bad = expandEditPayload(type, d, edit->doc);
|
||||
if(bad) {
|
||||
replyError(client, msg.id, proto_error_t::badRequest, bad);
|
||||
cancelModelEdit(edit);
|
||||
replyError(client, msg.id, proto_error_t::badRequest, bad);
|
||||
return;
|
||||
}
|
||||
edit->type = type;
|
||||
edit->replyClient = client->id();
|
||||
edit->replyId = msg.id;
|
||||
loop_item_t item;
|
||||
item.type = loop_item_type_t::model_edit;
|
||||
item.edit = edit;
|
||||
if(!loopQueue.submit(item)) {
|
||||
cancelModelEdit(edit);
|
||||
replyError(client, msg.id, proto_error_t::busy, "Command queue full");
|
||||
}
|
||||
edit->replyTo = {client->id(), msg.id};
|
||||
if(!submitModelEdit(edit)) replyError(client, msg.id, proto_error_t::busy, "Command queue full");
|
||||
}
|
||||
// A read op: loop() answers it from the shade model.
|
||||
static void queueReadOp(AsyncWebSocketClient *client, const proto_message_t &msg, JsonObjectConst d) {
|
||||
|
|
@ -218,10 +216,9 @@ static void queueReadOp(AsyncWebSocketClient *client, const proto_message_t &msg
|
|||
JsonVariantConst groupId = d[protoField(proto_field_t::groupId).key];
|
||||
if(groupId.isNull()) { replyError(client, msg.id, proto_error_t::badRequest, "groupId is missing"); return; }
|
||||
if(!groupId.is<uint8_t>()) { replyError(client, msg.id, proto_error_t::badRequest, "groupId must be 0-255"); return; }
|
||||
item.readId = groupId.as<uint8_t>();
|
||||
item.groupId = groupId.as<uint8_t>();
|
||||
}
|
||||
item.replyClient = client->id();
|
||||
item.replyId = msg.id;
|
||||
item.replyTo = {client->id(), msg.id};
|
||||
if(!loopQueue.submit(item)) replyError(client, msg.id, proto_error_t::busy, "Command queue full");
|
||||
}
|
||||
static bool authorized(AsyncWebServerRequest *request) {
|
||||
|
|
@ -249,7 +246,10 @@ static void onConnect(AsyncWebSocketClient *client, AsyncWebServerRequest *reque
|
|||
ESP_LOGI(TAG, "Socket [%lu] connected from %u.%u.%u.%u%s", (unsigned long)id, ip[0], ip[1], ip[2], ip[3], withSchema ? "" : " (has the schema)");
|
||||
}
|
||||
static void onDisconnect(AsyncWebSocketClient *client) {
|
||||
{
|
||||
AssemblerLock lock;
|
||||
assembler.drop(client->id());
|
||||
}
|
||||
proto_slot_t *slot = slotOf(slots, PROTO_MAX_CLIENTS, client->id());
|
||||
if(!slot) return;
|
||||
freeSlot(*slot);
|
||||
|
|
@ -286,28 +286,31 @@ static void onMessage(AsyncWebSocketClient *client, const char *text, size_t len
|
|||
replyOk(client, msg.id);
|
||||
return;
|
||||
}
|
||||
case proto_type_t::sendRemoteCommand:
|
||||
sendRemoteCommand(client, msg, doc["d"]);
|
||||
return;
|
||||
default: {
|
||||
if(protoIsShadeOp(msg.type)) {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// The ops, by what becomes of them (the TYPE table).
|
||||
switch(protoOpKind(msg.type)) {
|
||||
case proto_op_t::shadeRequest:
|
||||
queueShadeOp(client, msg, doc["d"]);
|
||||
return;
|
||||
}
|
||||
case proto_op_t::remoteFrame:
|
||||
sendRemoteCommand(client, msg, doc["d"]);
|
||||
return;
|
||||
case proto_op_t::edit: {
|
||||
model_edit_type_t edit;
|
||||
if(protoEditType(msg.type, edit)) {
|
||||
queueEditOp(client, msg, edit, doc["d"]);
|
||||
if(protoEditType(msg.type, edit)) queueEditOp(client, msg, edit, doc["d"]);
|
||||
else replyError(client, msg.id, proto_error_t::internal, "An edit op without an edit");
|
||||
return;
|
||||
}
|
||||
if(protoIsReadOp(msg.type)) {
|
||||
case proto_op_t::read:
|
||||
queueReadOp(client, msg, doc["d"]);
|
||||
return;
|
||||
}
|
||||
case proto_op_t::none:
|
||||
// The settings ops arrive with the protocol core's next PR.
|
||||
replyError(client, msg.id, proto_error_t::unknownOp, "Unknown message type");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
static void onFrame(AsyncWebSocketClient *client, AwsFrameInfo *info, uint8_t *data, size_t len) {
|
||||
// Most messages are a whole text frame in one piece.
|
||||
|
|
@ -327,10 +330,18 @@ static void onFrame(AsyncWebSocketClient *client, AwsFrameInfo *info, uint8_t *d
|
|||
piece.len = len;
|
||||
// Each refusal comes once per message, so the client gets one reply. The
|
||||
// message couldn't be read, so the reply has no id.
|
||||
switch(assembler.add(piece, millis())) {
|
||||
assembly_t result;
|
||||
char *message = nullptr;
|
||||
size_t messageLen = 0;
|
||||
{
|
||||
AssemblerLock lock;
|
||||
result = assembler.add(piece, millis());
|
||||
if(result == assembly_t::complete) message = assembler.take(messageLen);
|
||||
}
|
||||
switch(result) {
|
||||
case assembly_t::complete:
|
||||
onMessage(client, assembler.text(), assembler.length());
|
||||
assembler.done();
|
||||
onMessage(client, message, messageLen);
|
||||
free(message);
|
||||
return;
|
||||
case assembly_t::busy:
|
||||
replyError(client, 0, proto_error_t::busy, "Another long message is on its way, send it again");
|
||||
|
|
@ -344,9 +355,6 @@ static void onFrame(AsyncWebSocketClient *client, AwsFrameInfo *info, uint8_t *d
|
|||
case assembly_t::noMemory:
|
||||
replyError(client, 0, proto_error_t::internal, "Out of memory");
|
||||
return;
|
||||
case assembly_t::timedOut:
|
||||
replyError(client, 0, proto_error_t::badRequest, "The message took too long to arrive");
|
||||
return;
|
||||
case assembly_t::more:
|
||||
case assembly_t::ignored:
|
||||
return;
|
||||
|
|
@ -363,6 +371,7 @@ void ProtoSocket::wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client,
|
|||
}
|
||||
|
||||
void ProtoSocket::begin(AsyncWebServer &server) {
|
||||
assemblerMutex = xSemaphoreCreateMutex();
|
||||
ws.handleHandshake(authorized);
|
||||
ws.onEvent(ProtoSocket::wsEvent);
|
||||
server.addHandler(&ws);
|
||||
|
|
@ -404,25 +413,48 @@ void ProtoSocket::loop() {
|
|||
break;
|
||||
}
|
||||
}
|
||||
// A long message that stalled gives its buffer up, and its client hears so.
|
||||
uint32_t stalled;
|
||||
{
|
||||
AssemblerLock lock;
|
||||
stalled = assembler.expire(millis());
|
||||
}
|
||||
if(stalled) {
|
||||
char buf[160];
|
||||
BufferedJsonFormatter json;
|
||||
json.setBuffer(buf, sizeof(buf));
|
||||
writeErrorReply(json, 0, proto_error_t::badRequest, "The message took too long to arrive");
|
||||
ws.text(stalled, buf, json.length());
|
||||
}
|
||||
ws.cleanupClients(PROTO_MAX_CLIENTS);
|
||||
}
|
||||
|
||||
void ProtoSocket::replyRequest(uint32_t client, uint32_t id, const shade_request_t &req, shade_request_result_t result) {
|
||||
if(!sockEmit.onLoopTask() || id == 0) return;
|
||||
// Sends a reply formatted on loop(). One that didn't fit goes as an internal
|
||||
// error instead, so the op still gets its one reply. A client that has gone
|
||||
// is skipped.
|
||||
static void sendReply(const proto_reply_to_t &to, BufferedJsonFormatter &json, char *buf, size_t size) {
|
||||
if(json.overflowed()) {
|
||||
json.setBuffer(buf, size);
|
||||
writeErrorReply(json, to.id, proto_error_t::internal, "Reply too large");
|
||||
}
|
||||
ws.text(to.client, buf, json.length());
|
||||
}
|
||||
void ProtoSocket::replyRequest(const proto_reply_to_t &to, const shade_request_t &req, shade_request_result_t result) {
|
||||
if(!sockEmit.onLoopTask() || to.id == 0) return;
|
||||
char buf[192];
|
||||
BufferedJsonFormatter json;
|
||||
json.setBuffer(buf, sizeof(buf));
|
||||
const proto_error_t code = protoRequestError(result);
|
||||
if(code == proto_error_t::none) writeOkReply(json, id);
|
||||
else writeErrorReply(json, id, code, protoRequestErrorDesc(req, result));
|
||||
// A client that has gone is skipped.
|
||||
if(!json.overflowed()) ws.text(client, buf, json.length());
|
||||
if(code == proto_error_t::none) writeOkReply(json, to.id);
|
||||
else writeErrorReply(json, to.id, code, protoRequestErrorDesc(req, result));
|
||||
sendReply(to, json, buf, sizeof(buf));
|
||||
}
|
||||
|
||||
// What an edit op's reply says: for an add, the new item's id; for a refused
|
||||
// edit, the route's reason and the shade's validation code when it has one.
|
||||
void ProtoSocket::replyEdit(uint32_t client, uint32_t id, model_edit_type_t type, const edit_result_t &result) {
|
||||
if(!sockEmit.onLoopTask() || id == 0) return;
|
||||
void ProtoSocket::replyEdit(const proto_reply_to_t &to, model_edit_type_t type, const edit_result_t &result) {
|
||||
if(!sockEmit.onLoopTask() || to.id == 0) return;
|
||||
const uint32_t id = to.id;
|
||||
char buf[256];
|
||||
BufferedJsonFormatter json;
|
||||
json.setBuffer(buf, sizeof(buf));
|
||||
|
|
@ -430,7 +462,7 @@ void ProtoSocket::replyEdit(uint32_t client, uint32_t id, model_edit_type_t type
|
|||
ProtoWriter w(json);
|
||||
w.add(proto_field_t::ok, result.ok);
|
||||
if(!result.ok) {
|
||||
w.add(proto_field_t::code, static_cast<uint8_t>(proto_error_t::badRequest));
|
||||
w.add(proto_field_t::code, static_cast<uint8_t>(result.notFound ? proto_error_t::notFound : proto_error_t::badRequest));
|
||||
w.add(proto_field_t::desc, result.desc);
|
||||
w.add(proto_field_t::validation, result.code);
|
||||
}
|
||||
|
|
@ -438,13 +470,13 @@ void ProtoSocket::replyEdit(uint32_t client, uint32_t id, model_edit_type_t type
|
|||
else if(type == model_edit_type_t::add_shade) w.add(proto_field_t::shadeId, result.id);
|
||||
else if(type == model_edit_type_t::add_group) w.add(proto_field_t::groupId, result.id);
|
||||
endFrame(json);
|
||||
// A client that has gone is skipped.
|
||||
if(!json.overflowed()) ws.text(client, buf, json.length());
|
||||
sendReply(to, json, buf, sizeof(buf));
|
||||
}
|
||||
// The reads an edit form needs: what a new room, shade or group would get,
|
||||
// and the shades a group could take.
|
||||
void ProtoSocket::replyRead(uint32_t client, uint32_t id, uint8_t op, uint8_t groupId) {
|
||||
if(!sockEmit.onLoopTask() || id == 0) return;
|
||||
void ProtoSocket::replyRead(const proto_reply_to_t &to, uint8_t op, uint8_t groupId) {
|
||||
if(!sockEmit.onLoopTask() || to.id == 0) return;
|
||||
const uint32_t id = to.id;
|
||||
char buf[256];
|
||||
BufferedJsonFormatter json;
|
||||
json.setBuffer(buf, sizeof(buf));
|
||||
|
|
@ -455,7 +487,7 @@ void ProtoSocket::replyRead(uint32_t client, uint32_t id, uint8_t op, uint8_t gr
|
|||
switch(static_cast<proto_type_t>(op)) {
|
||||
case proto_type_t::getNextRoom: {
|
||||
const uint8_t roomId = somfy.getNextRoomId();
|
||||
if(roomId == 0) { refused = "Maximum number of rooms exceeded."; break; }
|
||||
if(roomId == 0) { refused = EDIT_ROOMS_FULL; break; }
|
||||
w.add(proto_field_t::ok, true);
|
||||
w.add(proto_field_t::roomId, roomId);
|
||||
break;
|
||||
|
|
@ -464,7 +496,7 @@ void ProtoSocket::replyRead(uint32_t client, uint32_t id, uint8_t op, uint8_t gr
|
|||
case proto_type_t::getNextGroup: {
|
||||
const bool isShade = op == static_cast<uint8_t>(proto_type_t::getNextShade);
|
||||
const uint8_t newId = isShade ? somfy.getNextShadeId() : somfy.getNextGroupId();
|
||||
if(newId == 255) { refused = isShade ? "Maximum number of shades exceeded." : "Maximum number of groups exceeded."; break; }
|
||||
if(newId == 255) { refused = isShade ? EDIT_SHADES_FULL : EDIT_GROUPS_FULL; break; }
|
||||
w.add(proto_field_t::ok, true);
|
||||
w.add(isShade ? proto_field_t::shadeId : proto_field_t::groupId, newId);
|
||||
w.add(proto_field_t::remoteAddress, somfy.getNextRemoteAddress(newId));
|
||||
|
|
@ -493,7 +525,7 @@ void ProtoSocket::replyRead(uint32_t client, uint32_t id, uint8_t op, uint8_t gr
|
|||
writeErrorReply(json, id, code, refused);
|
||||
}
|
||||
else endFrame(json);
|
||||
if(!json.overflowed()) ws.text(client, buf, json.length());
|
||||
sendReply(to, json, buf, sizeof(buf));
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
#include "ProtoSchema.h"
|
||||
#include "ModelEditor.h"
|
||||
#include "ShadeRequests.h"
|
||||
#include "ProtoFrame.h"
|
||||
|
||||
class SomfyShade;
|
||||
class SomfyGroup;
|
||||
|
|
@ -25,11 +26,11 @@ class ProtoSocket {
|
|||
void loop();
|
||||
bool hasClients() const;
|
||||
// The reply to a command op once loop() has run its shade request.
|
||||
void replyRequest(uint32_t client, uint32_t id, const shade_request_t &req, shade_request_result_t result);
|
||||
void replyRequest(const proto_reply_to_t &to, const shade_request_t &req, shade_request_result_t result);
|
||||
// The reply to an edit op once loop() has applied it.
|
||||
void replyEdit(uint32_t client, uint32_t id, model_edit_type_t type, const edit_result_t &result);
|
||||
void replyEdit(const proto_reply_to_t &to, model_edit_type_t type, const edit_result_t &result);
|
||||
// The reply to a read op, from the shade model. loop() only.
|
||||
void replyRead(uint32_t client, uint32_t id, uint8_t op, uint8_t groupId);
|
||||
void replyRead(const proto_reply_to_t &to, uint8_t op, uint8_t groupId);
|
||||
// Events, loop() only.
|
||||
void shadeState(SomfyShade &shade);
|
||||
void shade(SomfyShade &shade);
|
||||
|
|
|
|||
|
|
@ -75,15 +75,27 @@ def test_an_edit_of_a_missing_shade_says_why(connect, schema):
|
|||
"""Refused by the edit itself on loop(), as its route would be."""
|
||||
c, shades, _ = synced_model(connect, schema)
|
||||
reply = op(c, schema, "saveShade", {"shadeId": unused(shades, "shadeId"), "name": "x"})
|
||||
assert error_name(schema, reply) == "badRequest"
|
||||
assert error_name(schema, reply) == "notFound"
|
||||
assert reply["d"][schema.keys["desc"]] == "Shade Id not found."
|
||||
|
||||
|
||||
def test_a_field_an_edit_doesnt_take_is_refused(connect, schema):
|
||||
c, shades, _ = synced_model(connect, schema)
|
||||
reply = op(c, schema, "saveShade", {"shadeId": unused(shades, "shadeId"), "paired": 1})
|
||||
assert error_name(schema, reply) == "badRequest"
|
||||
assert reply["d"][schema.keys["desc"]] == "paired can't be set by this edit"
|
||||
|
||||
|
||||
def test_a_sort_order_set_to_what_it_is(connect, schema):
|
||||
c, shades, _ = synced_model(connect, schema)
|
||||
if not shades:
|
||||
pytest.skip("the device has no shades")
|
||||
ids = [s["shadeId"] for s in sorted(shades, key=lambda s: s.get("sortOrder", 0))]
|
||||
ordered = sorted(shades, key=lambda s: s.get("sortOrder", 0))
|
||||
# A sort order numbers the shades 0, 1, 2...: only a list already numbered
|
||||
# so stays as it is.
|
||||
if [s.get("sortOrder", 0) for s in ordered] != list(range(len(ordered))):
|
||||
pytest.skip("the shades' sort orders have gaps or repeats, which a sort order would renumber")
|
||||
ids = [s["shadeId"] for s in ordered]
|
||||
reply = op(c, schema, "shadeSortOrder", {"ids": ids})
|
||||
assert is_ok(schema, reply)
|
||||
order = c.recv_until(lambda f: f["t"] == schema.type_numbers["shadeOrder"])[-1]
|
||||
|
|
|
|||
|
|
@ -50,6 +50,26 @@ static void test_type_names_are_unique() {
|
|||
std::set<std::string> names;
|
||||
for(uint8_t i = 0; i < protoTypeCount(); i++) TEST_ASSERT_TRUE_MESSAGE(names.insert(protoTypeAt(i).name).second, protoTypeAt(i).name);
|
||||
}
|
||||
static void test_every_op_says_what_becomes_of_it() {
|
||||
for(uint8_t i = 0; i < protoTypeCount(); i++) {
|
||||
const proto_named_t &t = protoTypeAt(i);
|
||||
const bool op = t.number >= 16 && t.number <= 127;
|
||||
TEST_ASSERT_EQUAL_MESSAGE(op, protoOpKind(t.number) != proto_op_t::none, t.name);
|
||||
}
|
||||
TEST_ASSERT_EQUAL(proto_op_t::shadeRequest, protoOpKind(static_cast<uint8_t>(proto_type_t::shadeCommand)));
|
||||
TEST_ASSERT_EQUAL(proto_op_t::remoteFrame, protoOpKind(static_cast<uint8_t>(proto_type_t::sendRemoteCommand)));
|
||||
TEST_ASSERT_EQUAL(proto_op_t::edit, protoOpKind(static_cast<uint8_t>(proto_type_t::saveShade)));
|
||||
TEST_ASSERT_EQUAL(proto_op_t::read, protoOpKind(static_cast<uint8_t>(proto_type_t::groupOptions)));
|
||||
TEST_ASSERT_EQUAL(proto_op_t::none, protoOpKind(250));
|
||||
}
|
||||
static void test_a_field_by_its_key() {
|
||||
proto_field_t f;
|
||||
TEST_ASSERT_TRUE(protoFieldOfKey("s", f));
|
||||
TEST_ASSERT_EQUAL(proto_field_t::shadeId, f);
|
||||
TEST_ASSERT_TRUE(protoFieldOfKey("vc", f));
|
||||
TEST_ASSERT_EQUAL(proto_field_t::validation, f);
|
||||
TEST_ASSERT_FALSE(protoFieldOfKey("zz", f));
|
||||
}
|
||||
static void test_error_and_feed_numbers_are_unique() {
|
||||
std::set<uint8_t> errors, feeds;
|
||||
for(uint8_t i = 0; i < protoErrorCount(); i++) TEST_ASSERT_TRUE(errors.insert(protoErrorAt(i).number).second);
|
||||
|
|
@ -386,6 +406,8 @@ int main(int argc, char **argv) {
|
|||
RUN_TEST(test_every_field_has_a_known_kind);
|
||||
RUN_TEST(test_type_numbers_are_unique_and_in_their_sender_range);
|
||||
RUN_TEST(test_type_names_are_unique);
|
||||
RUN_TEST(test_every_op_says_what_becomes_of_it);
|
||||
RUN_TEST(test_a_field_by_its_key);
|
||||
RUN_TEST(test_error_and_feed_numbers_are_unique);
|
||||
RUN_TEST(test_bootstrap_keys_are_fixed);
|
||||
RUN_TEST(test_schema_hash_is_eight_hex_digits_and_stable);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#include <unity.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "ProtoAssembly.h"
|
||||
|
||||
|
|
@ -18,26 +19,32 @@ static proto_piece_t piece(uint32_t client, const char *text, bool first, bool l
|
|||
p.len = strlen(text);
|
||||
return p;
|
||||
}
|
||||
// Takes the complete message and checks it.
|
||||
static void assertTaken(MessageAssembler &a, const char *expected) {
|
||||
size_t len = 0;
|
||||
char *message = a.take(len);
|
||||
TEST_ASSERT_EQUAL_STRING(expected, message);
|
||||
TEST_ASSERT_EQUAL_UINT32(strlen(expected), len);
|
||||
free(message);
|
||||
}
|
||||
|
||||
static void test_pieces_make_the_message() {
|
||||
MessageAssembler a(64);
|
||||
TEST_ASSERT_EQUAL(assembly_t::more, a.add(piece(1, "{\"v\":2,", true, false), 0));
|
||||
TEST_ASSERT_EQUAL(assembly_t::more, a.add(piece(1, "\"t\":3", false, false), 0));
|
||||
TEST_ASSERT_EQUAL(assembly_t::complete, a.add(piece(1, "}", false, true), 0));
|
||||
TEST_ASSERT_EQUAL_STRING("{\"v\":2,\"t\":3}", a.text());
|
||||
TEST_ASSERT_EQUAL_UINT32(13, a.length());
|
||||
a.done();
|
||||
TEST_ASSERT_FALSE(a.holding());
|
||||
assertTaken(a, "{\"v\":2,\"t\":3}");
|
||||
}
|
||||
static void test_nothing_is_held_between_messages() {
|
||||
static void test_nothing_is_held_once_the_message_is_taken() {
|
||||
MessageAssembler a(64);
|
||||
TEST_ASSERT_FALSE(a.holding());
|
||||
a.add(piece(1, "ab", true, false), 0);
|
||||
TEST_ASSERT_TRUE(a.holding());
|
||||
a.add(piece(1, "c", false, true), 0);
|
||||
a.done();
|
||||
assertTaken(a, "abc");
|
||||
TEST_ASSERT_FALSE(a.holding());
|
||||
TEST_ASSERT_NULL(a.text());
|
||||
// A client leaving now can't free what was taken.
|
||||
a.drop(1);
|
||||
}
|
||||
static void test_another_clients_message_waits() {
|
||||
MessageAssembler a(64);
|
||||
|
|
@ -46,7 +53,7 @@ static void test_another_clients_message_waits() {
|
|||
TEST_ASSERT_EQUAL(assembly_t::busy, a.add(piece(2, "xy", true, false), 10));
|
||||
TEST_ASSERT_EQUAL(assembly_t::ignored, a.add(piece(2, "z", false, true), 10));
|
||||
TEST_ASSERT_EQUAL(assembly_t::complete, a.add(piece(1, "c", false, true), 20));
|
||||
TEST_ASSERT_EQUAL_STRING("abc", a.text());
|
||||
assertTaken(a, "abc");
|
||||
}
|
||||
static void test_too_large_is_said_once() {
|
||||
MessageAssembler a(4);
|
||||
|
|
@ -60,28 +67,40 @@ static void test_a_binary_message_is_refused_once() {
|
|||
TEST_ASSERT_EQUAL(assembly_t::notText, a.add(piece(1, "ab", true, false, false), 0));
|
||||
TEST_ASSERT_EQUAL(assembly_t::ignored, a.add(piece(1, "c", false, true, false), 0));
|
||||
}
|
||||
static void test_a_stalled_message_gives_the_buffer_up() {
|
||||
static void test_a_binary_message_leaves_another_clients_alone() {
|
||||
MessageAssembler a(64);
|
||||
a.add(piece(1, "ab", true, false), 0);
|
||||
TEST_ASSERT_EQUAL(assembly_t::busy, a.add(piece(2, "x", true, true), PROTO_ASSEMBLY_TIMEOUT_MS - 1));
|
||||
TEST_ASSERT_EQUAL(assembly_t::complete, a.add(piece(2, "x", true, true), PROTO_ASSEMBLY_TIMEOUT_MS));
|
||||
a.done();
|
||||
// The stalled client is told once, when its next piece comes.
|
||||
TEST_ASSERT_EQUAL(assembly_t::timedOut, a.add(piece(1, "c", false, false), PROTO_ASSEMBLY_TIMEOUT_MS + 1));
|
||||
TEST_ASSERT_EQUAL(assembly_t::ignored, a.add(piece(1, "d", false, true), PROTO_ASSEMBLY_TIMEOUT_MS + 1));
|
||||
TEST_ASSERT_EQUAL(assembly_t::notText, a.add(piece(2, "x", true, true, false), 0));
|
||||
TEST_ASSERT_EQUAL(assembly_t::complete, a.add(piece(1, "c", false, true), 0));
|
||||
assertTaken(a, "abc");
|
||||
}
|
||||
static void test_a_stalled_message_expires_and_its_client_is_owed_a_reply() {
|
||||
MessageAssembler a(64);
|
||||
a.add(piece(1, "ab", true, false), 0);
|
||||
TEST_ASSERT_EQUAL_UINT32(0, a.expire(PROTO_ASSEMBLY_TIMEOUT_MS - 1));
|
||||
TEST_ASSERT_TRUE(a.holding());
|
||||
TEST_ASSERT_EQUAL_UINT32(1, a.expire(PROTO_ASSEMBLY_TIMEOUT_MS));
|
||||
TEST_ASSERT_FALSE(a.holding());
|
||||
TEST_ASSERT_EQUAL_UINT32(0, a.expire(2 * PROTO_ASSEMBLY_TIMEOUT_MS));
|
||||
// Its late pieces were answered by the expiry.
|
||||
TEST_ASSERT_EQUAL(assembly_t::ignored, a.add(piece(1, "c", false, true), PROTO_ASSEMBLY_TIMEOUT_MS + 1));
|
||||
// And the buffer is free for anyone.
|
||||
TEST_ASSERT_EQUAL(assembly_t::complete, a.add(piece(2, "xy", true, true), PROTO_ASSEMBLY_TIMEOUT_MS + 1));
|
||||
assertTaken(a, "xy");
|
||||
}
|
||||
static void test_a_client_that_leaves_lets_go() {
|
||||
MessageAssembler a(64);
|
||||
a.add(piece(1, "ab", true, false), 0);
|
||||
a.drop(1);
|
||||
TEST_ASSERT_FALSE(a.holding());
|
||||
TEST_ASSERT_EQUAL_UINT32(0, a.expire(PROTO_ASSEMBLY_TIMEOUT_MS));
|
||||
TEST_ASSERT_EQUAL(assembly_t::complete, a.add(piece(2, "xy", true, true), 1));
|
||||
assertTaken(a, "xy");
|
||||
}
|
||||
static void test_a_message_of_one_piece_is_whole() {
|
||||
// A whole binary frame, or one piece too large for the rest of the code.
|
||||
MessageAssembler a(64);
|
||||
TEST_ASSERT_EQUAL(assembly_t::complete, a.add(piece(1, "{}", true, true), 0));
|
||||
TEST_ASSERT_EQUAL_STRING("{}", a.text());
|
||||
assertTaken(a, "{}");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
|
|
@ -89,11 +108,12 @@ int main(int argc, char **argv) {
|
|||
(void)argv;
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_pieces_make_the_message);
|
||||
RUN_TEST(test_nothing_is_held_between_messages);
|
||||
RUN_TEST(test_nothing_is_held_once_the_message_is_taken);
|
||||
RUN_TEST(test_another_clients_message_waits);
|
||||
RUN_TEST(test_too_large_is_said_once);
|
||||
RUN_TEST(test_a_binary_message_is_refused_once);
|
||||
RUN_TEST(test_a_stalled_message_gives_the_buffer_up);
|
||||
RUN_TEST(test_a_binary_message_leaves_another_clients_alone);
|
||||
RUN_TEST(test_a_stalled_message_expires_and_its_client_is_owed_a_reply);
|
||||
RUN_TEST(test_a_client_that_leaves_lets_go);
|
||||
RUN_TEST(test_a_message_of_one_piece_is_whole);
|
||||
return UNITY_END();
|
||||
|
|
|
|||
|
|
@ -91,6 +91,26 @@ static void test_values_are_checked_against_their_kind() {
|
|||
static void test_an_unknown_key_is_refused() {
|
||||
TEST_ASSERT_EQUAL_STRING("Unknown field zz", expand(model_edit_type_t::save_room, R"({"r":1,"zz":5})"));
|
||||
}
|
||||
static void test_a_field_the_route_would_ignore_is_refused() {
|
||||
// In the sync, but not something a save sets.
|
||||
TEST_ASSERT_EQUAL_STRING("linkedRemotes can't be set by this edit", expand(model_edit_type_t::save_shade, R"({"s":3,"lk":[1]})"));
|
||||
TEST_ASSERT_EQUAL_STRING("paired can't be set by this edit", expand(model_edit_type_t::save_shade, R"({"s":3,"i":1})"));
|
||||
TEST_ASSERT_EQUAL_STRING("shadeId can't be set by this edit", expand(model_edit_type_t::add_shade, R"({"s":3})"));
|
||||
TEST_ASSERT_EQUAL_STRING("upTime can't be set by this edit", expand(model_edit_type_t::save_group, R"({"g":1,"U":5})"));
|
||||
TEST_ASSERT_EQUAL_STRING("name can't be set by this edit", expand(model_edit_type_t::delete_room, R"({"r":1,"n":"x"})"));
|
||||
}
|
||||
static void test_link_remote_takes_the_routes_rolling_code() {
|
||||
TEST_ASSERT_GOOD(expand(model_edit_type_t::link_remote, R"({"s":3,"a":1193046,"c":12})"));
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"shadeId":3,"remoteAddress":1193046,"rollingCode":12})", written().c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("lastRollingCode can't be set by this edit", expand(model_edit_type_t::unlink_remote, R"({"s":3,"a":5,"c":12})"));
|
||||
}
|
||||
static void test_a_sort_order_takes_only_ids() {
|
||||
TEST_ASSERT_EQUAL_STRING("A sort order takes only ids", expand(model_edit_type_t::room_order, R"({"is":[1],"r":2})"));
|
||||
}
|
||||
static void test_a_shade_save_takes_what_the_form_sends() {
|
||||
TEST_ASSERT_GOOD(expand(model_edit_type_t::save_shade,
|
||||
R"({"s":3,"n":"Office","r":1,"a":5,"st":1,"y":2,"bl":80,"pr":1,"U":1,"D":2,"Y":3,"z":5,"fc":1,"fp":0,"ss":1,"li":0,"sm":1,"rp":2,"gu":4,"gd":5,"gm":6,"gl":1})"));
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
(void)argc;
|
||||
|
|
@ -105,5 +125,9 @@ int main(int argc, char **argv) {
|
|||
RUN_TEST(test_a_sort_order_is_its_list_of_ids);
|
||||
RUN_TEST(test_values_are_checked_against_their_kind);
|
||||
RUN_TEST(test_an_unknown_key_is_refused);
|
||||
RUN_TEST(test_a_field_the_route_would_ignore_is_refused);
|
||||
RUN_TEST(test_link_remote_takes_the_routes_rolling_code);
|
||||
RUN_TEST(test_a_sort_order_takes_only_ids);
|
||||
RUN_TEST(test_a_shade_save_takes_what_the_form_sends);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue