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

237
src/DeviceOps.cpp Normal file
View file

@ -0,0 +1,237 @@
#include <Arduino.h>
#include <WiFi.h>
#include <atomic>
#include <new>
#include "esp_log.h"
#include "DeviceOps.h"
#include "ConfigSettings.h"
#include "ESPNetwork.h"
#include "MqttConnection.h"
#include "Secrets.h"
#include "Somfy.h"
#include "Utils.h"
#include "entryPoints/Web.h"
#include "outputs/ProtoSocket.h"
static const char *TAG = "DeviceOps";
extern ConfigSettings settings;
extern SomfyShadeController somfy;
extern ESPNetwork net;
extern MqttConnection mqtt;
extern Web webServer;
extern rebootDelay_t rebootDelay;
static std::atomic<bool> saveBusy(false);
settings_save_t *newSettingsSave(bool &busy) {
busy = saveBusy.exchange(true);
if(busy) return nullptr;
settings_save_t *save = new (std::nothrow) settings_save_t();
if(!save) saveBusy.store(false);
return save;
}
void cancelSettingsSave(settings_save_t *save) {
delete save;
saveBusy.store(false);
}
/*********************************************************************
* getSettings: what the settings routes send, under the dictionary's
* keys. Secrets only say whether they are set.
********************************************************************/
// An address of 0.0.0.0 is sent as "", as IPSettings::toJSON() does.
static void addAddress(ProtoWriter &w, proto_field_t field, const IPAddress &ip) {
if(ip != IPAddress(0, 0, 0, 0)) w.add(field, ip.toString().c_str());
}
static void writeSettings(ProtoWriter &w, settings_section_t section) {
switch(section) {
case settings_section_t::general:
w.add(proto_field_t::hostname, settings.hostname);
w.add(proto_field_t::ssdpBroadcast, settings.ssdpBroadcast);
w.add(proto_field_t::ntpServer, settings.NTP.ntpServer);
w.add(proto_field_t::posixZone, settings.NTP.posixZone);
break;
case settings_section_t::network:
w.add(proto_field_t::connMode, static_cast<uint8_t>(settings.connType));
w.add(proto_field_t::ssid, settings.WIFI.ssid);
w.add(proto_field_t::hasPassphrase, hasSecret(settings.WIFI.passphrase));
w.add(proto_field_t::roaming, settings.WIFI.roaming);
w.add(proto_field_t::hidden, settings.WIFI.hidden);
#if HAS_ETHERNET
w.add(proto_field_t::hasEthernet, true);
w.add(proto_field_t::boardType, settings.Ethernet.boardType);
w.add(proto_field_t::phyType, settings.Ethernet.phyType);
w.add(proto_field_t::CLKMode, static_cast<uint8_t>(settings.Ethernet.CLKMode));
w.add(proto_field_t::phyAddress, settings.Ethernet.phyAddress);
w.add(proto_field_t::PWRPin, settings.Ethernet.PWRPin);
w.add(proto_field_t::MDCPin, settings.Ethernet.MDCPin);
w.add(proto_field_t::MDIOPin, settings.Ethernet.MDIOPin);
#endif
w.add(proto_field_t::dhcp, settings.IP.dhcp);
addAddress(w, proto_field_t::ip, settings.IP.ip);
addAddress(w, proto_field_t::gateway, settings.IP.gateway);
addAddress(w, proto_field_t::subnet, settings.IP.subnet);
addAddress(w, proto_field_t::dns1, settings.IP.dns1);
addAddress(w, proto_field_t::dns2, settings.IP.dns2);
break;
case settings_section_t::mqtt:
w.add(proto_field_t::enabled, settings.MQTT.enabled);
w.add(proto_field_t::pubDisco, settings.MQTT.pubDisco);
w.add(proto_field_t::protocol, settings.MQTT.protocol);
w.add(proto_field_t::hostname, settings.MQTT.hostname);
w.add(proto_field_t::port, settings.MQTT.port);
w.add(proto_field_t::username, settings.MQTT.username);
w.add(proto_field_t::hasPassword, hasSecret(settings.MQTT.password));
w.add(proto_field_t::rootTopic, settings.MQTT.rootTopic);
w.add(proto_field_t::discoTopic, settings.MQTT.discoTopic);
break;
case settings_section_t::security:
w.add(proto_field_t::securityType, static_cast<uint8_t>(settings.Security.type));
w.add(proto_field_t::username, settings.Security.username);
w.add(proto_field_t::hasPassword, hasSecret(settings.Security.password));
w.add(proto_field_t::hasPin, hasSecret(settings.Security.pin));
w.add(proto_field_t::permissions, settings.Security.permissions);
break;
case settings_section_t::radio: {
const transceiver_config_t &cfg = somfy.transceiver.config;
w.add(proto_field_t::bitLength, cfg.type);
w.add(proto_field_t::proto, static_cast<uint8_t>(cfg.proto));
w.add(proto_field_t::enabled, cfg.enabled);
w.add(proto_field_t::TXPin, cfg.TXPin);
w.add(proto_field_t::RXPin, cfg.RXPin);
w.add(proto_field_t::SCKPin, cfg.SCKPin);
w.add(proto_field_t::MOSIPin, cfg.MOSIPin);
w.add(proto_field_t::MISOPin, cfg.MISOPin);
w.add(proto_field_t::CSNPin, cfg.CSNPin);
w.add(proto_field_t::frequency, cfg.frequency);
w.add(proto_field_t::rxBandwidth, cfg.rxBandwidth);
w.add(proto_field_t::deviation, cfg.deviation);
w.add(proto_field_t::txPower, cfg.txPower);
w.add(proto_field_t::noiseDetection, cfg.noiseDetection);
w.add(proto_field_t::radioInit, cfg.radioInit);
break;
}
}
}
/*********************************************************************
* saveSettings: what the settings routes do with their request
********************************************************************/
// Applies the parts of the network section that were sent. True when the
// device must reboot for them, by the rules of /setNetwork.
static bool saveNetwork(JsonObject req, uint16_t parts) {
bool reboot = false;
if(settingsSets(parts, settings_part_t::conn)) {
const conn_types_t connType = static_cast<conn_types_t>(req["connType"].as<uint8_t>());
if(connType != settings.connType) {
settings.connType = connType;
settings.save();
reboot = true;
}
}
if(settingsSets(parts, settings_part_t::wifi)) {
// Compared after fromJSON, which decides whether an empty passphrase keeps the stored one.
WifiSettings before = settings.WIFI;
settings.WIFI.fromJSON(req);
settings.WIFI.save();
// Not while someone is on the soft AP: the device joins the network once
// they have left it.
if(settings.connType == conn_types_t::wifi && WiFi.softAPgetStationNum() == 0 &&
(strcmp(before.ssid, settings.WIFI.ssid) != 0 || strcmp(before.passphrase, settings.WIFI.passphrase) != 0))
reboot = true;
}
#if HAS_ETHERNET
if(settingsSets(parts, settings_part_t::ethernet)) {
settings.Ethernet.fromJSON(req);
settings.Ethernet.save();
if(settings.connType == conn_types_t::ethernet || settings.connType == conn_types_t::ethernetpref) reboot = true;
}
#endif
if(settingsSets(parts, settings_part_t::ip)) {
settings.IP.fromJSON(req);
settings.IP.save();
}
return reboot;
}
static void runSave(const proto_reply_to_t &to, settings_save_t *save) {
JsonObject req = save->request.as<JsonObject>();
const settings_section_t section = save->section;
const uint16_t parts = settingsParts(section, req);
bool reboot = false;
char token[65] = "";
switch(section) {
case settings_section_t::general:
if(settingsSets(parts, settings_part_t::general)) {
settings.fromJSON(req);
settings.save();
if(!req["hostname"].isNull()) net.updateHostname();
}
if(settingsSets(parts, settings_part_t::ntp)) {
settings.NTP.fromJSON(req);
settings.NTP.save();
settings.NTP.apply();
}
break;
case settings_section_t::network:
reboot = saveNetwork(req, parts);
break;
case settings_section_t::mqtt:
// loop() connects again with the new settings.
mqtt.disconnect();
settings.MQTT.fromJSON(req);
settings.MQTT.save();
break;
case settings_section_t::security:
settings.Security.fromJSON(req);
settings.Security.save();
if(settings.requiresAuth()) webServer.createAPIToken(IPAddress(save->ip), token);
break;
case settings_section_t::radio: {
// Parsed into a copy so the radio task never sees a half updated config.
transceiver_config_t cfg = somfy.transceiver.config;
cfg.fromJSON(req);
somfy.transceiver.setConfig(cfg);
break;
}
}
cancelSettingsSave(save);
ProtoWriter w = protoSocket.beginReply(to);
w.add(proto_field_t::apiKey, token);
w.add(proto_field_t::rebooting, reboot);
protoSocket.endReply(to);
ESP_LOGI(TAG, "Socket [%lu] saved the %s settings%s", (unsigned long)to.client, settingsSectionName(section), reboot ? ", rebooting" : "");
// The other clients' tokens may have changed, and some had none: they
// connect again with what they have, and log in when it is refused.
if(section == settings_section_t::security) protoSocket.closeAllBut(to.client);
else protoSocket.settingsChanged(to.client, settingsSectionName(section));
if(reboot) {
rebootDelay.reboot = true;
rebootDelay.rebootTime = millis() + 1000;
}
}
void runDeviceOp(const loop_item_t &item) {
switch(static_cast<proto_type_t>(item.op)) {
case proto_type_t::getSettings: {
ProtoWriter w = protoSocket.beginReply(item.replyTo);
writeSettings(w, static_cast<settings_section_t>(item.section));
protoSocket.endReply(item.replyTo);
break;
}
case proto_type_t::saveSettings:
runSave(item.replyTo, item.save);
break;
case proto_type_t::scanWifi:
// The networks follow as wifiNetworks once the scan is done.
protoSocket.wantsWifiNetworks(item.replyTo.client);
net.scanForClients();
protoSocket.beginReply(item.replyTo);
protoSocket.endReply(item.replyTo);
break;
default:
if(item.save) cancelSettingsSave(item.save);
protoSocket.replyRefused(item.replyTo, proto_error_t::internal, "Not a device op");
break;
}
}

28
src/DeviceOps.h Normal file
View file

@ -0,0 +1,28 @@
#ifndef DEVICE_OPS_H
#define DEVICE_OPS_H
#include <stdint.h>
#include <ArduinoJson.h>
#include "LoopQueue.h"
#include "ProtoSettings.h"
// Socket protocol 2's settings ops and the WiFi scan, run on loop()
// (docs/PROTOCOL.md). loop() also reads the settings while it runs the
// network, MQTT and the radio, so it is the one that changes them.
// A saveSettings on its way to loop(), which frees it.
struct settings_save_t {
settings_section_t section = settings_section_t::general;
// The client's address, for the token a security save answers with.
uint32_t ip = 0;
// The route's request, from expandSettingsPayload().
JsonDocument request;
};
// async_tcp: a save to fill in and hand to loop(). One runs at a time,
// whichever client sent it: nullptr and busy while another is waiting, and
// nullptr when there is no memory for it.
settings_save_t *newSettingsSave(bool &busy);
// Frees a save that never reached loop().
void cancelSettingsSave(settings_save_t *save);
// loop(): runs a proto_device item and replies.
void runDeviceOp(const loop_item_t &item);
#endif

View file

@ -24,7 +24,34 @@ extern SomfyShadeController somfy;
extern LoopStats loopStats;
static bool _apScanning = false;
// The scan for clients: asked for, then running. WiFi holds the results of
// one scan at a time, so it and the roaming scan never overlap.
static bool _clientScanWanted = false;
static bool _clientScanning = false;
int connectRetries = 0;
// A client that asks during a scan gets that scan's networks.
void ESPNetwork::scanForClients() { if(!_clientScanning) _clientScanWanted = true; }
static void clientScanLoop(ESPNetwork &net) {
if(_clientScanning) {
const int16_t n = WiFi.scanComplete();
if(n == WIFI_SCAN_RUNNING) return;
_clientScanning = false;
if(n < 0) ESP_LOGW(TAG, "WiFi scan failed");
else ESP_LOGI(TAG, "Scanned %d networks", n);
protoSocket.wifiNetworks(n);
WiFi.scanDelete();
return;
}
if(!_clientScanWanted || _apScanning) return;
_clientScanWanted = false;
// As /scanaps does: the soft AP's connection attempts would get in its way.
if(net.softAPOpened) WiFi.disconnect(false);
if(WiFi.scanNetworks(true, true) == WIFI_SCAN_RUNNING) _clientScanning = true;
else {
ESP_LOGW(TAG, "WiFi scan didn't start");
protoSocket.wifiNetworks(WIFI_SCAN_FAILED);
}
}
void ESPNetwork::end() {
SSDP.end();
mqtt.end();
@ -86,6 +113,8 @@ void ESPNetwork::loop() {
// b. WiFi: Perform synchronous scan for APs related to the SSID. If the SSID can be found then perform
// the connection process for the WiFi connection.
// c. SoftAP: This condition retains the Soft AP because no other connection method is available.
// Before the connection steps, which return early while connecting.
clientScanLoop(*this);
conn_types_t ctype = this->preferredConnType();
this->connect(ctype); // Connection timeout handled in connect function as well as the opening of the Soft AP if needed.
if(this->connecting()) return; // If we are currently attempting to connect to something then we need to bail here.
@ -119,7 +148,7 @@ void ESPNetwork::loop() {
}
}
}
if(!this->connecting() && !settings.WIFI.hidden) {
if(!this->connecting() && !settings.WIFI.hidden && !_clientScanWanted && !_clientScanning) {
if((this->softAPOpened && WiFi.softAPgetStationNum() == 0) ||
(!this->connected() && ctype == conn_types_t::wifi)) {
// If the Soft AP is opened and there are no clients connected then we need to scan for an AP. If

View file

@ -63,6 +63,10 @@ class ESPNetwork {
//int getStrengthByMac(const char *mac);
//int getStrengthBySSID(const char *ssid);
void updateHostname();
// A scan of every network, hidden ones included, for the WiFi settings.
// It runs once no roaming scan is, and its networks go to the protocol 2
// clients that asked (ProtoSocket::wifiNetworks). loop() only.
void scanForClients();
bool setup();
void loop();
void end();

View file

@ -9,6 +9,7 @@
#include "SomfyEditor.h"
#include "Utils.h"
#include "outputs/ProtoSocket.h"
#include "DeviceOps.h"
static const char *TAG = "LoopQueue";
@ -85,7 +86,10 @@ void LoopQueue::drain() {
runModelEdit(item.edit);
break;
case loop_item_type_t::proto_read:
protoSocket.replyRead(item.replyTo, item.readOp, item.groupId);
protoSocket.replyRead(item.replyTo, item.op, item.groupId);
break;
case loop_item_type_t::proto_device:
runDeviceOp(item);
break;
}
}

View file

@ -26,20 +26,24 @@ enum class loop_item_type_t : uint8_t {
load_shades, // replace the shades with /shades.tmp
snapshot_fill, // fill a read's ModelSnapshot
model_edit, // apply an edit with ModelEditor
proto_read // answer a protocol 2 read op from the shade model
proto_read, // answer a protocol 2 read op from the shade model
proto_device // run a protocol 2 settings op or the WiFi scan (DeviceOps)
};
struct model_edit_t;
struct settings_save_t;
struct loop_item_t {
loop_item_type_t type = loop_item_type_t::shade_request;
shade_request_t request; // shade_request
uint8_t restoreParts = 0; // restore_shades: RESTORE_* bits
uint8_t slot = 0; // snapshot_fill: the SnapshotSlots slot
model_edit_t *edit = nullptr; // model_edit: loop() runs and frees it
// shade_request and proto_read from a protocol 2 client: loop() replies
// once it has run.
// shade_request, proto_read and proto_device from a protocol 2 client:
// loop() replies once it has run.
proto_reply_to_t replyTo;
uint8_t readOp = 0; // proto_read: the op's type
uint8_t op = 0; // proto_read and proto_device: the op's type
uint8_t groupId = 0; // proto_read: groupOptions' group
uint8_t section = 0; // proto_device: getSettings' settings_section_t
settings_save_t *save = nullptr; // proto_device: saveSettings' save, which loop() frees
};
// Whether the item sends anything on air.

View file

@ -1,6 +1,7 @@
#include <stdio.h>
#include <string.h>
#include "ProtoEdits.h"
#include "ProtoParse.h"
struct edit_op_t {
proto_type_t type;
@ -96,41 +97,9 @@ static const char *routeName(model_edit_type_t type, proto_field_t f) {
if(type == model_edit_type_t::link_remote && f == proto_field_t::lastRollingCode) return "rollingCode";
return protoField(f).name;
}
static const char *kindName(char kind) {
switch(kind) {
case 'b': return "boolean (1 or 0)";
case 'i': return "whole number";
case 'n': return "number";
case 's': return "string";
case 'l': return "list";
default: return "object";
}
}
// What the reply's desc points at; every parse runs on async_tcp, one at a
// time.
static char why[64];
// One value under its field's kind: false when it isn't one.
static bool expandValue(const proto_field_info_t &f, JsonVariantConst v, JsonVariant out) {
switch(f.kind) {
case 'b':
// Booleans are 1 and 0; the edit routes read true and false.
if(!v.is<int>() || (v.as<int>() != 0 && v.as<int>() != 1)) return false;
out.set(v.as<int>() == 1);
return true;
case 'i':
if(v.is<int64_t>() || v.is<uint64_t>()) return out.set(v);
return false;
case 'n':
if(v.is<float>()) return out.set(v);
return false;
case 's':
if(v.is<const char *>()) return out.set(v);
return false;
default:
return false;
}
}
const char *expandEditPayload(model_edit_type_t type, JsonObjectConst d, JsonDocument &doc) {
doc.clear();
switch(type) {
@ -164,8 +133,8 @@ const char *expandEditPayload(model_edit_type_t type, JsonObjectConst d, JsonDoc
snprintf(why, sizeof(why), "%s can't be set by this edit", f.name);
return why;
}
if(!expandValue(f, kv.value(), obj[routeName(type, field)].to<JsonVariant>())) {
snprintf(why, sizeof(why), "%s is not a %s", f.name, kindName(f.kind));
if(!protoRouteValue(f, kv.value(), obj[routeName(type, field)].to<JsonVariant>())) {
snprintf(why, sizeof(why), "%s is not a %s", f.name, protoKindName(f.kind));
return why;
}
}

View file

@ -34,3 +34,34 @@ const char *protoString(JsonObjectConst d, proto_field_t field) {
return v.is<const char *>() ? v.as<const char *>() : nullptr;
}
bool protoHas(JsonObjectConst d, proto_field_t field) { return !d[protoField(field).key].isNull(); }
bool protoRouteValue(const proto_field_info_t &f, JsonVariantConst v, JsonVariant out) {
switch(f.kind) {
case 'b':
// Booleans are 1 and 0; the routes read true and false.
if(!v.is<int>() || (v.as<int>() != 0 && v.as<int>() != 1)) return false;
out.set(v.as<int>() == 1);
return true;
case 'i':
if(v.is<int64_t>() || v.is<uint64_t>()) return out.set(v);
return false;
case 'n':
if(v.is<float>()) return out.set(v);
return false;
case 's':
if(v.is<const char *>()) return out.set(v);
return false;
default:
return false;
}
}
const char *protoKindName(char kind) {
switch(kind) {
case 'b': return "boolean (1 or 0)";
case 'i': return "whole number";
case 'n': return "number";
case 's': return "string";
case 'l': return "list";
default: return "object";
}
}

View file

@ -29,4 +29,10 @@ bool protoBool(JsonObjectConst d, proto_field_t field);
// nullptr when it was left out, as a string's default is "".
const char *protoString(JsonObjectConst d, proto_field_t field);
bool protoHas(JsonObjectConst d, proto_field_t field);
// A value sent for field f, put into out as the HTTP route reads it: booleans
// as true and false. False when it isn't of the field's kind.
bool protoRouteValue(const proto_field_info_t &f, JsonVariantConst v, JsonVariant out);
// A kind as a bad request names it: "whole number", "string" ...
const char *protoKindName(char kind);
#endif

View file

@ -58,6 +58,12 @@
TYPE(getNextShade, 46, 'c', 'r') \
TYPE(getNextGroup, 47, 'c', 'r') \
TYPE(groupOptions, 48, 'c', 'r') \
TYPE(getSettings, 49, 'c', 'd') \
TYPE(saveSettings, 50, 'c', 'd') \
TYPE(scanWifi, 51, 'c', 'd') \
TYPE(beginFrequencyScan, 52, 'c', 'a') \
TYPE(endFrequencyScan, 53, 'c', 'a') \
TYPE(reboot, 54, 'c', 'a') \
TYPE(shadeState, 128, 'd', '-') \
TYPE(shade, 129, 'd', '-') \
TYPE(shadeRemoved, 130, 'd', '-') \
@ -75,7 +81,9 @@
TYPE(ethernet, 142, 'd', '-') \
TYPE(frame, 143, 'd', '-') \
TYPE(frequencyScan, 144, 'd', '-') \
TYPE(repeaters, 145, 'd', '-')
TYPE(repeaters, 145, 'd', '-') \
TYPE(settingsChanged, 146, 'd', '-') \
TYPE(wifiNetworks, 147, 'd', '-')
// Fields, in one dictionary for every type. FIELD(name, key, kind, default):
// kind is b bool (sent as 1/0), i integer, n number with a fraction, s string,
@ -187,7 +195,65 @@
FIELD(scanning, "sc", 'b', 0) \
FIELD(testFreq, "tf", 'n', 0) \
FIELD(testRssi, "tr", 'i', 0) \
FIELD(frequency, "fq", 'n', 0)
FIELD(frequency, "fq", 'n', 0) \
/* the settings ops: which section, and what a save did */ \
FIELD(section, "se", 's', 0) \
FIELD(apiKey, "ak", 's', 0) \
FIELD(rebooting, "rb", 'b', 0) \
/* general */ \
FIELD(ssdpBroadcast, "sb", 'b', 0) \
FIELD(ntpServer, "ns", 's', 0) \
FIELD(posixZone, "pz", 's', 0) \
/* network: the connection the settings ask for (connType is the one in \
use), WiFi, Ethernet and the IP address */ \
FIELD(connMode, "cn", 'i', 0) \
FIELD(hasEthernet, "he", 'b', 0) \
FIELD(passphrase, "pp", 's', 0) \
FIELD(hasPassphrase, "hq", 'b', 0) \
FIELD(roaming, "ro", 'b', 0) \
FIELD(hidden, "hd", 'b', 0) \
FIELD(boardType, "bo", 'i', 0) \
FIELD(phyType, "ph", 'i', 0) \
FIELD(CLKMode, "ck", 'i', 0) \
FIELD(phyAddress, "pa", 'i', 0) \
FIELD(PWRPin, "pw", 'i', 0) \
FIELD(MDCPin, "mc", 'i', 0) \
FIELD(MDIOPin, "mi", 'i', 0) \
FIELD(dhcp, "dh", 'b', 0) \
FIELD(ip, "ip", 's', 0) \
FIELD(gateway, "gw", 's', 0) \
FIELD(subnet, "su", 's', 0) \
FIELD(dns1, "d1", 's', 0) \
FIELD(dns2, "d2", 's', 0) \
/* MQTT and security; hostname is the broker's in MQTT */ \
FIELD(enabled, "en", 'b', 0) \
FIELD(pubDisco, "pd", 'b', 0) \
FIELD(protocol, "pt", 's', 0) \
FIELD(port, "po", 'i', 0) \
FIELD(username, "un", 's', 0) \
FIELD(password, "ps", 's', 0) \
FIELD(hasPassword, "hw", 'b', 0) \
FIELD(rootTopic, "rt", 's', 0) \
FIELD(discoTopic, "dt", 's', 0) \
FIELD(securityType, "sy", 'i', 0) \
FIELD(pin, "pn", 's', 0) \
FIELD(hasPin, "hi", 'b', 0) \
FIELD(permissions, "pm", 'i', 0) \
/* the radio: bitLength, proto, frequency and enabled as above */ \
FIELD(TXPin, "tx", 'i', 0) \
FIELD(RXPin, "rx", 'i', 0) \
FIELD(SCKPin, "sk", 'i', 0) \
FIELD(MOSIPin, "mo", 'i', 0) \
FIELD(MISOPin, "ms", 'i', 0) \
FIELD(CSNPin, "cs", 'i', 0) \
FIELD(rxBandwidth, "bw", 'n', 0) \
FIELD(deviation, "dv", 'n', 0) \
FIELD(txPower, "tp", 'i', 0) \
FIELD(noiseDetection, "nd", 'b', 0) \
FIELD(radioInit, "ri", 'b', 0) \
/* wifiNetworks: each network has ssid, channel and strength as above */ \
FIELD(networks, "nw", 'l', 0) \
FIELD(macAddress, "ma", 's', 0)
// Error codes in a reply. ERR(name, number)
#define PROTO_ERRORS(ERR) \
@ -231,7 +297,12 @@ enum class proto_op_t : char {
shadeRequest = 'q', // a command: a shade request loop() runs
remoteFrame = 'f', // sendRemoteCommand: a frame that goes to the radio at once
edit = 'e', // an edit loop() applies
read = 'r' // a read loop() answers from the shade model
read = 'r', // a read loop() answers from the shade model
device = 'd', // a settings op or the WiFi scan, which loop() runs
// The frequency scan and reboot, run at once as their routes are: they
// touch nothing loop() owns, and a scan's end mustn't wait in the loop
// queue behind an unpair that waits for the scan to end.
atOnce = 'a'
};
const proto_field_info_t &protoField(proto_field_t field);
const proto_field_info_t *protoFieldByKey(const char *key);

190
src/ProtoSettings.cpp Normal file
View file

@ -0,0 +1,190 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ProtoSettings.h"
#include "ProtoParse.h"
static const char *const SECTIONS[] = {"general", "network", "mqtt", "security", "radio"};
const char *settingsSectionName(settings_section_t section) { return SECTIONS[static_cast<uint8_t>(section)]; }
static settings_section_t sectionOf(settings_part_t part) {
switch(part) {
case settings_part_t::general:
case settings_part_t::ntp: return settings_section_t::general;
case settings_part_t::conn:
case settings_part_t::wifi:
case settings_part_t::ethernet:
case settings_part_t::ip: return settings_section_t::network;
case settings_part_t::mqtt: return settings_section_t::mqtt;
case settings_part_t::security: return settings_section_t::security;
default: return settings_section_t::radio;
}
}
// What a save may set. lo..hi is a whole number's range, and hi a string's
// longest length: what the setting holds, so the route's fromJSON neither cuts
// it short nor wraps it. The has* fields and radioInit are only ever sent.
struct settings_field_t {
settings_part_t part;
proto_field_t field;
const char *route; // the name the route reads; nullptr when it is the field's
int32_t lo;
int32_t hi;
};
static const settings_field_t FIELDS[] = {
{settings_part_t::general, proto_field_t::hostname, nullptr, 0, 31},
{settings_part_t::general, proto_field_t::ssdpBroadcast, nullptr, 0, 0},
{settings_part_t::ntp, proto_field_t::ntpServer, nullptr, 0, 64},
{settings_part_t::ntp, proto_field_t::posixZone, nullptr, 0, 63},
// wifi, ethernet and ethernet with WiFi as the fallback (conn_types_t).
{settings_part_t::conn, proto_field_t::connMode, "connType", 1, 3},
{settings_part_t::wifi, proto_field_t::ssid, nullptr, 0, 64},
{settings_part_t::wifi, proto_field_t::passphrase, nullptr, 0, 64},
{settings_part_t::wifi, proto_field_t::roaming, nullptr, 0, 0},
{settings_part_t::wifi, proto_field_t::hidden, nullptr, 0, 0},
{settings_part_t::ethernet, proto_field_t::boardType, nullptr, 0, 255},
{settings_part_t::ethernet, proto_field_t::phyType, nullptr, 0, 255},
{settings_part_t::ethernet, proto_field_t::CLKMode, nullptr, 0, 3},
{settings_part_t::ethernet, proto_field_t::phyAddress, nullptr, -1, 31},
{settings_part_t::ethernet, proto_field_t::PWRPin, nullptr, -1, 48},
{settings_part_t::ethernet, proto_field_t::MDCPin, nullptr, 0, 48},
{settings_part_t::ethernet, proto_field_t::MDIOPin, nullptr, 0, 48},
{settings_part_t::ip, proto_field_t::dhcp, nullptr, 0, 0},
{settings_part_t::ip, proto_field_t::ip, nullptr, 0, 15},
{settings_part_t::ip, proto_field_t::gateway, nullptr, 0, 15},
{settings_part_t::ip, proto_field_t::subnet, nullptr, 0, 15},
{settings_part_t::ip, proto_field_t::dns1, nullptr, 0, 15},
{settings_part_t::ip, proto_field_t::dns2, nullptr, 0, 15},
{settings_part_t::mqtt, proto_field_t::enabled, nullptr, 0, 0},
{settings_part_t::mqtt, proto_field_t::pubDisco, nullptr, 0, 0},
{settings_part_t::mqtt, proto_field_t::protocol, nullptr, 0, 9},
{settings_part_t::mqtt, proto_field_t::hostname, nullptr, 0, 64},
{settings_part_t::mqtt, proto_field_t::port, nullptr, 1, 65535},
{settings_part_t::mqtt, proto_field_t::username, nullptr, 0, 32},
{settings_part_t::mqtt, proto_field_t::password, nullptr, 0, 32},
{settings_part_t::mqtt, proto_field_t::rootTopic, nullptr, 0, 64},
{settings_part_t::mqtt, proto_field_t::discoTopic, nullptr, 0, 64},
// None, PIN or password (security_types).
{settings_part_t::security, proto_field_t::securityType, "type", 0, 2},
{settings_part_t::security, proto_field_t::username, nullptr, 0, 32},
{settings_part_t::security, proto_field_t::password, nullptr, 0, 32},
{settings_part_t::security, proto_field_t::pin, nullptr, 0, 4},
{settings_part_t::security, proto_field_t::permissions, nullptr, 0, 1},
// 56 or 80, checked on its own.
{settings_part_t::radio, proto_field_t::bitLength, "type", 56, 80},
{settings_part_t::radio, proto_field_t::proto, nullptr, 0, 9},
{settings_part_t::radio, proto_field_t::enabled, nullptr, 0, 0},
{settings_part_t::radio, proto_field_t::TXPin, nullptr, 0, 48},
{settings_part_t::radio, proto_field_t::RXPin, nullptr, 0, 48},
{settings_part_t::radio, proto_field_t::SCKPin, nullptr, 0, 48},
{settings_part_t::radio, proto_field_t::MOSIPin, nullptr, 0, 48},
{settings_part_t::radio, proto_field_t::MISOPin, nullptr, 0, 48},
{settings_part_t::radio, proto_field_t::CSNPin, nullptr, 0, 48},
{settings_part_t::radio, proto_field_t::frequency, nullptr, 0, 0},
{settings_part_t::radio, proto_field_t::rxBandwidth, nullptr, 0, 0},
{settings_part_t::radio, proto_field_t::deviation, nullptr, 0, 0},
{settings_part_t::radio, proto_field_t::txPower, nullptr, -30, 12},
{settings_part_t::radio, proto_field_t::noiseDetection, nullptr, 0, 0}
};
static const char *routeName(const settings_field_t &s) { return s.route ? s.route : protoField(s.field).name; }
static const settings_field_t *saved(settings_section_t section, proto_field_t field) {
for(const settings_field_t &s : FIELDS)
if(s.field == field && sectionOf(s.part) == section) return &s;
return nullptr;
}
const char *parseSettingsSection(JsonObjectConst d, settings_section_t &section) {
const char *name = protoString(d, proto_field_t::section);
if(!name) return "section is missing";
for(uint8_t i = 0; i < sizeof(SECTIONS) / sizeof(SECTIONS[0]); i++) {
if(strcmp(SECTIONS[i], name) != 0) continue;
section = static_cast<settings_section_t>(i);
return nullptr;
}
return "Unknown section";
}
// Four numbers 0-255 between dots.
static bool isAddress(const char *s) {
for(int part = 0; part < 4; part++) {
if(part > 0 && *s++ != '.') return false;
if(*s < '0' || *s > '9') return false;
char *end;
const long n = strtol(s, &end, 10);
if(n > 255 || end - s > 3) return false;
s = end;
}
return *s == '\0';
}
// What the reply's desc points at; every parse runs on async_tcp, one at a
// time.
static char why[64];
// A value already of its field's kind: nullptr when it is in range.
static const char *checkValue(const settings_field_t &s, const proto_field_info_t &f, JsonVariant value) {
if(f.kind == 'i') {
const bool inRange = value.is<int64_t>() && value.as<int64_t>() >= s.lo && value.as<int64_t>() <= s.hi;
if(s.field == proto_field_t::bitLength) {
if(inRange && (value.as<int>() == 56 || value.as<int>() == 80)) return nullptr;
snprintf(why, sizeof(why), "%s must be 56 or 80", f.name);
return why;
}
if(inRange) return nullptr;
snprintf(why, sizeof(why), "%s must be %ld-%ld", f.name, (long)s.lo, (long)s.hi);
return why;
}
if(f.kind != 's') return nullptr;
const char *text = value.as<const char *>();
if(strlen(text) > static_cast<size_t>(s.hi)) {
snprintf(why, sizeof(why), "%s is longer than %ld characters", f.name, (long)s.hi);
return why;
}
if(s.part != settings_part_t::ip) return nullptr;
// The settings send 0.0.0.0 as "", so "" sets it back.
if(!text[0]) value.set("0.0.0.0");
else if(!isAddress(text)) {
snprintf(why, sizeof(why), "%s is not an IP address", f.name);
return why;
}
return nullptr;
}
const char *expandSettingsPayload(JsonObjectConst d, bool hasEthernet, settings_section_t &section, JsonDocument &doc) {
doc.clear();
const char *bad = parseSettingsSection(d, section);
if(bad) return bad;
JsonObject obj = doc.to<JsonObject>();
for(JsonPairConst kv : d) {
proto_field_t field;
if(!protoFieldOfKey(kv.key().c_str(), field)) {
snprintf(why, sizeof(why), "Unknown field %s", kv.key().c_str());
return why;
}
if(field == proto_field_t::section) continue;
const proto_field_info_t &f = protoField(field);
const settings_field_t *s = saved(section, field);
if(!s) {
snprintf(why, sizeof(why), "%s can't be saved in %s", f.name, settingsSectionName(section));
return why;
}
if(s->part == settings_part_t::ethernet && !hasEthernet) return "This device has no Ethernet";
JsonVariant value = obj[routeName(*s)].to<JsonVariant>();
if(!protoRouteValue(f, kv.value(), value)) {
snprintf(why, sizeof(why), "%s is not a %s", f.name, protoKindName(f.kind));
return why;
}
bad = checkValue(*s, f, value);
if(bad) return bad;
}
if(obj.size() == 0) return "Nothing to save";
return doc.overflowed() ? "Too large" : nullptr;
}
uint16_t settingsParts(settings_section_t section, JsonObjectConst request) {
uint16_t parts = 0;
for(JsonPairConst kv : request)
for(const settings_field_t &s : FIELDS)
if(sectionOf(s.part) == section && strcmp(routeName(s), kv.key().c_str()) == 0)
parts |= 1u << static_cast<uint8_t>(s.part);
return parts;
}

50
src/ProtoSettings.h Normal file
View file

@ -0,0 +1,50 @@
#ifndef PROTO_SETTINGS_H
#define PROTO_SETTINGS_H
#include <stdint.h>
#include <ArduinoJson.h>
#include "ProtoSchema.h"
// Socket protocol 2's settings ops (docs/PROTOCOL.md): the sections that
// getSettings and saveSettings name, and what a save's payload becomes. A
// save is the settings route's request: its payload, in full names, is what
// loop() hands to the settings' own fromJSON(), so the rules stay in one
// place. Kept free of the settings and the web server so it can be unit
// tested on the PC (pio test -e native).
enum class settings_section_t : uint8_t {
general, // hostname, SSDP and the clock
network, // the connection, WiFi, Ethernet and the IP address
mqtt,
security,
radio
};
// The settings each part of a section belongs to, which loop() saves only
// when a field of it was sent.
enum class settings_part_t : uint8_t {
general, // ConfigSettings: hostname, ssdpBroadcast
ntp, // NTPSettings
conn, // ConfigSettings: connType
wifi, // WifiSettings
ethernet, // EthernetSettings
ip, // IPSettings
mqtt, // MQTTSettings
security, // SecuritySettings
radio // transceiver_config_t
};
const char *settingsSectionName(settings_section_t section);
// The section that payload d names in section. nullptr when it is good;
// otherwise what is wrong with it, for a bad request reply.
const char *parseSettingsSection(JsonObjectConst d, settings_section_t &section);
// saveSettings: the section d names, and in doc its fields under the names
// the route reads. Each is checked against its kind and its range or length,
// so nothing is cut short or wraps. An address may be "" to clear it. As
// with every op, only what was sent is in it; a field the section doesn't
// save, such as hasPassword, is refused. Without hasEthernet the Ethernet
// fields are refused too. nullptr when it is good; otherwise what is wrong.
const char *expandSettingsPayload(JsonObjectConst d, bool hasEthernet, settings_section_t &section, JsonDocument &doc);
// The parts that a save's request (from expandSettingsPayload) sets, as bits
// 1 << settings_part_t. A route name such as hostname means another setting
// in another section, hence the section.
uint16_t settingsParts(settings_section_t section, JsonObjectConst request);
inline bool settingsSets(uint16_t parts, settings_part_t part) { return parts & (1u << static_cast<uint8_t>(part)); }
#endif

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;

View file

@ -31,6 +31,21 @@ class ProtoSocket {
void replyEdit(const proto_reply_to_t &to, model_edit_type_t type, const edit_result_t &result);
// The reply to a read op, from the shade model. loop() only.
void replyRead(const proto_reply_to_t &to, uint8_t op, uint8_t groupId);
// Any other reply formatted on loop(): ok, and the fields written between
// the two. One too large for a frame goes as an internal error.
ProtoWriter beginReply(const proto_reply_to_t &to);
void endReply(const proto_reply_to_t &to);
void replyRefused(const proto_reply_to_t &to, proto_error_t code, const char *desc);
// settingsChanged, to every client but the one that saved them.
void settingsChanged(uint32_t savedBy, const char *section);
// Closes every client but keep, as a security save does: their tokens may
// no longer be good.
void closeAllBut(uint32_t keep);
// wifiNetworks goes to the clients that asked for a scan since the last
// one, once it is done: count is WiFi.scanComplete(), and the networks
// are WiFi's scan results.
void wantsWifiNetworks(uint32_t clientId);
void wifiNetworks(int16_t count);
// Events, loop() only.
void shadeState(SomfyShade &shade);
void shade(SomfyShade &shade);