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:
cjkas 2026-10-25 09:52:09 +01:00
parent 97a4e98183
commit 2517f40709
22 changed files with 1221 additions and 49 deletions

View file

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