fix: settings ops review findings

- The frequency scan ops and reboot run on loop() like every op (ADR 0005).
  endFrequencyScan goes to the front of the loop queue, so it never waits
  behind an unpair that waits for the scan to end.
- A security save that leaves the device Secured, or changes its mode,
  closes every client, the saver too once its reply has gone. None to None
  closes nobody.
- proto must be a radio_proto value; frequency, rxBandwidth and deviation
  are checked against what the CC1101 can be set to.
- wifiNetworks sends fewer networks instead of none when they don't fit.
- A client WiFi scan doesn't start while the device joins a network.
- rebootTime is set before reboot; cancelSettingsSave is freeSettingsSave.
- PROTOCOL.md: pins that may be -1, and the fields the routes sent that
  moved elsewhere. e2e: the WiFi scan test goes by the connection in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-25 10:27:53 +01:00
parent 2517f40709
commit 096eef0581
16 changed files with 158 additions and 93 deletions

View file

@ -143,7 +143,7 @@ The settings are in five sections. `getSettings` and `saveSettings` name one in
- **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;
- its range: `port` 1–65535, `txPower` -30–12, `bitLength` 56 or 80, `proto` 0, 1, 2, 8 or 9, a pin 0–48 (`PWRPin` and `phyAddress` may also be -1, for none), `frequency` 300–928, 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.
@ -152,8 +152,12 @@ The settings are in five sections. `getSettings` and `saveSettings` name one in
- 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`.
- The other clients get `settingsChanged` with the section.
- A security save that leaves the device Secured, or changes its mode, closes every client instead, the one that saved it too, once its reply has gone. Under Secured the tokens may no longer be good, and a client that connected under None had none. The saver connects again with the token from its reply; the others log in when theirs is refused. A save from None to None closes nobody.
- Where a section differs from its routes:
- `/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`.
- `/modulesettings` and `/networksettings` also sent `fwVersion` and `chipModel`, which are in the header (`fw`, `chipModel`). Their `connType` is `connMode` in `network`, and `/networksettings`' hostname and `ssdpBroadcast` are in `general`.
- `/scanaps` also sent the network the device is on: that is the `wifi` event, and `status` in the sync.
| Op | Payload | Reply |
|---|---|---|
@ -162,7 +166,7 @@ The settings are in five sections. `getSettings` and `saveSettings` name one in
| `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.
These ops run on `loop()` like the rest. `endFrequencyScan` goes ahead of whatever is waiting in the loop queue: an unpair waits there for the radio to send again, which only the scan's end allows.
## Replies

View file

@ -37,7 +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.
- **Changing the security settings:** a `saveSettings` of the security section that leaves the device Secured, or changes its mode, closes every socket, since their tokens may no longer be good, and a socket opened under None had none. The client that saved is closed once its reply, which carries its new token, has gone, so no socket stays open on a check it never passed. The others log in when refused.
- **Origin:** a handshake whose `Origin` header doesn't match `Host` is refused.
### CORS

View file

@ -31,7 +31,7 @@ settings_save_t *newSettingsSave(bool &busy) {
if(!save) saveBusy.store(false);
return save;
}
void cancelSettingsSave(settings_save_t *save) {
void freeSettingsSave(settings_save_t *save) {
delete save;
saveBusy.store(false);
}
@ -160,6 +160,7 @@ static void runSave(const proto_reply_to_t &to, settings_save_t *save) {
const uint16_t parts = settingsParts(section, req);
bool reboot = false;
char token[65] = "";
const security_types securityBefore = settings.Security.type;
switch(section) {
case settings_section_t::general:
if(settingsSets(parts, settings_part_t::general)) {
@ -195,19 +196,23 @@ static void runSave(const proto_reply_to_t &to, settings_save_t *save) {
break;
}
}
cancelSettingsSave(save);
freeSettingsSave(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);
// Under Secured every token may have changed, and a client that connected
// under None had none: they all connect again, the saver with the token in
// its reply, and the rest log in when theirs is refused. None to None
// changes nobody's access.
const bool closeAll = section == settings_section_t::security &&
(securityBefore != settings.Security.type || settings.requiresAuth());
if(closeAll) protoSocket.closeAll(to.client);
else protoSocket.settingsChanged(to.client, settingsSectionName(section));
if(reboot) {
rebootDelay.reboot = true;
rebootDelay.rebootTime = millis() + 1000;
rebootDelay.reboot = true;
}
}
@ -229,8 +234,30 @@ void runDeviceOp(const loop_item_t &item) {
protoSocket.beginReply(item.replyTo);
protoSocket.endReply(item.replyTo);
break;
case proto_type_t::beginFrequencyScan:
if(!somfy.transceiver.config.enabled) {
protoSocket.replyRefused(item.replyTo, proto_error_t::radioUnavailable, "The radio is disabled");
break;
}
somfy.transceiver.beginFrequencyScan();
protoSocket.beginReply(item.replyTo);
protoSocket.endReply(item.replyTo);
break;
case proto_type_t::endFrequencyScan:
somfy.transceiver.endFrequencyScan();
protoSocket.beginReply(item.replyTo);
protoSocket.endReply(item.replyTo);
break;
case proto_type_t::reboot:
ESP_LOGI(TAG, "Socket [%lu] asked for a reboot", (unsigned long)item.replyTo.client);
protoSocket.beginReply(item.replyTo);
protoSocket.endReply(item.replyTo);
// Time for the reply to go out.
rebootDelay.rebootTime = millis() + 500;
rebootDelay.reboot = true;
break;
default:
if(item.save) cancelSettingsSave(item.save);
if(item.save) freeSettingsSave(item.save);
protoSocket.replyRefused(item.replyTo, proto_error_t::internal, "Not a device op");
break;
}

View file

@ -21,8 +21,8 @@ struct settings_save_t {
// 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);
// Frees a save, run or refused, so another may follow.
void freeSettingsSave(settings_save_t *save);
// loop(): runs a proto_device item and replies.
void runDeviceOp(const loop_item_t &item);
#endif

View file

@ -42,7 +42,8 @@ static void clientScanLoop(ESPNetwork &net) {
WiFi.scanDelete();
return;
}
if(!_clientScanWanted || _apScanning) return;
// Not in the middle of joining a network either, as the roaming scan isn't.
if(!_clientScanWanted || _apScanning || net.connecting()) return;
_clientScanWanted = false;
// As /scanaps does: the soft AP's connection attempts would get in its way.
if(net.softAPOpened) WiFi.disconnect(false);

View file

@ -32,11 +32,12 @@ static restore_options_t restoreOptions(uint8_t parts) {
void LoopQueue::begin() {
if(!this->queue) this->queue = xQueueCreate(LOOP_QUEUE_SIZE, sizeof(loop_item_t));
}
bool LoopQueue::submit(const loop_item_t &item) {
bool LoopQueue::submit(const loop_item_t &item, bool first) {
if(!this->queue) return false;
// async_tcp also services the sockets, so it must never wait on loop() or
// the radio.
if(xQueueSend(static_cast<QueueHandle_t>(this->queue), &item, 0) != pdTRUE) {
QueueHandle_t q = static_cast<QueueHandle_t>(this->queue);
if((first ? xQueueSendToFront(q, &item, 0) : xQueueSend(q, &item, 0)) != pdTRUE) {
ESP_LOGE(TAG, "Loop queue full, dropping item");
return false;
}

View file

@ -67,8 +67,10 @@ class LoopQueue {
public:
void begin();
// Hands the item to loop() without waiting, so it is safe from any task,
// including async_tcp. False when the queue is full.
bool submit(const loop_item_t &item);
// including async_tcp. False when the queue is full. first puts it
// ahead of everything waiting: an item that ends a frequency scan, which
// an unpair at the head waits for (holdLoopItem).
bool submit(const loop_item_t &item, bool first = false);
// Runs the waiting items in order. Only from loop().
void drain();
private:

View file

@ -61,9 +61,9 @@
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(beginFrequencyScan, 52, 'c', 'd') \
TYPE(endFrequencyScan, 53, 'c', 'd') \
TYPE(reboot, 54, 'c', 'd') \
TYPE(shadeState, 128, 'd', '-') \
TYPE(shade, 129, 'd', '-') \
TYPE(shadeRemoved, 130, 'd', '-') \
@ -298,11 +298,7 @@ enum class proto_op_t : char {
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
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'
device = 'd' // a settings, scan or reboot op loop() runs
};
const proto_field_info_t &protoField(proto_field_t field);
const proto_field_info_t *protoFieldByKey(const char *key);

View file

@ -21,9 +21,10 @@ static settings_section_t sectionOf(settings_part_t part) {
}
}
// 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.
// What a save may set. lo..hi is a whole number's range, a number's in
// hundredths, 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;
@ -72,6 +73,7 @@ static const settings_field_t FIELDS[] = {
{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},
// RTS, RTW, RTV, GP Relay or GP Remote (radio_proto), checked on its own.
{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},
@ -80,9 +82,10 @@ static const settings_field_t FIELDS[] = {
{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},
// What the CC1101 can be set to, in MHz and kHz.
{settings_part_t::radio, proto_field_t::frequency, nullptr, 30000, 92800},
{settings_part_t::radio, proto_field_t::rxBandwidth, nullptr, 5803, 81250},
{settings_part_t::radio, proto_field_t::deviation, nullptr, 158, 38085},
{settings_part_t::radio, proto_field_t::txPower, nullptr, -30, 12},
{settings_part_t::radio, proto_field_t::noiseDetection, nullptr, 0, 0}
};
@ -129,10 +132,23 @@ static const char *checkValue(const settings_field_t &s, const proto_field_info_
snprintf(why, sizeof(why), "%s must be 56 or 80", f.name);
return why;
}
if(s.field == proto_field_t::proto) {
if(inRange && (value.as<int>() <= 2 || value.as<int>() >= 8)) return nullptr;
snprintf(why, sizeof(why), "%s must be 0, 1, 2, 8 or 9", 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 == 'n') {
// Half a hundredth either way, as 58.03 is a little under it in a float.
const double hundredths = value.as<double>() * 100;
if(hundredths > s.lo - 0.5 && hundredths < s.hi + 0.5) return nullptr;
snprintf(why, sizeof(why), "%s must be %ld.%02ld-%ld.%02ld", f.name, (long)(s.lo / 100), (long)(s.lo % 100),
(long)(s.hi / 100), (long)(s.hi % 100));
return why;
}
if(f.kind != 's') return nullptr;
const char *text = value.as<const char *>();
if(strlen(text) > static_cast<size_t>(s.hi)) {

View file

@ -36,7 +36,6 @@ extern Web webServer;
extern LoopStats loopStats;
extern SocketEmitter sockEmit;
extern LoopQueue loopQueue;
extern rebootDelay_t rebootDelay;
ProtoSocket protoSocket;
static AsyncWebSocket ws("/ws");
@ -244,7 +243,7 @@ static void queueDeviceOp(AsyncWebSocketClient *client, const proto_message_t &m
}
const char *bad = expandSettingsPayload(d, HAS_ETHERNET, save->section, save->request);
if(bad) {
cancelSettingsSave(save);
freeSettingsSave(save);
replyError(client, msg.id, proto_error_t::badRequest, bad);
return;
}
@ -252,35 +251,13 @@ static void queueDeviceOp(AsyncWebSocketClient *client, const proto_message_t &m
save->ip = client->remoteIP();
item.save = save;
}
if(loopQueue.submit(item)) return;
if(item.save) cancelSettingsSave(item.save);
// An unpair waits at the head of the queue while a frequency scan runs, so
// the scan's end goes ahead of it.
const bool first = msg.type == static_cast<uint8_t>(proto_type_t::endFrequencyScan);
if(loopQueue.submit(item, first)) return;
if(item.save) freeSettingsSave(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");
@ -369,9 +346,6 @@ static void onMessage(AsyncWebSocketClient *client, const char *text, size_t len
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:
replyError(client, msg.id, proto_error_t::unknownOp, "Unknown message type");
return;
@ -451,6 +425,7 @@ bool ProtoSocket::sending() const { return sockEmit.onLoopTask() && this->hasCli
/*********************************************************************
* loop(): syncs
********************************************************************/
static void closeOnceSent();
static void sendSyncFrame(proto_slot_t &s) {
LiveSource src;
char *buf = socketFrameBuffer();
@ -491,6 +466,7 @@ void ProtoSocket::loop() {
writeErrorReply(json, 0, proto_error_t::badRequest, "The message took too long to arrive");
ws.text(stalled, buf, json.length());
}
closeOnceSent();
ws.cleanupClients(PROTO_MAX_CLIENTS);
}
@ -754,12 +730,27 @@ void ProtoSocket::settingsChanged(uint32_t savedBy, const char *section) {
if(id && id != savedBy) ws.text(id, socketFrameBuffer(), eventJson.length());
}
}
void ProtoSocket::closeAllBut(uint32_t keep) {
// The client that saved the security settings, closed once its reply has
// gone: a close frame goes ahead of the messages waiting to be sent.
// loop() only.
static uint32_t closeWhenSent = 0;
static uint32_t closeWhenSentSince = 0;
#define CLOSE_WHEN_SENT_MAX_MS 2000
void ProtoSocket::closeAll(uint32_t savedBy) {
if(!sockEmit.onLoopTask()) return;
for(proto_slot_t &s : slots) {
const uint32_t id = s.clientId.load();
if(id && id != keep) ws.close(id);
if(id && id != savedBy) ws.close(id);
}
closeWhenSent = savedBy;
closeWhenSentSince = millis();
}
static void closeOnceSent() {
if(!closeWhenSent) return;
AsyncWebSocketClient *client = ws.client(closeWhenSent);
if(client && client->queueLen() > 0 && millis() - closeWhenSentSince < CLOSE_WHEN_SENT_MAX_MS) return;
if(client) client->close();
closeWhenSent = 0;
}
// The clients waiting for a WiFi scan. loop() only.
static uint32_t scanAskers[PROTO_MAX_CLIENTS];
@ -781,10 +772,14 @@ void ProtoSocket::wifiNetworks(int16_t count) {
bool asked = false;
for(uint32_t id : scanAskers) asked |= id != 0;
if(!asked) return;
// Each asker was told the networks would follow, so when they don't fit
// fewer go: names that need escaping can make one five times longer.
bool fits = false;
for(uint8_t most = PROTO_MAX_NETWORKS; !fits && most > 0; most /= 2) {
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++) {
for(int16_t i = 0; i < count && listed < most; i++) {
// As /scanaps lists them: named networks that can be heard.
if(WiFi.SSID(i).length() == 0 || WiFi.RSSI(i) < -95) continue;
w.beginItem();
@ -796,7 +791,9 @@ void ProtoSocket::wifiNetworks(int16_t count) {
listed++;
}
w.endList();
const bool fits = finishEvent();
endFrame(eventJson);
fits = !eventJson.overflowed();
}
for(uint32_t &id : scanAskers) {
if(fits && id) ws.text(id, socketFrameBuffer(), eventJson.length());
id = 0;

View file

@ -38,9 +38,10 @@ class ProtoSocket {
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);
// Closes every client, as a security save does: their tokens may no
// longer be good, or they had none. savedBy is closed once its reply has
// gone, so it reads its new token first.
void closeAll(uint32_t savedBy);
// 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.

View file

@ -15,6 +15,6 @@ Runs against a real device on your network, by hand, never in CI. It checks sock
| `--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. 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.
A plain run sends nothing on air, changes no setting (it saves only values the device already has) 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.

View file

@ -84,8 +84,11 @@ def test_a_save_tells_the_other_clients(connect, schema):
def test_a_wifi_scan_lists_the_networks(connect, schema):
c, _, _ = synced_model(connect, schema)
if settings(c, schema, "network").get("connMode") != 1:
c = connect(schema=schema.hash)
_, frames, _ = run_sync(c)
header = next(schema.expand(f["d"][schema.keys["header"]]) for f in frames if schema.keys["header"] in f["d"])
# The connection in use: Ethernet with WiFi as the fallback may be on either.
if header.get("connType") != "Wifi":
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]

View file

@ -21,7 +21,9 @@ static const shade_request_type_t OFF_RADIO_REQUESTS[] = {
shade_request_type_t::set_positions, shade_request_type_t::set_rolling_code
};
static const loop_item_type_t OTHER_ITEMS[] = {
loop_item_type_t::restore_shades, loop_item_type_t::load_shades, loop_item_type_t::model_edit, loop_item_type_t::proto_read
loop_item_type_t::restore_shades, loop_item_type_t::load_shades, loop_item_type_t::model_edit, loop_item_type_t::proto_read,
// A frequency scan's end among them, so it never waits for the radio.
loop_item_type_t::proto_device
};
void setUp() {}

View file

@ -63,10 +63,9 @@ static void test_every_op_says_what_becomes_of_it() {
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::device, protoOpKind(static_cast<uint8_t>(proto_type_t::beginFrequencyScan)));
TEST_ASSERT_EQUAL(proto_op_t::device, protoOpKind(static_cast<uint8_t>(proto_type_t::endFrequencyScan)));
TEST_ASSERT_EQUAL(proto_op_t::device, 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() {

View file

@ -105,6 +105,20 @@ static void test_the_bit_length_is_56_or_80() {
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_the_radio_proto_is_one_there_is() {
TEST_ASSERT_GOOD(expand(R"({"se":"radio","pr":2})"));
TEST_ASSERT_GOOD(expand(R"({"se":"radio","pr":8})"));
TEST_ASSERT_EQUAL_STRING("proto must be 0, 1, 2, 8 or 9", expand(R"({"se":"radio","pr":5})"));
TEST_ASSERT_EQUAL_STRING("proto must be 0, 1, 2, 8 or 9", expand(R"({"se":"radio","pr":10})"));
}
static void test_numbers_are_checked_against_what_the_radio_can_be_set_to() {
// The ends themselves, though 58.03 is a little under it as a float.
TEST_ASSERT_GOOD(expand(R"({"se":"radio","fq":300,"bw":58.03,"dv":380.85})"));
TEST_ASSERT_GOOD(expand(R"({"se":"radio","fq":928,"bw":812.5,"dv":1.58})"));
TEST_ASSERT_EQUAL_STRING("frequency must be 300.00-928.00", expand(R"({"se":"radio","fq":299.9})"));
TEST_ASSERT_EQUAL_STRING("rxBandwidth must be 58.03-812.50", expand(R"({"se":"radio","bw":900})"));
TEST_ASSERT_EQUAL_STRING("deviation must be 1.58-380.85", expand(R"({"se":"radio","dv":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"})"));
@ -188,6 +202,8 @@ int main(int argc, char **argv) {
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_the_radio_proto_is_one_there_is);
RUN_TEST(test_numbers_are_checked_against_what_the_radio_can_be_set_to);
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);