diff --git a/platformio.ini b/platformio.ini index 994eabf..c1c145c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -108,7 +108,9 @@ build_flags = platform = native test_framework = unity test_build_src = yes -build_src_filter = -<*> + + + + + + + + + + + + + + +build_src_filter = -<*> + + + + + + + + + + + + + + + + + +lib_deps = + bblanchon/ArduinoJson@^7.2.2 build_flags = -std=gnu++17 -Isrc diff --git a/src/ESPNetwork.cpp b/src/ESPNetwork.cpp index 054c42f..168aaa6 100644 --- a/src/ESPNetwork.cpp +++ b/src/ESPNetwork.cpp @@ -7,6 +7,7 @@ #include "ESPNetwork.h" #include "entryPoints/Web.h" #include "outputs/SocketEmitter.h" +#include "outputs/ProtoSocket.h" #include "Utils.h" #include "SSDP.h" #include "MqttConnection.h" @@ -223,6 +224,9 @@ void ESPNetwork::emitSockets(uint8_t num) { } // A broadcast comes from emitSockets(), which has already sent memStatus. if(num != 255) this->emitHeap(num); + // Protocol 2 clients get the status in their sync, so only broadcasts. + else if(this->connType == conn_types_t::ethernet) protoSocket.ethernet(); + else protoSocket.wifi(); } // Runs on the WiFi/Ethernet event task, so it sends nothing on the sockets. // loop() broadcasts the new status and a client that reconnects gets it from @@ -690,5 +694,8 @@ void ESPNetwork::emitHeap(uint8_t num) { json->addElem("loopMaxUs", loopStats.maxUs()); json->endObject(); sockEmit.endEmit(num); - if(num == 255) loopStats.publish(millis()); + if(num == 255) { + protoSocket.memory(); + loopStats.publish(millis()); + } } diff --git a/src/JsonFormatter.cpp b/src/JsonFormatter.cpp index a849f0c..3aeae85 100644 --- a/src/JsonFormatter.cpp +++ b/src/JsonFormatter.cpp @@ -27,6 +27,14 @@ void JsonFormatter::discard() { this->_objects = 0; this->_arrays = 0; } +void JsonFormatter::rollback(const json_mark_t &m) { + this->_len = m.len; + if(this->buff) this->buff[this->_len] = 0; + this->_objects = m.objects; + this->_arrays = m.arrays; + this->_nocomma = m.nocomma; + this->_overflow = false; +} void JsonFormatter::beginObject(const char *name) { if(name && strlen(name) > 0) this->appendElem(name); else if(!this->_nocomma) this->_safecat(","); @@ -101,7 +109,7 @@ void JsonFormatter::addElem(const char *name, bool bval) { strcpy(this->_numbuff void JsonFormatter::_safecat(const char *val, bool escape) { const size_t vlen = escape ? this->calcEscapedLength(val) + 2 : strlen(val); if(this->_len + vlen >= this->buffSize) { - if(!this->_overflow) ESP_LOGE(TAG, "JSON exceeded buffer size %u", (unsigned)this->buffSize); + if(!this->_overflow && this->_logOverflow) ESP_LOGE(TAG, "JSON exceeded buffer size %u", (unsigned)this->buffSize); this->_overflow = true; return; } diff --git a/src/JsonFormatter.h b/src/JsonFormatter.h index 639b465..b1c89f5 100644 --- a/src/JsonFormatter.h +++ b/src/JsonFormatter.h @@ -2,6 +2,15 @@ #define json_formatter_h #include +// Where a formatter's output stands, to go back to when what follows doesn't +// fit (JsonFormatter::mark()). +struct json_mark_t { + size_t len; + uint8_t objects; + uint8_t arrays; + bool nocomma; +}; + // Streams JSON into a fixed buffer. Free of the web server so it can be unit // tested on the PC (pio test -e native). class JsonFormatter { @@ -16,6 +25,8 @@ class JsonFormatter { bool _nocomma = true; // Something didn't fit and was left out so the JSON is incomplete. bool _overflow = false; + // Whether running out of room is logged. + bool _logOverflow = true; char _numbuff[25] = {0}; virtual void _safecat(const char *val, bool escape = false); // Appends val at buff + _len. vlen is what it takes, including the quotes @@ -24,6 +35,12 @@ class JsonFormatter { void _appendNumber(const char *name); public: bool overflowed() const { return _overflow; } + json_mark_t mark() const { return {_len, _objects, _arrays, _nocomma}; } + // Drops everything written since m, an overflow included. + void rollback(const json_mark_t &m); + // A writer that tries an item and rolls back when it doesn't fit turns the + // overflow log off, as running out of room is expected there. + void logOverflow(bool on) { _logOverflow = on; } // Drops whatever is written until a buffer is set again. Nothing is // written anywhere, so tasks can share one discarding formatter. void discard(); diff --git a/src/ProtoParse.cpp b/src/ProtoParse.cpp new file mode 100644 index 0000000..abf7aba --- /dev/null +++ b/src/ProtoParse.cpp @@ -0,0 +1,36 @@ +#include "ProtoParse.h" + +proto_error_t parseClientFrame(const char *text, size_t len, JsonDocument &doc, proto_request_t &out) { + out = proto_request_t(); + if(len > PROTO_MAX_CLIENT_FRAME) return proto_error_t::badRequest; + if(deserializeJson(doc, text, len) != DeserializationError::Ok) return proto_error_t::badRequest; + if(!doc.is()) return proto_error_t::badRequest; + JsonVariantConst id = doc["id"]; + if(id.is()) out.id = id.as(); + else if(!id.isNull()) return proto_error_t::badRequest; + if(!doc["v"].is() || doc["v"].as() != PROTO_VERSION) return proto_error_t::badRequest; + JsonVariantConst t = doc["t"]; + if(!t.is() || t.as() < 1 || t.as() > 255) return proto_error_t::badRequest; + out.type = static_cast(t.as()); + if(!protoClientMaySend(out.type)) return proto_error_t::unknownOp; + if(protoIsOp(out.type) && out.id == 0) return proto_error_t::badRequest; + JsonVariantConst d = doc["d"]; + if(!d.isNull() && !d.is()) return proto_error_t::badRequest; + return proto_error_t::none; +} + +int32_t protoInt(JsonObjectConst d, pf field) { + const proto_field_info_t &f = protoField(field); + JsonVariantConst v = d[f.key]; + return v.is() ? v.as() : f.def; +} +bool protoBool(JsonObjectConst d, pf field) { + const proto_field_info_t &f = protoField(field); + JsonVariantConst v = d[f.key]; + return v.is() ? v.as() != 0 : f.def != 0; +} +const char *protoString(JsonObjectConst d, pf field) { + JsonVariantConst v = d[protoField(field).key]; + return v.is() ? v.as() : nullptr; +} +bool protoHas(JsonObjectConst d, pf field) { return !d[protoField(field).key].isNull(); } diff --git a/src/ProtoParse.h b/src/ProtoParse.h new file mode 100644 index 0000000..8ab3d34 --- /dev/null +++ b/src/ProtoParse.h @@ -0,0 +1,32 @@ +#ifndef PROTO_PARSE_H +#define PROTO_PARSE_H +#include +#include +#include +#include "ProtoSchema.h" + +// Reading the frames a socket protocol 2 client sends (docs/PROTOCOL.md). +// Kept free of the web server so it can be unit tested on the PC +// (pio test -e native). + +// The largest frame a client may send; a longer one is refused unread. +#define PROTO_MAX_CLIENT_FRAME 4096 + +struct proto_request_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); +// 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); +// 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); +#endif diff --git a/src/ProtoSchema.cpp b/src/ProtoSchema.cpp new file mode 100644 index 0000000..f92b699 --- /dev/null +++ b/src/ProtoSchema.cpp @@ -0,0 +1,106 @@ +#include +#include +#include "ProtoSchema.h" + +#define PROTO_FIELD_ROW(name, key, kind, def) {#name, key, kind, def}, +static const proto_field_info_t FIELDS[] = { PROTO_FIELDS(PROTO_FIELD_ROW) }; +#undef PROTO_FIELD_ROW +// Errors and feeds have no sender, so it is left empty and starts as 0. +#define PROTO_NAMED_ROW(name, number, ...) {number, #name, PROTO_SENDER(__VA_ARGS__, 0)}, +#define PROTO_SENDER(sender, ...) sender +static const proto_named_t TYPES[] = { PROTO_TYPES(PROTO_NAMED_ROW) }; +static const proto_named_t ERRORS[] = { PROTO_ERRORS(PROTO_NAMED_ROW) }; +static const proto_named_t FEEDS[] = { PROTO_FEEDS(PROTO_NAMED_ROW) }; +#undef PROTO_SENDER +#undef PROTO_NAMED_ROW +static_assert(sizeof(FIELDS) / sizeof(FIELDS[0]) == static_cast(pf::count), "a field without a row"); + +const proto_field_info_t &protoField(pf field) { return FIELDS[static_cast(field)]; } +const proto_field_info_t *protoFieldByKey(const char *key) { + for(const proto_field_info_t &f : FIELDS) + if(strcmp(f.key, key) == 0) return &f; + return nullptr; +} +uint8_t protoTypeCount() { return sizeof(TYPES) / sizeof(TYPES[0]); } +const proto_named_t &protoTypeAt(uint8_t index) { return TYPES[index]; } +uint8_t protoErrorCount() { return sizeof(ERRORS) / sizeof(ERRORS[0]); } +const proto_named_t &protoErrorAt(uint8_t index) { return ERRORS[index]; } +uint8_t protoFeedCount() { return sizeof(FEEDS) / sizeof(FEEDS[0]); } +const proto_named_t &protoFeedAt(uint8_t index) { return FEEDS[index]; } +const char *protoTypeName(uint8_t number) { + for(const proto_named_t &t : TYPES) + if(t.number == number) return t.name; + return nullptr; +} +bool protoClientMaySend(uint8_t type) { + for(const proto_named_t &t : TYPES) + if(t.number == type) return t.sender == 'c' || t.sender == 'b'; + return false; +} + +static uint32_t fnv(uint32_t hash, const char *text) { + for(; *text; text++) { + hash ^= (uint8_t)*text; + hash *= 16777619u; + } + return hash; +} +static uint32_t fnvNamed(uint32_t hash, const proto_named_t &row) { + char num[12]; + snprintf(num, sizeof(num), "%u:", row.number); + hash = fnv(hash, num); + hash = fnv(hash, row.name); + return fnv(hash, ";"); +} +const char *protoSchemaHash() { + static char hex[9] = {0}; + if(hex[0]) return hex; + uint32_t hash = 2166136261u; + for(const proto_named_t &t : TYPES) hash = fnvNamed(hash, t); + for(const proto_field_info_t &f : FIELDS) { + char row[64]; + snprintf(row, sizeof(row), "%s:%s:%c:%ld;", f.name, f.key, f.kind, (long)f.def); + hash = fnv(hash, row); + } + for(const proto_named_t &e : ERRORS) hash = fnvNamed(hash, e); + for(const proto_named_t &q : FEEDS) hash = fnvNamed(hash, q); + snprintf(hex, sizeof(hex), "%08lx", (unsigned long)hash); + return hex; +} + +void ProtoWriter::addInt(const proto_field_info_t &f, int64_t value) { + if(value == f.def) return; + if(value >= INT32_MIN && value <= INT32_MAX) this->json.addElem(f.key, (int32_t)value); + else this->json.addElem(f.key, (uint32_t)value); +} +void ProtoWriter::item(int64_t value) { + if(value >= INT32_MIN && value <= INT32_MAX) this->json.addElem((int32_t)value); + else this->json.addElem((uint32_t)value); +} + +void beginFrame(JsonFormatter &json, proto_type_t type, uint32_t id) { + json.beginObject(); + json.addElem("v", (uint8_t)PROTO_VERSION); + json.addElem("t", static_cast(type)); + if(id) json.addElem("id", id); + json.beginObject("d"); +} +void endFrame(JsonFormatter &json) { + json.endObject(); + json.endObject(); +} +void writeErrorReply(JsonFormatter &json, uint32_t id, proto_error_t code, const char *desc) { + beginFrame(json, proto_type_t::reply, id); + ProtoWriter w(json); + w.add(pf::ok, false); + w.add(pf::code, static_cast(code)); + w.add(pf::desc, desc); + endFrame(json); +} +void writeHello(JsonFormatter &json, const char *fw) { + beginFrame(json, proto_type_t::hello); + ProtoWriter w(json); + w.add(pf::fw, fw); + w.add(pf::schemaHash, protoSchemaHash()); + endFrame(json); +} diff --git a/src/ProtoSchema.h b/src/ProtoSchema.h new file mode 100644 index 0000000..abefbe3 --- /dev/null +++ b/src/ProtoSchema.h @@ -0,0 +1,247 @@ +#ifndef PROTO_SCHEMA_H +#define PROTO_SCHEMA_H +#include +#include +#include "JsonFormatter.h" + +// Socket protocol 2 (docs/PROTOCOL.md, docs/adr/0005-one-websocket-protocol.md): +// the one table of message types, fields, error codes and feeds. The device +// sends it to clients as the schema, so it is the only copy anywhere. Writers +// name fields by proto_field_t, so a field that isn't declared here can't be +// sent. Kept free of the shade model and the web server so it can be unit +// tested on the PC (pio test -e native). + +#define PROTO_VERSION 2 + +// 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') + +// Fields, in one dictionary for every type. F(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) \ + /* 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) \ + /* 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) \ + /* 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) \ + /* shadeCommand */ \ + F(command, "cm", 's', 0) \ + F(source, "so", 's', 0) \ + F(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) \ + /* 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) \ + /* 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) + +// 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) + +// Feeds a client can subscribe to. Q(name, number) +#define PROTO_FEEDS(Q) \ + Q(frames, 1) \ + Q(frequencyScan, 2) + +#define PROTO_ENUM_ENTRY(name, number, ...) name = number, +enum class proto_type_t : uint8_t { PROTO_TYPES(PROTO_ENUM_ENTRY) }; +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 }; +#undef PROTO_FIELD_ENTRY + +struct proto_field_info_t { + const char *name; + const char *key; + char kind; + int32_t def; +}; +struct proto_named_t { + uint8_t number; + const char *name; + char sender; // types only +}; +const proto_field_info_t &protoField(pf field); +const proto_field_info_t *protoFieldByKey(const char *key); +// The tables as the schema lists them, in declaration order. +uint8_t protoTypeCount(); +const proto_named_t &protoTypeAt(uint8_t index); +uint8_t protoErrorCount(); +const proto_named_t &protoErrorAt(uint8_t index); +uint8_t protoFeedCount(); +const proto_named_t &protoFeedAt(uint8_t index); +const char *protoTypeName(uint8_t number); +// What a client may send: control messages it sends and the ops. +bool protoClientMaySend(uint8_t type); +// Hex FNV-1a of every row of the schema: 8 characters and a terminator. +const char *protoSchemaHash(); + +// Writes fields by their short keys into the open object of json: booleans as +// 1/0, and nothing at all for a value at the field's default. +class ProtoWriter { + public: + explicit ProtoWriter(JsonFormatter &json) : json(json) {} + template void add(pf field, T value) { + const proto_field_info_t &f = protoField(field); + if constexpr(std::is_same::value) { + if((value ? 1 : 0) == f.def) return; + this->json.addElem(f.key, (uint8_t)(value ? 1 : 0)); + } + else if constexpr(std::is_floating_point::value) { + if(value == (T)f.def) return; + this->json.addElem(f.key, (float)value); + } + else if constexpr(std::is_integral::value) { + this->addInt(f, (int64_t)value); + } + else { + const char *s = value; + if(!s || !s[0]) return; + this->json.addElem(f.key, s); + } + } + void beginList(pf field) { this->json.beginArray(protoField(field).key); } + void endList() { this->json.endArray(); } + void beginObject(pf 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(); } + void endItem() { this->json.endObject(); } + void item(int64_t value); + JsonFormatter &json; + private: + void addInt(const proto_field_info_t &f, int64_t value); +}; + +// A frame: {"v":2,"t":,"id":,"d":{ ... }}. id 0 leaves it out. +void beginFrame(JsonFormatter &json, proto_type_t type, uint32_t id = 0); +void endFrame(JsonFormatter &json); +// A reply carrying an error. +void writeErrorReply(JsonFormatter &json, uint32_t id, proto_error_t code, const char *desc); +// hello: the firmware version and the schema hash. +void writeHello(JsonFormatter &json, const char *fw); +#endif diff --git a/src/ProtoSync.cpp b/src/ProtoSync.cpp new file mode 100644 index 0000000..e0527f3 --- /dev/null +++ b/src/ProtoSync.cpp @@ -0,0 +1,248 @@ +#include "esp_log.h" +#include "ProtoSync.h" + +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); +} +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); +} +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); + if(shade.linkedRemoteCount == 0) return; + w.beginList(pf::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.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); + if(group.linkedShadeCount == 0) return; + w.beginList(pf::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); +} + +/********************************************************************* + * Sync + ********************************************************************/ +// What closing a frame cut short takes: the list's ], the }} of d and the +// frame, and the terminator. +#define SYNC_CLOSE_RESERVE 4 + +sync_cursor_t beginSync(bool withSchema) { + sync_cursor_t cur; + cur.section = withSchema ? sync_section_t::types : sync_section_t::header; + cur.index = 0; + return cur; +} + +static pf 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; + } +} +static uint8_t sectionSlots(sync_section_t section) { + switch(section) { + case sync_section_t::types: return protoTypeCount(); + case sync_section_t::fields: return static_cast(pf::count); + case sync_section_t::errors: return protoErrorCount(); + case sync_section_t::feeds: return protoFeedCount(); + case sync_section_t::rooms: return SOMFY_MAX_ROOMS; + case sync_section_t::shades: return SOMFY_MAX_SHADES; + case sync_section_t::groups: return SOMFY_MAX_GROUPS; + case sync_section_t::repeaters: return SOMFY_MAX_REPEATERS; + default: return 0; + } +} +static void namedRow(JsonFormatter &json, const proto_named_t &row) { + json.beginArray(); + json.addElem(row.number); + json.addElem(row.name); + json.endArray(); +} +// Writes the item in slot of a list section. False when the slot is empty. +static bool writeItem(SyncSource &src, ProtoWriter &w, sync_section_t section, uint8_t slot) { + JsonFormatter &json = w.json; + switch(section) { + case sync_section_t::types: namedRow(json, protoTypeAt(slot)); return true; + case sync_section_t::errors: namedRow(json, protoErrorAt(slot)); return true; + case sync_section_t::feeds: namedRow(json, protoFeedAt(slot)); return true; + case sync_section_t::fields: { + const proto_field_info_t &f = protoField(static_cast(slot)); + const char kind[2] = {f.kind, '\0'}; + json.beginArray(); + json.addElem(f.key); + json.addElem(f.name); + json.addElem(kind); + json.addElem(f.def); + json.endArray(); + return true; + } + case sync_section_t::rooms: { + room_snapshot_t room; + if(!src.room(slot, room)) return false; + w.beginItem(); + protoRoom(w, room); + w.endItem(); + return true; + } + case sync_section_t::shades: { + shade_snapshot_t shade; + if(!src.shade(slot, shade)) return false; + w.beginItem(); + protoShade(w, shade); + w.endItem(); + return true; + } + case sync_section_t::groups: { + group_snapshot_t group; + if(!src.group(slot, group)) return false; + w.beginItem(); + protoGroup(w, group); + w.endItem(); + return true; + } + case sync_section_t::repeaters: { + uint32_t address = 0; + if(!src.repeater(slot, address)) return false; + w.item(address); + return true; + } + default: + return false; + } +} +static void nextSection(sync_cursor_t &cur) { + cur.section = static_cast(static_cast(cur.section) + 1); + cur.index = 0; +} + +size_t writeSyncFrame(SyncSource &src, sync_cursor_t &cur, char *buff, size_t size) { + if(!cur.active()) return 0; + BufferedJsonFormatter json; + json.setBuffer(buff, size); + json.logOverflow(false); + ProtoWriter w(json); + beginFrame(json, proto_type_t::sync); + bool any = false; + auto fits = [&json, size]() { return !json.overflowed() && json.length() + SYNC_CLOSE_RESERVE < size; }; + while(cur.active()) { + if(cur.section == sync_section_t::header || cur.section == sync_section_t::status) { + const json_mark_t m = json.mark(); + w.beginObject(sectionField(cur.section)); + if(cur.section == sync_section_t::header) src.header(w); + else src.status(w); + w.endObject(); + if(!fits()) { + json.rollback(m); + // Only fixed size values, so this can't happen short of a bug. + if(!any) { ESP_LOGE(TAG, "Sync section %u doesn't fit in %u bytes", static_cast(cur.section), (unsigned)size); nextSection(cur); continue; } + break; + } + any = true; + nextSection(cur); + continue; + } + bool open = false; + bool full = false; + const uint8_t slots = sectionSlots(cur.section); + while(cur.index < slots) { + const json_mark_t m = json.mark(); + if(!open) w.beginList(sectionField(cur.section)); + if(!writeItem(src, w, cur.section, cur.index)) { + json.rollback(m); + cur.index++; + continue; + } + if(!fits()) { + json.rollback(m); + if(!any) { + ESP_LOGE(TAG, "Sync item %u of section %u doesn't fit in %u bytes, leaving it out", cur.index, static_cast(cur.section), (unsigned)size); + cur.index++; + continue; + } + full = true; + break; + } + open = true; + any = true; + cur.index++; + } + if(open) w.endList(); + if(full) break; + nextSection(cur); + } + endFrame(json); + return json.length(); +} diff --git a/src/ProtoSync.h b/src/ProtoSync.h new file mode 100644 index 0000000..19ebb2b --- /dev/null +++ b/src/ProtoSync.h @@ -0,0 +1,61 @@ +#ifndef PROTO_SYNC_H +#define PROTO_SYNC_H +#include +#include +#include "ModelSnapshot.h" +#include "ProtoSchema.h" + +// The shade model in socket protocol 2: how each room, shade and group is +// written, and the sync that sends a client all of them (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). + +// The largest frame: what a sync message, or any event, is formatted into. +#define PROTO_MAX_FRAME 4096 + +// The fields of one item, into the object the caller opened. +void protoRoom(ProtoWriter &w, const room_snapshot_t &room); +// Everything about a shade: its settings and its state. +void protoShade(ProtoWriter &w, const shade_snapshot_t &shade); +// Only what changes while a shade moves or is commanded. +void protoShadeState(ProtoWriter &w, const shade_snapshot_t &shade); +// A group, with the ids of its shades. +void protoGroup(ProtoWriter &w, const group_snapshot_t &group); +void protoGroupState(ProtoWriter &w, uint8_t groupId, uint8_t flags, int8_t direction); + +// Where a sync gets the shade model from: the live model on the device, a +// filled snapshot in the tests. Slots run from 0 to the SOMFY_MAX_* of their +// kind; an empty slot returns false. +class SyncSource { + public: + virtual ~SyncSource() {} + virtual bool room(uint8_t slot, room_snapshot_t &out) = 0; + virtual bool shade(uint8_t slot, shade_snapshot_t &out) = 0; + virtual bool group(uint8_t slot, group_snapshot_t &out) = 0; + virtual bool repeater(uint8_t slot, uint32_t &address) = 0; + // The fields of the header and status objects. + virtual void header(ProtoWriter &w) = 0; + virtual void status(ProtoWriter &w) = 0; +}; + +// The parts of a sync, in the order they are sent. +enum class sync_section_t : uint8_t { + types, fields, errors, feeds, // the schema + header, rooms, shades, groups, repeaters, status, // the device + done +}; +struct sync_cursor_t { + sync_section_t section = sync_section_t::done; + uint8_t index = 0; + bool active() const { return this->section != sync_section_t::done; } +}; +// A sync from the start: with the schema, or from the header when the client +// already has it. +sync_cursor_t beginSync(bool withSchema); +// Writes the next sync frame into buff: as many whole items as fit in size +// bytes, never one split in two. An item too big for an empty frame is left +// out. Returns the frame's length, or 0 once the sync is done and syncEnd is +// due. Each call reads the source afresh, so a sync shows each item as it is +// when it goes out, not one moment. +size_t writeSyncFrame(SyncSource &src, sync_cursor_t &cur, char *buff, size_t size); +#endif diff --git a/src/ShadeChanges.h b/src/ShadeChanges.h index 9adacd7..ab88b34 100644 --- a/src/ShadeChanges.h +++ b/src/ShadeChanges.h @@ -22,14 +22,19 @@ enum : uint16_t { SHADE_CHANGED_SUNNY = 0x0400, SHADE_CHANGED_WINDY = 0x0800, // Something only the socket's shadeState carries changed. - SHADE_CHANGED_STATE = 0x1000 + SHADE_CHANGED_STATE = 0x1000, + // A setting changed (an edit, pairing, its room). Protocol 2 clients get the + // whole shade rather than its state. + SHADE_CHANGED_CONFIG = 0x2000 }; // The bits that have an MQTT topic. #define SHADE_CHANGED_TOPICS 0x0FFF // The same for a group: its direction topic, and groupState for the socket. enum : uint8_t { GROUP_CHANGED_DIRECTION = 0x01, - GROUP_CHANGED_STATE = 0x02 + GROUP_CHANGED_STATE = 0x02, + // A setting or its shades changed: protocol 2 clients get the whole group. + GROUP_CHANGED_CONFIG = 0x04 }; // Publishes one MQTT topic: its name under shades//, its value and whether diff --git a/src/ShadeController.cpp b/src/ShadeController.cpp index a0d07c7..dbd7eba 100644 --- a/src/ShadeController.cpp +++ b/src/ShadeController.cpp @@ -5,6 +5,8 @@ #include "Somfy.h" #include "ConfigFile.h" #include "ModelEditor.h" +#include "ShadeChanges.h" +#include "outputs/ProtoSocket.h" static const char *TAG = "Somfy"; @@ -280,6 +282,7 @@ bool SomfyShadeController::deleteShade(uint8_t shadeId) { for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { if(this->shades[i].getShadeId() == shadeId) { shades[i].emitState("shadeRemoved"); + protoSocket.shadeRemoved(shadeId); shades[i].unpublish(); this->shades[i].clear(); } @@ -295,15 +298,18 @@ bool SomfyShadeController::deleteRoom(uint8_t roomId) { if(shades[j].roomId == roomId) { shades[j].roomId = 0; shades[j].emitState(); + shades[j].changed |= SHADE_CHANGED_CONFIG; } } 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; } } rooms[i].emitState("roomRemoved"); + protoSocket.roomRemoved(roomId); this->rooms[i].clear(); } } @@ -314,6 +320,7 @@ bool SomfyShadeController::deleteGroup(uint8_t groupId) { for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { if(this->groups[i].getGroupId() == groupId) { groups[i].emitState("groupRemoved"); + protoSocket.groupRemoved(groupId); groups[i].unpublish(); this->groups[i].clear(); } diff --git a/src/SomfyController.cpp b/src/SomfyController.cpp index 79f9254..bf1d7f3 100644 --- a/src/SomfyController.cpp +++ b/src/SomfyController.cpp @@ -6,6 +6,7 @@ #include "ESPNetwork.h" #include "entryPoints/Web.h" #include "outputs/SocketEmitter.h" +#include "outputs/ProtoSocket.h" #include "Utils.h" #include "Somfy.h" #include "MqttConnection.h" @@ -187,6 +188,7 @@ void loop() { logIfSlow(TAG, "Loop web server", timing.lap(), LOOP_SLOW_MS); esp_task_wdt_reset(); sockEmit.loop(); + protoSocket.loop(); logIfSlow(TAG, "Loop sockets", timing.lap(), LOOP_SLOW_MS); esp_task_wdt_reset(); } diff --git a/src/SomfyJson.cpp b/src/SomfyJson.cpp index a79dcf7..9f2036d 100644 --- a/src/SomfyJson.cpp +++ b/src/SomfyJson.cpp @@ -4,6 +4,8 @@ #include #include "ModelEditor.h" #include "SomfyEditor.h" +#include "ShadeChanges.h" +#include "outputs/ProtoSocket.h" static const char *TAG = "Somfy"; @@ -226,9 +228,53 @@ 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. +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; + } +} void runModelEdit(model_edit_t *edit) { const edit_result_t result = applyModelEdit(*edit); if(!result.ok) ESP_LOGW(TAG, "Edit %u refused: %s", static_cast(edit->type), result.desc); + else markEdited(*edit, result); emitRequestResult(edit->requestId, result); cancelModelEdit(edit); } diff --git a/src/SomfyShadeOps.cpp b/src/SomfyShadeOps.cpp index 062b17f..21f8265 100644 --- a/src/SomfyShadeOps.cpp +++ b/src/SomfyShadeOps.cpp @@ -1,5 +1,6 @@ #include "SomfyShadeOps.h" #include "Somfy.h" +#include "ShadeChanges.h" extern SomfyShadeController somfy; @@ -47,6 +48,7 @@ void SomfyShadeOps::setUnpaired(uint8_t shadeId) { SomfyShade *shade = somfy.getShadeById(shadeId); shade->paired = false; shade->save(); + shade->changed |= SHADE_CHANGED_CONFIG; } void SomfyShadeOps::setRollingCode(uint8_t shadeId, uint16_t code) { somfy.getShadeById(shadeId)->setRollingCode(code); } bool SomfyShadeOps::sendGroupCommand(uint8_t groupId, somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { diff --git a/src/Transceiver.cpp b/src/Transceiver.cpp index 17d7fb4..bbd853d 100644 --- a/src/Transceiver.cpp +++ b/src/Transceiver.cpp @@ -11,6 +11,7 @@ #include "ConfigSettings.h" #include "Somfy.h" #include "outputs/SocketEmitter.h" +#include "outputs/ProtoSocket.h" static const char *TAG = "Somfy"; @@ -319,6 +320,15 @@ void Transceiver::emitFrequencyScan(uint8_t num) { json->addElem("RSSI", (int32_t)markRSSI); json->endObject(); 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); + protoSocket.endFeed(proto_feed_t::frequencyScan); + } } bool Transceiver::receive(somfy_rx_t *rx) { // Check to see if there is anything in the buffer @@ -365,6 +375,26 @@ void Transceiver::emitFrame(somfy_frame_t *frame, somfy_rx_t *rx) { json->endObject(); sockEmit.endEmitRoom(ROOM_EMIT_FRAME); } + if(protoSocket.subscribed(proto_feed_t::frames)) { + ProtoWriter w = protoSocket.beginFeed(proto_type_t::frame); + w.add(pf::encKey, frame->encKey); + w.add(pf::remoteAddress, (uint32_t)frame->remoteAddress); + w.add(pf::lastRollingCode, (uint32_t)frame->rollingCode); + w.add(pf::command, translateSomfyCommand(frame->cmd).c_str()); + w.add(pf::rawCmd, frame->rawCmd); + w.add(pf::rssi, (int32_t)frame->rssi); + w.add(pf::bits, rx ? rx->bit_length : (uint8_t)0); + w.add(pf::proto, static_cast(frame->proto)); + w.add(pf::valid, frame->valid); + w.add(pf::hwsync, frame->hwsync); + if(frame->cmd == somfy_commands::StepUp || frame->cmd == somfy_commands::StepDown) w.add(pf::stepSize, frame->stepSize); + if(rx && rx->pulseCount) { + w.beginList(pf::pulses); + for(uint16_t i = 0; i < rx->pulseCount; i++) w.item(rx->pulses[i]); + w.endList(); + } + protoSocket.endFeed(proto_feed_t::frames); + } } void Transceiver::clearReceived(void) { //packet_received = false; diff --git a/src/entryPoints/Web.cpp b/src/entryPoints/Web.cpp index 58260f0..eda72b1 100644 --- a/src/entryPoints/Web.cpp +++ b/src/entryPoints/Web.cpp @@ -16,6 +16,7 @@ #include "SomfyEditor.h" #include "Web.h" #include "WebRoutes.h" +#include "outputs/ProtoSocket.h" #include "MqttConnection.h" #include "ESPNetwork.h" #include "Timing.h" @@ -747,6 +748,8 @@ void Web::begin() { DefaultHeaders::Instance().addHeader("Access-Control-Allow-Methods", "PUT,POST,GET,OPTIONS"); DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "*"); registerApiRoutes(asyncApiServer); + // Before the routes, so serveStatic never answers /ws. + protoSocket.begin(asyncServer); registerWebUiRoutes(asyncServer); asyncServer.begin(); } diff --git a/src/outputs/ProtoSocket.cpp b/src/outputs/ProtoSocket.cpp new file mode 100644 index 0000000..e309bb6 --- /dev/null +++ b/src/outputs/ProtoSocket.cpp @@ -0,0 +1,470 @@ +#include +#include +#include +#include "esp_log.h" +#include "ProtoSocket.h" +#include "ProtoSync.h" +#include "ProtoParse.h" +#include "SomfySnapshot.h" +#include "Somfy.h" +#include "ConfigSettings.h" +#include "ESPNetwork.h" +#include "Utils.h" +#include "SocketEmitter.h" +#include "../entryPoints/Web.h" +#if HAS_ETHERNET +#include +#endif + +static const char *TAG = "Proto"; + +extern SomfyShadeController somfy; +extern ConfigSettings settings; +extern ESPNetwork net; +extern Web webServer; +extern LoopStats loopStats; + +ProtoSocket protoSocket; +static AsyncWebSocket ws("/ws"); +static_assert(MAX_SOCK_RESPONSE >= PROTO_MAX_FRAME, "protocol 2 frames share the socket event buffer"); + +// What a client asked for, from async_tcp to loop(). +enum : uint32_t { + REQ_START = 0x01, // just connected + REQ_NEXT = 0x02, + REQ_RESYNC = 0x04 +}; +struct proto_slot_t { + // Set and cleared on async_tcp; 0 when the slot is free. + std::atomic asyncId{0}; + std::atomic requests{0}; + std::atomic feeds{0}; // a bit per feed number + std::atomic withSchema{true}; + // loop() only. + uint32_t loopId = 0; // the client loop() is serving here + sync_cursor_t cur; + bool awaitingNext = false; + uint32_t sentAt = 0; +}; +static proto_slot_t slots[PROTO_MAX_CLIENTS]; +static BufferedJsonFormatter fmt; + +static proto_slot_t *slotOf(uint32_t asyncId) { + for(proto_slot_t &s : slots) + if(s.asyncId.load() == asyncId) return &s; + return nullptr; +} + +/********************************************************************* + * 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); + // What a new shade or group gets. + w.add(pf::bitLength, somfy.transceiver.config.type); + w.add(pf::proto, static_cast(somfy.transceiver.config.proto)); + w.add(pf::serverId, settings.serverId); + w.add(pf::hostname, settings.hostname); + w.add(pf::model, "ESPSomfyRTS"); + w.add(pf::chipModel, settings.chipModel); + w.add(pf::fw, settings.fwVersion.name); + const char *connType = "Unknown"; + if(net.connType == conn_types_t::ethernet) connType = "Ethernet"; + else if(net.connType == conn_types_t::wifi) connType = "Wifi"; + w.add(pf::connType, connType); +} +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()); +} +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); + } + else { + w.add(pf::strength, (int32_t)-100); + w.add(pf::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()); + } +#else + (void)w; +#endif +} +class LiveSource : public SyncSource { + public: + bool room(uint8_t slot, room_snapshot_t &out) override { + if(somfy.rooms[slot].roomId == 0) return false; + snapshotRoom(somfy.rooms[slot], out); + return true; + } + bool shade(uint8_t slot, shade_snapshot_t &out) override { + if(somfy.shades[slot].getShadeId() == 255) return false; + snapshotShade(somfy.shades[slot], out); + return true; + } + bool group(uint8_t slot, group_snapshot_t &out) override { + if(somfy.groups[slot].getGroupId() == 255) return false; + snapshotGroup(somfy.groups[slot], out); + return true; + } + bool repeater(uint8_t slot, uint32_t &address) override { + address = somfy.repeaters[slot]; + return address != 0; + } + void header(ProtoWriter &w) override { writeHeader(w); } + void status(ProtoWriter &w) override { + writeMemory(w); + if(net.connType == conn_types_t::ethernet) writeEthernet(w); + else writeWifi(w); + } +}; + +/********************************************************************* + * async_tcp: connections and what clients send + ********************************************************************/ +// Small fixed frames sent straight from async_tcp: nothing in them comes from +// the shade model, so ADR 0002 doesn't send them through loop(). +static void sendFixed(AsyncWebSocketClient *client, void (*write)(JsonFormatter &json, void *ctx), void *ctx) { + char buf[192]; + BufferedJsonFormatter json; + json.setBuffer(buf, sizeof(buf)); + write(json, ctx); + if(!json.overflowed()) client->text(buf, json.length()); +} +struct error_reply_t { + uint32_t id; + proto_error_t code; + const char *desc; +}; +static void replyError(AsyncWebSocketClient *client, uint32_t id, proto_error_t code, const char *desc) { + error_reply_t e = {id, code, desc}; + sendFixed(client, [](JsonFormatter &json, void *ctx) { + const error_reply_t &e = *static_cast(ctx); + writeErrorReply(json, e.id, e.code, e.desc); + }, &e); +} +static void replyOk(AsyncWebSocketClient *client, uint32_t id) { + if(id == 0) return; + sendFixed(client, [](JsonFormatter &json, void *ctx) { + beginFrame(json, proto_type_t::reply, *static_cast(ctx)); + ProtoWriter(json).add(pf::ok, true); + endFrame(json); + }, &id); +} +static bool authorized(AsyncWebServerRequest *request) { + if(settings.Security.type == security_types::None) return true; + const AsyncWebParameter *key = request->getParam("apikey"); + if(!key || !request->client()) return false; + char token[65]; + webServer.createAPIToken(request->client()->remoteIP(), token); + return key->value() == token; +} +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; } + } + 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); + 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)"); +} +static void onDisconnect(AsyncWebSocketClient *client) { + proto_slot_t *slot = slotOf(client->id()); + if(!slot) return; + slot->requests = 0; + slot->feeds = 0; + slot->asyncId = 0; + 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"); + return; + } + proto_slot_t *slot = slotOf(client->id()); + if(!slot) return; + JsonDocument doc; + proto_request_t req; + const proto_error_t err = parseClientFrame(reinterpret_cast(data), len, doc, req); + if(err != proto_error_t::none) { + replyError(client, req.id, err, err == proto_error_t::unknownOp ? "Unknown message type" : "Malformed message"); + return; + } + switch(static_cast(req.type)) { + case proto_type_t::next: + slot->requests.fetch_or(REQ_NEXT); + return; + case proto_type_t::sync: + slot->requests.fetch_or(REQ_RESYNC); + return; + case proto_type_t::subscribe: + case proto_type_t::unsubscribe: { + const int32_t feed = protoInt(doc["d"], pf::feed); + bool known = false; + for(uint8_t i = 0; i < protoFeedCount(); i++) known |= protoFeedAt(i).number == feed; + if(!known) { replyError(client, req.id, proto_error_t::badRequest, "Unknown feed"); return; } + if(req.type == static_cast(proto_type_t::subscribe)) slot->feeds.fetch_or(1 << feed); + else slot->feeds.fetch_and(~(1 << feed)); + replyOk(client, req.id); + return; + } + default: + // The ops arrive with the protocol core's later PRs. + replyError(client, req.id, proto_error_t::unknownOp, "Unknown message type"); + return; + } +} +void ProtoSocket::wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) { + (void)server; + switch(type) { + case WS_EVT_CONNECT: onConnect(client, static_cast(arg)); break; + case WS_EVT_DISCONNECT: onDisconnect(client); break; + case WS_EVT_DATA: onFrame(client, static_cast(arg), data, len); break; + default: break; + } +} + +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; + return false; +} + +/********************************************************************* + * loop(): syncs + ********************************************************************/ +static void sendSyncFrame(proto_slot_t &s) { + LiveSource src; + 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; +} +void ProtoSocket::loop() { + if(!this->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); + } + } + ws.cleanupClients(PROTO_MAX_CLIENTS); +} + +/********************************************************************* + * loop(): events + ********************************************************************/ +JsonFormatter &ProtoSocket::beginEvent(proto_type_t type) { + fmt.setBuffer(socketFrameBuffer(), PROTO_MAX_FRAME); + beginFrame(fmt, type); + return fmt; +} +void ProtoSocket::endEvent() { + endFrame(fmt); + if(fmt.overflowed()) { + ESP_LOGE(TAG, "Event too large for %u bytes, not sent", PROTO_MAX_FRAME); + return; + } + ws.textAll(socketFrameBuffer(), fmt.length()); +} +// Each event checks the task and the clients first, so nothing is formatted +// for nobody. +#define PROTO_EVENT_GUARD() if(!this->onLoopTask() || !this->hasClients()) return + +void ProtoSocket::shadeState(SomfyShade &shade) { + PROTO_EVENT_GUARD(); + shade_snapshot_t s; + snapshotShade(shade, s); + ProtoWriter w(this->beginEvent(proto_type_t::shadeState)); + protoShadeState(w, s); + this->endEvent(); +} +void ProtoSocket::shade(SomfyShade &shade) { + PROTO_EVENT_GUARD(); + shade_snapshot_t s; + snapshotShade(shade, s); + ProtoWriter w(this->beginEvent(proto_type_t::shade)); + protoShade(w, s); + this->endEvent(); +} +void ProtoSocket::shadeRemoved(uint8_t shadeId) { + PROTO_EVENT_GUARD(); + ProtoWriter(this->beginEvent(proto_type_t::shadeRemoved)).add(pf::shadeId, shadeId); + this->endEvent(); +} +void ProtoSocket::group(SomfyGroup &group) { + PROTO_EVENT_GUARD(); + group_snapshot_t g; + snapshotGroup(group, g); + ProtoWriter w(this->beginEvent(proto_type_t::group)); + protoGroup(w, g); + this->endEvent(); +} +void ProtoSocket::groupState(SomfyGroup &group) { + PROTO_EVENT_GUARD(); + group_snapshot_t g; + snapshotGroup(group, g); + ProtoWriter w(this->beginEvent(proto_type_t::groupState)); + protoGroupState(w, g.groupId, g.flags, group.direction); + this->endEvent(); +} +void ProtoSocket::groupRemoved(uint8_t groupId) { + PROTO_EVENT_GUARD(); + ProtoWriter(this->beginEvent(proto_type_t::groupRemoved)).add(pf::groupId, groupId); + this->endEvent(); +} +void ProtoSocket::room(SomfyRoom &room) { + PROTO_EVENT_GUARD(); + room_snapshot_t r; + snapshotRoom(room, r); + ProtoWriter w(this->beginEvent(proto_type_t::room)); + protoRoom(w, r); + this->endEvent(); +} +void ProtoSocket::roomRemoved(uint8_t roomId) { + PROTO_EVENT_GUARD(); + ProtoWriter(this->beginEvent(proto_type_t::roomRemoved)).add(pf::roomId, roomId); + this->endEvent(); +} +void ProtoSocket::order(proto_type_t type) { + PROTO_EVENT_GUARD(); + struct entry_t { int8_t order; uint8_t id; }; + entry_t items[SOMFY_MAX_SHADES]; + uint8_t n = 0; + if(type == proto_type_t::roomOrder) { + for(SomfyRoom &r : somfy.rooms) if(r.roomId != 0) items[n++] = {r.sortOrder, r.roomId}; + } + else if(type == proto_type_t::shadeOrder) { + for(SomfyShade &s : somfy.shades) if(s.getShadeId() != 255) items[n++] = {s.sortOrder, s.getShadeId()}; + } + else { + for(SomfyGroup &g : somfy.groups) if(g.getGroupId() != 255) items[n++] = {g.sortOrder, g.getGroupId()}; + } + for(uint8_t i = 1; i < n; i++) + for(uint8_t j = i; j > 0 && items[j].order < items[j - 1].order; j--) { + const entry_t t = items[j]; + items[j] = items[j - 1]; + items[j - 1] = t; + } + ProtoWriter w(this->beginEvent(type)); + w.beginList(pf::ids); + for(uint8_t i = 0; i < n; i++) w.item(items[i].id); + w.endList(); + this->endEvent(); +} +void ProtoSocket::repeaters() { + PROTO_EVENT_GUARD(); + ProtoWriter w(this->beginEvent(proto_type_t::repeaters)); + w.beginList(pf::repeaters); + for(uint32_t address : somfy.repeaters) if(address != 0) w.item(address); + w.endList(); + this->endEvent(); +} +void ProtoSocket::shadeCommand(SomfyShade &shade, const char *command, const char *source, uint32_t sourceAddress) { + PROTO_EVENT_GUARD(); + ProtoWriter w(this->beginEvent(proto_type_t::shadeCommand)); + w.add(pf::shadeId, shade.getShadeId()); + w.add(pf::remoteAddress, shade.getRemoteAddress()); + w.add(pf::command, command); + w.add(pf::source, source); + w.add(pf::lastRollingCode, shade.lastRollingCode); + w.add(pf::sourceAddress, sourceAddress); + this->endEvent(); +} +void ProtoSocket::memory() { + PROTO_EVENT_GUARD(); + ProtoWriter w(this->beginEvent(proto_type_t::memory)); + writeMemory(w); + this->endEvent(); +} +void ProtoSocket::wifi() { + PROTO_EVENT_GUARD(); + ProtoWriter w(this->beginEvent(proto_type_t::wifi)); + writeWifi(w); + this->endEvent(); +} +void ProtoSocket::ethernet() { + PROTO_EVENT_GUARD(); + ProtoWriter w(this->beginEvent(proto_type_t::ethernet)); + writeEthernet(w); + this->endEvent(); +} +bool ProtoSocket::subscribed(proto_feed_t feed) const { + const uint8_t bit = 1 << static_cast(feed); + for(const proto_slot_t &s : slots) + if(s.asyncId.load() && (s.feeds.load() & bit)) return true; + return false; +} +ProtoWriter ProtoSocket::beginFeed(proto_type_t type) { return ProtoWriter(this->beginEvent(type)); } +void ProtoSocket::endFeed(proto_feed_t feed) { + endFrame(fmt); + if(fmt.overflowed() || !this->onLoopTask()) return; + const uint8_t bit = 1 << static_cast(feed); + for(proto_slot_t &s : slots) { + const uint32_t id = s.asyncId.load(); + if(id && (s.feeds.load() & bit)) ws.text(id, socketFrameBuffer(), fmt.length()); + } +} diff --git a/src/outputs/ProtoSocket.h b/src/outputs/ProtoSocket.h new file mode 100644 index 0000000..2fa70d3 --- /dev/null +++ b/src/outputs/ProtoSocket.h @@ -0,0 +1,57 @@ +#ifndef PROTO_SOCKET_H +#define PROTO_SOCKET_H +#include +#include "ProtoSchema.h" + +class SomfyShade; +class SomfyGroup; +class SomfyRoom; + +// The socket protocol 2 endpoint, ws://host/ws on port 80 (docs/PROTOCOL.md, +// docs/adr/0005-one-websocket-protocol.md). It runs next to the old socket on +// port 8080 until the web UI and Home Assistant have moved over. +// +// 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 + +class ProtoSocket { + public: + // Adds /ws to the server. From setup(), on loop()'s task. + void begin(AsyncWebServer &server); + // Moves each client's sync on. loop() only. + void loop(); + bool hasClients() const; + // Events, loop() only. + void shadeState(SomfyShade &shade); + void shade(SomfyShade &shade); + void shadeRemoved(uint8_t shadeId); + void group(SomfyGroup &group); + void groupState(SomfyGroup &group); + 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); + 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. + 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(); +}; +extern ProtoSocket protoSocket; +#endif diff --git a/src/outputs/SocketEmitter.cpp b/src/outputs/SocketEmitter.cpp index 5831d51..422d37b 100644 --- a/src/outputs/SocketEmitter.cpp +++ b/src/outputs/SocketEmitter.cpp @@ -49,6 +49,7 @@ static void removeClient(uint32_t asyncId) { } static char g_response[MAX_SOCK_RESPONSE]; +char *socketFrameBuffer() { return g_response; } bool room_t::isJoined(uint8_t num) { for(uint8_t i = 0; i < sizeof(this->clients); i++) { diff --git a/src/outputs/SocketEmitter.h b/src/outputs/SocketEmitter.h index 6b0dc67..4382ec0 100644 --- a/src/outputs/SocketEmitter.h +++ b/src/outputs/SocketEmitter.h @@ -9,6 +9,9 @@ // fit is logged and not sent, so the largest one has to fit: see remoteFrame // in Transceiver.cpp. #define MAX_SOCK_RESPONSE 4096 +// That buffer, which protocol 2 events and sync frames are formatted into too. +// loop() only, and only between an event's start and its send. +char *socketFrameBuffer(); struct room_t { uint8_t clients[5] = {255, 255, 255, 255, 255}; diff --git a/src/outputs/SomfyEvents.cpp b/src/outputs/SomfyEvents.cpp index b1ce85d..cb6c1e7 100644 --- a/src/outputs/SomfyEvents.cpp +++ b/src/outputs/SomfyEvents.cpp @@ -2,6 +2,7 @@ #include "Somfy.h" #include "SomfyEditor.h" #include "SocketEmitter.h" +#include "ProtoSocket.h" #include "MqttConnection.h" extern SomfyShadeController somfy; @@ -49,6 +50,7 @@ void SomfyShade::emitCommand(uint8_t num, somfy_commands cmd, const char *source json->addElem("sourceAddress", (uint32_t)sourceAddress); json->endObject(); sockEmit.endEmit(num); + if(num == 255) protoSocket.shadeCommand(*this, translateSomfyCommand(cmd).c_str(), source, sourceAddress); if(mqtt.connected()) { this->publish("cmdSource", source); this->publish("cmdAddress", sourceAddress); @@ -114,6 +116,8 @@ void SomfyShadeController::sendChanges() { if(!changed || shade.getShadeId() == 255) continue; if(changed & SHADE_CHANGED_TOPICS) shade.publishTopics(changed); shade.emitState(255, "shadeState"); + if(changed & SHADE_CHANGED_CONFIG) protoSocket.shade(shade); + else protoSocket.shadeState(shade); } for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { SomfyGroup &group = this->groups[i]; @@ -122,6 +126,8 @@ void SomfyShadeController::sendChanges() { if(!changed || group.getGroupId() == 255) continue; if(changed & GROUP_CHANGED_DIRECTION) group.publish("direction", group.direction); group.emitState(255, "groupState"); + if(changed & GROUP_CHANGED_CONFIG) protoSocket.group(group); + else protoSocket.groupState(group); } } void SomfyShadeController::emitState(uint8_t num) { diff --git a/test/test_json/test_json.cpp b/test/test_json/test_json.cpp index 4bc9109..17d591a 100644 --- a/test/test_json/test_json.cpp +++ b/test/test_json/test_json.cpp @@ -228,6 +228,42 @@ static void test_setBuffer_after_discard_writes_again() { TEST_ASSERT_EQUAL_STRING("{}", storage); } +/********************************************************************* + * Mark and roll back + ********************************************************************/ +static void test_rollback_drops_what_was_written_since_the_mark() { + fmt.beginObject(); + fmt.addElem("a", (uint8_t)1); + const json_mark_t m = fmt.mark(); + fmt.addElem("b", (uint8_t)2); + fmt.beginArray("c"); + fmt.rollback(m); + fmt.addElem("d", (uint8_t)3); + fmt.endObject(); + TEST_ASSERT_EQUAL_STRING("{\"a\":1,\"d\":3}", storage); +} +static void test_rollback_clears_an_overflow() { + fmt.beginObject(); + const json_mark_t m = fmt.mark(); + char big[BUFF_SIZE + 1]; + memset(big, 'z', BUFF_SIZE); + big[BUFF_SIZE] = '\0'; + fmt.addElem("big", big); + TEST_ASSERT_TRUE(fmt.overflowed()); + fmt.rollback(m); + TEST_ASSERT_FALSE(fmt.overflowed()); + fmt.addElem("ok", true); + fmt.endObject(); + TEST_ASSERT_EQUAL_STRING("{\"ok\":true}", storage); +} +static void test_rollback_at_the_start_leaves_an_empty_buffer() { + const json_mark_t m = fmt.mark(); + fmt.beginObject(); + fmt.rollback(m); + TEST_ASSERT_EQUAL_UINT32(0, fmt.length()); + TEST_ASSERT_EQUAL_STRING("", storage); +} + int main(int argc, char **argv) { (void)argc; (void)argv; @@ -251,5 +287,8 @@ int main(int argc, char **argv) { RUN_TEST(test_setBuffer_resets_length); RUN_TEST(test_discard_writes_nothing); RUN_TEST(test_setBuffer_after_discard_writes_again); + RUN_TEST(test_rollback_drops_what_was_written_since_the_mark); + RUN_TEST(test_rollback_clears_an_overflow); + RUN_TEST(test_rollback_at_the_start_leaves_an_empty_buffer); return UNITY_END(); } diff --git a/test/test_proto/test_proto.cpp b/test/test_proto/test_proto.cpp new file mode 100644 index 0000000..2166b6f --- /dev/null +++ b/test/test_proto/test_proto.cpp @@ -0,0 +1,399 @@ +#include +#include +#include +#include +#include +#include "ProtoSchema.h" +#include "ProtoSync.h" +#include "ProtoParse.h" + +// Socket protocol 2: the schema table, the writer, the sync and reading what +// clients send (docs/PROTOCOL.md). + +void setUp() {} +void tearDown() {} + +static char buff[PROTO_MAX_FRAME]; +static BufferedJsonFormatter fmt; + +/********************************************************************* + * The schema table + ********************************************************************/ +static void test_field_keys_and_names_are_unique() { + std::set keys, names; + for(uint8_t i = 0; i < static_cast(pf::count); i++) { + const proto_field_info_t &f = protoField(static_cast(i)); + TEST_ASSERT_TRUE_MESSAGE(keys.insert(f.key).second, f.key); + TEST_ASSERT_TRUE_MESSAGE(names.insert(f.name).second, f.name); + TEST_ASSERT_TRUE_MESSAGE(strlen(f.key) >= 1 && strlen(f.key) <= 2, f.key); + } +} +static void test_every_field_has_a_known_kind() { + for(uint8_t i = 0; i < static_cast(pf::count); i++) { + const proto_field_info_t &f = protoField(static_cast(i)); + TEST_ASSERT_NOT_NULL_MESSAGE(strchr("binslo", f.kind), f.name); + } +} +static void test_type_numbers_are_unique_and_in_their_sender_range() { + std::set numbers; + for(uint8_t i = 0; i < protoTypeCount(); i++) { + const proto_named_t &t = protoTypeAt(i); + TEST_ASSERT_TRUE_MESSAGE(numbers.insert(t.number).second, t.name); + if(t.number >= 128) TEST_ASSERT_EQUAL_CHAR_MESSAGE('d', t.sender, t.name); + else if(t.number >= 16) TEST_ASSERT_EQUAL_CHAR_MESSAGE('c', t.sender, t.name); + else TEST_ASSERT_TRUE_MESSAGE(t.number >= 1, t.name); + } +} +static void test_error_and_feed_numbers_are_unique() { + std::set errors, feeds; + for(uint8_t i = 0; i < protoErrorCount(); i++) TEST_ASSERT_TRUE(errors.insert(protoErrorAt(i).number).second); + for(uint8_t i = 0; i < protoFeedCount(); i++) TEST_ASSERT_TRUE(feeds.insert(protoFeedAt(i).number).second); + TEST_ASSERT_FALSE(errors.count(0)); +} +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); +} +static void test_schema_hash_is_eight_hex_digits_and_stable() { + const std::string first = protoSchemaHash(); + TEST_ASSERT_EQUAL_UINT32(8, first.size()); + TEST_ASSERT_EQUAL_UINT32(std::string::npos, first.find_first_not_of("0123456789abcdef")); + TEST_ASSERT_EQUAL_STRING(first.c_str(), protoSchemaHash()); +} +static void test_field_by_key() { + TEST_ASSERT_EQUAL_STRING("shadeId", protoFieldByKey("s")->name); + TEST_ASSERT_NULL(protoFieldByKey("??")); +} + +/********************************************************************* + * The writer + ********************************************************************/ +static std::string writeObject(void (*fill)(ProtoWriter &)) { + fmt.setBuffer(buff, sizeof(buff)); + ProtoWriter w(fmt); + fmt.beginObject(); + fill(w); + fmt.endObject(); + return buff; +} +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); + }).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); + }).c_str()); +} + +/********************************************************************* + * Frames + ********************************************************************/ +static void test_hello_frame() { + fmt.setBuffer(buff, sizeof(buff)); + writeHello(fmt, "3.1.0"); + const std::string expected = std::string("{\"v\":2,\"t\":1,\"d\":{\"fw\":\"3.1.0\",\"sh\":\"") + protoSchemaHash() + "\"}}"; + TEST_ASSERT_EQUAL_STRING(expected.c_str(), buff); +} +static void test_error_reply_frame() { + fmt.setBuffer(buff, sizeof(buff)); + writeErrorReply(fmt, 17, proto_error_t::notFound, "Shade not found"); + // 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); +} + +/********************************************************************* + * Items + ********************************************************************/ +static shade_snapshot_t restingShade(uint8_t id) { + shade_snapshot_t s = {}; + s.shadeId = id; + s.myPos = s.myTiltPos = -1; + s.stepSize = 100; + s.bitLength = 56; + s.repeats = 1; + return s; +} +static void test_shade_state_of_a_resting_shade_is_its_id() { + const shade_snapshot_t s = restingShade(3); + fmt.setBuffer(buff, sizeof(buff)); + ProtoWriter w(fmt); + fmt.beginObject(); + protoShadeState(w, s); + fmt.endObject(); + TEST_ASSERT_EQUAL_STRING("{\"s\":3}", buff); +} +static void test_group_lists_shade_ids() { + group_snapshot_t g = {}; + g.groupId = 2; + g.bitLength = 56; + g.repeats = 1; + g.linkedShadeCount = 3; + g.linkedShades[0] = 4; + g.linkedShades[1] = 7; + g.linkedShades[2] = 9; + fmt.setBuffer(buff, sizeof(buff)); + ProtoWriter w(fmt); + fmt.beginObject(); + protoGroup(w, g); + fmt.endObject(); + TEST_ASSERT_EQUAL_STRING("{\"g\":2,\"ls\":[4,7,9]}", buff); +} + +/********************************************************************* + * Sync + ********************************************************************/ +// The widest name: 20 control characters, each escaped to \u00XX. +static void widestName(char *name) { + memset(name, 0x01, 20); + name[20] = '\0'; +} +static shade_snapshot_t widestShade(uint8_t id) { + shade_snapshot_t s = {}; + s.shadeId = id; + s.roomId = 255; + widestName(s.name); + s.remoteAddress = s.upTime = s.downTime = s.tiltTime = 0xFFFFFFFF; + s.stepSize = s.lastRollingCode = 0xFFFF; + s.position = s.target = s.myPos = s.direction = -128; + s.tiltPosition = s.tiltTarget = s.myTiltPos = s.tiltDirection = -128; + s.tiltType = s.shadeType = s.bitLength = s.proto = s.flags = s.repeats = 255; + s.gpioUp = s.gpioDown = s.gpioMy = 255; + s.sortOrder = -128; + s.paired = s.flipCommands = s.flipPosition = s.inGroup = true; + s.sunSensor = s.light = s.simMy = s.gpioLLTrigger = true; + s.linkedRemoteCount = SOMFY_MAX_LINKED_REMOTES; + for(uint8_t j = 0; j < SOMFY_MAX_LINKED_REMOTES; j++) s.linkedRemotes[j] = {0xFFFFFFFF, 0xFFFF}; + return s; +} +// A source over plain arrays; a slot with id 0 is empty. +class ArraySource : public SyncSource { + public: + room_snapshot_t rooms[SOMFY_MAX_ROOMS] = {}; + shade_snapshot_t shades[SOMFY_MAX_SHADES] = {}; + group_snapshot_t groups[SOMFY_MAX_GROUPS] = {}; + uint32_t repeaters[SOMFY_MAX_REPEATERS] = {}; + bool room(uint8_t slot, room_snapshot_t &out) override { out = rooms[slot]; return out.roomId != 0; } + 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); } +}; +static void fillWidest(ArraySource &src) { + for(uint8_t i = 0; i < SOMFY_MAX_ROOMS; i++) { + src.rooms[i].roomId = i + 1; + widestName(src.rooms[i].name); + src.rooms[i].sortOrder = -128; + } + for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) src.shades[i] = widestShade(i + 1); + for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { + group_snapshot_t &g = src.groups[i]; + g.groupId = i + 1; + g.roomId = 255; + widestName(g.name); + g.remoteAddress = 0xFFFFFFFF; + g.lastRollingCode = 0xFFFF; + g.bitLength = g.proto = g.flags = g.repeats = 255; + g.sortOrder = -128; + g.linkedShadeCount = SOMFY_MAX_GROUPED_SHADES; + for(uint8_t j = 0; j < SOMFY_MAX_GROUPED_SHADES; j++) g.linkedShades[j] = j + 1; + } + for(uint8_t i = 0; i < SOMFY_MAX_REPEATERS; i++) src.repeaters[i] = 0xFFFFFFFF; +} +// Every frame of a sync, parsed. Each must be valid JSON within size bytes. +static std::vector runSync(SyncSource &src, bool withSchema, size_t size) { + std::vector frames; + static char frame[PROTO_MAX_FRAME]; + sync_cursor_t cur = beginSync(withSchema); + for(int guard = 0; guard < 1000; guard++) { + const size_t len = writeSyncFrame(src, cur, frame, size); + if(len == 0) return frames; + TEST_ASSERT_LESS_THAN_UINT32(size, len); + JsonDocument doc; + TEST_ASSERT_EQUAL_MESSAGE(DeserializationError::Ok, deserializeJson(doc, frame, len).code(), frame); + TEST_ASSERT_EQUAL(2, doc["v"].as()); + TEST_ASSERT_EQUAL(static_cast(proto_type_t::sync), doc["t"].as()); + frames.push_back(std::move(doc)); + } + TEST_FAIL_MESSAGE("the sync never ended"); + return frames; +} +// How many items a section has over every frame. +static size_t count(const std::vector &frames, pf section) { + size_t n = 0; + for(const JsonDocument &doc : frames) { + JsonVariantConst v = doc["d"][protoField(section).key]; + if(v.is()) n += v.as().size(); + else if(!v.isNull()) n++; + } + return n; +} +static void test_full_sync_sends_every_item_whole() { + static ArraySource src; + fillWidest(src); + const std::vector frames = runSync(src, true, PROTO_MAX_FRAME); + TEST_ASSERT_GREATER_THAN_UINT32(1, frames.size()); + TEST_ASSERT_EQUAL_UINT32(protoTypeCount(), count(frames, pf::types)); + TEST_ASSERT_EQUAL_UINT32(static_cast(pf::count), count(frames, pf::fields)); + TEST_ASSERT_EQUAL_UINT32(protoErrorCount(), count(frames, pf::errors)); + TEST_ASSERT_EQUAL_UINT32(protoFeedCount(), count(frames, pf::feeds)); + TEST_ASSERT_EQUAL_UINT32(1, count(frames, pf::header)); + TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_ROOMS, count(frames, pf::rooms)); + TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_SHADES, count(frames, pf::shades)); + TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_GROUPS, count(frames, pf::groups)); + TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_REPEATERS, count(frames, pf::repeaters)); + TEST_ASSERT_EQUAL_UINT32(1, count(frames, pf::status)); + // Every shade comes out with all its linked remotes. + for(const JsonDocument &doc : frames) + for(JsonVariantConst s : doc["d"]["S"].as()) + TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_LINKED_REMOTES, s["lk"].as().size()); +} +static void test_a_client_with_the_schema_skips_it() { + static ArraySource src; + src = ArraySource(); + src.shades[0] = restingShade(1); + const std::vector frames = runSync(src, false, PROTO_MAX_FRAME); + TEST_ASSERT_EQUAL_UINT32(0, count(frames, pf::types)); + TEST_ASSERT_EQUAL_UINT32(0, count(frames, pf::fields)); + TEST_ASSERT_EQUAL_UINT32(1, frames.size()); + TEST_ASSERT_EQUAL(1, frames[0]["d"]["S"][0]["s"].as()); +} +static void test_empty_slots_are_skipped() { + static ArraySource src; + src = ArraySource(); + src.shades[5] = restingShade(6); + src.shades[20] = restingShade(21); + src.rooms[3].roomId = 4; + const std::vector frames = runSync(src, false, PROTO_MAX_FRAME); + TEST_ASSERT_EQUAL_UINT32(2, count(frames, pf::shades)); + TEST_ASSERT_EQUAL_UINT32(1, count(frames, pf::rooms)); + TEST_ASSERT_EQUAL_UINT32(0, count(frames, pf::groups)); + TEST_ASSERT_TRUE(frames[0]["d"]["X"].isNull()); +} +static void test_a_small_frame_takes_more_frames_and_keeps_items_whole() { + static ArraySource src; + fillWidest(src); + const std::vector big = runSync(src, false, PROTO_MAX_FRAME); + const std::vector small = runSync(src, false, 1400); + TEST_ASSERT_GREATER_THAN_UINT32(big.size(), small.size()); + TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_SHADES, count(small, pf::shades)); + TEST_ASSERT_EQUAL_UINT32(SOMFY_MAX_GROUPS, count(small, pf::groups)); +} +static void test_an_item_too_big_for_a_frame_is_left_out_and_the_rest_go() { + static ArraySource src; + src = ArraySource(); + src.shades[0] = widestShade(1); + src.shades[1] = restingShade(2); + // Too small for the widest shade, big enough for a resting one. + const std::vector frames = runSync(src, false, 200); + TEST_ASSERT_EQUAL_UINT32(1, count(frames, pf::shades)); +} +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. + const shade_snapshot_t s = widestShade(1); + fmt.setBuffer(buff, sizeof(buff)); + ProtoWriter w(fmt); + beginFrame(fmt, proto_type_t::shade); + protoShade(w, s); + endFrame(fmt); + TEST_ASSERT_FALSE(fmt.overflowed()); + TEST_ASSERT_LESS_THAN_UINT32(PROTO_MAX_FRAME / 2, fmt.length()); +} + +/********************************************************************* + * What clients send + ********************************************************************/ +static proto_error_t parse(const char *text, proto_request_t &req) { + JsonDocument doc; + return parseClientFrame(text, strlen(text), doc, req); +} +static void test_next_is_read() { + proto_request_t req; + TEST_ASSERT_EQUAL(proto_error_t::none, parse("{\"v\":2,\"t\":3}", req)); + TEST_ASSERT_EQUAL_UINT8(static_cast(proto_type_t::next), req.type); + TEST_ASSERT_EQUAL_UINT32(0, req.id); +} +static void test_subscribe_carries_its_id_and_payload() { + proto_request_t req; + 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)); +} +static void test_bad_frames_are_refused() { + proto_request_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)); + TEST_ASSERT_EQUAL(proto_error_t::badRequest, parse("{\"v\":2}", req)); + TEST_ASSERT_EQUAL(proto_error_t::badRequest, parse("{\"v\":2,\"t\":\"next\"}", req)); + TEST_ASSERT_EQUAL(proto_error_t::badRequest, parse("{\"v\":2,\"t\":3,\"d\":[]}", req)); + 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; + // 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); + TEST_ASSERT_EQUAL(proto_error_t::unknownOp, parse("{\"v\":2,\"t\":99,\"id\":6}", req)); +} +static void test_a_left_out_field_reads_as_its_default() { + JsonDocument doc; + deserializeJson(doc, "{\"p\":40}"); + JsonObjectConst d = doc.as(); + TEST_ASSERT_EQUAL_INT32(40, protoInt(d, pf::position)); + TEST_ASSERT_EQUAL_INT32(-1, protoInt(d, pf::myPos)); + TEST_ASSERT_FALSE(protoBool(d, pf::paired)); + TEST_ASSERT_NULL(protoString(d, pf::name)); + TEST_ASSERT_TRUE(protoHas(d, pf::position)); + TEST_ASSERT_FALSE(protoHas(d, pf::target)); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_field_keys_and_names_are_unique); + RUN_TEST(test_every_field_has_a_known_kind); + RUN_TEST(test_type_numbers_are_unique_and_in_their_sender_range); + RUN_TEST(test_error_and_feed_numbers_are_unique); + RUN_TEST(test_bootstrap_keys_are_fixed); + RUN_TEST(test_schema_hash_is_eight_hex_digits_and_stable); + RUN_TEST(test_field_by_key); + RUN_TEST(test_values_at_their_default_are_left_out); + 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_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); + RUN_TEST(test_a_client_with_the_schema_skips_it); + RUN_TEST(test_empty_slots_are_skipped); + RUN_TEST(test_a_small_frame_takes_more_frames_and_keeps_items_whole); + RUN_TEST(test_an_item_too_big_for_a_frame_is_left_out_and_the_rest_go); + RUN_TEST(test_the_largest_shade_fits_one_frame); + RUN_TEST(test_next_is_read); + RUN_TEST(test_subscribe_carries_its_id_and_payload); + RUN_TEST(test_bad_frames_are_refused); + RUN_TEST(test_types_a_client_may_not_send_are_unknown); + RUN_TEST(test_a_left_out_field_reads_as_its_default); + return UNITY_END(); +}