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

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