From 482e49567887646b0df6d28d94f2ac1472063712 Mon Sep 17 00:00:00 2001 From: cjkas Date: Thu, 22 Oct 2026 21:19:29 +0200 Subject: [PATCH] feat: socket protocol 2 core on ws://host/ws The /ws endpoint runs next to the old socket on port 8080. The handshake checks the token under security, the device sends hello, then a sync of the schema (unless the client has it), header, rooms, shades, groups, repeaters and status, paced by the client's next. Events go out with the old ones: shadeState or the whole shade on a config change, groups, rooms, removals, orders, repeaters, shadeCommand, memory, wifi, ethernet, and the frames and frequencyScan feeds. The table of types, fields, errors and feeds is defined once and is sent as the schema. The writer, item writers, sync and client frame parser are pure and natively tested. Co-Authored-By: Claude Opus 5.5 --- platformio.ini | 4 +- src/ESPNetwork.cpp | 9 +- src/JsonFormatter.cpp | 10 +- src/JsonFormatter.h | 17 ++ src/ProtoParse.cpp | 36 +++ src/ProtoParse.h | 32 +++ src/ProtoSchema.cpp | 106 ++++++++ src/ProtoSchema.h | 247 +++++++++++++++++ src/ProtoSync.cpp | 248 +++++++++++++++++ src/ProtoSync.h | 61 +++++ src/ShadeChanges.h | 9 +- src/ShadeController.cpp | 7 + src/SomfyController.cpp | 2 + src/SomfyJson.cpp | 46 ++++ src/SomfyShadeOps.cpp | 2 + src/Transceiver.cpp | 30 +++ src/entryPoints/Web.cpp | 3 + src/outputs/ProtoSocket.cpp | 470 +++++++++++++++++++++++++++++++++ src/outputs/ProtoSocket.h | 57 ++++ src/outputs/SocketEmitter.cpp | 1 + src/outputs/SocketEmitter.h | 3 + src/outputs/SomfyEvents.cpp | 6 + test/test_json/test_json.cpp | 39 +++ test/test_proto/test_proto.cpp | 399 ++++++++++++++++++++++++++++ 24 files changed, 1839 insertions(+), 5 deletions(-) create mode 100644 src/ProtoParse.cpp create mode 100644 src/ProtoParse.h create mode 100644 src/ProtoSchema.cpp create mode 100644 src/ProtoSchema.h create mode 100644 src/ProtoSync.cpp create mode 100644 src/ProtoSync.h create mode 100644 src/outputs/ProtoSocket.cpp create mode 100644 src/outputs/ProtoSocket.h create mode 100644 test/test_proto/test_proto.cpp 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(); +}