feat: settings ops, the WiFi and frequency scans, and reboot for socket protocol 2

Step 1d of the protocol 2 core (docs/SECURITY_PLAN.md).

- getSettings/saveSettings by section: general, network, mqtt, security,
  radio. A save is the settings route's request, so loop() applies it with
  the settings' own fromJSON(). ProtoSettings checks every field first:
  kind, range, string length, IP addresses.
- Secrets are only reported as has*. A security save replies with the
  client's new token and closes the other sockets; other saves send
  settingsChanged to the other clients.
- scanWifi runs an async scan from ESPNetwork, kept apart from the roaming
  scan, and wifiNetworks goes to the clients that asked.
- beginFrequencyScan, endFrequencyScan and reboot run at once on async_tcp,
  as their routes do: a scan's end mustn't wait in the loop queue behind an
  unpair that waits for the scan to end (new op kind 'a').
- e2e: test_settings.py and a --reboot flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-25 09:52:09 +01:00
parent 97a4e98183
commit 2517f40709
22 changed files with 1221 additions and 49 deletions

View file

@ -12,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.

View file

@ -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
View 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)

View file

@ -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() {

View 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();
}