diff --git a/GLOSSARY.md b/GLOSSARY.md index a12a0ad..5fa4f78 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -170,5 +170,5 @@ A copy of the shade model taken for one read, so the reply describes a single mo _Avoid_: InstallationSnapshot **LoopQueue** -The single queue through which shade requests, model edits and snapshot requests reach `loop()` from other tasks, in the order they arrived. +The single queue through which shade requests, model edits, reads and snapshot requests reach `loop()` from other tasks, in the order they arrived. _Avoid_: web command queue diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 5971f85..bdbd934 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -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=` from `/login`; without a valid one it is refused with 401 ([ADR 0006](adr/0006-two-security-modes-eleven-http-routes.md)). - Optional `?schema=`: 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 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 @@ -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`; `setPaired` needs `paired`. 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, a field the edit doesn't set, or a value of the wrong kind, is a bad request before anything is queued. A shade's save doesn't set `paired`, `linkedRemotes` or `flags` (`light` is its own field, and `setSensor` sets the sun and wind), and a group's doesn't set `sunSensor`, which comes from its shades. +- 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 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 + +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. diff --git a/docs/SECURITY_PLAN.md b/docs/SECURITY_PLAN.md index bea9e4d..22ed37c 100644 --- a/docs/SECURITY_PLAN.md +++ b/docs/SECURITY_PLAN.md @@ -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 | diff --git a/lib/SmartRC-CC1101-Driver-Lib/ELECHOUSE_CC1101_SRC_DRV.cpp b/lib/SmartRC-CC1101-Driver-Lib/ELECHOUSE_CC1101_SRC_DRV.cpp index c5f014d..10e16fa 100644 --- a/lib/SmartRC-CC1101-Driver-Lib/ELECHOUSE_CC1101_SRC_DRV.cpp +++ b/lib/SmartRC-CC1101-Driver-Lib/ELECHOUSE_CC1101_SRC_DRV.cpp @@ -104,10 +104,19 @@ uint8_t PA_TABLE_915[10] {0x03,0x0E,0x1E,0x27,0x38,0x8E,0x84,0xCC,0xC3,0xC0,}; void ELECHOUSE_CC1101::SpiStart(void) { // initialize the SPI pins + #ifdef ESP32 + // ESPSomfy-RTS: SPI.begin() sets the bus pins up itself. Init() calls this + // again while the bus is running, and on Arduino 3.x a pinMode() on a pin + // the bus owns stops the bus while SPI.begin() still thinks it is up. The + // next transfer is lost on the ESP32 and hangs forever on the S3, whose + // stopped bus has no clock. SS is driven by hand, so it still needs one. + pinMode(SS_PIN, OUTPUT); + #else pinMode(SCK_PIN, OUTPUT); pinMode(MOSI_PIN, OUTPUT); pinMode(MISO_PIN, INPUT); pinMode(SS_PIN, OUTPUT); + #endif // enable SPI #ifdef ESP32 diff --git a/platformio.ini b/platformio.ini index 06f1d56..74bec54 100644 --- a/platformio.ini +++ b/platformio.ini @@ -108,7 +108,7 @@ build_flags = platform = native test_framework = unity test_build_src = yes -build_src_filter = -<*> + + + + + + + + + + + + + + + + + + + + +build_src_filter = -<*> + + + + + + + + + + + + + + + + + + + + + + lib_deps = bblanchon/ArduinoJson@^7.2.2 build_flags = diff --git a/src/LoopQueue.cpp b/src/LoopQueue.cpp index 7126123..9b13de4 100644 --- a/src/LoopQueue.cpp +++ b/src/LoopQueue.cpp @@ -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(item.request.type)); - if(item.replyClient) protoSocket.replyRequest(item.replyClient, item.replyId, item.request, result); + protoSocket.replyRequest(item.replyTo, item.request, result); break; } case loop_item_type_t::restore_shades: { @@ -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.replyTo, item.readOp, item.groupId); + break; } } } diff --git a/src/LoopQueue.h b/src/LoopQueue.h index b43eef7..e9cdbb6 100644 --- a/src/LoopQueue.h +++ b/src/LoopQueue.h @@ -2,6 +2,7 @@ #define LOOP_QUEUE_H #include #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). @@ -24,7 +25,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 +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 from a protocol 2 client: loop() replies to this socket - // client with replyId once the request 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 groupId = 0; // proto_read: groupOptions' group }; // Whether the item sends anything on air. diff --git a/src/ModelEditor.cpp b/src/ModelEditor.cpp index d8123fa..d7eeaf2 100644 --- a/src/ModelEditor.cpp +++ b/src/ModelEditor.cpp @@ -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 static bool addTo(T *list, size_t size, T item) { diff --git a/src/ModelEditor.h b/src/ModelEditor.h index ffa90ba..ad374d9 100644 --- a/src/ModelEditor.h +++ b/src/ModelEditor.h @@ -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 diff --git a/src/ModelSnapshot.cpp b/src/ModelSnapshot.cpp index d40abae..f891cdb 100644 --- a/src/ModelSnapshot.cpp +++ b/src/ModelSnapshot.cpp @@ -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); } /********************************************************************* diff --git a/src/ProtoAssembly.cpp b/src/ProtoAssembly.cpp new file mode 100644 index 0000000..ac99de1 --- /dev/null +++ b/src/ProtoAssembly.cpp @@ -0,0 +1,49 @@ +#include +#include +#include "ProtoAssembly.h" + +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->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) { + // 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(malloc(this->limit + 1)); + if(!this->buf) return assembly_t::noMemory; + this->owner = piece.clientId; + this->startedAt = now; + } + // The rest of a message that was already answered. + else if(this->owner != piece.clientId) return assembly_t::ignored; + if(this->len + piece.len > this->limit) { + this->release(); + 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; +} diff --git a/src/ProtoAssembly.h b/src/ProtoAssembly.h new file mode 100644 index 0000000..22c87eb --- /dev/null +++ b/src/ProtoAssembly.h @@ -0,0 +1,59 @@ +#ifndef PROTO_ASSEMBLY_H +#define PROTO_ASSEMBLY_H +#include +#include + +// 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 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. +#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: 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 +}; +class MessageAssembler { + public: + explicit MessageAssembler(size_t limit) : limit(limit) {} + ~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 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; + char *buf = nullptr; + size_t len = 0; + void release(); +}; +#endif diff --git a/src/ProtoEdits.cpp b/src/ProtoEdits.cpp new file mode 100644 index 0000000..b06fcb4 --- /dev/null +++ b/src/ProtoEdits.cpp @@ -0,0 +1,175 @@ +#include +#include +#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(op.type) != type) continue; + out = op.edit; + return true; + } + return false; +} +const char *parseReadPayload(uint8_t type, JsonObjectConst d, uint8_t &groupId) { + groupId = 0; + if(type != static_cast(proto_type_t::groupOptions)) return nullptr; + JsonVariantConst v = d[protoField(proto_field_t::groupId).key]; + if(v.isNull()) return "groupId is missing"; + if(!v.is()) return "groupId must be 0-255"; + groupId = v.as(); + return nullptr; +} + +// The fields each edit's route reads, so a field it would ignore is refused +// rather than dropped without a word. A shade's flags aren't among them: +// they hold light, which is set on its own, and the sun and wind state, which +// setSensor sets. A group's sunSensor is worked out from its shades. +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::repeats +}; +template +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"; + } +} + +// 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() || (v.as() != 0 && v.as() != 1)) return false; + out.set(v.as() == 1); + return true; + case 'i': + if(v.is() || v.is()) return out.set(v); + return false; + case 'n': + if(v.is()) return out.set(v); + return false; + case 's': + if(v.is()) 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. + for(JsonPairConst kv : d) { + if(strcmp(kv.key().c_str(), protoField(proto_field_t::ids).key) == 0) continue; + return "A sort order takes only ids"; + } + JsonVariantConst ids = d[protoField(proto_field_t::ids).key]; + if(!ids.is()) return "ids is missing"; + for(JsonVariantConst id : ids.as()) + if(!id.is()) return "ids must be a list of ids"; + doc.set(ids); + return doc.overflowed() ? "Too large" : nullptr; + } + default: + break; + } + JsonObject obj = doc.to(); + for(JsonPairConst kv : d) { + proto_field_t field; + if(!protoFieldOfKey(kv.key().c_str(), field)) { + snprintf(why, sizeof(why), "Unknown field %s", kv.key().c_str()); + return why; + } + 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())) { + snprintf(why, sizeof(why), "%s is not a %s", f.name, kindName(f.kind)); + return why; + } + } + // A left-out field is not given, so it can't stand for unpaired. + if(type == model_edit_type_t::set_paired && obj["paired"].isNull()) return "paired is missing"; + return doc.overflowed() ? "Too large" : nullptr; +} diff --git a/src/ProtoEdits.h b/src/ProtoEdits.h new file mode 100644 index 0000000..5bc7470 --- /dev/null +++ b/src/ProtoEdits.h @@ -0,0 +1,27 @@ +#ifndef PROTO_EDITS_H +#define PROTO_EDITS_H +#include +#include +#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); + +// A read op's payload (getNextRoom, getNextShade, getNextGroup and +// groupOptions, answered by loop() from the shade model): groupOptions' group, +// left 0 for the others. nullptr when it is good; otherwise what is wrong +// with it. +const char *parseReadPayload(uint8_t type, JsonObjectConst d, uint8_t &groupId); +#endif diff --git a/src/ProtoFrame.h b/src/ProtoFrame.h index 8ffeb5e..12c26e7 100644 --- a/src/ProtoFrame.h +++ b/src/ProtoFrame.h @@ -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":,"id":,"d":{ ... }}. id 0 leaves it out. void beginFrame(JsonFormatter &json, proto_type_t type, uint32_t id = 0); void endFrame(JsonFormatter &json); diff --git a/src/ProtoOps.cpp b/src/ProtoOps.cpp index 70e0cc4..e592ca7 100644 --- a/src/ProtoOps.cpp +++ b/src/ProtoOps.cpp @@ -103,10 +103,6 @@ bool protoCommand(const char *name, somfy_commands &cmd) { return false; } -bool protoIsShadeOp(uint8_t type) { - return type >= static_cast(proto_type_t::shadeCommand) && type <= static_cast(proto_type_t::setRollingCode); -} - // The repeat count and step size a command may carry. static void readRepeat(OpReader &r, shade_request_t &req) { int32_t n; diff --git a/src/ProtoOps.h b/src/ProtoOps.h index b53a8c2..75e1941 100644 --- a/src/ProtoOps.h +++ b/src/ProtoOps.h @@ -16,7 +16,6 @@ // Every command op except sendRemoteCommand becomes a shade request that // loop() runs. -bool protoIsShadeOp(uint8_t type); // nullptr when the payload is good and req is filled in; otherwise what is // wrong with it, for a bad request reply. const char *parseShadeOp(uint8_t type, JsonObjectConst d, shade_request_t &req); diff --git a/src/ProtoSchema.cpp b/src/ProtoSchema.cpp index c27dc90..3139a35 100644 --- a/src/ProtoSchema.cpp +++ b/src/ProtoSchema.cpp @@ -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(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(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(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'; diff --git a/src/ProtoSchema.h b/src/ProtoSchema.h index 7ad5ac2..9a61de2 100644 --- a/src/ProtoSchema.h +++ b/src/ProtoSchema.h @@ -15,43 +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(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, @@ -124,6 +148,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) \ @@ -197,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); @@ -210,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(); diff --git a/src/ShadeController.cpp b/src/ShadeController.cpp index 902ca78..e331c00 100644 --- a/src/ShadeController.cpp +++ b/src/ShadeController.cpp @@ -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 offeredRemoteAddress(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; diff --git a/src/Somfy.h b/src/Somfy.h index 223913c..15a64f8 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -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(); diff --git a/src/SomfyEditor.h b/src/SomfyEditor.h index 22d6a4e..b6db6ba 100644 --- a/src/SomfyEditor.h +++ b/src/SomfyEditor.h @@ -2,15 +2,19 @@ #define SOMFY_EDITOR_H #include #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 // 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 its reply. struct model_edit_t { model_edit_type_t type = model_edit_type_t::add_room; uint32_t requestId = 0; + proto_reply_to_t replyTo; JsonDocument doc; }; @@ -20,7 +24,13 @@ 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. +// 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. edit_result_t applyModelEdit(model_edit_t &edit); diff --git a/src/SomfyJson.cpp b/src/SomfyJson.cpp index 4db0009..881fd9a 100644 --- a/src/SomfyJson.cpp +++ b/src/SomfyJson.cpp @@ -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,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(edit->type), result.desc); else markEdited(*edit, result); - emitRequestResult(edit->requestId, result); + if(edit->replyTo.client) protoSocket.replyEdit(edit->replyTo, edit->type, result); + else emitRequestResult(edit->requestId, result); cancelModelEdit(edit); } @@ -272,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"); @@ -280,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. @@ -293,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"); @@ -302,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(); @@ -311,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(); @@ -321,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(); @@ -330,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); } @@ -338,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); @@ -346,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); } @@ -354,7 +376,7 @@ 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"]; if(edit.type == model_edit_type_t::unlink_remote) shade->unlinkRemote(address); @@ -369,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(); @@ -389,8 +411,9 @@ edit_result_t applyModelEdit(model_edit_t &edit) { return done(); } case model_edit_type_t::set_paired: { - SomfyShade *shade = obj["shadeId"].isNull() ? nullptr : somfy.getShadeById(obj["shadeId"]); - if(!shade) return edit_result_t::error("Shade not found to pair"); + 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::missing("Shade not found to pair"); shade->paired = obj["paired"] | false; shade->save(); shade->emitState(); diff --git a/src/entryPoints/WebUiRoutes.cpp b/src/entryPoints/WebUiRoutes.cpp index 26d9c73..b2683b5 100644 --- a/src/entryPoints/WebUiRoutes.cpp +++ b/src/entryPoints/WebUiRoutes.cpp @@ -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; } diff --git a/src/outputs/ProtoSocket.cpp b/src/outputs/ProtoSocket.cpp index 845c33d..426de8f 100644 --- a/src/outputs/ProtoSocket.cpp +++ b/src/outputs/ProtoSocket.cpp @@ -1,6 +1,8 @@ #include #include #include +#include +#include #include "esp_log.h" #include "ProtoSocket.h" #include "ProtoSync.h" @@ -8,6 +10,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 +42,18 @@ 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 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, so creating it can't fail. +static StaticSemaphore_t assemblerMutexBuffer; +static SemaphoreHandle_t assemblerMutex = nullptr; +struct AssemblerLock { + AssemblerLock() { xSemaphoreTake(assemblerMutex, portMAX_DELAY); } + ~AssemblerLock() { xSemaphoreGive(assemblerMutex); } +}; /********************************************************************* * The shade model as a sync source @@ -158,8 +175,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 @@ -175,6 +191,34 @@ 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) { + bool busy; + model_edit_t *edit = newModelEdit(type, busy); + if(!edit) { + 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) { + cancelModelEdit(edit); + replyError(client, msg.id, proto_error_t::badRequest, bad); + return; + } + 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) { + loop_item_t item; + item.type = loop_item_type_t::proto_read; + item.readOp = msg.type; + const char *bad = parseReadPayload(msg.type, d, item.groupId); + if(bad) { replyError(client, msg.id, proto_error_t::badRequest, bad); return; } + 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) { if(settings.Security.type == security_types::None) return true; const AsyncWebParameter *key = request->getParam("apikey"); @@ -200,23 +244,22 @@ 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); 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(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; @@ -241,19 +284,80 @@ static void onFrame(AsyncWebSocketClient *client, AwsFrameInfo *info, uint8_t *d replyOk(client, msg.id); return; } - case proto_type_t::sendRemoteCommand: + 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; - default: - if(protoIsShadeOp(msg.type)) { - queueShadeOp(client, msg, doc["d"]); - return; - } - // The edit and settings ops arrive with the protocol core's later PRs. + case proto_op_t::edit: { + model_edit_type_t edit; + 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; + } + 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. + if(info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) { + onMessage(client, reinterpret_cast(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. + 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, 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"); + 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::more: + case assembly_t::ignored: + return; + } +} void ProtoSocket::wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) { (void)server; switch(type) { @@ -265,6 +369,7 @@ void ProtoSocket::wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, } void ProtoSocket::begin(AsyncWebServer &server) { + assemblerMutex = xSemaphoreCreateMutexStatic(&assemblerMutexBuffer); ws.handleHandshake(authorized); ws.onEvent(ProtoSocket::wsEvent); server.addHandler(&ws); @@ -306,19 +411,119 @@ 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(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)); + 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(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); + } + 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); + 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(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)); + 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(op)) { + case proto_type_t::getNextRoom: { + const uint8_t roomId = somfy.getNextRoomId(); + if(roomId == 0) { refused = EDIT_ROOMS_FULL; 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(proto_type_t::getNextShade); + const uint8_t newId = isShade ? somfy.getNextShadeId() : somfy.getNextGroupId(); + 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)); + w.add(proto_field_t::bitLength, somfy.transceiver.config.type); + w.add(proto_field_t::proto, static_cast(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); + sendReply(to, json, buf, sizeof(buf)); } /********************************************************************* diff --git a/src/outputs/ProtoSocket.h b/src/outputs/ProtoSocket.h index 5c5fac7..44ce2ce 100644 --- a/src/outputs/ProtoSocket.h +++ b/src/outputs/ProtoSocket.h @@ -4,6 +4,7 @@ #include "ProtoSchema.h" #include "ModelEditor.h" #include "ShadeRequests.h" +#include "ProtoFrame.h" class SomfyShade; class SomfyGroup; @@ -25,7 +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(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(const proto_reply_to_t &to, uint8_t op, uint8_t groupId); // Events, loop() only. void shadeState(SomfyShade &shade); void shade(SomfyShade &shade); diff --git a/test/e2e/README.md b/test/e2e/README.md index 674ff1a..89a7da1 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -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 ` | 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. diff --git a/test/e2e/conftest.py b/test/e2e/conftest.py index 2a41a05..fc63108 100644 --- a/test/e2e/conftest.py +++ b/test/e2e/conftest.py @@ -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 , 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 diff --git a/test/e2e/test_edits.py b/test/e2e/test_edits.py new file mode 100644 index 0000000..71b8000 --- /dev/null +++ b/test/e2e/test_edits.py @@ -0,0 +1,129 @@ +"""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"), + ("setPaired", {"s": 1}, "paired is missing"), + ("saveGroup", {"g": 1, "ss": 1}, "sunSensor can't be set by this edit"), +]) +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) == "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") + 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] + 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 diff --git a/test/test_loopqueue/test_loopqueue.cpp b/test/test_loopqueue/test_loopqueue.cpp index 80fbd39..35b7971 100644 --- a/test/test_loopqueue/test_loopqueue.cpp +++ b/test/test_loopqueue/test_loopqueue.cpp @@ -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() {} diff --git a/test/test_modeleditor/test_modeleditor.cpp b/test/test_modeleditor/test_modeleditor.cpp index dbeac6c..3164b9f 100644 --- a/test/test_modeleditor/test_modeleditor.cpp +++ b/test/test_modeleditor/test_modeleditor.cpp @@ -31,6 +31,12 @@ static void test_free_remote_address_skips_every_taken_one() { TEST_ASSERT_EQUAL_UINT32(104, freeRemoteAddress(101, used, 4)); TEST_ASSERT_EQUAL_UINT32(100, freeRemoteAddress(100, used, 4)); } +static void test_a_new_one_is_offered_the_starting_address_plus_its_id() { + TEST_ASSERT_EQUAL_UINT32(1005, offeredRemoteAddress(1000, 5, nullptr, 0)); + // Taken by another shade or group, so the next free one. + const uint32_t used[] = { 1006, 1005 }; + TEST_ASSERT_EQUAL_UINT32(1007, offeredRemoteAddress(1000, 5, used, 2)); +} /********************************************************************* * Lists @@ -137,6 +143,23 @@ static void test_255_means_no_pin() { /********************************************************************* * What protocol 2 clients are sent after an edit ********************************************************************/ +/********************************************************************* + * Results + ********************************************************************/ +static void test_a_missing_id_is_not_found() { + const edit_result_t r = edit_result_t::missing("Shade Id not found."); + TEST_ASSERT_FALSE(r.ok); + TEST_ASSERT_TRUE(r.notFound); + TEST_ASSERT_EQUAL_STRING("Shade Id not found.", r.desc); +} +static void test_any_other_refusal_is_not_not_found() { + const edit_result_t r = edit_result_t::error("No shade id was supplied.", 3); + TEST_ASSERT_FALSE(r.ok); + TEST_ASSERT_FALSE(r.notFound); + TEST_ASSERT_EQUAL_INT8(3, r.code); + TEST_ASSERT_FALSE(edit_result_t().notFound); +} + static edit_result_t resultFor(uint8_t id, uint8_t shadeId = 0) { edit_result_t r; r.id = id; @@ -203,6 +226,9 @@ int main(int argc, char **argv) { RUN_TEST(test_first_free_id_is_0_when_every_one_is_taken); RUN_TEST(test_next_sort_order_goes_after_the_last); RUN_TEST(test_free_remote_address_skips_every_taken_one); + RUN_TEST(test_a_new_one_is_offered_the_starting_address_plus_its_id); + RUN_TEST(test_a_missing_id_is_not_found); + RUN_TEST(test_any_other_refusal_is_not_not_found); RUN_TEST(test_add_goes_into_the_first_empty_slot); RUN_TEST(test_adding_one_already_there_changes_nothing); RUN_TEST(test_adding_to_a_full_list_fails); diff --git a/test/test_proto/test_proto.cpp b/test/test_proto/test_proto.cpp index 7d17139..4e1a2fb 100644 --- a/test/test_proto/test_proto.cpp +++ b/test/test_proto/test_proto.cpp @@ -50,6 +50,26 @@ static void test_type_names_are_unique() { std::set 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(proto_type_t::shadeCommand))); + TEST_ASSERT_EQUAL(proto_op_t::remoteFrame, protoOpKind(static_cast(proto_type_t::sendRemoteCommand))); + TEST_ASSERT_EQUAL(proto_op_t::edit, protoOpKind(static_cast(proto_type_t::saveShade))); + TEST_ASSERT_EQUAL(proto_op_t::read, protoOpKind(static_cast(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 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); diff --git a/test/test_protoassembly/test_protoassembly.cpp b/test/test_protoassembly/test_protoassembly.cpp new file mode 100644 index 0000000..30e4c78 --- /dev/null +++ b/test/test_protoassembly/test_protoassembly.cpp @@ -0,0 +1,120 @@ +#include +#include +#include +#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(text); + 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)); + assertTaken(a, "{\"v\":2,\"t\":3}"); +} +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); + assertTaken(a, "abc"); + TEST_ASSERT_FALSE(a.holding()); + // A client leaving now can't free what was taken. + a.drop(1); +} +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)); + assertTaken(a, "abc"); +} +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_binary_message_leaves_another_clients_alone() { + MessageAssembler a(64); + a.add(piece(1, "ab", true, false), 0); + 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() { + MessageAssembler a(64); + TEST_ASSERT_EQUAL(assembly_t::complete, a.add(piece(1, "{}", true, true), 0)); + assertTaken(a, "{}"); +} + +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_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_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(); +} diff --git a/test/test_protoedits/test_protoedits.cpp b/test/test_protoedits/test_protoedits.cpp new file mode 100644 index 0000000..7242a4e --- /dev/null +++ b/test/test_protoedits/test_protoedits.cpp @@ -0,0 +1,169 @@ +#include +#include +#include +#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(), 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(proto_type_t::addShade), t)); + TEST_ASSERT_EQUAL(model_edit_type_t::add_shade, t); + TEST_ASSERT_TRUE(protoEditType(static_cast(proto_type_t::groupSortOrder), t)); + TEST_ASSERT_EQUAL(model_edit_type_t::group_order, t); + TEST_ASSERT_TRUE(protoEditType(static_cast(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(type), t)) count++; + TEST_ASSERT_EQUAL_INT(static_cast(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(proto_type_t::shadeCommand), t)); + TEST_ASSERT_FALSE(protoEditType(static_cast(proto_type_t::getNextShade), t)); + TEST_ASSERT_FALSE(protoEditType(static_cast(proto_type_t::shade), t)); +} +static void test_the_edit_ops_are_the_schemas_edit_ops() { + // The TYPE table marks the edit ops; each needs its edit here, and only they + // have one. + model_edit_type_t t; + for(int type = 0; type <= 255; type++) { + const bool edit = protoOpKind(static_cast(type)) == proto_op_t::edit; + TEST_ASSERT_EQUAL_MESSAGE(edit, protoEditType(static_cast(type), t), protoTypeName(static_cast(type))); + } +} + +/********************************************************************* + * Reads + ********************************************************************/ +static uint8_t groupId; +static const char *readPayload(proto_type_t type, const char *json) { + payload.clear(); + TEST_ASSERT_EQUAL(DeserializationError::Ok, deserializeJson(payload, json).code()); + return parseReadPayload(static_cast(type), payload.as(), groupId); +} +static void test_group_options_reads_its_group() { + TEST_ASSERT_GOOD(readPayload(proto_type_t::groupOptions, R"({"g":4})")); + TEST_ASSERT_EQUAL_UINT8(4, groupId); + TEST_ASSERT_EQUAL_STRING("groupId is missing", readPayload(proto_type_t::groupOptions, R"({})")); + TEST_ASSERT_EQUAL_STRING("groupId must be 0-255", readPayload(proto_type_t::groupOptions, R"({"g":300})")); + TEST_ASSERT_EQUAL_STRING("groupId must be 0-255", readPayload(proto_type_t::groupOptions, R"({"g":"4"})")); +} +static void test_the_other_reads_need_nothing() { + TEST_ASSERT_GOOD(readPayload(proto_type_t::getNextShade, R"({})")); + TEST_ASSERT_EQUAL_UINT8(0, groupId); + TEST_ASSERT_GOOD(readPayload(proto_type_t::getNextRoom, R"({})")); + TEST_ASSERT_GOOD(readPayload(proto_type_t::getNextGroup, R"({})")); +} + +/********************************************************************* + * 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})")); +} +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"})")); + // light is its own field, and setSensor sets the sun and wind. + TEST_ASSERT_EQUAL_STRING("flags can't be set by this edit", expand(model_edit_type_t::save_shade, R"({"s":3,"f":1})")); + // Worked out from the group's shades. + TEST_ASSERT_EQUAL_STRING("sunSensor can't be set by this edit", expand(model_edit_type_t::save_group, R"({"g":1,"ss":1})")); +} +static void test_set_paired_needs_paired() { + // Left out is not given, so it doesn't unpair the shade. + TEST_ASSERT_EQUAL_STRING("paired is missing", expand(model_edit_type_t::set_paired, R"({"s":3})")); +} +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; + (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_edit_ops_are_the_schemas_edit_ops); + RUN_TEST(test_group_options_reads_its_group); + RUN_TEST(test_the_other_reads_need_nothing); + 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); + RUN_TEST(test_a_field_the_route_would_ignore_is_refused); + RUN_TEST(test_set_paired_needs_paired); + 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(); +} diff --git a/test/test_protoops/test_protoops.cpp b/test/test_protoops/test_protoops.cpp index 6b4ece8..b8f8ed0 100644 --- a/test/test_protoops/test_protoops.cpp +++ b/test/test_protoops/test_protoops.cpp @@ -28,10 +28,10 @@ static void test_every_command_op_but_the_remote_frame_is_a_shade_request() { proto_type_t::setPositions, proto_type_t::setSensor, proto_type_t::setMyPosition, proto_type_t::unpairShade, proto_type_t::setRollingCode }; - for(proto_type_t t : shadeOps) TEST_ASSERT_TRUE(protoIsShadeOp(static_cast(t))); - TEST_ASSERT_FALSE(protoIsShadeOp(static_cast(proto_type_t::sendRemoteCommand))); - TEST_ASSERT_FALSE(protoIsShadeOp(static_cast(proto_type_t::subscribe))); - TEST_ASSERT_FALSE(protoIsShadeOp(static_cast(proto_type_t::shadeCommanded))); + for(proto_type_t t : shadeOps) TEST_ASSERT_EQUAL(proto_op_t::shadeRequest, protoOpKind(static_cast(t))); + TEST_ASSERT_EQUAL(proto_op_t::remoteFrame, protoOpKind(static_cast(proto_type_t::sendRemoteCommand))); + TEST_ASSERT_EQUAL(proto_op_t::none, protoOpKind(static_cast(proto_type_t::subscribe))); + TEST_ASSERT_EQUAL(proto_op_t::none, protoOpKind(static_cast(proto_type_t::shadeCommanded))); } /*********************************************************************