ESPSomfy-RTS/test/e2e/conftest.py
cjkas 2517f40709 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>
2026-10-25 09:52:09 +01:00

107 lines
3.7 KiB
Python

"""Options and fixtures for the e2e suite. See README.md."""
import os
import sys
import time
import pytest
sys.path.insert(0, os.path.dirname(__file__))
from proto_client import ProtoClient, Schema, http_json, run_sync # noqa: E402
def pytest_addoption(parser):
parser.addoption("--device", required=True, help="the device's address, e.g. 192.168.1.120")
parser.addoption("--password", default=None, help="the device's password, when it is Secured with one")
parser.addoption("--username", default="", help="the username that goes with --password")
parser.addoption("--pin", default=None, help="the device's PIN, when it is Secured with one")
parser.addoption("--record", action="store_true", help="save what the device sends to test/fixtures/proto2")
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")
def host(request):
return request.config.getoption("--device")
@pytest.fixture(scope="session")
def login_context(host):
return http_json(host, "/loginContext")
@pytest.fixture(scope="session")
def secured(login_context):
return login_context.get("type", 0) != 0
@pytest.fixture(scope="session")
def token(request, host, secured):
"""The token for this PC, or None when the device isn't Secured."""
if not secured:
return None
password = request.config.getoption("--password")
pin = request.config.getoption("--pin")
if password is None and pin is None:
pytest.skip("the device is Secured: pass --password or --pin")
body = {"username": request.config.getoption("--username"), "password": password or "", "pin": pin or ""}
return http_json(host, "/login", method="PUT", body=body)["apiKey"]
@pytest.fixture
def connect(host, token):
"""Opens protocol 2 clients and closes them after the test."""
clients = []
def open_client(**params):
if token and "apikey" not in params:
params["apikey"] = token
c = ProtoClient(host, params=params or None)
clients.append(c)
return c
yield open_client
for c in clients:
c.close()
# The device frees a slot when it sees the disconnect; the next test may
# need every slot.
time.sleep(0.5)
@pytest.fixture(scope="session")
def schema(host, token):
"""The device's schema, from one full sync."""
s = Schema()
c = ProtoClient(host, params={"apikey": token} if token else None)
try:
assert c.status == 101, "the device has no /ws: is it running protocol 2 firmware?"
run_sync(c, s)
finally:
c.close()
assert s.complete() and s.hash
return s
@pytest.fixture(scope="session")
def recording(request):
return request.config.getoption("--record")
@pytest.fixture(scope="session")
def radio_shade(request):
"""The shade --radio tests may move, or a skip."""
if not request.config.getoption("--radio"):
pytest.skip("sends on air: pass --radio --shade <id>")
shade = request.config.getoption("--shade")
if shade is None:
pytest.skip("--radio needs --shade <id>, a shade that may move")
return shade
@pytest.fixture(scope="session")
def editing(request):
"""For tests that change the shade model and change it back, or a skip."""
if not request.config.getoption("--edit"):
pytest.skip("changes the shade model and back: pass --edit")
return True