From dc2f1478b628de000d46be7bb1082c2743baaa7a Mon Sep 17 00:00:00 2001 From: cjkas Date: Sat, 24 Oct 2026 13:17:15 +0200 Subject: [PATCH] fix: protocol 2 core review findings From the review of #99: - A sync could stall when a client took a slot the moment another left: loop() used up the new client's sync on the old one. The slots move to ProtoSlots, a pure module with native tests, and loop() takes a slot's wants only for the client it read (takeWants). - Deleting a group sends its shades, whose inGroup changed. Deleting a shade unlinks it from its groups and sends them, so a new shade given its id doesn't join them. - next and sync with an id get an ok reply, and a message in pieces gets one bad request, not one per piece. - Feeds check for loop() before they write the event buffer, and log an event too large, as events do. - editChanges() (ModelEditor, native tests) says what clients get after an edit; the edit result carries the shade a group gained or lost. order() takes a sort_list_t. - The frame writers move to ProtoFrame. Renames: pf to proto_field_t, the X-macro letters to TYPE/FIELD/ERR/FEED, proto_request_t to proto_message_t, REQ_* to WANTS_*, fmt to eventJson. The event bodies share sendItem(), the connection type name lives in ESPNetwork, and the loop() task check in SocketEmitter. - e2e: the heap floor check skips when the low since boot is already under it; tests for replies to next and sync and a long message. - SECURITY_PLAN.md splits step 1 into 1a-1d. Co-Authored-By: Claude Opus 5.5 --- docs/PROTOCOL.md | 4 +- docs/SECURITY_PLAN.md | 9 +- platformio.ini | 2 +- src/ESPNetwork.h | 6 + src/ModelEditor.cpp | 39 ++ src/ModelEditor.h | 20 +- src/ProtoFrame.cpp | 33 ++ src/ProtoFrame.h | 20 + src/ProtoParse.cpp | 12 +- src/ProtoParse.h | 12 +- src/ProtoSchema.cpp | 31 +- src/ProtoSchema.h | 288 ++++++------ src/ProtoSlots.cpp | 62 +++ src/ProtoSlots.h | 59 +++ src/ProtoSync.cpp | 141 +++--- src/ShadeController.cpp | 13 +- src/Somfy.h | 4 + src/SomfyJson.cpp | 57 +-- src/SomfyShadeOps.cpp | 2 +- src/Transceiver.cpp | 34 +- src/entryPoints/Web.cpp | 5 +- src/outputs/ProtoSocket.cpp | 483 +++++++++------------ src/outputs/ProtoSocket.h | 21 +- src/outputs/SocketEmitter.h | 6 +- test/e2e/test_messages.py | 28 ++ test/e2e/test_survival.py | 10 +- test/test_modeleditor/test_modeleditor.cpp | 67 +++ test/test_proto/test_proto.cpp | 117 ++--- test/test_protoslots/test_protoslots.cpp | 172 ++++++++ 29 files changed, 1087 insertions(+), 670 deletions(-) create mode 100644 src/ProtoFrame.cpp create mode 100644 src/ProtoFrame.h create mode 100644 src/ProtoSlots.cpp create mode 100644 src/ProtoSlots.h create mode 100644 test/test_protoslots/test_protoslots.cpp diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index b9d4fa2..c861a6d 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 a bad request. A binary encoding, if one comes, would be asked for at the upgrade (for example `?enc=bin`) and use binary frames. +- 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. ## Envelope @@ -76,7 +76,7 @@ Events go out to every client from the moment it connects, so they can arrive du ## Replies -A client message with an `id` gets exactly one `reply` with that `id`, to that client only: `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. +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. A malformed message gets a bad request, with its `id` when it had one. A type a client may not send gets an unknown op. diff --git a/docs/SECURITY_PLAN.md b/docs/SECURITY_PLAN.md index d5720b3..bea9e4d 100644 --- a/docs/SECURITY_PLAN.md +++ b/docs/SECURITY_PLAN.md @@ -74,12 +74,15 @@ Protocol 2 lands as one PR per step, and `main` keeps working throughout because | # | Repo | Scope | |---|---|---| -| 1 | firmware | protocol core next to the existing HTTP: `hello`, socket auth, ops through the LoopQueue, sync, events, schema; the e2e suite starts | +| 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 | +| 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 | -| 4 | firmware | removals: the old routes, ports 8081 and 8080, `/discovery`, ModelSnapshot, the GitHub update, "Secure Config Only"; static serving limited to web assets; CORS headers; Origin check | +| 4 | firmware | removals: the old routes, ports 8081 and 8080, `/discovery`, ModelSnapshot, "Secure Config Only"; static serving limited to web assets; CORS headers; Origin check | -PR B (token key) and PR C (login backoff) are separate firmware PRs that can land at any point. +PR B (token key) and PR C (login backoff) are separate firmware PRs that can land at any point. The GitHub update was removed ahead of the rest ([ADR 0007](adr/0007-no-github-update.md)), as protocol 2 didn't fit beside it. ## Tests diff --git a/platformio.ini b/platformio.ini index c1c145c..ef41806 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/ESPNetwork.h b/src/ESPNetwork.h index d6ad114..8143097 100644 --- a/src/ESPNetwork.h +++ b/src/ESPNetwork.h @@ -38,6 +38,12 @@ class ESPNetwork { bool connecting(); void clearConnecting(); conn_types_t preferredConnType(); + // How the device is connected, as clients are told: Ethernet, Wifi or Unknown. + const char *connTypeName() const { + if(this->connType == conn_types_t::ethernet) return "Ethernet"; + if(this->connType == conn_types_t::wifi) return "Wifi"; + return "Unknown"; + } String ssid; String mac; int channel; diff --git a/src/ModelEditor.cpp b/src/ModelEditor.cpp index c20b680..d8123fa 100644 --- a/src/ModelEditor.cpp +++ b/src/ModelEditor.cpp @@ -1,5 +1,44 @@ #include "ModelEditor.h" +edit_changes_t editChanges(model_edit_type_t type, const edit_result_t &result) { + edit_changes_t c; + switch(type) { + case model_edit_type_t::add_shade: + case model_edit_type_t::save_shade: + case model_edit_type_t::set_paired: + case model_edit_type_t::link_remote: + case model_edit_type_t::unlink_remote: + c.shadeId = result.id; + break; + case model_edit_type_t::add_group: + case model_edit_type_t::save_group: + c.groupId = result.id; + break; + // The shade's inGroup goes with it. + case model_edit_type_t::link_to_group: + case model_edit_type_t::unlink_from_group: + c.groupId = result.id; + c.shadeId = result.shadeId; + break; + case model_edit_type_t::add_room: + case model_edit_type_t::save_room: + c.roomId = result.id; + break; + case model_edit_type_t::room_order: c.order = sort_list_t::rooms; break; + case model_edit_type_t::shade_order: c.order = sort_list_t::shades; break; + case model_edit_type_t::group_order: c.order = sort_list_t::groups; break; + case model_edit_type_t::link_repeater: + case model_edit_type_t::unlink_repeater: + c.repeaters = true; + break; + case model_edit_type_t::delete_room: + case model_edit_type_t::delete_shade: + case model_edit_type_t::delete_group: + break; + } + return c; +} + uint8_t firstFreeId(const uint8_t *usedIds, size_t count, uint8_t lastId) { for(uint16_t id = 1; id <= lastId; id++) { bool used = false; diff --git a/src/ModelEditor.h b/src/ModelEditor.h index f949261..ffa90ba 100644 --- a/src/ModelEditor.h +++ b/src/ModelEditor.h @@ -24,11 +24,13 @@ enum class model_edit_type_t : uint8_t { room_order, shade_order, group_order }; // What an edit did, sent to the clients as requestResult. id is the room, -// shade or group it added or changed, 0 when none. code is the validation -// error of a shade that wasn't saved, 0 otherwise. +// 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. struct edit_result_t { bool ok = true; uint8_t id = 0; + uint8_t shadeId = 0; int8_t code = 0; const char *desc = ""; static edit_result_t error(const char *desc, int8_t code = 0) { @@ -40,6 +42,20 @@ struct edit_result_t { } }; +// The lists an edit can put in a new order. +enum class sort_list_t : uint8_t { none, rooms, shades, groups }; +// What protocol 2 clients are sent once an edit has gone through: the whole +// items it changed (0 for none), a list's new order, the repeaters. Deletes +// send their removals as they happen, so they need nothing here. +struct edit_changes_t { + uint8_t shadeId = 0; + uint8_t groupId = 0; + uint8_t roomId = 0; + sort_list_t order = sort_list_t::none; + bool repeaters = false; +}; +edit_changes_t editChanges(model_edit_type_t type, const edit_result_t &result); + // The lowest id from 1 to lastId that isn't in usedIds, or 0 when every one // is taken. uint8_t firstFreeId(const uint8_t *usedIds, size_t count, uint8_t lastId); diff --git a/src/ProtoFrame.cpp b/src/ProtoFrame.cpp new file mode 100644 index 0000000..c3b58ef --- /dev/null +++ b/src/ProtoFrame.cpp @@ -0,0 +1,33 @@ +#include "ProtoFrame.h" + +void beginFrame(JsonFormatter &json, proto_type_t type, uint32_t id) { + json.beginObject(); + json.addElem("v", (uint8_t)PROTO_VERSION); + json.addElem("t", static_cast(type)); + if(id) json.addElem("id", id); + json.beginObject("d"); +} +void endFrame(JsonFormatter &json) { + json.endObject(); + json.endObject(); +} +void writeOkReply(JsonFormatter &json, uint32_t id) { + beginFrame(json, proto_type_t::reply, id); + ProtoWriter(json).add(proto_field_t::ok, true); + endFrame(json); +} +void writeErrorReply(JsonFormatter &json, uint32_t id, proto_error_t code, const char *desc) { + beginFrame(json, proto_type_t::reply, id); + ProtoWriter w(json); + w.add(proto_field_t::ok, false); + w.add(proto_field_t::code, static_cast(code)); + w.add(proto_field_t::desc, desc); + endFrame(json); +} +void writeHello(JsonFormatter &json, const char *fw) { + beginFrame(json, proto_type_t::hello); + ProtoWriter w(json); + w.add(proto_field_t::fw, fw); + w.add(proto_field_t::schemaHash, protoSchemaHash()); + endFrame(json); +} diff --git a/src/ProtoFrame.h b/src/ProtoFrame.h new file mode 100644 index 0000000..8ffeb5e --- /dev/null +++ b/src/ProtoFrame.h @@ -0,0 +1,20 @@ +#ifndef PROTO_FRAME_H +#define PROTO_FRAME_H +#include +#include "JsonFormatter.h" +#include "ProtoSchema.h" + +// The envelope of every socket protocol 2 frame, and the frames that hold +// 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). + +// 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); +// A reply that says ok. +void writeOkReply(JsonFormatter &json, uint32_t id); +// A reply carrying an error. +void writeErrorReply(JsonFormatter &json, uint32_t id, proto_error_t code, const char *desc); +// hello: the firmware version and the schema hash. +void writeHello(JsonFormatter &json, const char *fw); +#endif diff --git a/src/ProtoParse.cpp b/src/ProtoParse.cpp index abf7aba..966f368 100644 --- a/src/ProtoParse.cpp +++ b/src/ProtoParse.cpp @@ -1,7 +1,7 @@ #include "ProtoParse.h" -proto_error_t parseClientFrame(const char *text, size_t len, JsonDocument &doc, proto_request_t &out) { - out = proto_request_t(); +proto_error_t parseClientFrame(const char *text, size_t len, JsonDocument &doc, proto_message_t &out) { + out = proto_message_t(); if(len > PROTO_MAX_CLIENT_FRAME) return proto_error_t::badRequest; if(deserializeJson(doc, text, len) != DeserializationError::Ok) return proto_error_t::badRequest; if(!doc.is()) return proto_error_t::badRequest; @@ -19,18 +19,18 @@ proto_error_t parseClientFrame(const char *text, size_t len, JsonDocument &doc, return proto_error_t::none; } -int32_t protoInt(JsonObjectConst d, pf field) { +int32_t protoInt(JsonObjectConst d, proto_field_t field) { const proto_field_info_t &f = protoField(field); JsonVariantConst v = d[f.key]; return v.is() ? v.as() : f.def; } -bool protoBool(JsonObjectConst d, pf field) { +bool protoBool(JsonObjectConst d, proto_field_t field) { const proto_field_info_t &f = protoField(field); JsonVariantConst v = d[f.key]; return v.is() ? v.as() != 0 : f.def != 0; } -const char *protoString(JsonObjectConst d, pf field) { +const char *protoString(JsonObjectConst d, proto_field_t field) { JsonVariantConst v = d[protoField(field).key]; return v.is() ? v.as() : nullptr; } -bool protoHas(JsonObjectConst d, pf field) { return !d[protoField(field).key].isNull(); } +bool protoHas(JsonObjectConst d, proto_field_t field) { return !d[protoField(field).key].isNull(); } diff --git a/src/ProtoParse.h b/src/ProtoParse.h index 8ab3d34..d2c5c86 100644 --- a/src/ProtoParse.h +++ b/src/ProtoParse.h @@ -12,21 +12,21 @@ // The largest frame a client may send; a longer one is refused unread. #define PROTO_MAX_CLIENT_FRAME 4096 -struct proto_request_t { +struct proto_message_t { uint8_t type = 0; uint32_t id = 0; // 0 when the frame had none }; // Reads one frame into doc and out. none when it is a message a client may // send; otherwise the error to reply with. out.id is set whenever the frame // had one, so the reply can name it. The payload is doc["d"]. -proto_error_t parseClientFrame(const char *text, size_t len, JsonDocument &doc, proto_request_t &out); +proto_error_t parseClientFrame(const char *text, size_t len, JsonDocument &doc, proto_message_t &out); // An op (16-127) as opposed to a control message. inline bool protoIsOp(uint8_t type) { return type >= 16 && type <= 127; } // A field of a payload, or its default when it was left out. -int32_t protoInt(JsonObjectConst d, pf field); -bool protoBool(JsonObjectConst d, pf field); +int32_t protoInt(JsonObjectConst d, proto_field_t field); +bool protoBool(JsonObjectConst d, proto_field_t field); // nullptr when it was left out, as a string's default is "". -const char *protoString(JsonObjectConst d, pf field); -bool protoHas(JsonObjectConst d, pf field); +const char *protoString(JsonObjectConst d, proto_field_t field); +bool protoHas(JsonObjectConst d, proto_field_t field); #endif diff --git a/src/ProtoSchema.cpp b/src/ProtoSchema.cpp index f92b699..c27dc90 100644 --- a/src/ProtoSchema.cpp +++ b/src/ProtoSchema.cpp @@ -13,9 +13,9 @@ 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(pf::count), "a field without a row"); +static_assert(sizeof(FIELDS) / sizeof(FIELDS[0]) == static_cast(proto_field_t::count), "a field without a row"); -const proto_field_info_t &protoField(pf field) { return FIELDS[static_cast(field)]; } +const proto_field_info_t &protoField(proto_field_t field) { return FIELDS[static_cast(field)]; } const proto_field_info_t *protoFieldByKey(const char *key) { for(const proto_field_info_t &f : FIELDS) if(strcmp(f.key, key) == 0) return &f; @@ -77,30 +77,3 @@ void ProtoWriter::item(int64_t value) { if(value >= INT32_MIN && value <= INT32_MAX) this->json.addElem((int32_t)value); else this->json.addElem((uint32_t)value); } - -void beginFrame(JsonFormatter &json, proto_type_t type, uint32_t id) { - json.beginObject(); - json.addElem("v", (uint8_t)PROTO_VERSION); - json.addElem("t", static_cast(type)); - if(id) json.addElem("id", id); - json.beginObject("d"); -} -void endFrame(JsonFormatter &json) { - json.endObject(); - json.endObject(); -} -void writeErrorReply(JsonFormatter &json, uint32_t id, proto_error_t code, const char *desc) { - beginFrame(json, proto_type_t::reply, id); - ProtoWriter w(json); - w.add(pf::ok, false); - w.add(pf::code, static_cast(code)); - w.add(pf::desc, desc); - endFrame(json); -} -void writeHello(JsonFormatter &json, const char *fw) { - beginFrame(json, proto_type_t::hello); - ProtoWriter w(json); - w.add(pf::fw, fw); - w.add(pf::schemaHash, protoSchemaHash()); - endFrame(json); -} diff --git a/src/ProtoSchema.h b/src/ProtoSchema.h index abefbe3..4b5aa3d 100644 --- a/src/ProtoSchema.h +++ b/src/ProtoSchema.h @@ -15,155 +15,155 @@ // Message types: 1-15 control, 16-127 client ops, 128-255 device events. A // number is never reused; a retired type stays reserved. -// T(name, number, sender): sender is 'c' client, 'd' device, 'b' both. -#define PROTO_TYPES(T) \ - T(hello, 1, 'd') \ - T(reply, 2, 'd') \ - T(next, 3, 'c') \ - T(sync, 4, 'b') \ - T(syncEnd, 5, 'd') \ - T(subscribe, 6, 'c') \ - T(unsubscribe, 7, 'c') \ - T(shadeState, 128, 'd') \ - T(shade, 129, 'd') \ - T(shadeRemoved, 130, 'd') \ - T(group, 131, 'd') \ - T(groupState, 132, 'd') \ - T(groupRemoved, 133, 'd') \ - T(room, 134, 'd') \ - T(roomRemoved, 135, 'd') \ - T(roomOrder, 136, 'd') \ - T(shadeOrder, 137, 'd') \ - T(groupOrder, 138, 'd') \ - T(shadeCommand, 139, 'd') \ - T(memory, 140, 'd') \ - T(wifi, 141, 'd') \ - T(ethernet, 142, 'd') \ - T(frame, 143, 'd') \ - T(frequencyScan, 144, 'd') \ - T(repeaters, 145, 'd') +// TYPE(name, number, sender): sender is 'c' client, 'd' device, 'b' both. +#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(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(shadeCommand, 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. F(name, key, kind, default): +// 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, // l list, o object. A field at its default is left out; a string's default is // "" and a list's is empty. hello's and the schema's own keys (fw, sh, T, F, // E, Q) are fixed, as a client needs them before it has the schema. -#define PROTO_FIELDS(F) \ +#define PROTO_FIELDS(FIELD) \ /* the schema and the sync */ \ - F(types, "T", 'l', 0) \ - F(fields, "F", 'l', 0) \ - F(errors, "E", 'l', 0) \ - F(feeds, "Q", 'l', 0) \ - F(header, "h", 'o', 0) \ - F(rooms, "R", 'l', 0) \ - F(shades, "S", 'l', 0) \ - F(groups, "G", 'l', 0) \ - F(repeaters, "X", 'l', 0) \ - F(status, "Z", 'o', 0) \ - F(fw, "fw", 's', 0) \ - F(schemaHash, "sh", 's', 0) \ + FIELD(types, "T", 'l', 0) \ + FIELD(fields, "F", 'l', 0) \ + FIELD(errors, "E", 'l', 0) \ + FIELD(feeds, "Q", 'l', 0) \ + FIELD(header, "h", 'o', 0) \ + FIELD(rooms, "R", 'l', 0) \ + FIELD(shades, "S", 'l', 0) \ + FIELD(groups, "G", 'l', 0) \ + FIELD(repeaters, "X", 'l', 0) \ + FIELD(status, "Z", 'o', 0) \ + FIELD(fw, "fw", 's', 0) \ + FIELD(schemaHash, "sh", 's', 0) \ /* replies and control */ \ - F(ok, "k", 'b', 0) \ - F(code, "e", 'i', 0) \ - F(desc, "x", 's', 0) \ - F(feed, "fe", 'i', 0) \ - F(ids, "is", 'l', 0) \ + FIELD(ok, "k", 'b', 0) \ + FIELD(code, "e", 'i', 0) \ + FIELD(desc, "x", 's', 0) \ + FIELD(feed, "fe", 'i', 0) \ + FIELD(ids, "is", 'l', 0) \ /* rooms, shades and groups */ \ - F(shadeId, "s", 'i', 0) \ - F(groupId, "g", 'i', 0) \ - F(roomId, "r", 'i', 0) \ - F(name, "n", 's', 0) \ - F(remoteAddress, "a", 'i', 0) \ - F(lastRollingCode, "c", 'i', 0) \ - F(sortOrder, "o", 'i', 0) \ - F(flags, "f", 'i', 0) \ - F(position, "p", 'i', 0) \ - F(target, "t", 'i', 0) \ - F(direction, "d", 'i', 0) \ - F(myPos, "m", 'i', -1) \ - F(tiltType, "y", 'i', 0) \ - F(tiltPosition, "q", 'i', 0) \ - F(tiltTarget, "u", 'i', 0) \ - F(tiltDirection, "w", 'i', 0) \ - F(myTiltPos, "M", 'i', -1) \ - F(upTime, "U", 'i', 0) \ - F(downTime, "D", 'i', 0) \ - F(tiltTime, "Y", 'i', 0) \ - F(stepSize, "z", 'i', 100) \ - F(paired, "i", 'b', 0) \ - F(shadeType, "st", 'i', 0) \ - F(bitLength, "bl", 'i', 56) \ - F(proto, "pr", 'i', 0) \ - F(flipCommands, "fc", 'b', 0) \ - F(flipPosition, "fp", 'b', 0) \ - F(inGroup, "ig", 'b', 0) \ - F(sunSensor, "ss", 'b', 0) \ - F(light, "li", 'b', 0) \ - F(simMy, "sm", 'b', 0) \ - F(repeats, "rp", 'i', 1) \ - F(gpioUp, "gu", 'i', 0) \ - F(gpioDown, "gd", 'i', 0) \ - F(gpioMy, "gm", 'i', 0) \ - F(gpioLLTrigger, "gl", 'b', 0) \ - F(linkedRemotes, "lk", 'l', 0) \ - F(linkedShades, "ls", 'l', 0) \ + FIELD(shadeId, "s", 'i', 0) \ + FIELD(groupId, "g", 'i', 0) \ + FIELD(roomId, "r", 'i', 0) \ + FIELD(name, "n", 's', 0) \ + FIELD(remoteAddress, "a", 'i', 0) \ + FIELD(lastRollingCode, "c", 'i', 0) \ + FIELD(sortOrder, "o", 'i', 0) \ + FIELD(flags, "f", 'i', 0) \ + FIELD(position, "p", 'i', 0) \ + FIELD(target, "t", 'i', 0) \ + FIELD(direction, "d", 'i', 0) \ + FIELD(myPos, "m", 'i', -1) \ + FIELD(tiltType, "y", 'i', 0) \ + FIELD(tiltPosition, "q", 'i', 0) \ + FIELD(tiltTarget, "u", 'i', 0) \ + FIELD(tiltDirection, "w", 'i', 0) \ + FIELD(myTiltPos, "M", 'i', -1) \ + FIELD(upTime, "U", 'i', 0) \ + FIELD(downTime, "D", 'i', 0) \ + FIELD(tiltTime, "Y", 'i', 0) \ + FIELD(stepSize, "z", 'i', 100) \ + FIELD(paired, "i", 'b', 0) \ + FIELD(shadeType, "st", 'i', 0) \ + FIELD(bitLength, "bl", 'i', 56) \ + FIELD(proto, "pr", 'i', 0) \ + FIELD(flipCommands, "fc", 'b', 0) \ + FIELD(flipPosition, "fp", 'b', 0) \ + FIELD(inGroup, "ig", 'b', 0) \ + FIELD(sunSensor, "ss", 'b', 0) \ + FIELD(light, "li", 'b', 0) \ + FIELD(simMy, "sm", 'b', 0) \ + FIELD(repeats, "rp", 'i', 1) \ + FIELD(gpioUp, "gu", 'i', 0) \ + FIELD(gpioDown, "gd", 'i', 0) \ + FIELD(gpioMy, "gm", 'i', 0) \ + FIELD(gpioLLTrigger, "gl", 'b', 0) \ + FIELD(linkedRemotes, "lk", 'l', 0) \ + FIELD(linkedShades, "ls", 'l', 0) \ /* shadeCommand */ \ - F(command, "cm", 's', 0) \ - F(source, "so", 's', 0) \ - F(sourceAddress, "sr", 'i', 0) \ + FIELD(command, "cm", 's', 0) \ + FIELD(source, "so", 's', 0) \ + FIELD(sourceAddress, "sr", 'i', 0) \ /* the header */ \ - F(maxRooms, "xr", 'i', 0) \ - F(maxShades, "xs", 'i', 0) \ - F(maxGroups, "xg", 'i', 0) \ - F(maxGroupedShades, "xm", 'i', 0) \ - F(maxLinkedRemotes, "xl", 'i', 0) \ - F(startingAddress, "sa", 'i', 0) \ - F(serverId, "sv", 's', 0) \ - F(hostname, "hn", 's', 0) \ - F(model, "md", 's', 0) \ - F(chipModel, "cp", 's', 0) \ - F(connType, "ct", 's', 0) \ + FIELD(maxRooms, "xr", 'i', 0) \ + FIELD(maxShades, "xs", 'i', 0) \ + FIELD(maxGroups, "xg", 'i', 0) \ + FIELD(maxGroupedShades, "xm", 'i', 0) \ + FIELD(maxLinkedRemotes, "xl", 'i', 0) \ + FIELD(startingAddress, "sa", 'i', 0) \ + FIELD(serverId, "sv", 's', 0) \ + FIELD(hostname, "hn", 's', 0) \ + FIELD(model, "md", 's', 0) \ + FIELD(chipModel, "cp", 's', 0) \ + FIELD(connType, "ct", 's', 0) \ /* memory, wifi and ethernet */ \ - F(freeHeap, "fr", 'i', 0) \ - F(maxHeap, "mx", 'i', 0) \ - F(minHeap, "mn", 'i', 0) \ - F(totalHeap, "to", 'i', 0) \ - F(uptime, "up", 'i', 0) \ - F(loopRate, "lp", 'i', 0) \ - F(loopMaxUs, "lx", 'i', 0) \ - F(ssid, "sd", 's', 0) \ - F(strength, "sg", 'i', 0) \ - F(channel, "ch", 'i', 0) \ - F(connected, "co", 'b', 0) \ - F(speed, "sp", 'i', 0) \ - F(fullDuplex, "fd", 'b', 0) \ + FIELD(freeHeap, "fr", 'i', 0) \ + FIELD(maxHeap, "mx", 'i', 0) \ + FIELD(minHeap, "mn", 'i', 0) \ + FIELD(totalHeap, "to", 'i', 0) \ + FIELD(uptime, "up", 'i', 0) \ + FIELD(loopRate, "lp", 'i', 0) \ + FIELD(loopMaxUs, "lx", 'i', 0) \ + FIELD(ssid, "sd", 's', 0) \ + FIELD(strength, "sg", 'i', 0) \ + FIELD(channel, "ch", 'i', 0) \ + FIELD(connected, "co", 'b', 0) \ + FIELD(speed, "sp", 'i', 0) \ + FIELD(fullDuplex, "fd", 'b', 0) \ /* the frame and frequency scan feeds */ \ - F(encKey, "ek", 'i', 0) \ - F(rawCmd, "rw", 'i', 0) \ - F(rssi, "rs", 'i', 0) \ - F(bits, "bt", 'i', 0) \ - F(valid, "va", 'b', 0) \ - F(hwsync, "hs", 'i', 0) \ - F(pulses, "pu", 'l', 0) \ - F(scanning, "sc", 'b', 0) \ - F(testFreq, "tf", 'n', 0) \ - F(testRssi, "tr", 'i', 0) \ - F(frequency, "fq", 'n', 0) + FIELD(encKey, "ek", 'i', 0) \ + FIELD(rawCmd, "rw", 'i', 0) \ + FIELD(rssi, "rs", 'i', 0) \ + FIELD(bits, "bt", 'i', 0) \ + FIELD(valid, "va", 'b', 0) \ + FIELD(hwsync, "hs", 'i', 0) \ + FIELD(pulses, "pu", 'l', 0) \ + FIELD(scanning, "sc", 'b', 0) \ + FIELD(testFreq, "tf", 'n', 0) \ + FIELD(testRssi, "tr", 'i', 0) \ + FIELD(frequency, "fq", 'n', 0) -// Error codes in a reply. E(name, number) -#define PROTO_ERRORS(E) \ - E(badRequest, 1) \ - E(unknownOp, 2) \ - E(unauthorized, 3) \ - E(notFound, 4) \ - E(busy, 5) \ - E(radioUnavailable, 6) \ - E(internal, 7) +// Error codes in a reply. ERR(name, number) +#define PROTO_ERRORS(ERR) \ + ERR(badRequest, 1) \ + ERR(unknownOp, 2) \ + ERR(unauthorized, 3) \ + ERR(notFound, 4) \ + ERR(busy, 5) \ + ERR(radioUnavailable, 6) \ + ERR(internal, 7) -// Feeds a client can subscribe to. Q(name, number) -#define PROTO_FEEDS(Q) \ - Q(frames, 1) \ - Q(frequencyScan, 2) +// Feeds a client can subscribe to. FEED(name, number) +#define PROTO_FEEDS(FEED) \ + FEED(frames, 1) \ + FEED(frequencyScan, 2) #define PROTO_ENUM_ENTRY(name, number, ...) name = number, enum class proto_type_t : uint8_t { PROTO_TYPES(PROTO_ENUM_ENTRY) }; @@ -171,7 +171,7 @@ enum class proto_error_t : uint8_t { none = 0, PROTO_ERRORS(PROTO_ENUM_ENTRY) }; enum class proto_feed_t : uint8_t { PROTO_FEEDS(PROTO_ENUM_ENTRY) }; #undef PROTO_ENUM_ENTRY #define PROTO_FIELD_ENTRY(name, ...) name, -enum class pf : uint8_t { PROTO_FIELDS(PROTO_FIELD_ENTRY) count }; +enum class proto_field_t : uint8_t { PROTO_FIELDS(PROTO_FIELD_ENTRY) count }; #undef PROTO_FIELD_ENTRY struct proto_field_info_t { @@ -185,7 +185,7 @@ struct proto_named_t { const char *name; char sender; // types only }; -const proto_field_info_t &protoField(pf field); +const proto_field_info_t &protoField(proto_field_t field); const proto_field_info_t *protoFieldByKey(const char *key); // The tables as the schema lists them, in declaration order. uint8_t protoTypeCount(); @@ -205,7 +205,7 @@ const char *protoSchemaHash(); class ProtoWriter { public: explicit ProtoWriter(JsonFormatter &json) : json(json) {} - template void add(pf field, T value) { + template void add(proto_field_t field, T value) { const proto_field_info_t &f = protoField(field); if constexpr(std::is_same::value) { if((value ? 1 : 0) == f.def) return; @@ -224,9 +224,9 @@ class ProtoWriter { this->json.addElem(f.key, s); } } - void beginList(pf field) { this->json.beginArray(protoField(field).key); } + void beginList(proto_field_t field) { this->json.beginArray(protoField(field).key); } void endList() { this->json.endArray(); } - void beginObject(pf field) { this->json.beginObject(protoField(field).key); } + void beginObject(proto_field_t field) { this->json.beginObject(protoField(field).key); } void endObject() { this->json.endObject(); } // An object or a value inside a list. void beginItem() { this->json.beginObject(); } @@ -236,12 +236,4 @@ class ProtoWriter { private: void addInt(const proto_field_info_t &f, int64_t value); }; - -// 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); -// A reply carrying an error. -void writeErrorReply(JsonFormatter &json, uint32_t id, proto_error_t code, const char *desc); -// hello: the firmware version and the schema hash. -void writeHello(JsonFormatter &json, const char *fw); #endif diff --git a/src/ProtoSlots.cpp b/src/ProtoSlots.cpp new file mode 100644 index 0000000..ec6bc9c --- /dev/null +++ b/src/ProtoSlots.cpp @@ -0,0 +1,62 @@ +#include "ProtoSlots.h" + +proto_slot_t *claimSlot(proto_slot_t *slots, uint8_t count, uint32_t clientId, bool withSchema) { + for(uint8_t i = 0; i < count; i++) { + uint32_t expected = 0; + if(!slots[i].clientId.compare_exchange_strong(expected, clientId)) continue; + slots[i].withSchema = withSchema; + slots[i].feeds = 0; + return &slots[i]; + } + return nullptr; +} +void startSync(proto_slot_t &slot) { slot.wants.fetch_or(WANTS_SYNC); } +proto_slot_t *slotOf(proto_slot_t *slots, uint8_t count, uint32_t clientId) { + for(uint8_t i = 0; i < count; i++) + if(slots[i].clientId.load() == clientId) return &slots[i]; + return nullptr; +} +void freeSlot(proto_slot_t &slot) { + // Cleared before the slot is free, so the next client starts clean. + slot.wants = 0; + slot.feeds = 0; + slot.clientId = 0; +} + +uint32_t takeWants(proto_slot_t &slot, uint32_t seenId) { + const uint32_t wants = slot.wants.exchange(0); + if(slot.clientId.load() == seenId) return wants; + // The client left and another took the slot after loop() read it: what was + // taken may be the new client's, so it goes back for the next pass. A bit + // the old client left behind does no harm, as the new one wants a sync. + if(wants) slot.wants.fetch_or(wants); + return 0; +} +sync_step_t nextSyncStep(proto_slot_t &slot, uint32_t now) { + const uint32_t id = slot.clientId.load(); + if(id != slot.loopId) { + // A client left or another took the slot. + slot.loopId = id; + slot.cur = sync_cursor_t(); + slot.awaitingNext = false; + } + if(!id) return sync_step_t::none; + const uint32_t wants = takeWants(slot, id); + if(wants & (WANTS_SYNC | WANTS_SYNC_AGAIN)) { + // A client that just connected may skip the schema; one that asks again + // never gets it. + slot.cur = beginSync((wants & WANTS_SYNC) && slot.withSchema.load()); + return sync_step_t::send; + } + if((wants & WANTS_NEXT) && slot.awaitingNext) return sync_step_t::send; + if(slot.awaitingNext && now - slot.sentAt > PROTO_SYNC_TIMEOUT_MS) { + slot.awaitingNext = false; + slot.cur = sync_cursor_t(); + return sync_step_t::timedOut; + } + return sync_step_t::none; +} +void syncFrameSent(proto_slot_t &slot, uint32_t now) { + slot.awaitingNext = slot.cur.active(); + slot.sentAt = now; +} diff --git a/src/ProtoSlots.h b/src/ProtoSlots.h new file mode 100644 index 0000000..a822f36 --- /dev/null +++ b/src/ProtoSlots.h @@ -0,0 +1,59 @@ +#ifndef PROTO_SLOTS_H +#define PROTO_SLOTS_H +#include +#include +#include "ProtoSync.h" + +// The protocol 2 clients and their syncs (docs/PROTOCOL.md). async_tcp +// claims and frees a slot and records what its client asked for; loop() moves +// each client's sync on. The two sides share only the atomics. Kept free of +// the web server so it can be unit tested on the PC (pio test -e native). + +#define PROTO_MAX_CLIENTS 5 +// How long a client may take to ask for the next sync frame. +#define PROTO_SYNC_TIMEOUT_MS 30000 + +// What a client asked loop() for, as bits. +enum : uint32_t { + WANTS_SYNC = 0x01, // just connected: a sync, with the schema unless it has it + WANTS_NEXT = 0x02, // the next sync frame + WANTS_SYNC_AGAIN = 0x04 // a sync without the schema +}; +struct proto_slot_t { + // Set and cleared on async_tcp; clientId is 0 when the slot is free. + std::atomic clientId{0}; + std::atomic wants{0}; + std::atomic feeds{0}; // a bit per feed number + std::atomic withSchema{true}; + // loop() only. + uint32_t loopId = 0; // the client loop() is serving here + sync_cursor_t cur; + bool awaitingNext = false; + uint32_t sentAt = 0; +}; + +// async_tcp: the slot a new client takes, or nullptr when every slot is +// taken. +proto_slot_t *claimSlot(proto_slot_t *slots, uint8_t count, uint32_t clientId, bool withSchema); +// async_tcp: hello has gone out, so loop() may start the client's sync. +void startSync(proto_slot_t &slot); +proto_slot_t *slotOf(proto_slot_t *slots, uint8_t count, uint32_t clientId); +// async_tcp: the client has gone. +void freeSlot(proto_slot_t &slot); + +// What loop() does for a slot on this pass. +enum class sync_step_t : uint8_t { + none, + send, // send the sync frame at slot.cur to slot.loopId + timedOut // close slot.loopId: it sent no next in time +}; +// loop(): picks up a new client, takes what it asked for and moves its sync +// on. When the step is send, slot.cur is where the frame starts. +sync_step_t nextSyncStep(proto_slot_t &slot, uint32_t now); +// loop(): a sync frame went out, and more follow while slot.cur is active. +void syncFrameSent(proto_slot_t &slot, uint32_t now); +// loop(): what the slot's client asked for, provided it is still the client +// seenId that loop() read. When another client took the slot in between, +// what it asked for stays for the next pass and 0 is returned. +uint32_t takeWants(proto_slot_t &slot, uint32_t seenId); +#endif diff --git a/src/ProtoSync.cpp b/src/ProtoSync.cpp index e0527f3..6147528 100644 --- a/src/ProtoSync.cpp +++ b/src/ProtoSync.cpp @@ -1,5 +1,6 @@ #include "esp_log.h" #include "ProtoSync.h" +#include "ProtoFrame.h" static const char *TAG = "Proto"; @@ -7,81 +8,81 @@ static const char *TAG = "Proto"; * Items ********************************************************************/ void protoRoom(ProtoWriter &w, const room_snapshot_t &room) { - w.add(pf::roomId, room.roomId); - w.add(pf::name, room.name); - w.add(pf::sortOrder, room.sortOrder); + w.add(proto_field_t::roomId, room.roomId); + w.add(proto_field_t::name, room.name); + w.add(proto_field_t::sortOrder, room.sortOrder); } void protoShadeState(ProtoWriter &w, const shade_snapshot_t &shade) { - w.add(pf::shadeId, shade.shadeId); - w.add(pf::position, shade.position); - w.add(pf::target, shade.target); - w.add(pf::direction, shade.direction); - w.add(pf::myPos, shade.myPos); - w.add(pf::tiltPosition, shade.tiltPosition); - w.add(pf::tiltTarget, shade.tiltTarget); - w.add(pf::tiltDirection, shade.tiltDirection); - w.add(pf::myTiltPos, shade.myTiltPos); - w.add(pf::flags, shade.flags); - w.add(pf::lastRollingCode, shade.lastRollingCode); + w.add(proto_field_t::shadeId, shade.shadeId); + w.add(proto_field_t::position, shade.position); + w.add(proto_field_t::target, shade.target); + w.add(proto_field_t::direction, shade.direction); + w.add(proto_field_t::myPos, shade.myPos); + w.add(proto_field_t::tiltPosition, shade.tiltPosition); + w.add(proto_field_t::tiltTarget, shade.tiltTarget); + w.add(proto_field_t::tiltDirection, shade.tiltDirection); + w.add(proto_field_t::myTiltPos, shade.myTiltPos); + w.add(proto_field_t::flags, shade.flags); + w.add(proto_field_t::lastRollingCode, shade.lastRollingCode); } void protoShade(ProtoWriter &w, const shade_snapshot_t &shade) { protoShadeState(w, shade); - w.add(pf::roomId, shade.roomId); - w.add(pf::name, shade.name); - w.add(pf::remoteAddress, shade.remoteAddress); - w.add(pf::upTime, shade.upTime); - w.add(pf::downTime, shade.downTime); - w.add(pf::tiltTime, shade.tiltTime); - w.add(pf::stepSize, shade.stepSize); - w.add(pf::paired, shade.paired); - w.add(pf::tiltType, shade.tiltType); - w.add(pf::shadeType, shade.shadeType); - w.add(pf::bitLength, shade.bitLength); - w.add(pf::proto, shade.proto); - w.add(pf::flipCommands, shade.flipCommands); - w.add(pf::flipPosition, shade.flipPosition); - w.add(pf::inGroup, shade.inGroup); - w.add(pf::sunSensor, shade.sunSensor); - w.add(pf::light, shade.light); - w.add(pf::simMy, shade.simMy); - w.add(pf::repeats, shade.repeats); - w.add(pf::sortOrder, shade.sortOrder); - w.add(pf::gpioUp, shade.gpioUp); - w.add(pf::gpioDown, shade.gpioDown); - w.add(pf::gpioMy, shade.gpioMy); - w.add(pf::gpioLLTrigger, shade.gpioLLTrigger); + w.add(proto_field_t::roomId, shade.roomId); + w.add(proto_field_t::name, shade.name); + w.add(proto_field_t::remoteAddress, shade.remoteAddress); + w.add(proto_field_t::upTime, shade.upTime); + w.add(proto_field_t::downTime, shade.downTime); + w.add(proto_field_t::tiltTime, shade.tiltTime); + w.add(proto_field_t::stepSize, shade.stepSize); + w.add(proto_field_t::paired, shade.paired); + w.add(proto_field_t::tiltType, shade.tiltType); + w.add(proto_field_t::shadeType, shade.shadeType); + w.add(proto_field_t::bitLength, shade.bitLength); + w.add(proto_field_t::proto, shade.proto); + w.add(proto_field_t::flipCommands, shade.flipCommands); + w.add(proto_field_t::flipPosition, shade.flipPosition); + w.add(proto_field_t::inGroup, shade.inGroup); + w.add(proto_field_t::sunSensor, shade.sunSensor); + w.add(proto_field_t::light, shade.light); + w.add(proto_field_t::simMy, shade.simMy); + w.add(proto_field_t::repeats, shade.repeats); + w.add(proto_field_t::sortOrder, shade.sortOrder); + w.add(proto_field_t::gpioUp, shade.gpioUp); + w.add(proto_field_t::gpioDown, shade.gpioDown); + w.add(proto_field_t::gpioMy, shade.gpioMy); + w.add(proto_field_t::gpioLLTrigger, shade.gpioLLTrigger); if(shade.linkedRemoteCount == 0) return; - w.beginList(pf::linkedRemotes); + w.beginList(proto_field_t::linkedRemotes); for(uint8_t i = 0; i < shade.linkedRemoteCount; i++) { w.beginItem(); - w.add(pf::remoteAddress, shade.linkedRemotes[i].remoteAddress); - w.add(pf::lastRollingCode, shade.linkedRemotes[i].lastRollingCode); + w.add(proto_field_t::remoteAddress, shade.linkedRemotes[i].remoteAddress); + w.add(proto_field_t::lastRollingCode, shade.linkedRemotes[i].lastRollingCode); w.endItem(); } w.endList(); } void protoGroup(ProtoWriter &w, const group_snapshot_t &group) { - w.add(pf::groupId, group.groupId); - w.add(pf::roomId, group.roomId); - w.add(pf::name, group.name); - w.add(pf::remoteAddress, group.remoteAddress); - w.add(pf::lastRollingCode, group.lastRollingCode); - w.add(pf::bitLength, group.bitLength); - w.add(pf::proto, group.proto); - w.add(pf::sunSensor, group.sunSensor); - w.add(pf::flipCommands, group.flipCommands); - w.add(pf::flags, group.flags); - w.add(pf::repeats, group.repeats); - w.add(pf::sortOrder, group.sortOrder); + w.add(proto_field_t::groupId, group.groupId); + w.add(proto_field_t::roomId, group.roomId); + w.add(proto_field_t::name, group.name); + w.add(proto_field_t::remoteAddress, group.remoteAddress); + w.add(proto_field_t::lastRollingCode, group.lastRollingCode); + w.add(proto_field_t::bitLength, group.bitLength); + w.add(proto_field_t::proto, group.proto); + w.add(proto_field_t::sunSensor, group.sunSensor); + w.add(proto_field_t::flipCommands, group.flipCommands); + w.add(proto_field_t::flags, group.flags); + w.add(proto_field_t::repeats, group.repeats); + w.add(proto_field_t::sortOrder, group.sortOrder); if(group.linkedShadeCount == 0) return; - w.beginList(pf::linkedShades); + w.beginList(proto_field_t::linkedShades); for(uint8_t i = 0; i < group.linkedShadeCount; i++) w.item(group.linkedShades[i]); w.endList(); } void protoGroupState(ProtoWriter &w, uint8_t groupId, uint8_t flags, int8_t direction) { - w.add(pf::groupId, groupId); - w.add(pf::flags, flags); - w.add(pf::direction, direction); + w.add(proto_field_t::groupId, groupId); + w.add(proto_field_t::flags, flags); + w.add(proto_field_t::direction, direction); } /********************************************************************* @@ -98,24 +99,24 @@ sync_cursor_t beginSync(bool withSchema) { return cur; } -static pf sectionField(sync_section_t section) { +static proto_field_t sectionField(sync_section_t section) { switch(section) { - case sync_section_t::types: return pf::types; - case sync_section_t::fields: return pf::fields; - case sync_section_t::errors: return pf::errors; - case sync_section_t::feeds: return pf::feeds; - case sync_section_t::header: return pf::header; - case sync_section_t::rooms: return pf::rooms; - case sync_section_t::shades: return pf::shades; - case sync_section_t::groups: return pf::groups; - case sync_section_t::repeaters: return pf::repeaters; - default: return pf::status; + case sync_section_t::types: return proto_field_t::types; + case sync_section_t::fields: return proto_field_t::fields; + case sync_section_t::errors: return proto_field_t::errors; + case sync_section_t::feeds: return proto_field_t::feeds; + case sync_section_t::header: return proto_field_t::header; + case sync_section_t::rooms: return proto_field_t::rooms; + case sync_section_t::shades: return proto_field_t::shades; + case sync_section_t::groups: return proto_field_t::groups; + case sync_section_t::repeaters: return proto_field_t::repeaters; + default: return proto_field_t::status; } } static uint8_t sectionSlots(sync_section_t section) { switch(section) { case sync_section_t::types: return protoTypeCount(); - case sync_section_t::fields: return static_cast(pf::count); + case sync_section_t::fields: return static_cast(proto_field_t::count); case sync_section_t::errors: return protoErrorCount(); case sync_section_t::feeds: return protoFeedCount(); case sync_section_t::rooms: return SOMFY_MAX_ROOMS; @@ -139,7 +140,7 @@ static bool writeItem(SyncSource &src, ProtoWriter &w, sync_section_t section, u case sync_section_t::errors: namedRow(json, protoErrorAt(slot)); return true; case sync_section_t::feeds: namedRow(json, protoFeedAt(slot)); return true; case sync_section_t::fields: { - const proto_field_info_t &f = protoField(static_cast(slot)); + const proto_field_info_t &f = protoField(static_cast(slot)); const char kind[2] = {f.kind, '\0'}; json.beginArray(); json.addElem(f.key); diff --git a/src/ShadeController.cpp b/src/ShadeController.cpp index dbd7eba..902ca78 100644 --- a/src/ShadeController.cpp +++ b/src/ShadeController.cpp @@ -287,6 +287,10 @@ bool SomfyShadeController::deleteShade(uint8_t shadeId) { this->shades[i].clear(); } } + // Its groups lose it, or a new shade given its id would join them. + for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { + if(this->groups[i].getGroupId() != 255 && this->groups[i].unlinkShade(shadeId)) this->groups[i].configChanged(); + } this->commit(); return true; } @@ -298,14 +302,14 @@ bool SomfyShadeController::deleteRoom(uint8_t roomId) { if(shades[j].roomId == roomId) { shades[j].roomId = 0; shades[j].emitState(); - shades[j].changed |= SHADE_CHANGED_CONFIG; + shades[j].configChanged(); } } for(uint8_t j = 0; j < SOMFY_MAX_GROUPS; j++) { if(groups[j].roomId == roomId) { groups[j].roomId = 0; groups[j].emitState(); - groups[j].changed |= GROUP_CHANGED_CONFIG; + groups[j].configChanged(); } } rooms[i].emitState("roomRemoved"); @@ -319,6 +323,11 @@ bool SomfyShadeController::deleteRoom(uint8_t roomId) { bool SomfyShadeController::deleteGroup(uint8_t groupId) { for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { if(this->groups[i].getGroupId() == groupId) { + // Its shades are no longer in a group, which their inGroup shows. + for(uint8_t j = 0; j < SOMFY_MAX_GROUPED_SHADES && groups[i].linkedShades[j] != 0; j++) { + SomfyShade *shade = this->getShadeById(groups[i].linkedShades[j]); + if(shade) shade->configChanged(); + } groups[i].emitState("groupRemoved"); protoSocket.groupRemoved(groupId); groups[i].unpublish(); diff --git a/src/Somfy.h b/src/Somfy.h index 417566f..8d79736 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -140,6 +140,8 @@ class SomfyShade : public SomfyRemote, public ShadeSettings, private MotorLink, void emitState(uint8_t num, const char *evt = "shadeState"); // What changed since the fan-out last sent the shade (SHADE_CHANGED_* bits). uint16_t changed = 0; + // A setting changed: protocol 2 clients get the whole shade. + void configChanged() { this->changed |= SHADE_CHANGED_CONFIG; } void emitCommand(somfy_commands cmd, const char *source, uint32_t sourceAddress, const char *evt = "shadeCommand"); void emitCommand(uint8_t num, somfy_commands cmd, const char *source, uint32_t sourceAddress, const char *evt = "shadeCommand"); // False when the radio refused a command it sent. @@ -215,6 +217,8 @@ class SomfyGroup : public SomfyRemote { void emitState(uint8_t num, const char *evt = "groupState"); // What changed since the fan-out last sent the group (GROUP_CHANGED_* bits). uint8_t changed = 0; + // A setting or its shades changed: protocol 2 clients get the whole group. + void configChanged() { this->changed |= GROUP_CHANGED_CONFIG; } bool sendCommand(somfy_commands cmd); bool sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0); int8_t p_direction(int8_t dir); diff --git a/src/SomfyJson.cpp b/src/SomfyJson.cpp index 9f2036d..4db0009 100644 --- a/src/SomfyJson.cpp +++ b/src/SomfyJson.cpp @@ -228,48 +228,19 @@ void cancelModelEdit(model_edit_t *edit) { delete edit; editBusy.store(false); } -// What protocol 2 clients get after an edit: the whole item it changed, by its -// config bit for shades and groups, so the change fan-out sends it once. -// Deletes send their removal from the controller as they happen. +// 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. static void markEdited(const model_edit_t &edit, const edit_result_t &result) { - auto shade = [](uint8_t id) { - SomfyShade *s = somfy.getShadeById(id); - if(s) s->changed |= SHADE_CHANGED_CONFIG; - }; - auto group = [](uint8_t id) { - SomfyGroup *g = somfy.getGroupById(id); - if(g) g->changed |= GROUP_CHANGED_CONFIG; - }; - switch(edit.type) { - case model_edit_type_t::add_shade: - case model_edit_type_t::save_shade: - case model_edit_type_t::set_paired: - case model_edit_type_t::link_remote: - case model_edit_type_t::unlink_remote: - shade(result.id); - break; - case model_edit_type_t::add_group: - case model_edit_type_t::save_group: - group(result.id); - break; - case model_edit_type_t::link_to_group: - case model_edit_type_t::unlink_from_group: - group(result.id); - shade(edit.doc["shadeId"] | (uint8_t)0); - break; - case model_edit_type_t::add_room: - case model_edit_type_t::save_room: { - SomfyRoom *room = somfy.getRoomById(result.id); - if(room) protoSocket.room(*room); - break; - } - case model_edit_type_t::room_order: protoSocket.order(proto_type_t::roomOrder); break; - case model_edit_type_t::shade_order: protoSocket.order(proto_type_t::shadeOrder); break; - case model_edit_type_t::group_order: protoSocket.order(proto_type_t::groupOrder); break; - case model_edit_type_t::link_repeater: - case model_edit_type_t::unlink_repeater: protoSocket.repeaters(); break; - default: break; - } + const edit_changes_t c = editChanges(edit.type, result); + SomfyShade *shade = c.shadeId ? somfy.getShadeById(c.shadeId) : nullptr; + if(shade) shade->configChanged(); + SomfyGroup *group = c.groupId ? somfy.getGroupById(c.groupId) : nullptr; + if(group) group->configChanged(); + SomfyRoom *room = c.roomId ? somfy.getRoomById(c.roomId) : nullptr; + if(room) protoSocket.room(*room); + if(c.order != sort_list_t::none) protoSocket.order(c.order); + if(c.repeaters) protoSocket.repeaters(); } void runModelEdit(model_edit_t *edit) { const edit_result_t result = applyModelEdit(*edit); @@ -404,7 +375,9 @@ edit_result_t applyModelEdit(model_edit_t &edit) { else if(!group->linkShade(shadeId)) return edit_result_t::error("This group already has the most shades it can."); somfy.commit(); group->emitState(); - return done(groupId); + edit_result_t r = done(groupId); + r.shadeId = shadeId; + return r; } case model_edit_type_t::link_repeater: case model_edit_type_t::unlink_repeater: { diff --git a/src/SomfyShadeOps.cpp b/src/SomfyShadeOps.cpp index 21f8265..dca0dd1 100644 --- a/src/SomfyShadeOps.cpp +++ b/src/SomfyShadeOps.cpp @@ -48,7 +48,7 @@ void SomfyShadeOps::setUnpaired(uint8_t shadeId) { SomfyShade *shade = somfy.getShadeById(shadeId); shade->paired = false; shade->save(); - shade->changed |= SHADE_CHANGED_CONFIG; + shade->configChanged(); } void SomfyShadeOps::setRollingCode(uint8_t shadeId, uint16_t code) { somfy.getShadeById(shadeId)->setRollingCode(code); } bool SomfyShadeOps::sendGroupCommand(uint8_t groupId, somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { diff --git a/src/Transceiver.cpp b/src/Transceiver.cpp index bbd853d..adc9cbc 100644 --- a/src/Transceiver.cpp +++ b/src/Transceiver.cpp @@ -322,11 +322,11 @@ void Transceiver::emitFrequencyScan(uint8_t num) { sockEmit.endEmit(num); if(num == 255 && protoSocket.subscribed(proto_feed_t::frequencyScan)) { ProtoWriter w = protoSocket.beginFeed(proto_type_t::frequencyScan); - w.add(pf::scanning, rxmode == 3); - w.add(pf::testFreq, currFreq); - w.add(pf::testRssi, (int32_t)currRSSI); - w.add(pf::frequency, markFreq); - w.add(pf::rssi, (int32_t)markRSSI); + w.add(proto_field_t::scanning, rxmode == 3); + w.add(proto_field_t::testFreq, currFreq); + w.add(proto_field_t::testRssi, (int32_t)currRSSI); + w.add(proto_field_t::frequency, markFreq); + w.add(proto_field_t::rssi, (int32_t)markRSSI); protoSocket.endFeed(proto_feed_t::frequencyScan); } } @@ -377,19 +377,19 @@ void Transceiver::emitFrame(somfy_frame_t *frame, somfy_rx_t *rx) { } if(protoSocket.subscribed(proto_feed_t::frames)) { ProtoWriter w = protoSocket.beginFeed(proto_type_t::frame); - w.add(pf::encKey, frame->encKey); - w.add(pf::remoteAddress, (uint32_t)frame->remoteAddress); - w.add(pf::lastRollingCode, (uint32_t)frame->rollingCode); - w.add(pf::command, translateSomfyCommand(frame->cmd).c_str()); - w.add(pf::rawCmd, frame->rawCmd); - w.add(pf::rssi, (int32_t)frame->rssi); - w.add(pf::bits, rx ? rx->bit_length : (uint8_t)0); - w.add(pf::proto, static_cast(frame->proto)); - w.add(pf::valid, frame->valid); - w.add(pf::hwsync, frame->hwsync); - if(frame->cmd == somfy_commands::StepUp || frame->cmd == somfy_commands::StepDown) w.add(pf::stepSize, frame->stepSize); + w.add(proto_field_t::encKey, frame->encKey); + w.add(proto_field_t::remoteAddress, (uint32_t)frame->remoteAddress); + w.add(proto_field_t::lastRollingCode, (uint32_t)frame->rollingCode); + w.add(proto_field_t::command, translateSomfyCommand(frame->cmd).c_str()); + w.add(proto_field_t::rawCmd, frame->rawCmd); + w.add(proto_field_t::rssi, (int32_t)frame->rssi); + w.add(proto_field_t::bits, rx ? rx->bit_length : (uint8_t)0); + w.add(proto_field_t::proto, static_cast(frame->proto)); + w.add(proto_field_t::valid, frame->valid); + w.add(proto_field_t::hwsync, frame->hwsync); + if(frame->cmd == somfy_commands::StepUp || frame->cmd == somfy_commands::StepDown) w.add(proto_field_t::stepSize, frame->stepSize); if(rx && rx->pulseCount) { - w.beginList(pf::pulses); + w.beginList(proto_field_t::pulses); for(uint16_t i = 0; i < rx->pulseCount; i++) w.item(rx->pulses[i]); w.endList(); } diff --git a/src/entryPoints/Web.cpp b/src/entryPoints/Web.cpp index eda72b1..786fb85 100644 --- a/src/entryPoints/Web.cpp +++ b/src/entryPoints/Web.cpp @@ -231,9 +231,6 @@ static void controllerHeader(JsonFormatter &json, const model_snapshot_t &snap) // The parts of /discovery that aren't in the shade model. static void discoveryHeader(JsonFormatter &json, const model_snapshot_t &snap) { (void)snap; - const char *connType = "Unknown"; - if(net.connType == conn_types_t::ethernet) connType = "Ethernet"; - else if(net.connType == conn_types_t::wifi) connType = "Wifi"; json.addElem("serverId", settings.serverId); json.addElem("version", settings.fwVersion.name); json.addElem("model", "ESPSomfyRTS"); @@ -241,7 +238,7 @@ static void discoveryHeader(JsonFormatter &json, const model_snapshot_t &snap) { json.addElem("authType", static_cast(settings.Security.type)); json.addElem("permissions", settings.Security.permissions); json.addElem("chipModel", settings.chipModel); - json.addElem("connType", connType); + json.addElem("connType", net.connTypeName()); json.beginObject("memory"); json.addElem("max", ESP.getMaxAllocHeap()); json.addElem("free", ESP.getFreeHeap()); diff --git a/src/outputs/ProtoSocket.cpp b/src/outputs/ProtoSocket.cpp index e309bb6..b60b1d3 100644 --- a/src/outputs/ProtoSocket.cpp +++ b/src/outputs/ProtoSocket.cpp @@ -1,10 +1,12 @@ #include #include -#include +#include #include "esp_log.h" #include "ProtoSocket.h" #include "ProtoSync.h" +#include "ProtoFrame.h" #include "ProtoParse.h" +#include "ProtoSlots.h" #include "SomfySnapshot.h" #include "Somfy.h" #include "ConfigSettings.h" @@ -23,87 +25,62 @@ extern ConfigSettings settings; extern ESPNetwork net; extern Web webServer; extern LoopStats loopStats; +extern SocketEmitter sockEmit; ProtoSocket protoSocket; static AsyncWebSocket ws("/ws"); static_assert(MAX_SOCK_RESPONSE >= PROTO_MAX_FRAME, "protocol 2 frames share the socket event buffer"); -// What a client asked for, from async_tcp to loop(). -enum : uint32_t { - REQ_START = 0x01, // just connected - REQ_NEXT = 0x02, - REQ_RESYNC = 0x04 -}; -struct proto_slot_t { - // Set and cleared on async_tcp; 0 when the slot is free. - std::atomic asyncId{0}; - std::atomic requests{0}; - std::atomic feeds{0}; // a bit per feed number - std::atomic withSchema{true}; - // loop() only. - uint32_t loopId = 0; // the client loop() is serving here - sync_cursor_t cur; - bool awaitingNext = false; - uint32_t sentAt = 0; -}; static proto_slot_t slots[PROTO_MAX_CLIENTS]; -static BufferedJsonFormatter fmt; - -static proto_slot_t *slotOf(uint32_t asyncId) { - for(proto_slot_t &s : slots) - if(s.asyncId.load() == asyncId) return &s; - return nullptr; -} +// What loop() formats events and syncEnd with, into the socket event buffer. +static BufferedJsonFormatter eventJson; /********************************************************************* * The shade model as a sync source ********************************************************************/ static void writeHeader(ProtoWriter &w) { - w.add(pf::maxRooms, (uint8_t)SOMFY_MAX_ROOMS); - w.add(pf::maxShades, (uint8_t)SOMFY_MAX_SHADES); - w.add(pf::maxGroups, (uint8_t)SOMFY_MAX_GROUPS); - w.add(pf::maxGroupedShades, (uint8_t)SOMFY_MAX_GROUPED_SHADES); - w.add(pf::maxLinkedRemotes, (uint8_t)SOMFY_MAX_LINKED_REMOTES); - w.add(pf::startingAddress, somfy.startingAddress); + w.add(proto_field_t::maxRooms, (uint8_t)SOMFY_MAX_ROOMS); + w.add(proto_field_t::maxShades, (uint8_t)SOMFY_MAX_SHADES); + w.add(proto_field_t::maxGroups, (uint8_t)SOMFY_MAX_GROUPS); + w.add(proto_field_t::maxGroupedShades, (uint8_t)SOMFY_MAX_GROUPED_SHADES); + w.add(proto_field_t::maxLinkedRemotes, (uint8_t)SOMFY_MAX_LINKED_REMOTES); + w.add(proto_field_t::startingAddress, somfy.startingAddress); // What a new shade or group gets. - w.add(pf::bitLength, somfy.transceiver.config.type); - w.add(pf::proto, static_cast(somfy.transceiver.config.proto)); - w.add(pf::serverId, settings.serverId); - w.add(pf::hostname, settings.hostname); - w.add(pf::model, "ESPSomfyRTS"); - w.add(pf::chipModel, settings.chipModel); - w.add(pf::fw, settings.fwVersion.name); - const char *connType = "Unknown"; - if(net.connType == conn_types_t::ethernet) connType = "Ethernet"; - else if(net.connType == conn_types_t::wifi) connType = "Wifi"; - w.add(pf::connType, connType); + w.add(proto_field_t::bitLength, somfy.transceiver.config.type); + w.add(proto_field_t::proto, static_cast(somfy.transceiver.config.proto)); + w.add(proto_field_t::serverId, settings.serverId); + w.add(proto_field_t::hostname, settings.hostname); + w.add(proto_field_t::model, "ESPSomfyRTS"); + w.add(proto_field_t::chipModel, settings.chipModel); + w.add(proto_field_t::fw, settings.fwVersion.name); + w.add(proto_field_t::connType, net.connTypeName()); } static void writeMemory(ProtoWriter &w) { - w.add(pf::freeHeap, ESP.getFreeHeap()); - w.add(pf::maxHeap, ESP.getMaxAllocHeap()); - w.add(pf::minHeap, ESP.getMinFreeHeap()); - w.add(pf::totalHeap, ESP.getHeapSize()); - w.add(pf::uptime, (uint32_t)millis()); - w.add(pf::loopRate, loopStats.publishedRate()); - w.add(pf::loopMaxUs, loopStats.publishedMaxUs()); + w.add(proto_field_t::freeHeap, ESP.getFreeHeap()); + w.add(proto_field_t::maxHeap, ESP.getMaxAllocHeap()); + w.add(proto_field_t::minHeap, ESP.getMinFreeHeap()); + w.add(proto_field_t::totalHeap, ESP.getHeapSize()); + w.add(proto_field_t::uptime, (uint32_t)millis()); + w.add(proto_field_t::loopRate, loopStats.publishedRate()); + w.add(proto_field_t::loopMaxUs, loopStats.publishedMaxUs()); } static void writeWifi(ProtoWriter &w) { if(WiFi.status() == WL_CONNECTED) { - w.add(pf::ssid, WiFi.SSID().c_str()); - w.add(pf::strength, (int32_t)WiFi.RSSI()); - w.add(pf::channel, (int32_t)net.channel); + w.add(proto_field_t::ssid, WiFi.SSID().c_str()); + w.add(proto_field_t::strength, (int32_t)WiFi.RSSI()); + w.add(proto_field_t::channel, (int32_t)net.channel); } else { - w.add(pf::strength, (int32_t)-100); - w.add(pf::channel, (int32_t)-1); + w.add(proto_field_t::strength, (int32_t)-100); + w.add(proto_field_t::channel, (int32_t)-1); } } static void writeEthernet(ProtoWriter &w) { #if HAS_ETHERNET if(net.connType == conn_types_t::ethernet) { - w.add(pf::connected, net.connected()); - w.add(pf::speed, (uint32_t)ETH.linkSpeed()); - w.add(pf::fullDuplex, ETH.fullDuplex()); + w.add(proto_field_t::connected, net.connected()); + w.add(proto_field_t::speed, (uint32_t)ETH.linkSpeed()); + w.add(proto_field_t::fullDuplex, ETH.fullDuplex()); } #else (void)w; @@ -162,13 +139,10 @@ static void replyError(AsyncWebSocketClient *client, uint32_t id, proto_error_t writeErrorReply(json, e.id, e.code, e.desc); }, &e); } +// Only a message with an id wants a reply. static void replyOk(AsyncWebSocketClient *client, uint32_t id) { if(id == 0) return; - sendFixed(client, [](JsonFormatter &json, void *ctx) { - beginFrame(json, proto_type_t::reply, *static_cast(ctx)); - ProtoWriter(json).add(pf::ok, true); - endFrame(json); - }, &id); + sendFixed(client, [](JsonFormatter &json, void *ctx) { writeOkReply(json, *static_cast(ctx)); }, &id); } static bool authorized(AsyncWebServerRequest *request) { if(settings.Security.type == security_types::None) return true; @@ -180,68 +154,65 @@ static bool authorized(AsyncWebServerRequest *request) { } static void onConnect(AsyncWebSocketClient *client, AsyncWebServerRequest *request) { const uint32_t id = client->id(); - proto_slot_t *slot = nullptr; - for(proto_slot_t &s : slots) { - uint32_t expected = 0; - if(s.asyncId.compare_exchange_strong(expected, id)) { slot = &s; break; } - } + const AsyncWebParameter *schema = request ? request->getParam("schema") : nullptr; + const bool withSchema = !(schema && schema->value() == protoSchemaHash()); + proto_slot_t *slot = claimSlot(slots, PROTO_MAX_CLIENTS, id, withSchema); if(!slot) { ESP_LOGW(TAG, "Socket [%lu] refused: all %u clients connected", (unsigned long)id, PROTO_MAX_CLIENTS); client->close(); return; } - const AsyncWebParameter *schema = request ? request->getParam("schema") : nullptr; - slot->withSchema = !(schema && schema->value() == protoSchemaHash()); - slot->feeds = 0; client->setCloseClientOnQueueFull(true); sendFixed(client, [](JsonFormatter &json, void *) { writeHello(json, settings.fwVersion.name); }, nullptr); - slot->requests.fetch_or(REQ_START); + startSync(*slot); IPAddress ip = client->remoteIP(); - ESP_LOGI(TAG, "Socket [%lu] connected from %u.%u.%u.%u%s", (unsigned long)id, ip[0], ip[1], ip[2], ip[3], slot->withSchema ? "" : " (has the schema)"); + 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) { - proto_slot_t *slot = slotOf(client->id()); + proto_slot_t *slot = slotOf(slots, PROTO_MAX_CLIENTS, client->id()); if(!slot) return; - slot->requests = 0; - slot->feeds = 0; - slot->asyncId = 0; + 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)) { - replyError(client, 0, proto_error_t::badRequest, "Send each message as one text frame"); + // 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; } - proto_slot_t *slot = slotOf(client->id()); + proto_slot_t *slot = slotOf(slots, PROTO_MAX_CLIENTS, client->id()); if(!slot) return; JsonDocument doc; - proto_request_t req; - const proto_error_t err = parseClientFrame(reinterpret_cast(data), len, doc, req); + proto_message_t msg; + const proto_error_t err = parseClientFrame(reinterpret_cast(data), len, doc, msg); if(err != proto_error_t::none) { - replyError(client, req.id, err, err == proto_error_t::unknownOp ? "Unknown message type" : "Malformed message"); + replyError(client, msg.id, err, err == proto_error_t::unknownOp ? "Unknown message type" : "Malformed message"); return; } - switch(static_cast(req.type)) { + switch(static_cast(msg.type)) { case proto_type_t::next: - slot->requests.fetch_or(REQ_NEXT); + slot->wants.fetch_or(WANTS_NEXT); + replyOk(client, msg.id); return; case proto_type_t::sync: - slot->requests.fetch_or(REQ_RESYNC); + slot->wants.fetch_or(WANTS_SYNC_AGAIN); + replyOk(client, msg.id); return; case proto_type_t::subscribe: case proto_type_t::unsubscribe: { - const int32_t feed = protoInt(doc["d"], pf::feed); + const int32_t feed = protoInt(doc["d"], proto_field_t::feed); bool known = false; for(uint8_t i = 0; i < protoFeedCount(); i++) known |= protoFeedAt(i).number == feed; - if(!known) { replyError(client, req.id, proto_error_t::badRequest, "Unknown feed"); return; } - if(req.type == static_cast(proto_type_t::subscribe)) slot->feeds.fetch_or(1 << feed); + if(!known) { replyError(client, msg.id, proto_error_t::badRequest, "Unknown feed"); return; } + if(msg.type == static_cast(proto_type_t::subscribe)) slot->feeds.fetch_or(1 << feed); else slot->feeds.fetch_and(~(1 << feed)); - replyOk(client, req.id); + replyOk(client, msg.id); return; } default: // The ops arrive with the protocol core's later PRs. - replyError(client, req.id, proto_error_t::unknownOp, "Unknown message type"); + replyError(client, msg.id, proto_error_t::unknownOp, "Unknown message type"); return; } } @@ -256,17 +227,16 @@ void ProtoSocket::wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, } void ProtoSocket::begin(AsyncWebServer &server) { - this->_owner = xTaskGetCurrentTaskHandle(); ws.handleHandshake(authorized); ws.onEvent(ProtoSocket::wsEvent); server.addHandler(&ws); } -bool ProtoSocket::onLoopTask() const { return emitAllowed(xTaskGetCurrentTaskHandle(), this->_owner); } bool ProtoSocket::hasClients() const { for(const proto_slot_t &s : slots) - if(s.asyncId.load()) return true; + if(s.clientId.load()) return true; return false; } +bool ProtoSocket::sending() const { return sockEmit.onLoopTask() && this->hasClients(); } /********************************************************************* * loop(): syncs @@ -276,39 +246,26 @@ static void sendSyncFrame(proto_slot_t &s) { char *buf = socketFrameBuffer(); const size_t len = writeSyncFrame(src, s.cur, buf, PROTO_MAX_FRAME); if(len) ws.text(s.loopId, buf, len); - if(s.cur.active()) { - s.awaitingNext = true; - s.sentAt = millis(); - return; - } - fmt.setBuffer(buf, PROTO_MAX_FRAME); - beginFrame(fmt, proto_type_t::syncEnd); - endFrame(fmt); - ws.text(s.loopId, buf, fmt.length()); - s.awaitingNext = false; + syncFrameSent(s, millis()); + if(s.cur.active()) return; + eventJson.setBuffer(buf, PROTO_MAX_FRAME); + beginFrame(eventJson, proto_type_t::syncEnd); + endFrame(eventJson); + ws.text(s.loopId, buf, eventJson.length()); } void ProtoSocket::loop() { - if(!this->onLoopTask()) return; + if(!sockEmit.onLoopTask()) return; for(proto_slot_t &s : slots) { - const uint32_t id = s.asyncId.load(); - if(id != s.loopId) { - // A client left or another took the slot. - s.loopId = id; - s.cur = sync_cursor_t(); - s.awaitingNext = false; - } - if(!id) continue; - const uint32_t req = s.requests.exchange(0); - if(req & (REQ_START | REQ_RESYNC)) { - s.cur = beginSync((req & REQ_START) && s.withSchema.load()); - sendSyncFrame(s); - } - else if((req & REQ_NEXT) && s.awaitingNext) sendSyncFrame(s); - else if(s.awaitingNext && millis() - s.sentAt > PROTO_SYNC_TIMEOUT_MS) { - ESP_LOGW(TAG, "Socket [%lu] sent no next for %us, closing it", (unsigned long)id, PROTO_SYNC_TIMEOUT_MS / 1000); - s.awaitingNext = false; - s.cur = sync_cursor_t(); - ws.close(id); + switch(nextSyncStep(s, millis())) { + case sync_step_t::send: + sendSyncFrame(s); + break; + case sync_step_t::timedOut: + ESP_LOGW(TAG, "Socket [%lu] sent no next for %us, closing it", (unsigned long)s.loopId, PROTO_SYNC_TIMEOUT_MS / 1000); + ws.close(s.loopId); + break; + case sync_step_t::none: + break; } } ws.cleanupClients(PROTO_MAX_CLIENTS); @@ -317,154 +274,148 @@ void ProtoSocket::loop() { /********************************************************************* * loop(): events ********************************************************************/ -JsonFormatter &ProtoSocket::beginEvent(proto_type_t type) { - fmt.setBuffer(socketFrameBuffer(), PROTO_MAX_FRAME); - beginFrame(fmt, type); - return fmt; +static JsonFormatter &beginEvent(proto_type_t type) { + eventJson.setBuffer(socketFrameBuffer(), PROTO_MAX_FRAME); + beginFrame(eventJson, type); + return eventJson; } -void ProtoSocket::endEvent() { - endFrame(fmt); - if(fmt.overflowed()) { - ESP_LOGE(TAG, "Event too large for %u bytes, not sent", PROTO_MAX_FRAME); - return; - } - ws.textAll(socketFrameBuffer(), fmt.length()); -} -// Each event checks the task and the clients first, so nothing is formatted -// for nobody. -#define PROTO_EVENT_GUARD() if(!this->onLoopTask() || !this->hasClients()) return - -void ProtoSocket::shadeState(SomfyShade &shade) { - PROTO_EVENT_GUARD(); - shade_snapshot_t s; - snapshotShade(shade, s); - ProtoWriter w(this->beginEvent(proto_type_t::shadeState)); - protoShadeState(w, s); - this->endEvent(); -} -void ProtoSocket::shade(SomfyShade &shade) { - PROTO_EVENT_GUARD(); - shade_snapshot_t s; - snapshotShade(shade, s); - ProtoWriter w(this->beginEvent(proto_type_t::shade)); - protoShade(w, s); - this->endEvent(); -} -void ProtoSocket::shadeRemoved(uint8_t shadeId) { - PROTO_EVENT_GUARD(); - ProtoWriter(this->beginEvent(proto_type_t::shadeRemoved)).add(pf::shadeId, shadeId); - this->endEvent(); -} -void ProtoSocket::group(SomfyGroup &group) { - PROTO_EVENT_GUARD(); - group_snapshot_t g; - snapshotGroup(group, g); - ProtoWriter w(this->beginEvent(proto_type_t::group)); - protoGroup(w, g); - this->endEvent(); -} -void ProtoSocket::groupState(SomfyGroup &group) { - PROTO_EVENT_GUARD(); - group_snapshot_t g; - snapshotGroup(group, g); - ProtoWriter w(this->beginEvent(proto_type_t::groupState)); - protoGroupState(w, g.groupId, g.flags, group.direction); - this->endEvent(); -} -void ProtoSocket::groupRemoved(uint8_t groupId) { - PROTO_EVENT_GUARD(); - ProtoWriter(this->beginEvent(proto_type_t::groupRemoved)).add(pf::groupId, groupId); - this->endEvent(); -} -void ProtoSocket::room(SomfyRoom &room) { - PROTO_EVENT_GUARD(); - room_snapshot_t r; - snapshotRoom(room, r); - ProtoWriter w(this->beginEvent(proto_type_t::room)); - protoRoom(w, r); - this->endEvent(); -} -void ProtoSocket::roomRemoved(uint8_t roomId) { - PROTO_EVENT_GUARD(); - ProtoWriter(this->beginEvent(proto_type_t::roomRemoved)).add(pf::roomId, roomId); - this->endEvent(); -} -void ProtoSocket::order(proto_type_t type) { - PROTO_EVENT_GUARD(); - struct entry_t { int8_t order; uint8_t id; }; - entry_t items[SOMFY_MAX_SHADES]; - uint8_t n = 0; - if(type == proto_type_t::roomOrder) { - for(SomfyRoom &r : somfy.rooms) if(r.roomId != 0) items[n++] = {r.sortOrder, r.roomId}; - } - else if(type == proto_type_t::shadeOrder) { - for(SomfyShade &s : somfy.shades) if(s.getShadeId() != 255) items[n++] = {s.sortOrder, s.getShadeId()}; - } - else { - for(SomfyGroup &g : somfy.groups) if(g.getGroupId() != 255) items[n++] = {g.sortOrder, g.getGroupId()}; - } - for(uint8_t i = 1; i < n; i++) - for(uint8_t j = i; j > 0 && items[j].order < items[j - 1].order; j--) { - const entry_t t = items[j]; - items[j] = items[j - 1]; - items[j - 1] = t; - } - ProtoWriter w(this->beginEvent(type)); - w.beginList(pf::ids); - for(uint8_t i = 0; i < n; i++) w.item(items[i].id); - w.endList(); - this->endEvent(); -} -void ProtoSocket::repeaters() { - PROTO_EVENT_GUARD(); - ProtoWriter w(this->beginEvent(proto_type_t::repeaters)); - w.beginList(pf::repeaters); - for(uint32_t address : somfy.repeaters) if(address != 0) w.item(address); - w.endList(); - this->endEvent(); -} -void ProtoSocket::shadeCommand(SomfyShade &shade, const char *command, const char *source, uint32_t sourceAddress) { - PROTO_EVENT_GUARD(); - ProtoWriter w(this->beginEvent(proto_type_t::shadeCommand)); - w.add(pf::shadeId, shade.getShadeId()); - w.add(pf::remoteAddress, shade.getRemoteAddress()); - w.add(pf::command, command); - w.add(pf::source, source); - w.add(pf::lastRollingCode, shade.lastRollingCode); - w.add(pf::sourceAddress, sourceAddress); - this->endEvent(); -} -void ProtoSocket::memory() { - PROTO_EVENT_GUARD(); - ProtoWriter w(this->beginEvent(proto_type_t::memory)); - writeMemory(w); - this->endEvent(); -} -void ProtoSocket::wifi() { - PROTO_EVENT_GUARD(); - ProtoWriter w(this->beginEvent(proto_type_t::wifi)); - writeWifi(w); - this->endEvent(); -} -void ProtoSocket::ethernet() { - PROTO_EVENT_GUARD(); - ProtoWriter w(this->beginEvent(proto_type_t::ethernet)); - writeEthernet(w); - this->endEvent(); -} -bool ProtoSocket::subscribed(proto_feed_t feed) const { - const uint8_t bit = 1 << static_cast(feed); - for(const proto_slot_t &s : slots) - if(s.asyncId.load() && (s.feeds.load() & bit)) return true; +// Closes the event; false when it didn't fit, which is logged. +static bool finishEvent() { + endFrame(eventJson); + if(!eventJson.overflowed()) return true; + ESP_LOGE(TAG, "Event too large for %u bytes, not sent", PROTO_MAX_FRAME); return false; } -ProtoWriter ProtoSocket::beginFeed(proto_type_t type) { return ProtoWriter(this->beginEvent(type)); } +static void sendEvent() { + if(finishEvent()) ws.textAll(socketFrameBuffer(), eventJson.length()); +} +// An item whole, or only its state: a snapshot of it, written by write. +template +static void sendItem(proto_type_t type, Model &item, void (*snapshot)(Model &, Snapshot &), void (*write)(ProtoWriter &, const Snapshot &)) { + Snapshot s; + snapshot(item, s); + ProtoWriter w(beginEvent(type)); + write(w, s); + sendEvent(); +} + +void ProtoSocket::shadeState(SomfyShade &shade) { + if(this->sending()) sendItem(proto_type_t::shadeState, shade, snapshotShade, protoShadeState); +} +void ProtoSocket::shade(SomfyShade &shade) { + if(this->sending()) sendItem(proto_type_t::shade, shade, snapshotShade, protoShade); +} +void ProtoSocket::shadeRemoved(uint8_t shadeId) { + if(!this->sending()) return; + ProtoWriter(beginEvent(proto_type_t::shadeRemoved)).add(proto_field_t::shadeId, shadeId); + sendEvent(); +} +void ProtoSocket::group(SomfyGroup &group) { + if(this->sending()) sendItem(proto_type_t::group, group, snapshotGroup, protoGroup); +} +void ProtoSocket::groupState(SomfyGroup &group) { + if(!this->sending()) return; + group_snapshot_t g; + snapshotGroup(group, g); + ProtoWriter w(beginEvent(proto_type_t::groupState)); + protoGroupState(w, g.groupId, g.flags, group.direction); + sendEvent(); +} +void ProtoSocket::groupRemoved(uint8_t groupId) { + if(!this->sending()) return; + ProtoWriter(beginEvent(proto_type_t::groupRemoved)).add(proto_field_t::groupId, groupId); + sendEvent(); +} +void ProtoSocket::room(SomfyRoom &room) { + if(this->sending()) sendItem(proto_type_t::room, room, snapshotRoom, protoRoom); +} +void ProtoSocket::roomRemoved(uint8_t roomId) { + if(!this->sending()) return; + ProtoWriter(beginEvent(proto_type_t::roomRemoved)).add(proto_field_t::roomId, roomId); + sendEvent(); +} +void ProtoSocket::order(sort_list_t list) { + if(!this->sending()) return; + struct entry_t { int8_t order; uint8_t id; }; + static constexpr uint8_t MOST = std::max({SOMFY_MAX_ROOMS, SOMFY_MAX_SHADES, SOMFY_MAX_GROUPS}); + entry_t items[MOST]; + uint8_t n = 0; + proto_type_t type; + switch(list) { + case sort_list_t::rooms: + type = proto_type_t::roomOrder; + for(SomfyRoom &r : somfy.rooms) if(r.roomId != 0) items[n++] = {r.sortOrder, r.roomId}; + break; + case sort_list_t::shades: + type = proto_type_t::shadeOrder; + for(SomfyShade &s : somfy.shades) if(s.getShadeId() != 255) items[n++] = {s.sortOrder, s.getShadeId()}; + break; + case sort_list_t::groups: + type = proto_type_t::groupOrder; + for(SomfyGroup &g : somfy.groups) if(g.getGroupId() != 255) items[n++] = {g.sortOrder, g.getGroupId()}; + break; + default: + return; + } + for(uint8_t i = 1; i < n; i++) + for(uint8_t j = i; j > 0 && items[j].order < items[j - 1].order; j--) std::swap(items[j], items[j - 1]); + ProtoWriter w(beginEvent(type)); + w.beginList(proto_field_t::ids); + for(uint8_t i = 0; i < n; i++) w.item(items[i].id); + w.endList(); + sendEvent(); +} +void ProtoSocket::repeaters() { + if(!this->sending()) return; + ProtoWriter w(beginEvent(proto_type_t::repeaters)); + w.beginList(proto_field_t::repeaters); + for(uint32_t address : somfy.repeaters) if(address != 0) w.item(address); + w.endList(); + sendEvent(); +} +void ProtoSocket::shadeCommand(SomfyShade &shade, const char *command, const char *source, uint32_t sourceAddress) { + if(!this->sending()) return; + ProtoWriter w(beginEvent(proto_type_t::shadeCommand)); + w.add(proto_field_t::shadeId, shade.getShadeId()); + w.add(proto_field_t::remoteAddress, shade.getRemoteAddress()); + w.add(proto_field_t::command, command); + w.add(proto_field_t::source, source); + w.add(proto_field_t::lastRollingCode, shade.lastRollingCode); + w.add(proto_field_t::sourceAddress, sourceAddress); + sendEvent(); +} +void ProtoSocket::memory() { + if(!this->sending()) return; + ProtoWriter w(beginEvent(proto_type_t::memory)); + writeMemory(w); + sendEvent(); +} +void ProtoSocket::wifi() { + if(!this->sending()) return; + ProtoWriter w(beginEvent(proto_type_t::wifi)); + writeWifi(w); + sendEvent(); +} +void ProtoSocket::ethernet() { + if(!this->sending()) return; + ProtoWriter w(beginEvent(proto_type_t::ethernet)); + writeEthernet(w); + sendEvent(); +} +bool ProtoSocket::subscribed(proto_feed_t feed) const { + // The feed is formatted into the event buffer, which is loop()'s. + if(!sockEmit.onLoopTask()) return false; + const uint8_t bit = 1 << static_cast(feed); + for(const proto_slot_t &s : slots) + if(s.clientId.load() && (s.feeds.load() & bit)) return true; + return false; +} +ProtoWriter ProtoSocket::beginFeed(proto_type_t type) { return ProtoWriter(beginEvent(type)); } void ProtoSocket::endFeed(proto_feed_t feed) { - endFrame(fmt); - if(fmt.overflowed() || !this->onLoopTask()) return; + if(!finishEvent()) return; const uint8_t bit = 1 << static_cast(feed); for(proto_slot_t &s : slots) { - const uint32_t id = s.asyncId.load(); - if(id && (s.feeds.load() & bit)) ws.text(id, socketFrameBuffer(), fmt.length()); + const uint32_t id = s.clientId.load(); + if(id && (s.feeds.load() & bit)) ws.text(id, socketFrameBuffer(), eventJson.length()); } } diff --git a/src/outputs/ProtoSocket.h b/src/outputs/ProtoSocket.h index 2fa70d3..149e8de 100644 --- a/src/outputs/ProtoSocket.h +++ b/src/outputs/ProtoSocket.h @@ -2,6 +2,7 @@ #define PROTO_SOCKET_H #include #include "ProtoSchema.h" +#include "ModelEditor.h" class SomfyShade; class SomfyGroup; @@ -13,11 +14,8 @@ class SomfyRoom; // // async_tcp only reads what clients send and answers with fixed frames that // hold nothing of the shade model (hello, errors, ok). Everything formatted -// from the model goes out from loop(), in the socket event buffer. -#define PROTO_MAX_CLIENTS 5 -// How long a client may take to ask for the next sync frame. -#define PROTO_SYNC_TIMEOUT_MS 30000 - +// from the model goes out from loop(), in the socket event buffer. The +// clients' slots and syncs are in ProtoSlots. class ProtoSocket { public: // Adds /ws to the server. From setup(), on loop()'s task. @@ -34,24 +32,23 @@ class ProtoSocket { void groupRemoved(uint8_t groupId); void room(SomfyRoom &room); void roomRemoved(uint8_t roomId); - // roomOrder, shadeOrder or groupOrder: the ids, in sort order. - void order(proto_type_t type); + // roomOrder, shadeOrder or groupOrder: the list's ids, in sort order. + void order(sort_list_t list); void repeaters(); void shadeCommand(SomfyShade &shade, const char *command, const char *source, uint32_t sourceAddress); void memory(); void wifi(); void ethernet(); // A feed event: write its fields into the writer between the two. Only - // when someone subscribed, so check subscribed() first. + // when subscribed() says someone is, which is also only on loop(). bool subscribed(proto_feed_t feed) const; ProtoWriter beginFeed(proto_type_t type); void endFeed(proto_feed_t feed); static void wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len); private: - TaskHandle_t _owner = nullptr; - bool onLoopTask() const; - JsonFormatter &beginEvent(proto_type_t type); - void endEvent(); + // On loop()'s task, with a client to send to: nothing is formatted for + // nobody, or off the task that owns the event buffer. + bool sending() const; }; extern ProtoSocket protoSocket; #endif diff --git a/src/outputs/SocketEmitter.h b/src/outputs/SocketEmitter.h index 4382ec0..e5676cc 100644 --- a/src/outputs/SocketEmitter.h +++ b/src/outputs/SocketEmitter.h @@ -21,6 +21,8 @@ struct room_t { bool leave(uint8_t num); void clear(); }; +// The old socket on port 8080. It goes with step 4 of docs/SECURITY_PLAN.md, +// and with it every sockEmit call that sits next to a protoSocket one. class SocketEmitter { protected: uint8_t newclients = 0; @@ -29,9 +31,11 @@ class SocketEmitter { TaskHandle_t _owner = nullptr; // What beginEmit() hands any other task, so the event goes nowhere. JsonSockEvent _discard; - bool onLoopTask() const; void delayInit(uint8_t num); public: + // Whether this runs on loop()'s task, the only one that may send socket + // events: this socket's and protocol 2's. + bool onLoopTask() const; JsonSockEvent json; room_t rooms[SOCK_MAX_ROOMS]; uint8_t activeClients(uint8_t room); diff --git a/test/e2e/test_messages.py b/test/e2e/test_messages.py index 9179e39..4fc04f5 100644 --- a/test/e2e/test_messages.py +++ b/test/e2e/test_messages.py @@ -35,6 +35,34 @@ def test_an_event_type_from_a_client_is_unknown(connect, schema): assert error_name(schema, reply_to(c, msg_id)) == "unknownOp" +def test_next_and_sync_with_an_id_are_answered(connect, schema): + """A message with an id gets one reply, control messages too.""" + c = synced(connect, schema) + msg_id = c.send(schema.type_numbers["next"], with_id=True) + assert reply_to(c, msg_id)["d"].get(schema.keys["ok"]) == 1 + msg_id = c.send(schema.type_numbers["sync"], with_id=True) + assert reply_to(c, msg_id)["d"].get(schema.keys["ok"]) == 1 + + +def test_a_long_message_gets_one_reply(connect, schema): + """A frame bigger than a TCP segment reaches the device in pieces, which it + can't put together yet: still one reply for the whole message. The message + is wrong either way, so a reply is due however it arrives.""" + c = synced(connect, schema) + c.send_text('{"v":2,"t":%d,"id":5,"x":"%s"}' % (schema.type_numbers["shadeState"], "a" * 3000)) + replies = [] + try: + while True: + f = c.recv(timeout=3) + if f["t"] == 2: + replies.append(f) + except TimeoutError: + pass + assert len(replies) == 1, replies + # In pieces it can't be read, so its id isn't known; whole, it is an event. + assert error_name(schema, replies[0]) in ("badRequest", "unknownOp") + + def test_subscribing_to_a_feed_is_ok(connect, schema): c = synced(connect, schema) for feed in ("frames", "frequencyScan"): diff --git a/test/e2e/test_survival.py b/test/e2e/test_survival.py index 357c6d1..457e40e 100644 --- a/test/e2e/test_survival.py +++ b/test/e2e/test_survival.py @@ -16,13 +16,17 @@ MAX_CLIENTS = 5 @pytest.fixture def watch_heap(host): - """Fails the test when the device rebooted or its heap fell under the floor.""" + """Fails the test when the device rebooted or its heap fell under the floor. + + The device reports its lowest heap since it booted, so a dip under the + floor only shows while that low is still above it.""" before = heap(host) + if before["min"] < HEAP_FLOOR: + pytest.skip("the heap has been down to %u bytes since boot: reboot the device to check the floor" % before["min"]) yield before after = heap(host) assert after["uptime"] > before["uptime"], "the device rebooted" - assert after["min"] >= HEAP_FLOOR or after["min"] == before["min"], ( - "the heap fell to %u bytes (floor %u)" % (after["min"], HEAP_FLOOR)) + assert after["min"] >= HEAP_FLOOR, "the heap fell to %u bytes (floor %u)" % (after["min"], HEAP_FLOOR) def test_every_client_reconnecting_at_once(connect, schema, watch_heap): diff --git a/test/test_modeleditor/test_modeleditor.cpp b/test/test_modeleditor/test_modeleditor.cpp index 894c190..dbeac6c 100644 --- a/test/test_modeleditor/test_modeleditor.cpp +++ b/test/test_modeleditor/test_modeleditor.cpp @@ -134,6 +134,66 @@ static void test_255_means_no_pin() { TEST_ASSERT_EQUAL_INT8(0, conflict(shade_types::roller, radio_proto::GP_Remote, 255, 26, 255)); } +/********************************************************************* + * What protocol 2 clients are sent after an edit + ********************************************************************/ +static edit_result_t resultFor(uint8_t id, uint8_t shadeId = 0) { + edit_result_t r; + r.id = id; + r.shadeId = shadeId; + return r; +} +static void test_a_shade_edit_sends_the_whole_shade() { + const model_edit_type_t types[] = { + model_edit_type_t::add_shade, model_edit_type_t::save_shade, model_edit_type_t::set_paired, + model_edit_type_t::link_remote, model_edit_type_t::unlink_remote + }; + for(model_edit_type_t t : types) { + const edit_changes_t c = editChanges(t, resultFor(7)); + TEST_ASSERT_EQUAL_UINT8(7, c.shadeId); + TEST_ASSERT_EQUAL_UINT8(0, c.groupId); + TEST_ASSERT_EQUAL_UINT8(0, c.roomId); + } +} +static void test_a_group_edit_sends_the_whole_group() { + TEST_ASSERT_EQUAL_UINT8(3, editChanges(model_edit_type_t::add_group, resultFor(3)).groupId); + TEST_ASSERT_EQUAL_UINT8(3, editChanges(model_edit_type_t::save_group, resultFor(3)).groupId); +} +static void test_linking_a_shade_to_a_group_sends_both() { + // The shade's inGroup changes with the group's shades. + for(model_edit_type_t t : {model_edit_type_t::link_to_group, model_edit_type_t::unlink_from_group}) { + const edit_changes_t c = editChanges(t, resultFor(3, 9)); + TEST_ASSERT_EQUAL_UINT8(3, c.groupId); + TEST_ASSERT_EQUAL_UINT8(9, c.shadeId); + } +} +static void test_a_room_edit_sends_the_room() { + TEST_ASSERT_EQUAL_UINT8(2, editChanges(model_edit_type_t::add_room, resultFor(2)).roomId); + TEST_ASSERT_EQUAL_UINT8(2, editChanges(model_edit_type_t::save_room, resultFor(2)).roomId); +} +static void test_a_sort_edit_sends_its_list() { + TEST_ASSERT_EQUAL(sort_list_t::rooms, editChanges(model_edit_type_t::room_order, resultFor(0)).order); + TEST_ASSERT_EQUAL(sort_list_t::shades, editChanges(model_edit_type_t::shade_order, resultFor(0)).order); + TEST_ASSERT_EQUAL(sort_list_t::groups, editChanges(model_edit_type_t::group_order, resultFor(0)).order); + TEST_ASSERT_EQUAL(sort_list_t::none, editChanges(model_edit_type_t::save_shade, resultFor(1)).order); +} +static void test_a_repeater_edit_sends_the_repeaters() { + TEST_ASSERT_TRUE(editChanges(model_edit_type_t::link_repeater, resultFor(0)).repeaters); + TEST_ASSERT_TRUE(editChanges(model_edit_type_t::unlink_repeater, resultFor(0)).repeaters); + TEST_ASSERT_FALSE(editChanges(model_edit_type_t::save_room, resultFor(1)).repeaters); +} +static void test_deletes_send_nothing_more() { + // Their removals go out as they happen. + for(model_edit_type_t t : {model_edit_type_t::delete_room, model_edit_type_t::delete_shade, model_edit_type_t::delete_group}) { + const edit_changes_t c = editChanges(t, resultFor(4)); + TEST_ASSERT_EQUAL_UINT8(0, c.shadeId); + TEST_ASSERT_EQUAL_UINT8(0, c.groupId); + TEST_ASSERT_EQUAL_UINT8(0, c.roomId); + TEST_ASSERT_EQUAL(sort_list_t::none, c.order); + TEST_ASSERT_FALSE(c.repeaters); + } +} + int main(int argc, char **argv) { (void)argc; (void)argv; @@ -157,5 +217,12 @@ int main(int argc, char **argv) { RUN_TEST(test_a_pin_another_shade_uses); RUN_TEST(test_only_the_pins_a_shade_drives_are_checked); RUN_TEST(test_255_means_no_pin); + RUN_TEST(test_a_shade_edit_sends_the_whole_shade); + RUN_TEST(test_a_group_edit_sends_the_whole_group); + RUN_TEST(test_linking_a_shade_to_a_group_sends_both); + RUN_TEST(test_a_room_edit_sends_the_room); + RUN_TEST(test_a_sort_edit_sends_its_list); + RUN_TEST(test_a_repeater_edit_sends_the_repeaters); + RUN_TEST(test_deletes_send_nothing_more); return UNITY_END(); } diff --git a/test/test_proto/test_proto.cpp b/test/test_proto/test_proto.cpp index 2166b6f..61da5ce 100644 --- a/test/test_proto/test_proto.cpp +++ b/test/test_proto/test_proto.cpp @@ -4,6 +4,7 @@ #include #include #include "ProtoSchema.h" +#include "ProtoFrame.h" #include "ProtoSync.h" #include "ProtoParse.h" @@ -21,16 +22,16 @@ static BufferedJsonFormatter fmt; ********************************************************************/ static void test_field_keys_and_names_are_unique() { std::set keys, names; - for(uint8_t i = 0; i < static_cast(pf::count); i++) { - const proto_field_info_t &f = protoField(static_cast(i)); + for(uint8_t i = 0; i < static_cast(proto_field_t::count); i++) { + const proto_field_info_t &f = protoField(static_cast(i)); TEST_ASSERT_TRUE_MESSAGE(keys.insert(f.key).second, f.key); TEST_ASSERT_TRUE_MESSAGE(names.insert(f.name).second, f.name); TEST_ASSERT_TRUE_MESSAGE(strlen(f.key) >= 1 && strlen(f.key) <= 2, f.key); } } static void test_every_field_has_a_known_kind() { - for(uint8_t i = 0; i < static_cast(pf::count); i++) { - const proto_field_info_t &f = protoField(static_cast(i)); + for(uint8_t i = 0; i < static_cast(proto_field_t::count); i++) { + const proto_field_info_t &f = protoField(static_cast(i)); TEST_ASSERT_NOT_NULL_MESSAGE(strchr("binslo", f.kind), f.name); } } @@ -52,12 +53,12 @@ static void test_error_and_feed_numbers_are_unique() { } static void test_bootstrap_keys_are_fixed() { // A client needs these before it has the schema. - TEST_ASSERT_EQUAL_STRING("fw", protoField(pf::fw).key); - TEST_ASSERT_EQUAL_STRING("sh", protoField(pf::schemaHash).key); - TEST_ASSERT_EQUAL_STRING("T", protoField(pf::types).key); - TEST_ASSERT_EQUAL_STRING("F", protoField(pf::fields).key); - TEST_ASSERT_EQUAL_STRING("E", protoField(pf::errors).key); - TEST_ASSERT_EQUAL_STRING("Q", protoField(pf::feeds).key); + TEST_ASSERT_EQUAL_STRING("fw", protoField(proto_field_t::fw).key); + TEST_ASSERT_EQUAL_STRING("sh", protoField(proto_field_t::schemaHash).key); + TEST_ASSERT_EQUAL_STRING("T", protoField(proto_field_t::types).key); + TEST_ASSERT_EQUAL_STRING("F", protoField(proto_field_t::fields).key); + TEST_ASSERT_EQUAL_STRING("E", protoField(proto_field_t::errors).key); + TEST_ASSERT_EQUAL_STRING("Q", protoField(proto_field_t::feeds).key); } static void test_schema_hash_is_eight_hex_digits_and_stable() { const std::string first = protoSchemaHash(); @@ -83,22 +84,22 @@ static std::string writeObject(void (*fill)(ProtoWriter &)) { } static void test_values_at_their_default_are_left_out() { TEST_ASSERT_EQUAL_STRING("{}", writeObject([](ProtoWriter &w) { - w.add(pf::position, (int8_t)0); - w.add(pf::myPos, (int8_t)-1); - w.add(pf::repeats, (uint8_t)1); - w.add(pf::paired, false); - w.add(pf::name, ""); - w.add(pf::name, (const char *)nullptr); + w.add(proto_field_t::position, (int8_t)0); + w.add(proto_field_t::myPos, (int8_t)-1); + w.add(proto_field_t::repeats, (uint8_t)1); + w.add(proto_field_t::paired, false); + w.add(proto_field_t::name, ""); + w.add(proto_field_t::name, (const char *)nullptr); }).c_str()); } static void test_values_off_their_default_are_written_by_key() { TEST_ASSERT_EQUAL_STRING("{\"p\":50,\"m\":0,\"i\":1,\"n\":\"Office\",\"a\":4294967295,\"q\":-5}", writeObject([](ProtoWriter &w) { - w.add(pf::position, (int8_t)50); - w.add(pf::myPos, (int8_t)0); - w.add(pf::paired, true); - w.add(pf::name, "Office"); - w.add(pf::remoteAddress, (uint32_t)0xFFFFFFFF); - w.add(pf::tiltPosition, (int8_t)-5); + w.add(proto_field_t::position, (int8_t)50); + w.add(proto_field_t::myPos, (int8_t)0); + w.add(proto_field_t::paired, true); + w.add(proto_field_t::name, "Office"); + w.add(proto_field_t::remoteAddress, (uint32_t)0xFFFFFFFF); + w.add(proto_field_t::tiltPosition, (int8_t)-5); }).c_str()); } @@ -117,6 +118,11 @@ static void test_error_reply_frame() { // ok is 0, its default, so it is left out. TEST_ASSERT_EQUAL_STRING("{\"v\":2,\"t\":2,\"id\":17,\"d\":{\"e\":4,\"x\":\"Shade not found\"}}", buff); } +static void test_ok_reply_frame() { + fmt.setBuffer(buff, sizeof(buff)); + writeOkReply(fmt, 9); + TEST_ASSERT_EQUAL_STRING("{\"v\":2,\"t\":2,\"id\":9,\"d\":{\"k\":1}}", buff); +} /********************************************************************* * Items @@ -193,8 +199,8 @@ class ArraySource : public SyncSource { bool shade(uint8_t slot, shade_snapshot_t &out) override { out = shades[slot]; return out.shadeId != 0; } bool group(uint8_t slot, group_snapshot_t &out) override { out = groups[slot]; return out.groupId != 0; } bool repeater(uint8_t slot, uint32_t &address) override { address = repeaters[slot]; return address != 0; } - void header(ProtoWriter &w) override { w.add(pf::maxShades, (uint8_t)SOMFY_MAX_SHADES); } - void status(ProtoWriter &w) override { w.add(pf::freeHeap, (uint32_t)80000); } + void header(ProtoWriter &w) override { w.add(proto_field_t::maxShades, (uint8_t)SOMFY_MAX_SHADES); } + void status(ProtoWriter &w) override { w.add(proto_field_t::freeHeap, (uint32_t)80000); } }; static void fillWidest(ArraySource &src) { for(uint8_t i = 0; i < SOMFY_MAX_ROOMS; i++) { @@ -236,7 +242,7 @@ static std::vector runSync(SyncSource &src, bool withSchema, size_ return frames; } // How many items a section has over every frame. -static size_t count(const std::vector &frames, pf section) { +static size_t count(const std::vector &frames, proto_field_t section) { size_t n = 0; for(const JsonDocument &doc : frames) { JsonVariantConst v = doc["d"][protoField(section).key]; @@ -250,16 +256,16 @@ static void test_full_sync_sends_every_item_whole() { fillWidest(src); const std::vector frames = runSync(src, true, PROTO_MAX_FRAME); TEST_ASSERT_GREATER_THAN_UINT32(1, frames.size()); - TEST_ASSERT_EQUAL_UINT32(protoTypeCount(), count(frames, pf::types)); - TEST_ASSERT_EQUAL_UINT32(static_cast(pf::count), count(frames, pf::fields)); - TEST_ASSERT_EQUAL_UINT32(protoErrorCount(), count(frames, pf::errors)); - TEST_ASSERT_EQUAL_UINT32(protoFeedCount(), count(frames, pf::feeds)); - TEST_ASSERT_EQUAL_UINT32(1, count(frames, pf::header)); - TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_ROOMS, count(frames, pf::rooms)); - TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_SHADES, count(frames, pf::shades)); - TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_GROUPS, count(frames, pf::groups)); - TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_REPEATERS, count(frames, pf::repeaters)); - TEST_ASSERT_EQUAL_UINT32(1, count(frames, pf::status)); + TEST_ASSERT_EQUAL_UINT32(protoTypeCount(), count(frames, proto_field_t::types)); + TEST_ASSERT_EQUAL_UINT32(static_cast(proto_field_t::count), count(frames, proto_field_t::fields)); + TEST_ASSERT_EQUAL_UINT32(protoErrorCount(), count(frames, proto_field_t::errors)); + TEST_ASSERT_EQUAL_UINT32(protoFeedCount(), count(frames, proto_field_t::feeds)); + TEST_ASSERT_EQUAL_UINT32(1, count(frames, proto_field_t::header)); + TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_ROOMS, count(frames, proto_field_t::rooms)); + TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_SHADES, count(frames, proto_field_t::shades)); + TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_GROUPS, count(frames, proto_field_t::groups)); + TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_REPEATERS, count(frames, proto_field_t::repeaters)); + TEST_ASSERT_EQUAL_UINT32(1, count(frames, proto_field_t::status)); // Every shade comes out with all its linked remotes. for(const JsonDocument &doc : frames) for(JsonVariantConst s : doc["d"]["S"].as()) @@ -270,8 +276,8 @@ static void test_a_client_with_the_schema_skips_it() { src = ArraySource(); src.shades[0] = restingShade(1); const std::vector frames = runSync(src, false, PROTO_MAX_FRAME); - TEST_ASSERT_EQUAL_UINT32(0, count(frames, pf::types)); - TEST_ASSERT_EQUAL_UINT32(0, count(frames, pf::fields)); + TEST_ASSERT_EQUAL_UINT32(0, count(frames, proto_field_t::types)); + TEST_ASSERT_EQUAL_UINT32(0, count(frames, proto_field_t::fields)); TEST_ASSERT_EQUAL_UINT32(1, frames.size()); TEST_ASSERT_EQUAL(1, frames[0]["d"]["S"][0]["s"].as()); } @@ -282,9 +288,9 @@ static void test_empty_slots_are_skipped() { src.shades[20] = restingShade(21); src.rooms[3].roomId = 4; const std::vector frames = runSync(src, false, PROTO_MAX_FRAME); - TEST_ASSERT_EQUAL_UINT32(2, count(frames, pf::shades)); - TEST_ASSERT_EQUAL_UINT32(1, count(frames, pf::rooms)); - TEST_ASSERT_EQUAL_UINT32(0, count(frames, pf::groups)); + TEST_ASSERT_EQUAL_UINT32(2, count(frames, proto_field_t::shades)); + TEST_ASSERT_EQUAL_UINT32(1, count(frames, proto_field_t::rooms)); + TEST_ASSERT_EQUAL_UINT32(0, count(frames, proto_field_t::groups)); TEST_ASSERT_TRUE(frames[0]["d"]["X"].isNull()); } static void test_a_small_frame_takes_more_frames_and_keeps_items_whole() { @@ -293,8 +299,8 @@ static void test_a_small_frame_takes_more_frames_and_keeps_items_whole() { const std::vector big = runSync(src, false, PROTO_MAX_FRAME); const std::vector small = runSync(src, false, 1400); TEST_ASSERT_GREATER_THAN_UINT32(big.size(), small.size()); - TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_SHADES, count(small, pf::shades)); - TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_GROUPS, count(small, pf::groups)); + TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_SHADES, count(small, proto_field_t::shades)); + TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_GROUPS, count(small, proto_field_t::groups)); } static void test_an_item_too_big_for_a_frame_is_left_out_and_the_rest_go() { static ArraySource src; @@ -303,7 +309,7 @@ static void test_an_item_too_big_for_a_frame_is_left_out_and_the_rest_go() { src.shades[1] = restingShade(2); // Too small for the widest shade, big enough for a resting one. const std::vector frames = runSync(src, false, 200); - TEST_ASSERT_EQUAL_UINT32(1, count(frames, pf::shades)); + TEST_ASSERT_EQUAL_UINT32(1, count(frames, proto_field_t::shades)); } static void test_the_largest_shade_fits_one_frame() { // test_full_sync covers it in the sync; this is the shade event on its own. @@ -320,26 +326,26 @@ static void test_the_largest_shade_fits_one_frame() { /********************************************************************* * What clients send ********************************************************************/ -static proto_error_t parse(const char *text, proto_request_t &req) { +static proto_error_t parse(const char *text, proto_message_t &req) { JsonDocument doc; return parseClientFrame(text, strlen(text), doc, req); } static void test_next_is_read() { - proto_request_t req; + proto_message_t req; TEST_ASSERT_EQUAL(proto_error_t::none, parse("{\"v\":2,\"t\":3}", req)); TEST_ASSERT_EQUAL_UINT8(static_cast(proto_type_t::next), req.type); TEST_ASSERT_EQUAL_UINT32(0, req.id); } static void test_subscribe_carries_its_id_and_payload() { - proto_request_t req; + proto_message_t req; JsonDocument doc; const char *text = "{\"v\":2,\"t\":6,\"id\":9,\"d\":{\"fe\":1}}"; TEST_ASSERT_EQUAL(proto_error_t::none, parseClientFrame(text, strlen(text), doc, req)); TEST_ASSERT_EQUAL_UINT32(9, req.id); - TEST_ASSERT_EQUAL_INT32(1, protoInt(doc["d"], pf::feed)); + TEST_ASSERT_EQUAL_INT32(1, protoInt(doc["d"], proto_field_t::feed)); } static void test_bad_frames_are_refused() { - proto_request_t req; + proto_message_t req; TEST_ASSERT_EQUAL(proto_error_t::badRequest, parse("not json", req)); TEST_ASSERT_EQUAL(proto_error_t::badRequest, parse("[2,3]", req)); TEST_ASSERT_EQUAL(proto_error_t::badRequest, parse("{\"v\":1,\"t\":3}", req)); @@ -349,7 +355,7 @@ static void test_bad_frames_are_refused() { TEST_ASSERT_EQUAL(proto_error_t::badRequest, parse("{\"v\":2,\"t\":3,\"id\":\"x\"}", req)); } static void test_types_a_client_may_not_send_are_unknown() { - proto_request_t req; + proto_message_t req; // A device event, and a number nobody uses. TEST_ASSERT_EQUAL(proto_error_t::unknownOp, parse("{\"v\":2,\"t\":128,\"id\":5}", req)); TEST_ASSERT_EQUAL_UINT32(5, req.id); @@ -359,12 +365,12 @@ static void test_a_left_out_field_reads_as_its_default() { JsonDocument doc; deserializeJson(doc, "{\"p\":40}"); JsonObjectConst d = doc.as(); - TEST_ASSERT_EQUAL_INT32(40, protoInt(d, pf::position)); - TEST_ASSERT_EQUAL_INT32(-1, protoInt(d, pf::myPos)); - TEST_ASSERT_FALSE(protoBool(d, pf::paired)); - TEST_ASSERT_NULL(protoString(d, pf::name)); - TEST_ASSERT_TRUE(protoHas(d, pf::position)); - TEST_ASSERT_FALSE(protoHas(d, pf::target)); + TEST_ASSERT_EQUAL_INT32(40, protoInt(d, proto_field_t::position)); + TEST_ASSERT_EQUAL_INT32(-1, protoInt(d, proto_field_t::myPos)); + TEST_ASSERT_FALSE(protoBool(d, proto_field_t::paired)); + TEST_ASSERT_NULL(protoString(d, proto_field_t::name)); + TEST_ASSERT_TRUE(protoHas(d, proto_field_t::position)); + TEST_ASSERT_FALSE(protoHas(d, proto_field_t::target)); } int main(int argc, char **argv) { @@ -382,6 +388,7 @@ int main(int argc, char **argv) { RUN_TEST(test_values_off_their_default_are_written_by_key); RUN_TEST(test_hello_frame); RUN_TEST(test_error_reply_frame); + RUN_TEST(test_ok_reply_frame); RUN_TEST(test_shade_state_of_a_resting_shade_is_its_id); RUN_TEST(test_group_lists_shade_ids); RUN_TEST(test_full_sync_sends_every_item_whole); diff --git a/test/test_protoslots/test_protoslots.cpp b/test/test_protoslots/test_protoslots.cpp new file mode 100644 index 0000000..93e0578 --- /dev/null +++ b/test/test_protoslots/test_protoslots.cpp @@ -0,0 +1,172 @@ +#include +#include "ProtoSlots.h" + +// Protocol 2's client slots and the pace of their syncs: what async_tcp and +// loop() each do with a slot, in the orders the two tasks can interleave. + +static proto_slot_t slots[PROTO_MAX_CLIENTS]; + +void setUp() { + for(proto_slot_t &s : slots) { + freeSlot(s); + s.loopId = 0; + s.cur = sync_cursor_t(); + s.awaitingNext = false; + s.sentAt = 0; + } +} +void tearDown() {} + +// A client as onConnect sets it up: a slot, then hello, then the sync. +static proto_slot_t &connect(uint32_t id, bool withSchema = true) { + proto_slot_t *slot = claimSlot(slots, PROTO_MAX_CLIENTS, id, withSchema); + TEST_ASSERT_NOT_NULL(slot); + startSync(*slot); + return *slot; +} +// What sendSyncFrame does with the cursor: on to the end, or one frame in. +static void sendAll(proto_slot_t &slot, uint32_t now) { + slot.cur.section = sync_section_t::done; + syncFrameSent(slot, now); +} +static void sendSome(proto_slot_t &slot, uint32_t now) { + slot.cur.section = sync_section_t::shades; + syncFrameSent(slot, now); +} + +/********************************************************************* + * Slots + ********************************************************************/ +static void test_a_sixth_client_gets_no_slot() { + for(uint32_t id = 1; id <= PROTO_MAX_CLIENTS; id++) connect(id); + TEST_ASSERT_NULL(claimSlot(slots, PROTO_MAX_CLIENTS, 99, true)); +} +static void test_a_freed_slot_is_taken_again_clean() { + proto_slot_t &a = connect(1); + a.feeds.fetch_or(0x02); + a.wants.fetch_or(WANTS_NEXT); + freeSlot(a); + TEST_ASSERT_NULL(slotOf(slots, PROTO_MAX_CLIENTS, 1)); + proto_slot_t *b = claimSlot(slots, PROTO_MAX_CLIENTS, 2, false); + TEST_ASSERT_EQUAL_PTR(&a, b); + TEST_ASSERT_EQUAL_UINT8(0, b->feeds.load()); + TEST_ASSERT_EQUAL_UINT32(0, b->wants.load()); + TEST_ASSERT_FALSE(b->withSchema.load()); +} +static void test_no_sync_before_hello() { + // claimSlot alone, as onConnect has it before hello has gone out. + proto_slot_t *slot = claimSlot(slots, PROTO_MAX_CLIENTS, 1, true); + TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(*slot, 0)); + startSync(*slot); + TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(*slot, 0)); +} + +/********************************************************************* + * The sync's pace + ********************************************************************/ +static void test_a_new_client_gets_the_schema_unless_it_has_it() { + proto_slot_t &a = connect(1, true); + proto_slot_t &b = connect(2, false); + TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(a, 0)); + TEST_ASSERT_EQUAL(sync_section_t::types, a.cur.section); + TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(b, 0)); + TEST_ASSERT_EQUAL(sync_section_t::header, b.cur.section); + TEST_ASSERT_EQUAL_UINT32(2, b.loopId); +} +static void test_the_next_frame_waits_for_next() { + proto_slot_t &a = connect(1); + nextSyncStep(a, 0); + sendSome(a, 100); + TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 200)); + a.wants.fetch_or(WANTS_NEXT); + TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(a, 300)); +} +static void test_next_after_the_sync_has_ended_does_nothing() { + proto_slot_t &a = connect(1); + nextSyncStep(a, 0); + sendAll(a, 100); + a.wants.fetch_or(WANTS_NEXT); + TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 200)); +} +static void test_a_sync_again_starts_over_without_the_schema() { + proto_slot_t &a = connect(1); + nextSyncStep(a, 0); + sendSome(a, 100); + a.wants.fetch_or(WANTS_SYNC_AGAIN); + TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(a, 200)); + TEST_ASSERT_EQUAL(sync_section_t::header, a.cur.section); +} +static void test_no_next_for_30s_closes_the_client_once() { + proto_slot_t &a = connect(1); + nextSyncStep(a, 0); + sendSome(a, 1000); + TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 1000 + PROTO_SYNC_TIMEOUT_MS)); + TEST_ASSERT_EQUAL(sync_step_t::timedOut, nextSyncStep(a, 1001 + PROTO_SYNC_TIMEOUT_MS)); + TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 2000 + PROTO_SYNC_TIMEOUT_MS)); +} +static void test_a_finished_sync_never_times_out() { + proto_slot_t &a = connect(1); + nextSyncStep(a, 0); + sendAll(a, 0); + TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 10 * PROTO_SYNC_TIMEOUT_MS)); +} + +/********************************************************************* + * A slot changing hands under loop() + ********************************************************************/ +static void test_a_client_that_left_mid_sync_is_forgotten() { + proto_slot_t &a = connect(1); + nextSyncStep(a, 0); + sendSome(a, 0); + freeSlot(a); + TEST_ASSERT_EQUAL(sync_step_t::none, nextSyncStep(a, 1)); + TEST_ASSERT_FALSE(a.awaitingNext); + TEST_ASSERT_EQUAL_UINT32(0, a.loopId); +} +static void test_the_next_client_in_the_slot_gets_its_own_sync() { + proto_slot_t &a = connect(1); + nextSyncStep(a, 0); + sendSome(a, 0); + freeSlot(a); + connect(2); + TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(a, 1)); + TEST_ASSERT_EQUAL_UINT32(2, a.loopId); + TEST_ASSERT_EQUAL(sync_section_t::types, a.cur.section); +} +static void test_wants_taken_for_a_client_that_left_stay_for_the_next() { + // loop() read client 1, then 1 left and 2 took the slot before loop() took + // what it wanted: 2's sync must not be used up on 1. + proto_slot_t &a = connect(1); + nextSyncStep(a, 0); + freeSlot(a); + connect(2); + TEST_ASSERT_EQUAL_UINT32(0, takeWants(a, 1)); + TEST_ASSERT_EQUAL_UINT32(WANTS_SYNC, a.wants.load()); + TEST_ASSERT_EQUAL(sync_step_t::send, nextSyncStep(a, 1)); + TEST_ASSERT_EQUAL_UINT32(2, a.loopId); +} +static void test_wants_of_the_client_loop_read_are_taken() { + proto_slot_t &a = connect(1); + TEST_ASSERT_EQUAL_UINT32(WANTS_SYNC, takeWants(a, 1)); + TEST_ASSERT_EQUAL_UINT32(0, a.wants.load()); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_a_sixth_client_gets_no_slot); + RUN_TEST(test_a_freed_slot_is_taken_again_clean); + RUN_TEST(test_no_sync_before_hello); + RUN_TEST(test_a_new_client_gets_the_schema_unless_it_has_it); + RUN_TEST(test_the_next_frame_waits_for_next); + RUN_TEST(test_next_after_the_sync_has_ended_does_nothing); + RUN_TEST(test_a_sync_again_starts_over_without_the_schema); + RUN_TEST(test_no_next_for_30s_closes_the_client_once); + RUN_TEST(test_a_finished_sync_never_times_out); + RUN_TEST(test_a_client_that_left_mid_sync_is_forgotten); + RUN_TEST(test_the_next_client_in_the_slot_gets_its_own_sync); + RUN_TEST(test_wants_taken_for_a_client_that_left_stay_for_the_next); + RUN_TEST(test_wants_of_the_client_loop_read_are_taken); + return UNITY_END(); +}