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 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-24 13:17:15 +02:00
parent d869bb53d5
commit dc2f1478b6
29 changed files with 1087 additions and 670 deletions

View file

@ -9,7 +9,7 @@ Why the protocol looks like this: [ADR 0005](adr/0005-one-websocket-protocol.md)
- `ws://host/ws` on port 80. Under Secured the upgrade needs `?apikey=<token>` from `/login`; without a valid one it is refused with 401 ([ADR 0006](adr/0006-two-security-modes-eleven-http-routes.md)).
- Optional `?schema=<hash>`: the client already has the schema with that hash and doesn't need it again.
- At most 5 clients. A sixth is closed as soon as it connects.
- Text frames only, and each message in one frame: a fragmented message is answered with 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.

View file

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

View file

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

View file

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

View file

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

View file

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

33
src/ProtoFrame.cpp Normal file
View file

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

20
src/ProtoFrame.h Normal file
View file

@ -0,0 +1,20 @@
#ifndef PROTO_FRAME_H
#define PROTO_FRAME_H
#include <stdint.h>
#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":<type>,"id":<id>,"d":{ ... }}. id 0 leaves it out.
void beginFrame(JsonFormatter &json, proto_type_t type, uint32_t id = 0);
void endFrame(JsonFormatter &json);
// 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

View file

@ -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<JsonObject>()) 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<int32_t>() ? v.as<int32_t>() : 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<int>() ? v.as<int>() != 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<const char *>() ? v.as<const char *>() : 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(); }

View file

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

View file

@ -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<size_t>(pf::count), "a field without a row");
static_assert(sizeof(FIELDS) / sizeof(FIELDS[0]) == static_cast<size_t>(proto_field_t::count), "a field without a row");
const proto_field_info_t &protoField(pf field) { return FIELDS[static_cast<uint8_t>(field)]; }
const proto_field_info_t &protoField(proto_field_t field) { return FIELDS[static_cast<uint8_t>(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<uint8_t>(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<uint8_t>(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);
}

View file

@ -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<typename T> void add(pf field, T value) {
template<typename T> void add(proto_field_t field, T value) {
const proto_field_info_t &f = protoField(field);
if constexpr(std::is_same<T, bool>::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":<type>,"id":<id>,"d":{ ... }}. id 0 leaves it out.
void beginFrame(JsonFormatter &json, proto_type_t type, uint32_t id = 0);
void endFrame(JsonFormatter &json);
// 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

62
src/ProtoSlots.cpp Normal file
View file

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

59
src/ProtoSlots.h Normal file
View file

@ -0,0 +1,59 @@
#ifndef PROTO_SLOTS_H
#define PROTO_SLOTS_H
#include <stdint.h>
#include <atomic>
#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<uint32_t> clientId{0};
std::atomic<uint32_t> wants{0};
std::atomic<uint8_t> feeds{0}; // a bit per feed number
std::atomic<bool> 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

View file

@ -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<uint8_t>(pf::count);
case sync_section_t::fields: return static_cast<uint8_t>(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<pf>(slot));
const proto_field_info_t &f = protoField(static_cast<proto_field_t>(slot));
const char kind[2] = {f.kind, '\0'};
json.beginArray();
json.addElem(f.key);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,12 @@
#include <Arduino.h>
#include <WiFi.h>
#include <atomic>
#include <algorithm>
#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<uint32_t> asyncId{0};
std::atomic<uint32_t> requests{0};
std::atomic<uint8_t> feeds{0}; // a bit per feed number
std::atomic<bool> 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<uint8_t>(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<uint8_t>(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<uint32_t *>(ctx));
ProtoWriter(json).add(pf::ok, true);
endFrame(json);
}, &id);
sendFixed(client, [](JsonFormatter &json, void *ctx) { writeOkReply(json, *static_cast<uint32_t *>(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<const char *>(data), len, doc, req);
proto_message_t msg;
const proto_error_t err = parseClientFrame(reinterpret_cast<const char *>(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<proto_type_t>(req.type)) {
switch(static_cast<proto_type_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(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<typename Model, typename Snapshot>
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<uint8_t>(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<uint8_t>(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());
}
}

View file

@ -2,6 +2,7 @@
#define PROTO_SOCKET_H
#include <ESPAsyncWebServer.h>
#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

View file

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

View file

@ -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"):

View file

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

View file

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

View file

@ -4,6 +4,7 @@
#include <vector>
#include <ArduinoJson.h>
#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<std::string> keys, names;
for(uint8_t i = 0; i < static_cast<uint8_t>(pf::count); i++) {
const proto_field_info_t &f = protoField(static_cast<pf>(i));
for(uint8_t i = 0; i < static_cast<uint8_t>(proto_field_t::count); i++) {
const proto_field_info_t &f = protoField(static_cast<proto_field_t>(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<uint8_t>(pf::count); i++) {
const proto_field_info_t &f = protoField(static_cast<pf>(i));
for(uint8_t i = 0; i < static_cast<uint8_t>(proto_field_t::count); i++) {
const proto_field_info_t &f = protoField(static_cast<proto_field_t>(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<JsonDocument> runSync(SyncSource &src, bool withSchema, size_
return frames;
}
// How many items a section has over every frame.
static size_t count(const std::vector<JsonDocument> &frames, pf section) {
static size_t count(const std::vector<JsonDocument> &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<JsonDocument> 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<uint8_t>(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<uint8_t>(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<JsonArrayConst>())
@ -270,8 +276,8 @@ static void test_a_client_with_the_schema_skips_it() {
src = ArraySource();
src.shades[0] = restingShade(1);
const std::vector<JsonDocument> 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<int>());
}
@ -282,9 +288,9 @@ static void test_empty_slots_are_skipped() {
src.shades[20] = restingShade(21);
src.rooms[3].roomId = 4;
const std::vector<JsonDocument> 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<JsonDocument> big = runSync(src, false, PROTO_MAX_FRAME);
const std::vector<JsonDocument> 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<JsonDocument> 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<uint8_t>(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<JsonObjectConst>();
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);

View file

@ -0,0 +1,172 @@
#include <unity.h>
#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();
}