mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 13:52:15 +01:00
feat: settings ops, the WiFi and frequency scans, and reboot for socket protocol 2
Step 1d of the protocol 2 core (docs/SECURITY_PLAN.md). - getSettings/saveSettings by section: general, network, mqtt, security, radio. A save is the settings route's request, so loop() applies it with the settings' own fromJSON(). ProtoSettings checks every field first: kind, range, string length, IP addresses. - Secrets are only reported as has*. A security save replies with the client's new token and closes the other sockets; other saves send settingsChanged to the other clients. - scanWifi runs an async scan from ESPNetwork, kept apart from the roaming scan, and wifiNetworks goes to the clients that asked. - beginFrequencyScan, endFrequencyScan and reboot run at once on async_tcp, as their routes do: a scan's end mustn't wait in the loop queue behind an unpair that waits for the scan to end (new op kind 'a'). - e2e: test_settings.py and a --reboot flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
97a4e98183
commit
2517f40709
22 changed files with 1221 additions and 49 deletions
|
|
@ -12,6 +12,8 @@
|
|||
#include "ProtoOps.h"
|
||||
#include "ProtoEdits.h"
|
||||
#include "ProtoAssembly.h"
|
||||
#include "ProtoSettings.h"
|
||||
#include "DeviceOps.h"
|
||||
#include "SomfyEditor.h"
|
||||
#include "LoopQueue.h"
|
||||
#include "SomfySnapshot.h"
|
||||
|
|
@ -34,6 +36,7 @@ extern Web webServer;
|
|||
extern LoopStats loopStats;
|
||||
extern SocketEmitter sockEmit;
|
||||
extern LoopQueue loopQueue;
|
||||
extern rebootDelay_t rebootDelay;
|
||||
|
||||
ProtoSocket protoSocket;
|
||||
static AsyncWebSocket ws("/ws");
|
||||
|
|
@ -213,12 +216,71 @@ static void queueEditOp(AsyncWebSocketClient *client, const proto_message_t &msg
|
|||
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.readOp = msg.type;
|
||||
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) {
|
||||
cancelSettingsSave(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;
|
||||
}
|
||||
if(loopQueue.submit(item)) return;
|
||||
if(item.save) cancelSettingsSave(item.save);
|
||||
replyError(client, msg.id, proto_error_t::busy, "Command queue full");
|
||||
}
|
||||
// The frequency scan and reboot, as their routes run them.
|
||||
static void runAtOnce(AsyncWebSocketClient *client, const proto_message_t &msg) {
|
||||
switch(static_cast<proto_type_t>(msg.type)) {
|
||||
case proto_type_t::beginFrequencyScan:
|
||||
if(!somfy.transceiver.config.enabled) {
|
||||
replyError(client, msg.id, proto_error_t::radioUnavailable, "The radio is disabled");
|
||||
return;
|
||||
}
|
||||
somfy.transceiver.beginFrequencyScan();
|
||||
break;
|
||||
case proto_type_t::endFrequencyScan:
|
||||
somfy.transceiver.endFrequencyScan();
|
||||
break;
|
||||
case proto_type_t::reboot:
|
||||
ESP_LOGI(TAG, "Socket [%lu] asked for a reboot", (unsigned long)client->id());
|
||||
// Time for the reply to go out.
|
||||
rebootDelay.reboot = true;
|
||||
rebootDelay.rebootTime = millis() + 500;
|
||||
break;
|
||||
default:
|
||||
replyError(client, msg.id, proto_error_t::internal, "Not an op run at once");
|
||||
return;
|
||||
}
|
||||
replyOk(client, msg.id);
|
||||
}
|
||||
static bool authorized(AsyncWebServerRequest *request) {
|
||||
if(settings.Security.type == security_types::None) return true;
|
||||
const AsyncWebParameter *key = request->getParam("apikey");
|
||||
|
|
@ -304,8 +366,13 @@ static void onMessage(AsyncWebSocketClient *client, const char *text, size_t len
|
|||
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::atOnce:
|
||||
runAtOnce(client, msg);
|
||||
return;
|
||||
case proto_op_t::none:
|
||||
// The settings ops arrive with the protocol core's next PR.
|
||||
replyError(client, msg.id, proto_error_t::unknownOp, "Unknown message type");
|
||||
return;
|
||||
}
|
||||
|
|
@ -526,6 +593,27 @@ void ProtoSocket::replyRead(const proto_reply_to_t &to, uint8_t op, uint8_t grou
|
|||
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
|
||||
********************************************************************/
|
||||
|
|
@ -657,6 +745,63 @@ void ProtoSocket::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());
|
||||
}
|
||||
}
|
||||
void ProtoSocket::closeAllBut(uint32_t keep) {
|
||||
if(!sockEmit.onLoopTask()) return;
|
||||
for(proto_slot_t &s : slots) {
|
||||
const uint32_t id = s.clientId.load();
|
||||
if(id && id != keep) ws.close(id);
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
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 < PROTO_MAX_NETWORKS; 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();
|
||||
const bool fits = finishEvent();
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue