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)