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
|
|
@ -39,8 +39,8 @@ The keys of `hello` (`fw`, `sh`) and of the schema itself (`T`, `F`, `E`, `Q`) n
|
|||
| Range | Sender | Types |
|
||||
|---|---|---|
|
||||
| 1–15 | control | `hello` 1, `reply` 2, `next` 3, `sync` 4, `syncEnd` 5, `subscribe` 6, `unsubscribe` 7 |
|
||||
| 16–127 | client ops | commands: `shadeCommand` 16, `groupCommand` 17, `tiltCommand` 18, `repeatCommand` 19, `setPositions` 20, `setSensor` 21, `setMyPosition` 22, `unpairShade` 23, `setRollingCode` 24, `sendRemoteCommand` 25; edits: 26–44 (`addRoom` … `groupSortOrder`); reads: `getNextRoom` 45, `getNextShade` 46, `getNextGroup` 47, `groupOptions` 48; settings follow |
|
||||
| 128–255 | device events | `shadeState`, `shade`, `shadeRemoved`, `group`, `groupState`, `groupRemoved`, `room`, `roomRemoved`, `roomOrder`, `shadeOrder`, `groupOrder`, `shadeCommanded`, `memory`, `wifi`, `ethernet`, `frame`, `frequencyScan`, `repeaters` |
|
||||
| 16–127 | client ops | commands: `shadeCommand` 16, `groupCommand` 17, `tiltCommand` 18, `repeatCommand` 19, `setPositions` 20, `setSensor` 21, `setMyPosition` 22, `unpairShade` 23, `setRollingCode` 24, `sendRemoteCommand` 25; edits: 26–44 (`addRoom` … `groupSortOrder`); reads: `getNextRoom` 45, `getNextShade` 46, `getNextGroup` 47, `groupOptions` 48; settings and the device: `getSettings` 49, `saveSettings` 50, `scanWifi` 51, `beginFrequencyScan` 52, `endFrequencyScan` 53, `reboot` 54 |
|
||||
| 128–255 | device events | `shadeState`, `shade`, `shadeRemoved`, `group`, `groupState`, `groupRemoved`, `room`, `roomRemoved`, `roomOrder`, `shadeOrder`, `groupOrder`, `shadeCommanded`, `memory`, `wifi`, `ethernet`, `frame`, `frequencyScan`, `repeaters`, `settingsChanged`, `wifiNetworks` |
|
||||
|
||||
A number is never reused. A retired type stays reserved.
|
||||
|
||||
|
|
@ -73,6 +73,8 @@ Events go out to every client from the moment it connects, so they can arrive du
|
|||
| `repeaters` | a repeater was linked or unlinked | the list |
|
||||
| `shadeCommanded` | a remote or the device commanded a shade | id, address, command, source, rolling code, source address |
|
||||
| `memory`, `wifi`, `ethernet` | every 30 s, WiFi only when the signal moved | |
|
||||
| `settingsChanged` | another client saved settings | `section` |
|
||||
| `wifiNetworks` | a WiFi scan this client asked for is done; to that client only | `networks`, each with `ssid`, `channel`, `strength`, `macAddress` |
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -124,6 +126,44 @@ What an edit form needs before it is sent, worked out by the device as the edit
|
|||
|
||||
When every slot is taken the reply is a bad request; `groupOptions` for a group that doesn't exist is not found.
|
||||
|
||||
## Settings
|
||||
|
||||
The settings are in five sections. `getSettings` and `saveSettings` name one in `section`:
|
||||
|
||||
| Section | Fields | The routes it replaces |
|
||||
|---|---|---|
|
||||
| `general` | `hostname`, `ssdpBroadcast`, `ntpServer`, `posixZone` | `/modulesettings`, `/setgeneral` |
|
||||
| `network` | `connMode`; WiFi: `ssid`, `passphrase`, `roaming`, `hidden`; Ethernet: `boardType`, `phyType`, `CLKMode`, `phyAddress`, `PWRPin`, `MDCPin`, `MDIOPin`; the address: `dhcp`, `ip`, `gateway`, `subnet`, `dns1`, `dns2` | `/networksettings`, `/setNetwork`, `/setIP`, `/connectwifi` |
|
||||
| `mqtt` | `enabled`, `pubDisco`, `protocol`, `hostname` (the broker's), `port`, `username`, `password`, `rootTopic`, `discoTopic` | `/mqttsettings`, `/connectmqtt` |
|
||||
| `security` | `securityType`, `username`, `password`, `pin`, `permissions` | `/getSecurity`, `/saveSecurity` |
|
||||
| `radio` | `bitLength`, `proto`, `enabled`, `TXPin`, `RXPin`, `SCKPin`, `MOSIPin`, `MISOPin`, `CSNPin`, `frequency`, `rxBandwidth`, `deviation`, `txPower`, `noiseDetection` | `/getRadio`, `/saveRadio` |
|
||||
|
||||
- **Reading.** `getSettings` replies with the section's fields. A secret is never sent: `hasPassphrase`, `hasPassword` and `hasPin` say whether one is set. `network` also has `hasEthernet`, and has the Ethernet fields only on a device with Ethernet. `radio` also has `radioInit`.
|
||||
- `connMode` is the connection the settings ask for: 1 WiFi, 2 Ethernet, 3 Ethernet with WiFi as the fallback. The header's `connType` is the connection in use.
|
||||
- **Saving.** `saveSettings` takes the section and the fields to change. As with every op, a field left out stays as it is. A secret sent empty keeps the stored one, except alongside a new `ssid` or MQTT `username`, as on the routes.
|
||||
- Every field is checked before anything is saved:
|
||||
- its kind;
|
||||
- its range: `port` 1–65535, `txPower` -30–12, `bitLength` 56 or 80, a pin 0–48, and so on;
|
||||
- a string's length, which is what the setting holds: `hostname` 31, `pin` 4, and so on;
|
||||
- an address: four numbers 0–255, or `""` for none.
|
||||
|
||||
A bad request also comes for a field the section doesn't save (a `has*` field, `radioInit`, a field of another section, Ethernet on a device without it), and for a save with no fields.
|
||||
- One save runs at a time, whichever client sent it: while one is waiting, another gets busy.
|
||||
- The reply comes once the settings are saved and applied, as their route applies them. A saved time zone takes effect at once, where the route waited for a reboot.
|
||||
- `rebooting` 1 means the device restarts a second later for them. That happens for a new `connMode`, for a new SSID or passphrase while on WiFi with nobody on the soft AP, and for Ethernet settings while on Ethernet.
|
||||
- A security save's reply has the client's new token in `apiKey`, or none under None.
|
||||
- The other clients get `settingsChanged` with the section. After a security save they are closed instead, since their tokens may no longer be good. They connect again with what they have, and log in when it is refused.
|
||||
- `/connectwifi` refused an SSID the device couldn't hear; the save doesn't check. The check stalled the device for a scan, and a hidden network can't be heard anyway. A client offers the networks from `scanWifi`.
|
||||
|
||||
| Op | Payload | Reply |
|
||||
|---|---|---|
|
||||
| `scanWifi` | | ok at once. When the scan is done, `wifiNetworks` goes to each client that asked since the last one: the named networks that can be heard, at most 30. A failed scan lists none. |
|
||||
| `beginFrequencyScan` | | ok, or radio unavailable when the radio is disabled. The radio sends nothing until the scan ends. Subscribe to the `frequencyScan` feed to follow it. |
|
||||
| `endFrequencyScan` | | ok. A scan nobody ends stops after 5 minutes. |
|
||||
| `reboot` | | ok, and the device restarts half a second later. |
|
||||
|
||||
The frequency scan ops and `reboot` run at once, as their routes do, not through `loop()`. A scan's end mustn't wait in the loop queue behind an unpair that is itself waiting for the scan to end.
|
||||
|
||||
## Replies
|
||||
|
||||
A client message with an `id` gets exactly one `reply` with that `id`, to that client only, whether it is an op or a control message such as `next`: `ok` 1 on success, or an error `code` and a `desc` for people. Error codes are in the schema: bad request, unknown op, unauthorized, not found, busy, radio unavailable, internal.
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
- **Handshake:** the socket is `ws://host/ws` on port 80. The token goes in `?apikey=<token>`, because browsers can't set headers on a websocket. `handleHandshake` checks it with the same comparison the HTTP routes use. `request->url()` is the path only, so the token doesn't reach the slow-request log.
|
||||
- **Rules:** under None anyone can connect. Under Secured the handshake needs a valid token, and a connection that has one may send every op and subscribe to every feed.
|
||||
- **Logging in with a socket already open:** the UI reconnects the socket with the token. There is no in-band upgrade message.
|
||||
- **Changing the security settings:** a `saveSettings` of the security section closes every other socket, since their tokens may no longer be good, and a socket opened under None had none. They connect again and log in when refused. The client that saved gets its new token in the reply.
|
||||
- **Origin:** a handshake whose `Origin` header doesn't match `Host` is refused.
|
||||
|
||||
### CORS
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ build_flags =
|
|||
platform = native
|
||||
test_framework = unity
|
||||
test_build_src = yes
|
||||
build_src_filter = -<*> +<Utils.cpp> +<SomfyFrame.cpp> +<JsonFormatter.cpp> +<ShadeMotion.cpp> +<ShadeSensors.cpp> +<ShadeBehaviour.cpp> +<PulseDecoder.cpp> +<TxBuffer.cpp> +<RadioScheduler.cpp> +<ShadeRequests.cpp> +<entryPoints/MqttCommands.cpp> +<ModelSnapshot.cpp> +<ModelEditor.cpp> +<ShadeChanges.cpp> +<ProtoSchema.cpp> +<ProtoFrame.cpp> +<ProtoSync.cpp> +<ProtoParse.cpp> +<ProtoSlots.cpp> +<ProtoOps.cpp> +<ProtoEdits.cpp> +<ProtoAssembly.cpp>
|
||||
build_src_filter = -<*> +<Utils.cpp> +<SomfyFrame.cpp> +<JsonFormatter.cpp> +<ShadeMotion.cpp> +<ShadeSensors.cpp> +<ShadeBehaviour.cpp> +<PulseDecoder.cpp> +<TxBuffer.cpp> +<RadioScheduler.cpp> +<ShadeRequests.cpp> +<entryPoints/MqttCommands.cpp> +<ModelSnapshot.cpp> +<ModelEditor.cpp> +<ShadeChanges.cpp> +<ProtoSchema.cpp> +<ProtoFrame.cpp> +<ProtoSync.cpp> +<ProtoParse.cpp> +<ProtoSlots.cpp> +<ProtoOps.cpp> +<ProtoEdits.cpp> +<ProtoSettings.cpp> +<ProtoAssembly.cpp>
|
||||
lib_deps =
|
||||
bblanchon/ArduinoJson@^7.2.2
|
||||
build_flags =
|
||||
|
|
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ Runs against a real device on your network, by hand, never in CI. It checks sock
|
|||
| `--password`, `--username` / `--pin` | the credentials of a Secured device |
|
||||
| `--radio --shade <id>` | also send real commands: that shade moves a little and back |
|
||||
| `--edit` | also add a room and delete it again |
|
||||
| `--reboot` | also reboot the device and wait for it to come back |
|
||||
| `--record` | save hello, the sync and the schema to `test/fixtures/proto2/`, for the web UI and Home Assistant tests |
|
||||
|
||||
A plain run sends nothing on air, changes no settings and doesn't reboot the device. The command tests send ops that are refused before they reach the radio, and one `setPositions` that sets a shade to the position the sync gave: that drops the fraction of a percent the shade's believed position may have had. The edit tests send edits that are refused, and one shade sort order set to the order it has, which saves the shades again unchanged. It does fill every socket slot for a few seconds, and replays a page load.
|
||||
A plain run sends nothing on air, changes no settings and doesn't reboot the device. The command tests send ops that are refused before they reach the radio, and one `setPositions` that sets a shade to the position the sync gave: that drops the fraction of a percent the shade's believed position may have had. The edit tests send edits that are refused, and one shade sort order set to the order it has, which saves the shades again unchanged. The settings tests send saves that are refused, and one that saves the NTP server and time zone the device already has. The frequency scan test pauses the radio's sending for a second or two, as a scan does. It does fill every socket slot for a few seconds, and replays a page load.
|
||||
|
||||
The tests run in whatever security mode the device is in; the suite never changes it. To test both modes, run the suite once in each.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ def pytest_addoption(parser):
|
|||
parser.addoption("--radio", action="store_true", help="also run the tests that send commands on air")
|
||||
parser.addoption("--shade", type=int, default=None, help="with --radio: the id of the shade the commands may move")
|
||||
parser.addoption("--edit", action="store_true", help="also run the edits that change the shade model, then undo them")
|
||||
parser.addoption("--reboot", action="store_true", help="also run the test that reboots the device")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
|
|
|
|||
137
test/e2e/test_settings.py
Normal file
137
test/e2e/test_settings.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""The settings ops, the WiFi and frequency scans, and reboot (docs/PROTOCOL.md).
|
||||
Without --reboot nothing changes: saves are refused, or save what is already
|
||||
there, and nothing goes on air."""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from proto_client import ProtoClient, run_sync
|
||||
from test_commands import is_ok, op, synced_model
|
||||
from test_messages import error_name
|
||||
|
||||
SETTINGS_OPS = ("getSettings", "saveSettings", "scanWifi", "beginFrequencyScan", "endFrequencyScan", "reboot")
|
||||
SECTIONS = ("general", "network", "mqtt", "security", "radio")
|
||||
SECRETS = ("password", "passphrase", "pin")
|
||||
|
||||
|
||||
def desc(schema, reply):
|
||||
return reply["d"].get(schema.keys["desc"])
|
||||
|
||||
|
||||
def settings(c, schema, section):
|
||||
reply = op(c, schema, "getSettings", {"section": section})
|
||||
assert is_ok(schema, reply), desc(schema, reply)
|
||||
return schema.expand(reply["d"])
|
||||
|
||||
|
||||
def test_every_settings_op_is_in_the_schema(schema):
|
||||
for name in SETTINGS_OPS:
|
||||
assert 16 <= schema.type_numbers[name] <= 127, name
|
||||
for name in ("settingsChanged", "wifiNetworks"):
|
||||
assert schema.type_numbers[name] >= 128, name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("section", SECTIONS)
|
||||
def test_every_section_reads_without_its_secrets(connect, schema, section):
|
||||
c, _, _ = synced_model(connect, schema)
|
||||
got = settings(c, schema, section)
|
||||
for name in SECRETS:
|
||||
assert name not in got, name
|
||||
|
||||
|
||||
def test_the_sections_hold_what_the_routes_send(connect, schema):
|
||||
c, _, _ = synced_model(connect, schema)
|
||||
assert settings(c, schema, "general").get("hostname")
|
||||
assert settings(c, schema, "radio").get("bitLength", schema.default("bitLength")) in (56, 80)
|
||||
assert settings(c, schema, "mqtt").get("port", 0) > 0
|
||||
|
||||
|
||||
def test_an_unknown_section_is_a_bad_request(connect, schema):
|
||||
c, _, _ = synced_model(connect, schema)
|
||||
reply = op(c, schema, "getSettings", {"section": "wifi"})
|
||||
assert error_name(schema, reply) == "badRequest"
|
||||
assert desc(schema, reply) == "Unknown section"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("section, fields, why", [
|
||||
("mqtt", {}, "Nothing to save"),
|
||||
("mqtt", {"hasPassword": 1}, "hasPassword can't be saved in mqtt"),
|
||||
("general", {"ssid": "x"}, "ssid can't be saved in general"),
|
||||
("network", {"connMode": 9}, "connMode must be 1-3"),
|
||||
("network", {"ip": "10.0.0"}, "ip is not an IP address"),
|
||||
("radio", {"bitLength": 60}, "bitLength must be 56 or 80"),
|
||||
("security", {"pin": "12345"}, "pin is longer than 4 characters"),
|
||||
])
|
||||
def test_a_save_is_checked_before_it_runs(connect, schema, section, fields, why):
|
||||
c, _, _ = synced_model(connect, schema)
|
||||
reply = op(c, schema, "saveSettings", dict(fields, section=section))
|
||||
assert error_name(schema, reply) == "badRequest"
|
||||
assert desc(schema, reply) == why
|
||||
|
||||
|
||||
def test_a_save_tells_the_other_clients(connect, schema):
|
||||
c, _, _ = synced_model(connect, schema)
|
||||
other, _, _ = synced_model(connect, schema)
|
||||
general = settings(c, schema, "general")
|
||||
# What is there already, so nothing changes.
|
||||
same = {name: general.get(name, schema.default(name)) for name in ("ntpServer", "posixZone")}
|
||||
reply = op(c, schema, "saveSettings", dict(same, section="general"))
|
||||
assert is_ok(schema, reply), desc(schema, reply)
|
||||
assert not schema.expand(reply["d"]).get("rebooting")
|
||||
changed = other.recv_until(lambda f: f["t"] == schema.type_numbers["settingsChanged"])[-1]
|
||||
assert schema.expand(changed["d"])["section"] == "general"
|
||||
assert settings(c, schema, "general") == general
|
||||
|
||||
|
||||
def test_a_wifi_scan_lists_the_networks(connect, schema):
|
||||
c, _, _ = synced_model(connect, schema)
|
||||
if settings(c, schema, "network").get("connMode") != 1:
|
||||
pytest.skip("the device isn't on WiFi")
|
||||
assert is_ok(schema, op(c, schema, "scanWifi", {}))
|
||||
found = c.recv_until(lambda f: f["t"] == schema.type_numbers["wifiNetworks"], timeout=20)[-1]
|
||||
networks = schema.expand(found["d"]).get("networks", [])
|
||||
assert networks, "no networks, not even the one the device is on"
|
||||
for n in networks:
|
||||
assert n["ssid"] and n["macAddress"]
|
||||
|
||||
|
||||
def test_a_frequency_scan_starts_and_ends(connect, schema):
|
||||
c, _, _ = synced_model(connect, schema)
|
||||
scan = schema.type_numbers["frequencyScan"]
|
||||
assert is_ok(schema, op(c, schema, "subscribe", {"feed": schema.feed_numbers["frequencyScan"]}))
|
||||
reply = op(c, schema, "beginFrequencyScan", {})
|
||||
if not is_ok(schema, reply):
|
||||
assert error_name(schema, reply) == "radioUnavailable"
|
||||
pytest.skip("the radio is disabled")
|
||||
try:
|
||||
started = c.recv_until(lambda f: f["t"] == scan, timeout=5)[-1]
|
||||
assert schema.expand(started["d"]).get("scanning")
|
||||
finally:
|
||||
assert is_ok(schema, op(c, schema, "endFrequencyScan", {}))
|
||||
ended = c.recv_until(lambda f: f["t"] == scan and not schema.expand(f["d"]).get("scanning"), timeout=5)[-1]
|
||||
assert ended
|
||||
|
||||
|
||||
def test_reboot(request, host, token, connect, schema):
|
||||
if not request.config.getoption("--reboot"):
|
||||
pytest.skip("reboots the device: pass --reboot")
|
||||
c, _, _ = synced_model(connect, schema)
|
||||
assert is_ok(schema, op(c, schema, "reboot", {}))
|
||||
assert c.closed.wait(15), "the socket stayed open"
|
||||
# Back once it answers a connect with hello again.
|
||||
end = time.time() + 90
|
||||
while True:
|
||||
assert time.time() < end, "the device didn't come back"
|
||||
try:
|
||||
back = ProtoClient(host, params={"apikey": token} if token else None, timeout=3)
|
||||
except OSError:
|
||||
time.sleep(2)
|
||||
continue
|
||||
try:
|
||||
if back.status == 101:
|
||||
hello, _, _ = run_sync(back)
|
||||
assert hello["d"]["sh"] == schema.hash
|
||||
return
|
||||
finally:
|
||||
back.close()
|
||||
time.sleep(2)
|
||||
|
|
@ -60,6 +60,13 @@ static void test_every_op_says_what_becomes_of_it() {
|
|||
TEST_ASSERT_EQUAL(proto_op_t::remoteFrame, protoOpKind(static_cast<uint8_t>(proto_type_t::sendRemoteCommand)));
|
||||
TEST_ASSERT_EQUAL(proto_op_t::edit, protoOpKind(static_cast<uint8_t>(proto_type_t::saveShade)));
|
||||
TEST_ASSERT_EQUAL(proto_op_t::read, protoOpKind(static_cast<uint8_t>(proto_type_t::groupOptions)));
|
||||
TEST_ASSERT_EQUAL(proto_op_t::device, protoOpKind(static_cast<uint8_t>(proto_type_t::getSettings)));
|
||||
TEST_ASSERT_EQUAL(proto_op_t::device, protoOpKind(static_cast<uint8_t>(proto_type_t::saveSettings)));
|
||||
TEST_ASSERT_EQUAL(proto_op_t::device, protoOpKind(static_cast<uint8_t>(proto_type_t::scanWifi)));
|
||||
// A scan's end mustn't wait in the loop queue.
|
||||
TEST_ASSERT_EQUAL(proto_op_t::atOnce, protoOpKind(static_cast<uint8_t>(proto_type_t::beginFrequencyScan)));
|
||||
TEST_ASSERT_EQUAL(proto_op_t::atOnce, protoOpKind(static_cast<uint8_t>(proto_type_t::endFrequencyScan)));
|
||||
TEST_ASSERT_EQUAL(proto_op_t::atOnce, protoOpKind(static_cast<uint8_t>(proto_type_t::reboot)));
|
||||
TEST_ASSERT_EQUAL(proto_op_t::none, protoOpKind(250));
|
||||
}
|
||||
static void test_a_field_by_its_key() {
|
||||
|
|
|
|||
202
test/test_protosettings/test_protosettings.cpp
Normal file
202
test/test_protosettings/test_protosettings.cpp
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
#include <unity.h>
|
||||
#include <string>
|
||||
#include <ArduinoJson.h>
|
||||
#include "ProtoSettings.h"
|
||||
|
||||
// Socket protocol 2's settings ops: the sections, and the settings route's
|
||||
// request that a save's payload becomes (docs/PROTOCOL.md).
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
static JsonDocument payload;
|
||||
static JsonDocument doc;
|
||||
static settings_section_t section;
|
||||
|
||||
static void load(const char *json) {
|
||||
payload.clear();
|
||||
TEST_ASSERT_EQUAL(DeserializationError::Ok, deserializeJson(payload, json).code());
|
||||
}
|
||||
static const char *expand(const char *json, bool hasEthernet = true) {
|
||||
load(json);
|
||||
return expandSettingsPayload(payload.as<JsonObjectConst>(), hasEthernet, section, doc);
|
||||
}
|
||||
static const char *sectionOf(const char *json) {
|
||||
load(json);
|
||||
return parseSettingsSection(payload.as<JsonObjectConst>(), section);
|
||||
}
|
||||
static std::string written() {
|
||||
std::string out;
|
||||
serializeJson(doc, out);
|
||||
return out;
|
||||
}
|
||||
static uint16_t parts() { return settingsParts(section, doc.as<JsonObjectConst>()); }
|
||||
static uint16_t bit(settings_part_t part) { return 1u << static_cast<uint8_t>(part); }
|
||||
#define TEST_ASSERT_GOOD(why) TEST_ASSERT_NULL_MESSAGE(why, why)
|
||||
|
||||
/*********************************************************************
|
||||
* Sections
|
||||
********************************************************************/
|
||||
static void test_every_section_by_its_name() {
|
||||
const settings_section_t all[] = {settings_section_t::general, settings_section_t::network, settings_section_t::mqtt,
|
||||
settings_section_t::security, settings_section_t::radio};
|
||||
for(settings_section_t s : all) {
|
||||
std::string json = std::string(R"({"se":")") + settingsSectionName(s) + "\"}";
|
||||
TEST_ASSERT_GOOD(sectionOf(json.c_str()));
|
||||
TEST_ASSERT_EQUAL(s, section);
|
||||
}
|
||||
TEST_ASSERT_EQUAL_STRING("general", settingsSectionName(settings_section_t::general));
|
||||
TEST_ASSERT_EQUAL_STRING("radio", settingsSectionName(settings_section_t::radio));
|
||||
}
|
||||
static void test_a_section_must_be_named() {
|
||||
TEST_ASSERT_EQUAL_STRING("section is missing", sectionOf(R"({})"));
|
||||
TEST_ASSERT_EQUAL_STRING("section is missing", sectionOf(R"({"se":2})"));
|
||||
TEST_ASSERT_EQUAL_STRING("Unknown section", sectionOf(R"({"se":"wifi"})"));
|
||||
// Names are matched as they are.
|
||||
TEST_ASSERT_EQUAL_STRING("Unknown section", sectionOf(R"({"se":"General"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("section is missing", expand(R"({"hn":"x"})"));
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
* A save's payload as the route's request
|
||||
********************************************************************/
|
||||
static void test_short_keys_become_the_route_names() {
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"general","hn":"Somfy","ns":"pool.ntp.org"})"));
|
||||
TEST_ASSERT_EQUAL(settings_section_t::general, section);
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"hostname":"Somfy","ntpServer":"pool.ntp.org"})", written().c_str());
|
||||
}
|
||||
static void test_fields_named_apart_from_the_dictionary() {
|
||||
// connType in the header is the connection in use; the setting is connMode.
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"network","cn":2})"));
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"connType":2})", written().c_str());
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"security","sy":1,"pn":"1234"})"));
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"type":1,"pin":"1234"})", written().c_str());
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"radio","bl":80})"));
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"type":80})", written().c_str());
|
||||
}
|
||||
static void test_booleans_become_true_and_false() {
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"mqtt","en":1,"pd":0})"));
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"enabled":true,"pubDisco":false})", written().c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("enabled is not a boolean (1 or 0)", expand(R"({"se":"radio","en":true})"));
|
||||
}
|
||||
static void test_values_are_checked_against_their_kind() {
|
||||
TEST_ASSERT_EQUAL_STRING("port is not a whole number", expand(R"({"se":"mqtt","po":"1883"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("hostname is not a string", expand(R"({"se":"general","hn":5})"));
|
||||
TEST_ASSERT_EQUAL_STRING("frequency is not a number", expand(R"({"se":"radio","fq":"433"})"));
|
||||
}
|
||||
static void test_a_number_may_be_whole() {
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"radio","fq":433,"bw":99.97})"));
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"frequency":433,"rxBandwidth":99.97})", written().c_str());
|
||||
}
|
||||
static void test_whole_numbers_are_checked_against_what_the_setting_holds() {
|
||||
TEST_ASSERT_EQUAL_STRING("connMode must be 1-3", expand(R"({"se":"network","cn":4})"));
|
||||
TEST_ASSERT_EQUAL_STRING("connMode must be 1-3", expand(R"({"se":"network","cn":0})"));
|
||||
TEST_ASSERT_EQUAL_STRING("port must be 1-65535", expand(R"({"se":"mqtt","po":65536})"));
|
||||
TEST_ASSERT_EQUAL_STRING("txPower must be -30-12", expand(R"({"se":"radio","tp":13})"));
|
||||
TEST_ASSERT_EQUAL_STRING("TXPin must be 0-48", expand(R"({"se":"radio","tx":-1})"));
|
||||
TEST_ASSERT_EQUAL_STRING("securityType must be 0-2", expand(R"({"se":"security","sy":3})"));
|
||||
TEST_ASSERT_EQUAL_STRING("port must be 1-65535", expand(R"({"se":"mqtt","po":18446744073709551615})"));
|
||||
// A pin of -1 is none.
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"network","pw":-1})"));
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"radio","tp":-30})"));
|
||||
}
|
||||
static void test_the_bit_length_is_56_or_80() {
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"radio","bl":56})"));
|
||||
TEST_ASSERT_EQUAL_STRING("bitLength must be 56 or 80", expand(R"({"se":"radio","bl":60})"));
|
||||
TEST_ASSERT_EQUAL_STRING("bitLength must be 56 or 80", expand(R"({"se":"radio","bl":0})"));
|
||||
}
|
||||
static void test_strings_must_fit_their_setting() {
|
||||
// hostname holds 31 characters, a PIN 4.
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"general","hn":"0123456789012345678901234567890"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("hostname is longer than 31 characters", expand(R"({"se":"general","hn":"01234567890123456789012345678901"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("pin is longer than 4 characters", expand(R"({"se":"security","pn":"12345"})"));
|
||||
// The broker's hostname is another setting, with room for 64.
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"mqtt","hn":"01234567890123456789012345678901"})"));
|
||||
}
|
||||
static void test_addresses_are_checked_and_empty_clears_them() {
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"network","dh":0,"ip":"192.168.1.20","gw":"192.168.1.1","su":"255.255.255.0","d1":"","d2":""})"));
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"dhcp":false,"ip":"192.168.1.20","gateway":"192.168.1.1","subnet":"255.255.255.0","dns1":"0.0.0.0","dns2":"0.0.0.0"})", written().c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("ip is not an IP address", expand(R"({"se":"network","ip":"192.168.1"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("gateway is not an IP address", expand(R"({"se":"network","gw":"192.168.1.256"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("dns1 is not an IP address", expand(R"({"se":"network","d1":"1.2.3.4.5"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("dns2 is not an IP address", expand(R"({"se":"network","d2":"a.b.c.d"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("ip is not an IP address", expand(R"({"se":"network","ip":"1..2.3"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("ip is longer than 15 characters", expand(R"({"se":"network","ip":"0001.0002.0003.0004"})"));
|
||||
}
|
||||
static void test_only_what_was_sent_is_in_the_request() {
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"network","ro":0})"));
|
||||
TEST_ASSERT_EQUAL_STRING(R"({"roaming":false})", written().c_str());
|
||||
}
|
||||
static void test_a_save_needs_something_to_save() {
|
||||
TEST_ASSERT_EQUAL_STRING("Nothing to save", expand(R"({"se":"mqtt"})"));
|
||||
}
|
||||
static void test_an_unknown_key_is_refused() {
|
||||
TEST_ASSERT_EQUAL_STRING("Unknown field zz", expand(R"({"se":"general","zz":1})"));
|
||||
}
|
||||
static void test_what_is_only_sent_is_refused() {
|
||||
// Secrets are only said to be set; a save sends the secret itself.
|
||||
TEST_ASSERT_EQUAL_STRING("hasPassword can't be saved in mqtt", expand(R"({"se":"mqtt","hw":1})"));
|
||||
TEST_ASSERT_EQUAL_STRING("hasPin can't be saved in security", expand(R"({"se":"security","hi":1})"));
|
||||
TEST_ASSERT_EQUAL_STRING("hasPassphrase can't be saved in network", expand(R"({"se":"network","hq":1})"));
|
||||
TEST_ASSERT_EQUAL_STRING("hasEthernet can't be saved in network", expand(R"({"se":"network","he":1})"));
|
||||
TEST_ASSERT_EQUAL_STRING("radioInit can't be saved in radio", expand(R"({"se":"radio","ri":1})"));
|
||||
}
|
||||
static void test_a_field_of_another_section_is_refused() {
|
||||
TEST_ASSERT_EQUAL_STRING("ssid can't be saved in general", expand(R"({"se":"general","sd":"home"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("ntpServer can't be saved in network", expand(R"({"se":"network","ns":"pool.ntp.org"})"));
|
||||
TEST_ASSERT_EQUAL_STRING("TXPin can't be saved in mqtt", expand(R"({"se":"mqtt","tx":12})"));
|
||||
TEST_ASSERT_EQUAL_STRING("shadeId can't be saved in radio", expand(R"({"se":"radio","s":1})"));
|
||||
}
|
||||
static void test_ethernet_needs_a_chip_with_it() {
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"network","bo":1})"));
|
||||
TEST_ASSERT_EQUAL_STRING("This device has no Ethernet", expand(R"({"se":"network","bo":1})", false));
|
||||
// The rest of the section saves without it.
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"network","sd":"home","pp":"secret"})", false));
|
||||
}
|
||||
|
||||
/*********************************************************************
|
||||
* The parts a save sets
|
||||
********************************************************************/
|
||||
static void test_a_save_sets_the_parts_it_has_fields_of() {
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"network","sd":"home","dh":1})"));
|
||||
TEST_ASSERT_EQUAL_UINT16(bit(settings_part_t::wifi) | bit(settings_part_t::ip), parts());
|
||||
TEST_ASSERT_TRUE(settingsSets(parts(), settings_part_t::wifi));
|
||||
TEST_ASSERT_FALSE(settingsSets(parts(), settings_part_t::conn));
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"network","cn":3,"ck":1})"));
|
||||
TEST_ASSERT_EQUAL_UINT16(bit(settings_part_t::conn) | bit(settings_part_t::ethernet), parts());
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"general","sb":1,"pz":"CET-1CEST"})"));
|
||||
TEST_ASSERT_EQUAL_UINT16(bit(settings_part_t::general) | bit(settings_part_t::ntp), parts());
|
||||
}
|
||||
static void test_a_route_name_belongs_to_its_section() {
|
||||
// hostname is the broker's in mqtt, and type the radio's bit length in radio.
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"mqtt","hn":"broker"})"));
|
||||
TEST_ASSERT_EQUAL_UINT16(bit(settings_part_t::mqtt), parts());
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"radio","bl":56})"));
|
||||
TEST_ASSERT_EQUAL_UINT16(bit(settings_part_t::radio), parts());
|
||||
TEST_ASSERT_GOOD(expand(R"({"se":"security","sy":0})"));
|
||||
TEST_ASSERT_EQUAL_UINT16(bit(settings_part_t::security), parts());
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_every_section_by_its_name);
|
||||
RUN_TEST(test_a_section_must_be_named);
|
||||
RUN_TEST(test_short_keys_become_the_route_names);
|
||||
RUN_TEST(test_fields_named_apart_from_the_dictionary);
|
||||
RUN_TEST(test_booleans_become_true_and_false);
|
||||
RUN_TEST(test_values_are_checked_against_their_kind);
|
||||
RUN_TEST(test_a_number_may_be_whole);
|
||||
RUN_TEST(test_whole_numbers_are_checked_against_what_the_setting_holds);
|
||||
RUN_TEST(test_the_bit_length_is_56_or_80);
|
||||
RUN_TEST(test_strings_must_fit_their_setting);
|
||||
RUN_TEST(test_addresses_are_checked_and_empty_clears_them);
|
||||
RUN_TEST(test_only_what_was_sent_is_in_the_request);
|
||||
RUN_TEST(test_a_save_needs_something_to_save);
|
||||
RUN_TEST(test_an_unknown_key_is_refused);
|
||||
RUN_TEST(test_what_is_only_sent_is_refused);
|
||||
RUN_TEST(test_a_field_of_another_section_is_refused);
|
||||
RUN_TEST(test_ethernet_needs_a_chip_with_it);
|
||||
RUN_TEST(test_a_save_sets_the_parts_it_has_fields_of);
|
||||
RUN_TEST(test_a_route_name_belongs_to_its_section);
|
||||
return UNITY_END();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue