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
237
src/DeviceOps.cpp
Normal file
237
src/DeviceOps.cpp
Normal 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
28
src/DeviceOps.h
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
190
src/ProtoSettings.cpp
Normal 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 §ion) {
|
||||
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 §ion, 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
50
src/ProtoSettings.h
Normal 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 §ion);
|
||||
// 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 §ion, 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue