mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 13:52:15 +01:00
- Load the token key in Web::startup(), before either web server starts, so no token can be made with the all-zero key; log a failed pref.begin(). - createAPIToken checks mbedtls' return codes. On a failure it leaves the token empty, which tokenMatches never accepts, instead of hex from uninitialised stack. - API_TOKEN_SIZE in ApiToken.h replaces the five char[65] buffers; the HMAC's description moves to createAPIToken, with the None form. - handleLogin builds the candidate token once and compares it once. - e2e: the serverId test skips on `secured` like its neighbours, names its values, and says it assumes the device sees this PC's address. - KNOWN_ISSUES keeps #81, which #108 removes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
819 lines
32 KiB
C++
819 lines
32 KiB
C++
#include <Arduino.h>
|
|
#include <WiFi.h>
|
|
#include <algorithm>
|
|
#include <freertos/FreeRTOS.h>
|
|
#include <freertos/semphr.h>
|
|
#include "esp_log.h"
|
|
#include "ProtoSocket.h"
|
|
#include "ProtoSync.h"
|
|
#include "ProtoFrame.h"
|
|
#include "ProtoParse.h"
|
|
#include "ProtoSlots.h"
|
|
#include "ProtoOps.h"
|
|
#include "ProtoEdits.h"
|
|
#include "ProtoAssembly.h"
|
|
#include "ProtoSettings.h"
|
|
#include "DeviceOps.h"
|
|
#include "SomfyEditor.h"
|
|
#include "LoopQueue.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 <ETH.h>
|
|
#endif
|
|
|
|
static const char *TAG = "Proto";
|
|
|
|
extern SomfyShadeController somfy;
|
|
extern ConfigSettings settings;
|
|
extern ESPNetwork net;
|
|
extern Web webServer;
|
|
extern LoopStats loopStats;
|
|
extern SocketEmitter sockEmit;
|
|
extern LoopQueue loopQueue;
|
|
|
|
ProtoSocket protoSocket;
|
|
static AsyncWebSocket ws("/ws");
|
|
static_assert(MAX_SOCK_RESPONSE >= PROTO_MAX_FRAME, "protocol 2 frames share the socket event buffer");
|
|
|
|
static proto_slot_t slots[PROTO_MAX_CLIENTS];
|
|
// What loop() formats events and syncEnd with, into the socket event buffer.
|
|
static BufferedJsonFormatter eventJson;
|
|
// Messages that arrive in pieces. async_tcp adds to it, and a disconnect can
|
|
// drop from it on another task (a client closed for a full queue is closed by
|
|
// whoever was sending), so it is only touched under its lock, and never while
|
|
// replying.
|
|
static MessageAssembler assembler(PROTO_MAX_CLIENT_FRAME);
|
|
// Static, so creating it can't fail.
|
|
static StaticSemaphore_t assemblerMutexBuffer;
|
|
static SemaphoreHandle_t assemblerMutex = nullptr;
|
|
struct AssemblerLock {
|
|
AssemblerLock() { xSemaphoreTake(assemblerMutex, portMAX_DELAY); }
|
|
~AssemblerLock() { xSemaphoreGive(assemblerMutex); }
|
|
};
|
|
|
|
/*********************************************************************
|
|
* The shade model as a sync source
|
|
********************************************************************/
|
|
static void writeHeader(ProtoWriter &w) {
|
|
w.add(proto_field_t::maxRooms, (uint8_t)SOMFY_MAX_ROOMS);
|
|
w.add(proto_field_t::maxShades, (uint8_t)SOMFY_MAX_SHADES);
|
|
w.add(proto_field_t::maxGroups, (uint8_t)SOMFY_MAX_GROUPS);
|
|
w.add(proto_field_t::maxGroupedShades, (uint8_t)SOMFY_MAX_GROUPED_SHADES);
|
|
w.add(proto_field_t::maxLinkedRemotes, (uint8_t)SOMFY_MAX_LINKED_REMOTES);
|
|
w.add(proto_field_t::startingAddress, somfy.startingAddress);
|
|
// What a new shade or group gets.
|
|
w.add(proto_field_t::bitLength, somfy.transceiver.config.type);
|
|
w.add(proto_field_t::proto, static_cast<uint8_t>(somfy.transceiver.config.proto));
|
|
w.add(proto_field_t::serverId, settings.serverId);
|
|
w.add(proto_field_t::hostname, settings.hostname);
|
|
w.add(proto_field_t::model, "ESPSomfyRTS");
|
|
w.add(proto_field_t::chipModel, settings.chipModel);
|
|
w.add(proto_field_t::fw, settings.fwVersion.name);
|
|
w.add(proto_field_t::connType, net.connTypeName());
|
|
}
|
|
static void writeMemory(ProtoWriter &w) {
|
|
w.add(proto_field_t::freeHeap, ESP.getFreeHeap());
|
|
w.add(proto_field_t::maxHeap, ESP.getMaxAllocHeap());
|
|
w.add(proto_field_t::minHeap, ESP.getMinFreeHeap());
|
|
w.add(proto_field_t::totalHeap, ESP.getHeapSize());
|
|
w.add(proto_field_t::uptime, (uint32_t)millis());
|
|
w.add(proto_field_t::loopRate, loopStats.publishedRate());
|
|
w.add(proto_field_t::loopMaxUs, loopStats.publishedMaxUs());
|
|
}
|
|
static void writeWifi(ProtoWriter &w) {
|
|
if(WiFi.status() == WL_CONNECTED) {
|
|
w.add(proto_field_t::ssid, WiFi.SSID().c_str());
|
|
w.add(proto_field_t::strength, (int32_t)WiFi.RSSI());
|
|
w.add(proto_field_t::channel, (int32_t)net.channel);
|
|
}
|
|
else {
|
|
w.add(proto_field_t::strength, (int32_t)-100);
|
|
w.add(proto_field_t::channel, (int32_t)-1);
|
|
}
|
|
}
|
|
static void writeEthernet(ProtoWriter &w) {
|
|
#if HAS_ETHERNET
|
|
if(net.connType == conn_types_t::ethernet) {
|
|
w.add(proto_field_t::connected, net.connected());
|
|
w.add(proto_field_t::speed, (uint32_t)ETH.linkSpeed());
|
|
w.add(proto_field_t::fullDuplex, ETH.fullDuplex());
|
|
}
|
|
#else
|
|
(void)w;
|
|
#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<error_reply_t *>(ctx);
|
|
writeErrorReply(json, e.id, e.code, e.desc);
|
|
}, &e);
|
|
}
|
|
// Only a message with an id wants a reply.
|
|
static void replyOk(AsyncWebSocketClient *client, uint32_t id) {
|
|
if(id == 0) return;
|
|
sendFixed(client, [](JsonFormatter &json, void *ctx) { writeOkReply(json, *static_cast<uint32_t *>(ctx)); }, &id);
|
|
}
|
|
// A command op that becomes a shade request: loop() runs it and replies.
|
|
static void queueShadeOp(AsyncWebSocketClient *client, const proto_message_t &req, JsonObjectConst d) {
|
|
loop_item_t item;
|
|
const char *bad = parseShadeOp(req.type, d, item.request);
|
|
if(bad) { replyError(client, req.id, proto_error_t::badRequest, bad); return; }
|
|
// Nothing goes on air during a frequency scan, so say so now rather than
|
|
// once it reaches the radio.
|
|
if(loopItemUsesRadio(item) && somfy.transceiver.txSuspended()) {
|
|
replyError(client, req.id, proto_error_t::radioUnavailable, "The radio is paused");
|
|
return;
|
|
}
|
|
item.replyTo = {client->id(), req.id};
|
|
if(!loopQueue.submit(item)) replyError(client, req.id, proto_error_t::busy, "Command queue full");
|
|
}
|
|
// A frame as a remote that isn't one of the shades. It touches nothing of the
|
|
// shade model and the radio takes jobs from any task, so it goes from here.
|
|
static void sendRemoteCommand(AsyncWebSocketClient *client, const proto_message_t &req, JsonObjectConst d) {
|
|
somfy_frame_t frame;
|
|
uint8_t repeats;
|
|
const char *bad = parseRemoteCommandOp(d, frame, repeats);
|
|
if(bad) { replyError(client, req.id, proto_error_t::badRequest, bad); return; }
|
|
if(!somfy.sendFrame(frame, repeats)) {
|
|
replyError(client, req.id, proto_error_t::radioUnavailable, PROTO_RADIO_REFUSED);
|
|
return;
|
|
}
|
|
replyOk(client, req.id);
|
|
}
|
|
// An edit op: loop() applies it as its edit route does, and replies.
|
|
static void queueEditOp(AsyncWebSocketClient *client, const proto_message_t &msg, model_edit_type_t type, JsonObjectConst d) {
|
|
bool busy;
|
|
model_edit_t *edit = newModelEdit(type, busy);
|
|
if(!edit) {
|
|
if(busy) replyError(client, msg.id, proto_error_t::busy, "Another edit is running, send it again");
|
|
else replyError(client, msg.id, proto_error_t::internal, "Out of memory");
|
|
return;
|
|
}
|
|
const char *bad = expandEditPayload(type, d, edit->doc);
|
|
if(bad) {
|
|
cancelModelEdit(edit);
|
|
replyError(client, msg.id, proto_error_t::badRequest, bad);
|
|
return;
|
|
}
|
|
edit->replyTo = {client->id(), msg.id};
|
|
if(!submitModelEdit(edit)) replyError(client, msg.id, proto_error_t::busy, "Command queue full");
|
|
}
|
|
// A read op: loop() answers it from the shade model.
|
|
static void queueReadOp(AsyncWebSocketClient *client, const proto_message_t &msg, JsonObjectConst d) {
|
|
loop_item_t item;
|
|
item.type = loop_item_type_t::proto_read;
|
|
item.op = msg.type;
|
|
const char *bad = parseReadPayload(msg.type, d, item.groupId);
|
|
if(bad) { replyError(client, msg.id, proto_error_t::badRequest, bad); return; }
|
|
item.replyTo = {client->id(), msg.id};
|
|
if(!loopQueue.submit(item)) replyError(client, msg.id, proto_error_t::busy, "Command queue full");
|
|
}
|
|
// A settings op or the WiFi scan: loop() runs it and replies.
|
|
static void queueDeviceOp(AsyncWebSocketClient *client, const proto_message_t &msg, JsonObjectConst d) {
|
|
loop_item_t item;
|
|
item.type = loop_item_type_t::proto_device;
|
|
item.op = msg.type;
|
|
item.replyTo = {client->id(), msg.id};
|
|
if(msg.type == static_cast<uint8_t>(proto_type_t::getSettings)) {
|
|
settings_section_t section;
|
|
const char *bad = parseSettingsSection(d, section);
|
|
if(bad) { replyError(client, msg.id, proto_error_t::badRequest, bad); return; }
|
|
item.section = static_cast<uint8_t>(section);
|
|
}
|
|
else if(msg.type == static_cast<uint8_t>(proto_type_t::saveSettings)) {
|
|
bool busy;
|
|
settings_save_t *save = newSettingsSave(busy);
|
|
if(!save) {
|
|
if(busy) replyError(client, msg.id, proto_error_t::busy, "Another save is running, send it again");
|
|
else replyError(client, msg.id, proto_error_t::internal, "Out of memory");
|
|
return;
|
|
}
|
|
const char *bad = expandSettingsPayload(d, HAS_ETHERNET, save->section, save->request);
|
|
if(bad) {
|
|
freeSettingsSave(save);
|
|
replyError(client, msg.id, proto_error_t::badRequest, bad);
|
|
return;
|
|
}
|
|
// A security save answers with the client's new token.
|
|
save->ip = client->remoteIP();
|
|
item.save = save;
|
|
}
|
|
// An unpair waits at the head of the queue while a frequency scan runs, so
|
|
// the scan's end goes ahead of it.
|
|
const bool first = msg.type == static_cast<uint8_t>(proto_type_t::endFrequencyScan);
|
|
if(loopQueue.submit(item, first)) return;
|
|
if(item.save) freeSettingsSave(item.save);
|
|
replyError(client, msg.id, proto_error_t::busy, "Command queue full");
|
|
}
|
|
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[API_TOKEN_SIZE];
|
|
webServer.createAPIToken(request->client()->remoteIP(), token);
|
|
return tokenMatches(token, key->value().c_str());
|
|
}
|
|
static void onConnect(AsyncWebSocketClient *client, AsyncWebServerRequest *request) {
|
|
const uint32_t id = client->id();
|
|
const AsyncWebParameter *schema = request ? request->getParam("schema") : nullptr;
|
|
const bool withSchema = !(schema && schema->value() == protoSchemaHash());
|
|
proto_slot_t *slot = claimSlot(slots, PROTO_MAX_CLIENTS, id, withSchema);
|
|
if(!slot) {
|
|
ESP_LOGW(TAG, "Socket [%lu] refused: all %u clients connected", (unsigned long)id, PROTO_MAX_CLIENTS);
|
|
client->close();
|
|
return;
|
|
}
|
|
client->setCloseClientOnQueueFull(true);
|
|
sendFixed(client, [](JsonFormatter &json, void *) { writeHello(json, settings.fwVersion.name); }, nullptr);
|
|
startSync(*slot);
|
|
IPAddress ip = client->remoteIP();
|
|
ESP_LOGI(TAG, "Socket [%lu] connected from %u.%u.%u.%u%s", (unsigned long)id, ip[0], ip[1], ip[2], ip[3], withSchema ? "" : " (has the schema)");
|
|
}
|
|
static void onDisconnect(AsyncWebSocketClient *client) {
|
|
{
|
|
AssemblerLock lock;
|
|
assembler.drop(client->id());
|
|
}
|
|
proto_slot_t *slot = slotOf(slots, PROTO_MAX_CLIENTS, client->id());
|
|
if(!slot) return;
|
|
freeSlot(*slot);
|
|
ESP_LOGI(TAG, "Socket [%lu] disconnected", (unsigned long)client->id());
|
|
}
|
|
// One whole message from a client.
|
|
static void onMessage(AsyncWebSocketClient *client, const char *text, size_t len) {
|
|
proto_slot_t *slot = slotOf(slots, PROTO_MAX_CLIENTS, client->id());
|
|
if(!slot) return;
|
|
JsonDocument doc;
|
|
proto_message_t msg;
|
|
const proto_error_t err = parseClientFrame(text, len, doc, msg);
|
|
if(err != proto_error_t::none) {
|
|
replyError(client, msg.id, err, err == proto_error_t::unknownOp ? "Unknown message type" : "Malformed message");
|
|
return;
|
|
}
|
|
switch(static_cast<proto_type_t>(msg.type)) {
|
|
case proto_type_t::next:
|
|
slot->wants.fetch_or(WANTS_NEXT);
|
|
replyOk(client, msg.id);
|
|
return;
|
|
case proto_type_t::sync:
|
|
slot->wants.fetch_or(WANTS_SYNC_AGAIN);
|
|
replyOk(client, msg.id);
|
|
return;
|
|
case proto_type_t::subscribe:
|
|
case proto_type_t::unsubscribe: {
|
|
const int32_t feed = protoInt(doc["d"], proto_field_t::feed);
|
|
bool known = false;
|
|
for(uint8_t i = 0; i < protoFeedCount(); i++) known |= protoFeedAt(i).number == feed;
|
|
if(!known) { replyError(client, msg.id, proto_error_t::badRequest, "Unknown feed"); return; }
|
|
if(msg.type == static_cast<uint8_t>(proto_type_t::subscribe)) slot->feeds.fetch_or(1 << feed);
|
|
else slot->feeds.fetch_and(~(1 << feed));
|
|
replyOk(client, msg.id);
|
|
return;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
// The ops, by what becomes of them (the TYPE table).
|
|
switch(protoOpKind(msg.type)) {
|
|
case proto_op_t::shadeRequest:
|
|
queueShadeOp(client, msg, doc["d"]);
|
|
return;
|
|
case proto_op_t::remoteFrame:
|
|
sendRemoteCommand(client, msg, doc["d"]);
|
|
return;
|
|
case proto_op_t::edit: {
|
|
model_edit_type_t edit;
|
|
if(protoEditType(msg.type, edit)) queueEditOp(client, msg, edit, doc["d"]);
|
|
else replyError(client, msg.id, proto_error_t::internal, "An edit op without an edit");
|
|
return;
|
|
}
|
|
case proto_op_t::read:
|
|
queueReadOp(client, msg, doc["d"]);
|
|
return;
|
|
case proto_op_t::device:
|
|
queueDeviceOp(client, msg, doc["d"]);
|
|
return;
|
|
case proto_op_t::none:
|
|
replyError(client, msg.id, proto_error_t::unknownOp, "Unknown message type");
|
|
return;
|
|
}
|
|
}
|
|
static void onFrame(AsyncWebSocketClient *client, AwsFrameInfo *info, uint8_t *data, size_t len) {
|
|
// Most messages are a whole text frame in one piece.
|
|
if(info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
|
|
onMessage(client, reinterpret_cast<const char *>(data), len);
|
|
return;
|
|
}
|
|
// The rest come in pieces: a frame larger than a TCP segment, or a message
|
|
// sent as several frames. Only a message's first frame has a text or
|
|
// binary opcode; the frames after it are continuations.
|
|
proto_piece_t piece;
|
|
piece.clientId = client->id();
|
|
piece.first = info->index == 0 && info->opcode != WS_CONTINUATION;
|
|
piece.last = info->final && info->index + len == info->len;
|
|
piece.text = info->message_opcode == WS_TEXT;
|
|
piece.data = data;
|
|
piece.len = len;
|
|
// Each refusal comes once per message, so the client gets one reply. The
|
|
// message couldn't be read, so the reply has no id.
|
|
assembly_t result;
|
|
char *message = nullptr;
|
|
size_t messageLen = 0;
|
|
{
|
|
AssemblerLock lock;
|
|
result = assembler.add(piece, millis());
|
|
if(result == assembly_t::complete) message = assembler.take(messageLen);
|
|
}
|
|
switch(result) {
|
|
case assembly_t::complete:
|
|
onMessage(client, message, messageLen);
|
|
free(message);
|
|
return;
|
|
case assembly_t::busy:
|
|
replyError(client, 0, proto_error_t::busy, "Another long message is on its way, send it again");
|
|
return;
|
|
case assembly_t::tooLarge:
|
|
replyError(client, 0, proto_error_t::badRequest, "Message too large");
|
|
return;
|
|
case assembly_t::notText:
|
|
replyError(client, 0, proto_error_t::badRequest, "Send text messages");
|
|
return;
|
|
case assembly_t::noMemory:
|
|
replyError(client, 0, proto_error_t::internal, "Out of memory");
|
|
return;
|
|
case assembly_t::more:
|
|
case assembly_t::ignored:
|
|
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<AsyncWebServerRequest *>(arg)); break;
|
|
case WS_EVT_DISCONNECT: onDisconnect(client); break;
|
|
case WS_EVT_DATA: onFrame(client, static_cast<AwsFrameInfo *>(arg), data, len); break;
|
|
default: break;
|
|
}
|
|
}
|
|
|
|
void ProtoSocket::begin(AsyncWebServer &server) {
|
|
assemblerMutex = xSemaphoreCreateMutexStatic(&assemblerMutexBuffer);
|
|
ws.handleHandshake(authorized);
|
|
ws.onEvent(ProtoSocket::wsEvent);
|
|
server.addHandler(&ws);
|
|
}
|
|
void ProtoSocket::end() { ws.closeAll(1001, "Restarting"); }
|
|
bool ProtoSocket::hasClients() const {
|
|
for(const proto_slot_t &s : slots)
|
|
if(s.clientId.load()) return true;
|
|
return false;
|
|
}
|
|
bool ProtoSocket::sending() const { return sockEmit.onLoopTask() && this->hasClients(); }
|
|
|
|
/*********************************************************************
|
|
* loop(): syncs
|
|
********************************************************************/
|
|
static void closeOnceSent();
|
|
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);
|
|
syncFrameSent(s, millis());
|
|
if(s.cur.active()) return;
|
|
eventJson.setBuffer(buf, PROTO_MAX_FRAME);
|
|
beginFrame(eventJson, proto_type_t::syncEnd);
|
|
endFrame(eventJson);
|
|
ws.text(s.loopId, buf, eventJson.length());
|
|
}
|
|
void ProtoSocket::loop() {
|
|
if(!sockEmit.onLoopTask()) return;
|
|
for(proto_slot_t &s : slots) {
|
|
switch(nextSyncStep(s, millis())) {
|
|
case sync_step_t::send:
|
|
sendSyncFrame(s);
|
|
break;
|
|
case sync_step_t::timedOut:
|
|
ESP_LOGW(TAG, "Socket [%lu] sent no next for %us, closing it", (unsigned long)s.loopId, PROTO_SYNC_TIMEOUT_MS / 1000);
|
|
ws.close(s.loopId);
|
|
break;
|
|
case sync_step_t::none:
|
|
break;
|
|
}
|
|
}
|
|
// A long message that stalled gives its buffer up, and its client hears so.
|
|
uint32_t stalled;
|
|
{
|
|
AssemblerLock lock;
|
|
stalled = assembler.expire(millis());
|
|
}
|
|
if(stalled) {
|
|
char buf[160];
|
|
BufferedJsonFormatter json;
|
|
json.setBuffer(buf, sizeof(buf));
|
|
writeErrorReply(json, 0, proto_error_t::badRequest, "The message took too long to arrive");
|
|
ws.text(stalled, buf, json.length());
|
|
}
|
|
closeOnceSent();
|
|
ws.cleanupClients(PROTO_MAX_CLIENTS);
|
|
}
|
|
|
|
// Sends a reply formatted on loop(). One that didn't fit goes as an internal
|
|
// error instead, so the op still gets its one reply. A client that has gone
|
|
// is skipped.
|
|
static void sendReply(const proto_reply_to_t &to, BufferedJsonFormatter &json, char *buf, size_t size) {
|
|
if(json.overflowed()) {
|
|
json.setBuffer(buf, size);
|
|
writeErrorReply(json, to.id, proto_error_t::internal, "Reply too large");
|
|
}
|
|
ws.text(to.client, buf, json.length());
|
|
}
|
|
void ProtoSocket::replyRequest(const proto_reply_to_t &to, const shade_request_t &req, shade_request_result_t result) {
|
|
if(!sockEmit.onLoopTask() || to.id == 0) return;
|
|
char buf[192];
|
|
BufferedJsonFormatter json;
|
|
json.setBuffer(buf, sizeof(buf));
|
|
const proto_error_t code = protoRequestError(result);
|
|
if(code == proto_error_t::none) writeOkReply(json, to.id);
|
|
else writeErrorReply(json, to.id, code, protoRequestErrorDesc(req, result));
|
|
sendReply(to, json, buf, sizeof(buf));
|
|
}
|
|
|
|
// What an edit op's reply says: for an add, the new item's id; for a refused
|
|
// edit, the route's reason and the shade's validation code when it has one.
|
|
void ProtoSocket::replyEdit(const proto_reply_to_t &to, model_edit_type_t type, const edit_result_t &result) {
|
|
if(!sockEmit.onLoopTask() || to.id == 0) return;
|
|
const uint32_t id = to.id;
|
|
char buf[256];
|
|
BufferedJsonFormatter json;
|
|
json.setBuffer(buf, sizeof(buf));
|
|
beginFrame(json, proto_type_t::reply, id);
|
|
ProtoWriter w(json);
|
|
w.add(proto_field_t::ok, result.ok);
|
|
if(!result.ok) {
|
|
w.add(proto_field_t::code, static_cast<uint8_t>(result.notFound ? proto_error_t::notFound : proto_error_t::badRequest));
|
|
w.add(proto_field_t::desc, result.desc);
|
|
w.add(proto_field_t::validation, result.code);
|
|
}
|
|
else if(type == model_edit_type_t::add_room) w.add(proto_field_t::roomId, result.id);
|
|
else if(type == model_edit_type_t::add_shade) w.add(proto_field_t::shadeId, result.id);
|
|
else if(type == model_edit_type_t::add_group) w.add(proto_field_t::groupId, result.id);
|
|
endFrame(json);
|
|
sendReply(to, json, buf, sizeof(buf));
|
|
}
|
|
// The reads an edit form needs: what a new room, shade or group would get,
|
|
// and the shades a group could take.
|
|
void ProtoSocket::replyRead(const proto_reply_to_t &to, uint8_t op, uint8_t groupId) {
|
|
if(!sockEmit.onLoopTask() || to.id == 0) return;
|
|
const uint32_t id = to.id;
|
|
char buf[256];
|
|
BufferedJsonFormatter json;
|
|
json.setBuffer(buf, sizeof(buf));
|
|
const char *refused = nullptr;
|
|
proto_error_t code = proto_error_t::badRequest;
|
|
beginFrame(json, proto_type_t::reply, id);
|
|
ProtoWriter w(json);
|
|
switch(static_cast<proto_type_t>(op)) {
|
|
case proto_type_t::getNextRoom: {
|
|
const uint8_t roomId = somfy.getNextRoomId();
|
|
if(roomId == 0) { refused = EDIT_ROOMS_FULL; break; }
|
|
w.add(proto_field_t::ok, true);
|
|
w.add(proto_field_t::roomId, roomId);
|
|
break;
|
|
}
|
|
case proto_type_t::getNextShade:
|
|
case proto_type_t::getNextGroup: {
|
|
const bool isShade = op == static_cast<uint8_t>(proto_type_t::getNextShade);
|
|
const uint8_t newId = isShade ? somfy.getNextShadeId() : somfy.getNextGroupId();
|
|
if(newId == 255) { refused = isShade ? EDIT_SHADES_FULL : EDIT_GROUPS_FULL; break; }
|
|
w.add(proto_field_t::ok, true);
|
|
w.add(isShade ? proto_field_t::shadeId : proto_field_t::groupId, newId);
|
|
w.add(proto_field_t::remoteAddress, somfy.getNextRemoteAddress(newId));
|
|
w.add(proto_field_t::bitLength, somfy.transceiver.config.type);
|
|
w.add(proto_field_t::proto, static_cast<uint8_t>(somfy.transceiver.config.proto));
|
|
break;
|
|
}
|
|
case proto_type_t::groupOptions: {
|
|
SomfyGroup *group = somfy.getGroupById(groupId);
|
|
if(!group) { refused = "No group with that id"; code = proto_error_t::notFound; break; }
|
|
w.add(proto_field_t::ok, true);
|
|
w.add(proto_field_t::groupId, groupId);
|
|
// The shades it could take: every one it doesn't have.
|
|
w.beginList(proto_field_t::ids);
|
|
for(SomfyShade &shade : somfy.shades)
|
|
if(shade.getShadeId() != 255 && !group->hasShadeId(shade.getShadeId())) w.item(shade.getShadeId());
|
|
w.endList();
|
|
break;
|
|
}
|
|
default:
|
|
refused = "Not a read op";
|
|
break;
|
|
}
|
|
if(refused) {
|
|
json.setBuffer(buf, sizeof(buf));
|
|
writeErrorReply(json, id, code, refused);
|
|
}
|
|
else endFrame(json);
|
|
sendReply(to, json, buf, sizeof(buf));
|
|
}
|
|
|
|
ProtoWriter ProtoSocket::beginReply(const proto_reply_to_t &to) {
|
|
eventJson.setBuffer(socketFrameBuffer(), PROTO_MAX_FRAME);
|
|
beginFrame(eventJson, proto_type_t::reply, to.id);
|
|
ProtoWriter w(eventJson);
|
|
w.add(proto_field_t::ok, true);
|
|
return w;
|
|
}
|
|
void ProtoSocket::endReply(const proto_reply_to_t &to) {
|
|
if(!sockEmit.onLoopTask() || to.id == 0) return;
|
|
endFrame(eventJson);
|
|
sendReply(to, eventJson, socketFrameBuffer(), PROTO_MAX_FRAME);
|
|
}
|
|
void ProtoSocket::replyRefused(const proto_reply_to_t &to, proto_error_t code, const char *desc) {
|
|
if(!sockEmit.onLoopTask() || to.id == 0) return;
|
|
char buf[192];
|
|
BufferedJsonFormatter json;
|
|
json.setBuffer(buf, sizeof(buf));
|
|
writeErrorReply(json, to.id, code, desc);
|
|
sendReply(to, json, buf, sizeof(buf));
|
|
}
|
|
|
|
/*********************************************************************
|
|
* loop(): events
|
|
********************************************************************/
|
|
static JsonFormatter &beginEvent(proto_type_t type) {
|
|
eventJson.setBuffer(socketFrameBuffer(), PROTO_MAX_FRAME);
|
|
beginFrame(eventJson, type);
|
|
return eventJson;
|
|
}
|
|
// Closes the event; false when it didn't fit, which is logged.
|
|
static bool finishEvent() {
|
|
endFrame(eventJson);
|
|
if(!eventJson.overflowed()) return true;
|
|
ESP_LOGE(TAG, "Event too large for %u bytes, not sent", PROTO_MAX_FRAME);
|
|
return false;
|
|
}
|
|
static void sendEvent() {
|
|
if(finishEvent()) ws.textAll(socketFrameBuffer(), eventJson.length());
|
|
}
|
|
// An item whole, or only its state: a snapshot of it, written by write.
|
|
template<typename Model, typename Snapshot>
|
|
static void sendItem(proto_type_t type, Model &item, void (*snapshot)(Model &, Snapshot &), void (*write)(ProtoWriter &, const Snapshot &)) {
|
|
Snapshot s;
|
|
snapshot(item, s);
|
|
ProtoWriter w(beginEvent(type));
|
|
write(w, s);
|
|
sendEvent();
|
|
}
|
|
|
|
void ProtoSocket::shadeState(SomfyShade &shade) {
|
|
if(this->sending()) sendItem(proto_type_t::shadeState, shade, snapshotShade, protoShadeState);
|
|
}
|
|
void ProtoSocket::shade(SomfyShade &shade) {
|
|
if(this->sending()) sendItem(proto_type_t::shade, shade, snapshotShade, protoShade);
|
|
}
|
|
void ProtoSocket::shadeRemoved(uint8_t shadeId) {
|
|
if(!this->sending()) return;
|
|
ProtoWriter(beginEvent(proto_type_t::shadeRemoved)).add(proto_field_t::shadeId, shadeId);
|
|
sendEvent();
|
|
}
|
|
void ProtoSocket::group(SomfyGroup &group) {
|
|
if(this->sending()) sendItem(proto_type_t::group, group, snapshotGroup, protoGroup);
|
|
}
|
|
void ProtoSocket::groupState(SomfyGroup &group) {
|
|
if(!this->sending()) return;
|
|
group_snapshot_t g;
|
|
snapshotGroup(group, g);
|
|
ProtoWriter w(beginEvent(proto_type_t::groupState));
|
|
protoGroupState(w, g.groupId, g.flags, group.direction);
|
|
sendEvent();
|
|
}
|
|
void ProtoSocket::groupRemoved(uint8_t groupId) {
|
|
if(!this->sending()) return;
|
|
ProtoWriter(beginEvent(proto_type_t::groupRemoved)).add(proto_field_t::groupId, groupId);
|
|
sendEvent();
|
|
}
|
|
void ProtoSocket::room(SomfyRoom &room) {
|
|
if(this->sending()) sendItem(proto_type_t::room, room, snapshotRoom, protoRoom);
|
|
}
|
|
void ProtoSocket::roomRemoved(uint8_t roomId) {
|
|
if(!this->sending()) return;
|
|
ProtoWriter(beginEvent(proto_type_t::roomRemoved)).add(proto_field_t::roomId, roomId);
|
|
sendEvent();
|
|
}
|
|
void ProtoSocket::order(sort_list_t list) {
|
|
if(!this->sending()) return;
|
|
struct entry_t { int8_t order; uint8_t id; };
|
|
static constexpr uint8_t MOST = std::max({SOMFY_MAX_ROOMS, SOMFY_MAX_SHADES, SOMFY_MAX_GROUPS});
|
|
entry_t items[MOST];
|
|
uint8_t n = 0;
|
|
proto_type_t type;
|
|
switch(list) {
|
|
case sort_list_t::rooms:
|
|
type = proto_type_t::roomOrder;
|
|
for(SomfyRoom &r : somfy.rooms) if(r.roomId != 0) items[n++] = {r.sortOrder, r.roomId};
|
|
break;
|
|
case sort_list_t::shades:
|
|
type = proto_type_t::shadeOrder;
|
|
for(SomfyShade &s : somfy.shades) if(s.getShadeId() != 255) items[n++] = {s.sortOrder, s.getShadeId()};
|
|
break;
|
|
case sort_list_t::groups:
|
|
type = proto_type_t::groupOrder;
|
|
for(SomfyGroup &g : somfy.groups) if(g.getGroupId() != 255) items[n++] = {g.sortOrder, g.getGroupId()};
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
for(uint8_t i = 1; i < n; i++)
|
|
for(uint8_t j = i; j > 0 && items[j].order < items[j - 1].order; j--) std::swap(items[j], items[j - 1]);
|
|
ProtoWriter w(beginEvent(type));
|
|
w.beginList(proto_field_t::ids);
|
|
for(uint8_t i = 0; i < n; i++) w.item(items[i].id);
|
|
w.endList();
|
|
sendEvent();
|
|
}
|
|
void ProtoSocket::repeaters() {
|
|
if(!this->sending()) return;
|
|
ProtoWriter w(beginEvent(proto_type_t::repeaters));
|
|
w.beginList(proto_field_t::repeaters);
|
|
for(uint32_t address : somfy.repeaters) if(address != 0) w.item(address);
|
|
w.endList();
|
|
sendEvent();
|
|
}
|
|
void ProtoSocket::shadeCommanded(SomfyShade &shade, const char *command, const char *source, uint32_t sourceAddress) {
|
|
if(!this->sending()) return;
|
|
ProtoWriter w(beginEvent(proto_type_t::shadeCommanded));
|
|
w.add(proto_field_t::shadeId, shade.getShadeId());
|
|
w.add(proto_field_t::remoteAddress, shade.getRemoteAddress());
|
|
w.add(proto_field_t::command, command);
|
|
w.add(proto_field_t::source, source);
|
|
w.add(proto_field_t::lastRollingCode, shade.lastRollingCode);
|
|
w.add(proto_field_t::sourceAddress, sourceAddress);
|
|
sendEvent();
|
|
}
|
|
void ProtoSocket::memory() {
|
|
if(!this->sending()) return;
|
|
ProtoWriter w(beginEvent(proto_type_t::memory));
|
|
writeMemory(w);
|
|
sendEvent();
|
|
}
|
|
void ProtoSocket::wifi() {
|
|
if(!this->sending()) return;
|
|
ProtoWriter w(beginEvent(proto_type_t::wifi));
|
|
writeWifi(w);
|
|
sendEvent();
|
|
}
|
|
void ProtoSocket::ethernet() {
|
|
if(!this->sending()) return;
|
|
ProtoWriter w(beginEvent(proto_type_t::ethernet));
|
|
writeEthernet(w);
|
|
sendEvent();
|
|
}
|
|
void ProtoSocket::settingsChanged(uint32_t savedBy, const char *section) {
|
|
if(!this->sending()) return;
|
|
ProtoWriter(beginEvent(proto_type_t::settingsChanged)).add(proto_field_t::section, section);
|
|
if(!finishEvent()) return;
|
|
for(proto_slot_t &s : slots) {
|
|
const uint32_t id = s.clientId.load();
|
|
if(id && id != savedBy) ws.text(id, socketFrameBuffer(), eventJson.length());
|
|
}
|
|
}
|
|
// The client that saved the security settings, closed once its reply has
|
|
// gone: a close frame goes ahead of the messages waiting to be sent.
|
|
// loop() only.
|
|
static uint32_t closeWhenSent = 0;
|
|
static uint32_t closeWhenSentSince = 0;
|
|
#define CLOSE_WHEN_SENT_MAX_MS 2000
|
|
void ProtoSocket::closeAll(uint32_t savedBy) {
|
|
if(!sockEmit.onLoopTask()) return;
|
|
for(proto_slot_t &s : slots) {
|
|
const uint32_t id = s.clientId.load();
|
|
if(id && id != savedBy) ws.close(id);
|
|
}
|
|
closeWhenSent = savedBy;
|
|
closeWhenSentSince = millis();
|
|
}
|
|
static void closeOnceSent() {
|
|
if(!closeWhenSent) return;
|
|
AsyncWebSocketClient *client = ws.client(closeWhenSent);
|
|
if(client && client->queueLen() > 0 && millis() - closeWhenSentSince < CLOSE_WHEN_SENT_MAX_MS) return;
|
|
if(client) client->close();
|
|
closeWhenSent = 0;
|
|
}
|
|
// The clients waiting for a WiFi scan. loop() only.
|
|
static uint32_t scanAskers[PROTO_MAX_CLIENTS];
|
|
// The most networks wifiNetworks lists, so the event fits a frame.
|
|
#define PROTO_MAX_NETWORKS 30
|
|
void ProtoSocket::wantsWifiNetworks(uint32_t clientId) {
|
|
if(!sockEmit.onLoopTask()) return;
|
|
uint32_t *spare = nullptr;
|
|
for(uint32_t &id : scanAskers) {
|
|
if(id == clientId) return;
|
|
// A client that has gone is skipped when the event is sent, so its place
|
|
// can go to another.
|
|
if(!spare && (id == 0 || !slotOf(slots, PROTO_MAX_CLIENTS, id))) spare = &id;
|
|
}
|
|
if(spare) *spare = clientId;
|
|
}
|
|
void ProtoSocket::wifiNetworks(int16_t count) {
|
|
if(!sockEmit.onLoopTask()) return;
|
|
bool asked = false;
|
|
for(uint32_t id : scanAskers) asked |= id != 0;
|
|
if(!asked) return;
|
|
// Each asker was told the networks would follow, so when they don't fit
|
|
// fewer go: names that need escaping can make one five times longer.
|
|
bool fits = false;
|
|
for(uint8_t most = PROTO_MAX_NETWORKS; !fits && most > 0; most /= 2) {
|
|
ProtoWriter w(beginEvent(proto_type_t::wifiNetworks));
|
|
w.beginList(proto_field_t::networks);
|
|
uint8_t listed = 0;
|
|
for(int16_t i = 0; i < count && listed < most; i++) {
|
|
// As /scanaps lists them: named networks that can be heard.
|
|
if(WiFi.SSID(i).length() == 0 || WiFi.RSSI(i) < -95) continue;
|
|
w.beginItem();
|
|
w.add(proto_field_t::ssid, WiFi.SSID(i).c_str());
|
|
w.add(proto_field_t::channel, (int32_t)WiFi.channel(i));
|
|
w.add(proto_field_t::strength, (int32_t)WiFi.RSSI(i));
|
|
w.add(proto_field_t::macAddress, WiFi.BSSIDstr(i).c_str());
|
|
w.endItem();
|
|
listed++;
|
|
}
|
|
w.endList();
|
|
endFrame(eventJson);
|
|
fits = !eventJson.overflowed();
|
|
}
|
|
for(uint32_t &id : scanAskers) {
|
|
if(fits && id) ws.text(id, socketFrameBuffer(), eventJson.length());
|
|
id = 0;
|
|
}
|
|
}
|
|
bool ProtoSocket::subscribed(proto_feed_t feed) const {
|
|
// The feed is formatted into the event buffer, which is loop()'s.
|
|
if(!sockEmit.onLoopTask()) return false;
|
|
const uint8_t bit = 1 << static_cast<uint8_t>(feed);
|
|
for(const proto_slot_t &s : slots)
|
|
if(s.clientId.load() && (s.feeds.load() & bit)) return true;
|
|
return false;
|
|
}
|
|
ProtoWriter ProtoSocket::beginFeed(proto_type_t type) { return ProtoWriter(beginEvent(type)); }
|
|
void ProtoSocket::endFeed(proto_feed_t feed) {
|
|
if(!finishEvent()) return;
|
|
const uint8_t bit = 1 << static_cast<uint8_t>(feed);
|
|
for(proto_slot_t &s : slots) {
|
|
const uint32_t id = s.clientId.load();
|
|
if(id && (s.feeds.load() & bit)) ws.text(id, socketFrameBuffer(), eventJson.length());
|
|
}
|
|
}
|