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

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