feat: edit ops and the reads their forms need, for socket protocol 2

The edit routes become ops 26-44 (addRoom ... groupSortOrder): ProtoEdits
turns an op's payload into the route's request, full names and real
booleans, and loop() applies it with applyModelEdit() as the route
does. The reply goes to the client that sent it, with an add's new id,
or the route's reason and a shade's validation code; the HTTP routes
keep sending requestResult.

getNextRoom, getNextShade, getNextGroup and groupOptions (45-48) are
answered by loop() from the shade model, through a new LoopQueue item.

Messages that arrive in pieces, a frame over one TCP segment or several
frames, are put back together by ProtoAssembly: one at a time across
clients, up to 4 KB, in a buffer held only while one is on its way.
A message in pieces is now told from its first frame's opcode, which
also stops a fragmented message's last frame drawing a second reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-24 14:29:53 +02:00
parent ea973809e2
commit 77237111ea
22 changed files with 851 additions and 22 deletions

View file

@ -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 frames only, and each message in one frame: a fragmented message is answered with one bad request, without an `id` since it couldn't be read. Until the device puts messages together from their pieces, that includes a frame larger than one TCP segment (about 1.4 KB), which also reaches it in pieces. 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 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.
## Envelope
@ -39,7 +39,7 @@ The keys of `hello` (`fw`, `sh`) and of the schema itself (`T`, `F`, `E`, `Q`) n
| Range | Sender | Types |
|---|---|---|
| 1–15 | control | `hello` 1, `reply` 2, `next` 3, `sync` 4, `syncEnd` 5, `subscribe` 6, `unsubscribe` 7 |
| 16–127 | client ops | commands: `shadeCommand` 16, `groupCommand` 17, `tiltCommand` 18, `repeatCommand` 19, `setPositions` 20, `setSensor` 21, `setMyPosition` 22, `unpairShade` 23, `setRollingCode` 24, `sendRemoteCommand` 25; model edits and settings follow |
| 16–127 | client ops | commands: `shadeCommand` 16, `groupCommand` 17, `tiltCommand` 18, `repeatCommand` 19, `setPositions` 20, `setSensor` 21, `setMyPosition` 22, `unpairShade` 23, `setRollingCode` 24, `sendRemoteCommand` 25; edits: 26–44 (`addRoom` … `groupSortOrder`); reads: `getNextRoom` 45, `getNextShade` 46, `getNextGroup` 47, `groupOptions` 48; settings follow |
| 128–255 | device events | `shadeState`, `shade`, `shadeRemoved`, `group`, `groupState`, `groupRemoved`, `room`, `roomRemoved`, `roomOrder`, `shadeOrder`, `groupOrder`, `shadeCommanded`, `memory`, `wifi`, `ethernet`, `frame`, `frequencyScan`, `repeaters` |
A number is never reused. A retired type stays reserved.
@ -102,6 +102,28 @@ Every op carries an `id` and gets one reply. The ops do what the HTTP routes of
- The reply comes once the command has gone to the radio, or was refused: not found (no shade or group with that id), radio unavailable (the radio is paused, as during a frequency scan, or didn't take it), busy (the device's command queue is full, or `setMyPosition` on a shade that is moving) or bad request (`setPositions` with only a tilt, for a shade without one). It carries no state: the shade's `shadeState` events follow as it moves.
- `sendRemoteCommand` is answered as soon as the radio has taken the frame, not after the ops queued before it, and with radio unavailable when the radio is full rather than waiting for room.
## Edits
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.
- 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 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
What an edit form needs before it is sent, worked out by the device as the edit would be:
| Op | Payload | Reply |
|---|---|---|
| `getNextRoom` | | the `roomId` a new room gets |
| `getNextShade`, `getNextGroup` | | the `shadeId` or `groupId` a new one gets, a `remoteAddress` no shade or group uses, and the radio's `bitLength` and `proto` |
| `groupOptions` | `groupId` | the group's id, and in `ids` the shades it doesn't have yet |
When every slot is taken the reply is a bad request; `groupOptions` for a group that doesn't exist is not found.
## Replies
A client message with an `id` gets exactly one `reply` with that `id`, to that client only, whether it is an op or a control message such as `next`: `ok` 1 on success, or an error `code` and a `desc` for people. Error codes are in the schema: bad request, unknown op, unauthorized, not found, busy, radio unavailable, internal.

View file

@ -76,7 +76,7 @@ Protocol 2 lands as one PR per step, and `main` keeps working throughout because
|---|---|---|
| 1a | firmware | protocol core next to the existing HTTP: `hello`, socket auth, sync, events, feeds, schema; the e2e suite starts |
| 1b | firmware | command ops through the LoopQueue, with replies from `loop()` |
| 1c | firmware | model edit ops, replacing `requestResult`; messages reassembled from their pieces |
| 1c | firmware | model edit ops, replacing `requestResult`, and the reads their forms need; messages reassembled from their pieces |
| 1d | firmware | settings ops (`getSettings`/`saveSettings` by section), the WiFi and frequency scans, reboot |
| 2 | firmware | web UI on ops; the CSS files bundled; token in `sessionStorage`; banner on a version mismatch |
| 3 | HA | protocol 2 with the old-firmware fallback; `/loginContext` setup; runtime login and reauth |

View file

@ -108,7 +108,7 @@ build_flags =
platform = native
test_framework = unity
test_build_src = yes
build_src_filter = -<*> +<Utils.cpp> +<SomfyFrame.cpp> +<JsonFormatter.cpp> +<ShadeMotion.cpp> +<ShadeSensors.cpp> +<ShadeBehaviour.cpp> +<PulseDecoder.cpp> +<TxBuffer.cpp> +<RadioScheduler.cpp> +<ShadeRequests.cpp> +<entryPoints/MqttCommands.cpp> +<ModelSnapshot.cpp> +<ModelEditor.cpp> +<ShadeChanges.cpp> +<ProtoSchema.cpp> +<ProtoFrame.cpp> +<ProtoSync.cpp> +<ProtoParse.cpp> +<ProtoSlots.cpp> +<ProtoOps.cpp>
build_src_filter = -<*> +<Utils.cpp> +<SomfyFrame.cpp> +<JsonFormatter.cpp> +<ShadeMotion.cpp> +<ShadeSensors.cpp> +<ShadeBehaviour.cpp> +<PulseDecoder.cpp> +<TxBuffer.cpp> +<RadioScheduler.cpp> +<ShadeRequests.cpp> +<entryPoints/MqttCommands.cpp> +<ModelSnapshot.cpp> +<ModelEditor.cpp> +<ShadeChanges.cpp> +<ProtoSchema.cpp> +<ProtoFrame.cpp> +<ProtoSync.cpp> +<ProtoParse.cpp> +<ProtoSlots.cpp> +<ProtoOps.cpp> +<ProtoEdits.cpp> +<ProtoAssembly.cpp>
lib_deps =
bblanchon/ArduinoJson@^7.2.2
build_flags =

View file

@ -84,6 +84,9 @@ void LoopQueue::drain() {
case loop_item_type_t::model_edit:
runModelEdit(item.edit);
break;
case loop_item_type_t::proto_read:
protoSocket.replyRead(item.replyClient, item.replyId, item.readOp, item.readId);
break;
}
}
}

View file

@ -24,7 +24,8 @@ enum class loop_item_type_t : uint8_t {
restore_shades, // restore from /shades.tmp, then reboot
load_shades, // replace the shades with /shades.tmp
snapshot_fill, // fill a read's ModelSnapshot
model_edit // apply an edit with ModelEditor
model_edit, // apply an edit with ModelEditor
proto_read // answer a protocol 2 read op from the shade model
};
struct model_edit_t;
struct loop_item_t {
@ -33,10 +34,12 @@ 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 from a protocol 2 client: loop() replies to this socket
// client with replyId once the request has run. 0 for no reply.
// 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;
uint8_t readOp = 0; // proto_read: the op's type
uint8_t readId = 0; // proto_read: the group, for groupOptions
};
// Whether the item sends anything on air.

47
src/ProtoAssembly.cpp Normal file
View file

@ -0,0 +1,47 @@
#include <stdlib.h>
#include <string.h>
#include "ProtoAssembly.h"
void MessageAssembler::free() {
::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;
}
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();
if(!piece.text) return assembly_t::notText;
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;
}
if(this->len + piece.len > this->limit) {
this->free();
return assembly_t::tooLarge;
}
memcpy(this->buf + this->len, piece.data, piece.len);
this->len += piece.len;
if(!piece.last) return assembly_t::more;
this->buf[this->len] = '\0';
return assembly_t::complete;
}

61
src/ProtoAssembly.h Normal file
View file

@ -0,0 +1,61 @@
#ifndef PROTO_ASSEMBLY_H
#define PROTO_ASSEMBLY_H
#include <stddef.h>
#include <stdint.h>
// 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).
// How long a message may take to arrive in full before another client's may
// take the buffer.
#define PROTO_ASSEMBLY_TIMEOUT_MS 10000
// One piece of a message.
struct proto_piece_t {
uint32_t clientId = 0;
bool first = false; // its first piece: the start of its first frame
bool last = false; // its last piece: the end of its final frame
bool text = true; // a text message rather than binary
const uint8_t *data = nullptr;
size_t len = 0;
};
enum class assembly_t : uint8_t {
more, // wait for the next piece
complete, // the message is whole: text() and length(), then done()
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
};
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.
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 client has gone: its part of a message goes with it.
void drop(uint32_t clientId);
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();
};
#endif

108
src/ProtoEdits.cpp Normal file
View file

@ -0,0 +1,108 @@
#include <stdio.h>
#include "ProtoEdits.h"
struct edit_op_t {
proto_type_t type;
model_edit_type_t edit;
};
static const edit_op_t EDIT_OPS[] = {
{proto_type_t::addRoom, model_edit_type_t::add_room},
{proto_type_t::addShade, model_edit_type_t::add_shade},
{proto_type_t::addGroup, model_edit_type_t::add_group},
{proto_type_t::saveRoom, model_edit_type_t::save_room},
{proto_type_t::saveShade, model_edit_type_t::save_shade},
{proto_type_t::saveGroup, model_edit_type_t::save_group},
{proto_type_t::deleteRoom, model_edit_type_t::delete_room},
{proto_type_t::deleteShade, model_edit_type_t::delete_shade},
{proto_type_t::deleteGroup, model_edit_type_t::delete_group},
{proto_type_t::linkRemote, model_edit_type_t::link_remote},
{proto_type_t::unlinkRemote, model_edit_type_t::unlink_remote},
{proto_type_t::linkToGroup, model_edit_type_t::link_to_group},
{proto_type_t::unlinkFromGroup, model_edit_type_t::unlink_from_group},
{proto_type_t::linkRepeater, model_edit_type_t::link_repeater},
{proto_type_t::unlinkRepeater, model_edit_type_t::unlink_repeater},
{proto_type_t::setPaired, model_edit_type_t::set_paired},
{proto_type_t::roomSortOrder, model_edit_type_t::room_order},
{proto_type_t::shadeSortOrder, model_edit_type_t::shade_order},
{proto_type_t::groupSortOrder, model_edit_type_t::group_order}
};
bool protoEditType(uint8_t type, model_edit_type_t &out) {
for(const edit_op_t &op : EDIT_OPS) {
if(static_cast<uint8_t>(op.type) != type) continue;
out = op.edit;
return true;
}
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:
return false;
}
}
// What the reply's desc points at; every parse runs on async_tcp, one at a
// time.
static char why[64];
// One value under its field's kind: false when it isn't one.
static bool expandValue(const proto_field_info_t &f, JsonVariantConst v, JsonVariant out) {
switch(f.kind) {
case 'b':
// Booleans are 1 and 0; the edit routes read true and false.
if(!v.is<int>() || (v.as<int>() != 0 && v.as<int>() != 1)) return false;
out.set(v.as<int>() == 1);
return true;
case 'i':
if(v.is<int64_t>() || v.is<uint64_t>()) return out.set(v);
return false;
case 'n':
if(v.is<float>()) return out.set(v);
return false;
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;
}
}
const char *expandEditPayload(model_edit_type_t type, JsonObjectConst d, JsonDocument &doc) {
doc.clear();
switch(type) {
case model_edit_type_t::room_order:
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.
JsonVariantConst ids = d[protoField(proto_field_t::ids).key];
if(!ids.is<JsonArrayConst>()) return "ids is missing";
for(JsonVariantConst id : ids.as<JsonArrayConst>())
if(!id.is<uint8_t>()) return "ids must be a list of ids";
doc.set(ids);
return doc.overflowed() ? "Too large" : nullptr;
}
default:
break;
}
JsonObject obj = doc.to<JsonObject>();
for(JsonPairConst kv : d) {
const proto_field_info_t *f = protoFieldByKey(kv.key().c_str());
if(!f) {
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");
return why;
}
}
return doc.overflowed() ? "Too large" : nullptr;
}

25
src/ProtoEdits.h Normal file
View file

@ -0,0 +1,25 @@
#ifndef PROTO_EDITS_H
#define PROTO_EDITS_H
#include <stdint.h>
#include <ArduinoJson.h>
#include "ProtoSchema.h"
#include "ModelEditor.h"
// Socket protocol 2's edit ops and the reads an edit form needs
// (docs/PROTOCOL.md). An edit op is the edit route of the same name: its
// payload, in full names, is what loop() applies with applyModelEdit(), so the
// rules stay in one place. Kept free of the shade model and the web server so
// it can be unit tested on the PC (pio test -e native).
// The edit an op asks for; false for a type that isn't an edit op.
bool protoEditType(uint8_t type, model_edit_type_t &out);
// The request of an edit route, from the op's payload d: full names for the
// short keys, and booleans as true and false. A sort order's request is its
// list of ids. nullptr when it is good; otherwise what is wrong with it, for
// a bad request reply. As with every op, only what was sent is in it.
const char *expandEditPayload(model_edit_type_t type, JsonObjectConst d, JsonDocument &doc);
// getNextRoom, getNextShade, getNextGroup and groupOptions: answered by
// loop() from the shade model.
bool protoIsReadOp(uint8_t type);
#endif

View file

@ -34,6 +34,29 @@
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') \
@ -124,6 +147,8 @@
FIELD(repeat, "re", 'i', 0) \
FIELD(sunny, "sn", 'b', 0) \
FIELD(windy, "wn", 'b', 0) \
/* the edit ops */ \
FIELD(validation, "vc", 'i', 0) \
/* the header */ \
FIELD(maxRooms, "xr", 'i', 0) \
FIELD(maxShades, "xs", 'i', 0) \

View file

@ -176,6 +176,15 @@ uint8_t SomfyShadeController::getNextGroupId() {
const uint8_t id = firstFreeId(ids, SOMFY_MAX_GROUPS, SOMFY_MAX_GROUPS);
return id == 0 ? 255 : id;
}
uint32_t SomfyShadeController::getNextRemoteAddress(uint8_t id) {
uint32_t used[SOMFY_MAX_SHADES + SOMFY_MAX_GROUPS];
size_t count = 0;
for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++)
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);
}
uint8_t SomfyShadeController::getNextRoomId() {
uint8_t ids[SOMFY_MAX_ROOMS];
for(uint8_t i = 0; i < SOMFY_MAX_ROOMS; i++) ids[i] = this->rooms[i].roomId;

View file

@ -239,6 +239,9 @@ class SomfyShadeController {
uint8_t getNextRoomId();
uint8_t getNextShadeId();
uint8_t getNextGroupId();
// The remote address a new shade or group with that id is offered: one no
// shade or group uses.
uint32_t getNextRemoteAddress(uint8_t id);
int8_t getMaxRoomOrder();
int8_t getMaxShadeOrder();
int8_t getMaxGroupOrder();

View file

@ -8,9 +8,14 @@
// the clients how it went.
// 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.
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;
JsonDocument doc;
};
@ -20,7 +25,7 @@ bool beginModelEdit();
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.
// 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.
edit_result_t applyModelEdit(model_edit_t &edit);

View file

@ -246,7 +246,8 @@ 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);
emitRequestResult(edit->requestId, result);
if(edit->replyClient) protoSocket.replyEdit(edit->replyClient, edit->replyId, edit->type, result);
else emitRequestResult(edit->requestId, result);
cancelModelEdit(edit);
}
@ -357,8 +358,10 @@ edit_result_t applyModelEdit(model_edit_t &edit) {
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"];
// 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, obj["rollingCode"] | (uint16_t)0))
else if(!shade->linkRemote(address, rollingCode))
return edit_result_t::error("This shade already has the most linked remotes it can.");
return done(shade->getShadeId());
}

View file

@ -8,6 +8,9 @@
#include "ProtoParse.h"
#include "ProtoSlots.h"
#include "ProtoOps.h"
#include "ProtoEdits.h"
#include "ProtoAssembly.h"
#include "SomfyEditor.h"
#include "LoopQueue.h"
#include "SomfySnapshot.h"
#include "Somfy.h"
@ -37,6 +40,8 @@ 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.
static MessageAssembler assembler(PROTO_MAX_CLIENT_FRAME);
/*********************************************************************
* The shade model as a sync source
@ -175,6 +180,50 @@ static void sendRemoteCommand(AsyncWebSocketClient *client, const proto_message_
}
replyOk(client, req.id);
}
// 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();
if(!edit) {
cancelModelEdit(nullptr);
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);
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");
}
}
// A read op: loop() answers it from the shade model.
static void queueReadOp(AsyncWebSocketClient *client, const proto_message_t &msg, JsonObjectConst d) {
loop_item_t item;
item.type = loop_item_type_t::proto_read;
item.readOp = msg.type;
if(msg.type == static_cast<uint8_t>(proto_type_t::groupOptions)) {
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.replyClient = client->id();
item.replyId = msg.id;
if(!loopQueue.submit(item)) replyError(client, msg.id, proto_error_t::busy, "Command queue full");
}
static bool authorized(AsyncWebServerRequest *request) {
if(settings.Security.type == security_types::None) return true;
const AsyncWebParameter *key = request->getParam("apikey");
@ -200,23 +249,19 @@ 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) {
assembler.drop(client->id());
proto_slot_t *slot = slotOf(slots, PROTO_MAX_CLIENTS, client->id());
if(!slot) return;
freeSlot(*slot);
ESP_LOGI(TAG, "Socket [%lu] disconnected", (unsigned long)client->id());
}
static void onFrame(AsyncWebSocketClient *client, AwsFrameInfo *info, uint8_t *data, size_t len) {
if(!(info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT)) {
// One reply for the whole message: its first piece gets it, the rest are
// dropped. A frame larger than a TCP segment arrives in pieces too.
if(info->num == 0 && info->index == 0) replyError(client, 0, proto_error_t::badRequest, "Send each message as one text frame");
return;
}
// One whole message from a client.
static void onMessage(AsyncWebSocketClient *client, const char *text, size_t len) {
proto_slot_t *slot = slotOf(slots, PROTO_MAX_CLIENTS, client->id());
if(!slot) return;
JsonDocument doc;
proto_message_t msg;
const proto_error_t err = parseClientFrame(reinterpret_cast<const char *>(data), len, doc, msg);
const proto_error_t err = parseClientFrame(text, len, doc, msg);
if(err != proto_error_t::none) {
replyError(client, msg.id, err, err == proto_error_t::unknownOp ? "Unknown message type" : "Malformed message");
return;
@ -244,14 +289,67 @@ static void onFrame(AsyncWebSocketClient *client, AwsFrameInfo *info, uint8_t *d
case proto_type_t::sendRemoteCommand:
sendRemoteCommand(client, msg, doc["d"]);
return;
default:
default: {
if(protoIsShadeOp(msg.type)) {
queueShadeOp(client, msg, doc["d"]);
return;
}
// The edit and settings ops arrive with the protocol core's later PRs.
model_edit_type_t edit;
if(protoEditType(msg.type, edit)) {
queueEditOp(client, msg, edit, doc["d"]);
return;
}
if(protoIsReadOp(msg.type)) {
queueReadOp(client, msg, doc["d"]);
return;
}
// 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.
if(info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
onMessage(client, reinterpret_cast<const char *>(data), len);
return;
}
// The rest come in pieces: a frame larger than a TCP segment, or a message
// sent as several frames. Only a message's first frame has a text or
// binary opcode; the frames after it are continuations.
proto_piece_t piece;
piece.clientId = client->id();
piece.first = info->index == 0 && info->opcode != WS_CONTINUATION;
piece.last = info->final && info->index + len == info->len;
piece.text = info->message_opcode == WS_TEXT;
piece.data = data;
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())) {
case assembly_t::complete:
onMessage(client, assembler.text(), assembler.length());
assembler.done();
return;
case assembly_t::busy:
replyError(client, 0, proto_error_t::busy, "Another long message is on its way, send it again");
return;
case assembly_t::tooLarge:
replyError(client, 0, proto_error_t::badRequest, "Message too large");
return;
case assembly_t::notText:
replyError(client, 0, proto_error_t::badRequest, "Send text messages");
return;
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;
}
}
void ProtoSocket::wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) {
@ -321,6 +419,83 @@ void ProtoSocket::replyRequest(uint32_t client, uint32_t id, const shade_request
if(!json.overflowed()) ws.text(client, buf, json.length());
}
// 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;
char buf[256];
BufferedJsonFormatter json;
json.setBuffer(buf, sizeof(buf));
beginFrame(json, proto_type_t::reply, id);
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::desc, result.desc);
w.add(proto_field_t::validation, result.code);
}
else if(type == model_edit_type_t::add_room) w.add(proto_field_t::roomId, result.id);
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());
}
// 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;
char buf[256];
BufferedJsonFormatter json;
json.setBuffer(buf, sizeof(buf));
const char *refused = nullptr;
proto_error_t code = proto_error_t::badRequest;
beginFrame(json, proto_type_t::reply, id);
ProtoWriter w(json);
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; }
w.add(proto_field_t::ok, true);
w.add(proto_field_t::roomId, roomId);
break;
}
case proto_type_t::getNextShade:
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; }
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));
w.add(proto_field_t::bitLength, somfy.transceiver.config.type);
w.add(proto_field_t::proto, static_cast<uint8_t>(somfy.transceiver.config.proto));
break;
}
case proto_type_t::groupOptions: {
SomfyGroup *group = somfy.getGroupById(groupId);
if(!group) { refused = "No group with that id"; code = proto_error_t::notFound; break; }
w.add(proto_field_t::ok, true);
w.add(proto_field_t::groupId, groupId);
// The shades it could take: every one it doesn't have.
w.beginList(proto_field_t::ids);
for(SomfyShade &shade : somfy.shades)
if(shade.getShadeId() != 255 && !group->hasShadeId(shade.getShadeId())) w.item(shade.getShadeId());
w.endList();
break;
}
default:
refused = "Not a read op";
break;
}
if(refused) {
json.setBuffer(buf, sizeof(buf));
writeErrorReply(json, id, code, refused);
}
else endFrame(json);
if(!json.overflowed()) ws.text(client, buf, json.length());
}
/*********************************************************************
* loop(): events
********************************************************************/

View file

@ -26,6 +26,10 @@ class ProtoSocket {
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);
// 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);
// 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);
// Events, loop() only.
void shadeState(SomfyShade &shade);
void shade(SomfyShade &shade);

View file

@ -11,8 +11,9 @@ Runs against a real device on your network, by hand, never in CI. It checks sock
| `--device` | the device's address (required) |
| `--password`, `--username` / `--pin` | the credentials of a Secured device |
| `--radio --shade <id>` | also send real commands: that shade moves a little and back |
| `--edit` | also add a room and delete it again |
| `--record` | save hello, the sync and the schema to `test/fixtures/proto2/`, for the web UI and Home Assistant tests |
A plain run sends nothing on air, changes no settings and doesn't reboot the device. The command tests send ops that are refused before they reach the radio, and one `setPositions` that sets a shade to the position the sync gave: that drops the fraction of a percent the shade's believed position may have had. It does fill every socket slot for a few seconds, and replays a page load.
A plain run sends nothing on air, changes no settings and doesn't reboot the device. The command tests send ops that are refused before they reach the radio, and one `setPositions` that sets a shade to the position the sync gave: that drops the fraction of a percent the shade's believed position may have had. The edit tests send edits that are refused, and one shade sort order set to the order it has, which saves the shades again unchanged. It does fill every socket slot for a few seconds, and replays a page load.
The tests run in whatever security mode the device is in; the suite never changes it. To test both modes, run the suite once in each.

View file

@ -17,6 +17,7 @@ def pytest_addoption(parser):
parser.addoption("--record", action="store_true", help="save what the device sends to test/fixtures/proto2")
parser.addoption("--radio", action="store_true", help="also run the tests that send commands on air")
parser.addoption("--shade", type=int, default=None, help="with --radio: the id of the shade the commands may move")
parser.addoption("--edit", action="store_true", help="also run the edits that change the shade model, then undo them")
@pytest.fixture(scope="session")
@ -95,3 +96,11 @@ def radio_shade(request):
if shade is None:
pytest.skip("--radio needs --shade <id>, a shade that may move")
return shade
@pytest.fixture(scope="session")
def editing(request):
"""For tests that change the shade model and change it back, or a skip."""
if not request.config.getoption("--edit"):
pytest.skip("changes the shade model and back: pass --edit")
return True

115
test/e2e/test_edits.py Normal file
View file

@ -0,0 +1,115 @@
"""The edit ops and the reads an edit form needs (docs/PROTOCOL.md). Without
--edit nothing changes: edits are refused, or set what is already there."""
import pytest
from test_commands import is_ok, op, pack, synced_model
from test_messages import error_name, reply_to
EDIT_OPS = ("addRoom", "addShade", "addGroup", "saveRoom", "saveShade", "saveGroup", "deleteRoom", "deleteShade",
"deleteGroup", "linkRemote", "unlinkRemote", "linkToGroup", "unlinkFromGroup", "linkRepeater",
"unlinkRepeater", "setPaired", "roomSortOrder", "shadeSortOrder", "groupSortOrder")
READ_OPS = ("getNextRoom", "getNextShade", "getNextGroup", "groupOptions")
def field(schema, reply, name):
return reply["d"].get(schema.keys[name], schema.default(name))
def unused(items, key):
return next(i for i in range(250, 0, -1) if i not in {x[key] for x in items})
def test_every_edit_and_read_op_is_in_the_schema(schema):
for name in EDIT_OPS + READ_OPS:
assert 16 <= schema.type_numbers[name] <= 127, name
def test_the_next_shade_gets_a_free_id_and_address(connect, schema):
c, shades, groups = synced_model(connect, schema)
reply = op(c, schema, "getNextShade", {})
assert is_ok(schema, reply)
assert field(schema, reply, "shadeId") not in {s["shadeId"] for s in shades}
address = field(schema, reply, "remoteAddress")
assert address and address not in {s.get("remoteAddress") for s in shades + groups}
def test_the_next_room_and_group(connect, schema):
c, _, groups = synced_model(connect, schema)
assert is_ok(schema, op(c, schema, "getNextRoom", {}))
reply = op(c, schema, "getNextGroup", {})
assert is_ok(schema, reply)
assert field(schema, reply, "groupId") not in {g["groupId"] for g in groups}
def test_group_options_are_the_shades_it_lacks(connect, schema):
c, shades, groups = synced_model(connect, schema)
if not groups:
pytest.skip("the device has no groups")
group = groups[0]
reply = op(c, schema, "groupOptions", {"groupId": group["groupId"]})
assert is_ok(schema, reply)
expected = {s["shadeId"] for s in shades} - set(group.get("linkedShades", []))
assert set(field(schema, reply, "ids")) == expected
def test_group_options_of_a_missing_group(connect, schema):
c, _, groups = synced_model(connect, schema)
reply = op(c, schema, "groupOptions", {"groupId": unused(groups, "groupId")})
assert error_name(schema, reply) == "notFound"
@pytest.mark.parametrize("name, payload, why", [
("saveRoom", {"zz": 1}, "Unknown field zz"),
("setPaired", {"s": 1, "i": 2}, "paired is not a boolean (1 or 0)"),
("shadeSortOrder", {}, "ids is missing"),
])
def test_a_bad_payload_is_refused_before_it_is_queued(connect, schema, name, payload, why):
c, _, _ = synced_model(connect, schema)
msg_id = c.send(schema.type_numbers[name], payload, with_id=True)
reply = reply_to(c, msg_id)
assert error_name(schema, reply) == "badRequest"
assert reply["d"][schema.keys["desc"]] == why
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 reply["d"][schema.keys["desc"]] == "Shade Id not found."
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))]
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]
assert order["d"][schema.keys["ids"]] == ids
def test_a_long_edit_is_put_back_together(connect, schema):
"""3 KB arrives in pieces: the reply carries its id, so it was read whole."""
c, shades, _ = synced_model(connect, schema)
padding = " " * 3000
msg_id = 4242
c.send_text('{"v":2,"t":%d,"id":%d,"d":{"%s":%d%s}}' % (
schema.type_numbers["saveShade"], msg_id, schema.keys["shadeId"], unused(shades, "shadeId"), padding))
reply = reply_to(c, msg_id)
assert reply["d"][schema.keys["desc"]] == "Shade Id not found."
def test_adding_and_deleting_a_room(connect, schema, editing):
"""With --edit: a room is added, then deleted again."""
c, _, _ = synced_model(connect, schema)
reply = op(c, schema, "addRoom", {"name": "e2e room"})
assert is_ok(schema, reply), reply
room_id = field(schema, reply, "roomId")
assert room_id
added = c.recv_until(lambda f: f["t"] == schema.type_numbers["room"])[-1]
assert schema.expand(added["d"])["name"] == "e2e room"
assert is_ok(schema, op(c, schema, "deleteRoom", {"roomId": room_id}))
removed = c.recv_until(lambda f: f["t"] == schema.type_numbers["roomRemoved"])[-1]
assert removed["d"][schema.keys["roomId"]] == room_id

View file

@ -20,7 +20,9 @@ static const shade_request_type_t RADIO_REQUESTS[] = {
static const shade_request_type_t OFF_RADIO_REQUESTS[] = {
shade_request_type_t::set_positions, shade_request_type_t::set_rolling_code
};
static const loop_item_type_t OTHER_ITEMS[] = { loop_item_type_t::restore_shades, loop_item_type_t::load_shades };
static const loop_item_type_t OTHER_ITEMS[] = {
loop_item_type_t::restore_shades, loop_item_type_t::load_shades, loop_item_type_t::model_edit, loop_item_type_t::proto_read
};
void setUp() {}
void tearDown() {}

View file

@ -0,0 +1,100 @@
#include <unity.h>
#include <string.h>
#include "ProtoAssembly.h"
// Putting protocol 2 messages back together from their pieces: one message at
// a time, one reply-worthy result per message.
void setUp() {}
void tearDown() {}
static proto_piece_t piece(uint32_t client, const char *text, bool first, bool last, bool isText = true) {
proto_piece_t p;
p.clientId = client;
p.first = first;
p.last = last;
p.text = isText;
p.data = reinterpret_cast<const uint8_t *>(text);
p.len = strlen(text);
return p;
}
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());
}
static void test_nothing_is_held_between_messages() {
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();
TEST_ASSERT_FALSE(a.holding());
TEST_ASSERT_NULL(a.text());
}
static void test_another_clients_message_waits() {
MessageAssembler a(64);
a.add(piece(1, "ab", true, false), 0);
// One busy for the other client's message, then its pieces are let go.
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());
}
static void test_too_large_is_said_once() {
MessageAssembler a(4);
TEST_ASSERT_EQUAL(assembly_t::more, a.add(piece(1, "abc", true, false), 0));
TEST_ASSERT_EQUAL(assembly_t::tooLarge, a.add(piece(1, "de", false, false), 0));
TEST_ASSERT_EQUAL(assembly_t::ignored, a.add(piece(1, "f", false, true), 0));
TEST_ASSERT_FALSE(a.holding());
}
static void test_a_binary_message_is_refused_once() {
MessageAssembler a(64);
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() {
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));
}
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(assembly_t::complete, a.add(piece(2, "xy", true, true), 1));
}
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());
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_pieces_make_the_message);
RUN_TEST(test_nothing_is_held_between_messages);
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_client_that_leaves_lets_go);
RUN_TEST(test_a_message_of_one_piece_is_whole);
return UNITY_END();
}

View file

@ -0,0 +1,109 @@
#include <unity.h>
#include <string>
#include <ArduinoJson.h>
#include "ProtoEdits.h"
// Socket protocol 2's edit ops: which edit each is, and the edit route's
// request that its payload becomes (docs/PROTOCOL.md).
void setUp() {}
void tearDown() {}
static JsonDocument payload;
static JsonDocument doc;
static const char *expand(model_edit_type_t type, const char *json) {
payload.clear();
TEST_ASSERT_EQUAL(DeserializationError::Ok, deserializeJson(payload, json).code());
return expandEditPayload(type, payload.as<JsonObjectConst>(), doc);
}
static std::string written() {
std::string out;
serializeJson(doc, out);
return out;
}
#define TEST_ASSERT_GOOD(why) TEST_ASSERT_NULL_MESSAGE(why, why)
/*********************************************************************
* Which edit an op is
********************************************************************/
static void test_every_edit_op_is_an_edit() {
model_edit_type_t t;
TEST_ASSERT_TRUE(protoEditType(static_cast<uint8_t>(proto_type_t::addShade), t));
TEST_ASSERT_EQUAL(model_edit_type_t::add_shade, t);
TEST_ASSERT_TRUE(protoEditType(static_cast<uint8_t>(proto_type_t::groupSortOrder), t));
TEST_ASSERT_EQUAL(model_edit_type_t::group_order, t);
TEST_ASSERT_TRUE(protoEditType(static_cast<uint8_t>(proto_type_t::setPaired), t));
TEST_ASSERT_EQUAL(model_edit_type_t::set_paired, t);
// One op for each edit.
int count = 0;
for(int type = 16; type <= 127; type++)
if(protoEditType(static_cast<uint8_t>(type), t)) count++;
TEST_ASSERT_EQUAL_INT(static_cast<int>(model_edit_type_t::group_order) + 1, count);
}
static void test_other_types_are_not_edits() {
model_edit_type_t t;
TEST_ASSERT_FALSE(protoEditType(static_cast<uint8_t>(proto_type_t::shadeCommand), t));
TEST_ASSERT_FALSE(protoEditType(static_cast<uint8_t>(proto_type_t::getNextShade), t));
TEST_ASSERT_FALSE(protoEditType(static_cast<uint8_t>(proto_type_t::shade), t));
}
static void test_the_read_ops() {
TEST_ASSERT_TRUE(protoIsReadOp(static_cast<uint8_t>(proto_type_t::getNextRoom)));
TEST_ASSERT_TRUE(protoIsReadOp(static_cast<uint8_t>(proto_type_t::getNextShade)));
TEST_ASSERT_TRUE(protoIsReadOp(static_cast<uint8_t>(proto_type_t::getNextGroup)));
TEST_ASSERT_TRUE(protoIsReadOp(static_cast<uint8_t>(proto_type_t::groupOptions)));
TEST_ASSERT_FALSE(protoIsReadOp(static_cast<uint8_t>(proto_type_t::addRoom)));
}
/*********************************************************************
* The payload as the edit route's request
********************************************************************/
static void test_short_keys_become_the_route_names() {
TEST_ASSERT_GOOD(expand(model_edit_type_t::save_shade, R"({"s":3,"n":"Office","U":12000,"a":1193046})"));
TEST_ASSERT_EQUAL_STRING(R"({"shadeId":3,"name":"Office","upTime":12000,"remoteAddress":1193046})", written().c_str());
}
static void test_booleans_become_true_and_false() {
// set_paired reads paired | false, which takes only a boolean.
TEST_ASSERT_GOOD(expand(model_edit_type_t::set_paired, R"({"s":3,"i":1})"));
TEST_ASSERT_EQUAL_STRING(R"({"shadeId":3,"paired":true})", written().c_str());
TEST_ASSERT_GOOD(expand(model_edit_type_t::save_shade, R"({"s":3,"fp":0})"));
TEST_ASSERT_EQUAL_STRING(R"({"shadeId":3,"flipPosition":false})", written().c_str());
}
static void test_only_what_was_sent_is_in_the_request() {
TEST_ASSERT_GOOD(expand(model_edit_type_t::save_room, R"({"r":2})"));
TEST_ASSERT_EQUAL_STRING(R"({"roomId":2})", written().c_str());
TEST_ASSERT_GOOD(expand(model_edit_type_t::add_room, R"({})"));
TEST_ASSERT_EQUAL_STRING("{}", written().c_str());
}
static void test_a_sort_order_is_its_list_of_ids() {
TEST_ASSERT_GOOD(expand(model_edit_type_t::shade_order, R"({"is":[3,1,2]})"));
TEST_ASSERT_EQUAL_STRING("[3,1,2]", written().c_str());
TEST_ASSERT_EQUAL_STRING("ids is missing", expand(model_edit_type_t::room_order, R"({})"));
TEST_ASSERT_EQUAL_STRING("ids must be a list of ids", expand(model_edit_type_t::group_order, R"({"is":[1,"x"]})"));
TEST_ASSERT_EQUAL_STRING("ids must be a list of ids", expand(model_edit_type_t::group_order, R"({"is":[1,300]})"));
}
static void test_values_are_checked_against_their_kind() {
TEST_ASSERT_EQUAL_STRING("paired is not a boolean (1 or 0)", expand(model_edit_type_t::set_paired, R"({"s":3,"i":true})"));
TEST_ASSERT_EQUAL_STRING("paired is not a boolean (1 or 0)", expand(model_edit_type_t::set_paired, R"({"s":3,"i":2})"));
TEST_ASSERT_EQUAL_STRING("shadeId is not a whole number", expand(model_edit_type_t::delete_shade, R"({"s":"3"})"));
TEST_ASSERT_EQUAL_STRING("name is not a string", expand(model_edit_type_t::save_room, R"({"r":1,"n":5})"));
}
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})"));
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
UNITY_BEGIN();
RUN_TEST(test_every_edit_op_is_an_edit);
RUN_TEST(test_other_types_are_not_edits);
RUN_TEST(test_the_read_ops);
RUN_TEST(test_short_keys_become_the_route_names);
RUN_TEST(test_booleans_become_true_and_false);
RUN_TEST(test_only_what_was_sent_is_in_the_request);
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);
return UNITY_END();
}