test: start the e2e suite for socket protocol 2

A pytest suite run by hand against a device (test/e2e/README.md): hello, the schema and its hash, the sync with next and resync, replies to bad frames, feed subscriptions, the status event, every client syncing at once, a sixth client refused, a page load, and the socket under the device's security mode. It never transmits, changes settings or reboots. --record saves hello, the sync and the schema as fixtures.

PROTOCOL.md now describes the protocol as built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-22 21:38:06 +02:00
parent 482e495678
commit 10521cf78d
9 changed files with 685 additions and 20 deletions

124
test/e2e/test_sync.py Normal file
View file

@ -0,0 +1,124 @@
"""hello, the schema and the sync (docs/PROTOCOL.md)."""
import json
import os
from proto_client import Schema, http_json, run_sync
FIXTURES = os.path.join(os.path.dirname(__file__), "..", "fixtures", "proto2")
def items(frames, schema, section):
key = schema.keys[section]
out = []
for f in frames:
v = f["d"].get(key)
if isinstance(v, list):
out.extend(v)
elif v is not None:
out.append(v)
return out
def test_hello_names_the_firmware_and_schema(connect):
c = connect()
assert c.status == 101
hello = c.recv()
assert hello["v"] == 2
assert hello["t"] == 1
assert hello["d"]["fw"]
assert len(hello["d"]["sh"]) == 8
def test_first_sync_carries_the_schema(connect):
c = connect()
schema = Schema()
hello, frames, _ = run_sync(c, schema)
assert schema.complete()
assert schema.type_numbers["hello"] == 1
assert schema.keys["shadeId"] == "s"
for f in frames:
assert len(json.dumps(f, separators=(",", ":"))) < 4096
def test_a_client_with_the_schema_gets_none(connect, schema):
c = connect(schema=schema.hash)
_, frames, _ = run_sync(c)
for f in frames:
for k in ("T", "F", "E", "Q"):
assert k not in f["d"]
def test_an_old_schema_hash_gets_the_schema_again(connect):
c = connect(schema="00000000")
schema = Schema()
run_sync(c, schema)
assert schema.complete()
def test_sync_holds_the_same_model_as_controller(connect, schema, host, token):
c = connect(schema=schema.hash)
_, frames, _ = run_sync(c)
headers = {"apikey": token} if token else {}
controller = http_json(host, "/controller", headers=headers)
shades = [schema.expand(s) for s in items(frames, schema, "shades")]
groups = [schema.expand(g) for g in items(frames, schema, "groups")]
rooms = items(frames, schema, "rooms")
assert sorted(s["shadeId"] for s in shades) == sorted(s["shadeId"] for s in controller["shades"])
assert sorted(g["groupId"] for g in groups) == sorted(g["groupId"] for g in controller["groups"])
assert len(rooms) == len(controller["rooms"])
by_id = {s["shadeId"]: s for s in controller["shades"]}
for s in shades:
assert s.get("name", "") == by_id[s["shadeId"]]["name"]
assert s.get("remoteAddress", 0) == by_id[s["shadeId"]]["remoteAddress"]
for g in groups:
linked = sorted(x["shadeId"] for x in next(cg for cg in controller["groups"] if cg["groupId"] == g["groupId"])["linkedShades"])
assert sorted(g.get("linkedShades", [])) == linked
assert len(items(frames, schema, "header")) == 1
assert len(items(frames, schema, "status")) == 1
def test_sync_waits_for_next(connect):
c = connect()
c.recv() # hello
first = c.recv()
assert first["t"] == 4
# Without next nothing more comes, unless the whole sync fit one frame.
try:
more = c.recv(timeout=2)
except TimeoutError:
return
assert more["t"] == 5, "a second sync frame came without next"
def test_resync_sends_it_all_again(connect, schema):
c = connect(schema=schema.hash)
_, first, _ = run_sync(c)
c.send(schema.type_numbers["sync"])
_, again, _ = run_sync(c, hello=False)
assert len(items(again, schema, "shades")) == len(items(first, schema, "shades"))
for f in again:
assert "T" not in f["d"]
def test_record_fixtures(connect, recording):
"""With --record, saves hello and a whole first sync for the other suites."""
if not recording:
return
c = connect()
schema = Schema()
run_sync(c, schema)
os.makedirs(FIXTURES, exist_ok=True)
hello = json.loads(c.raw[0])
with open(os.path.join(FIXTURES, "hello.json"), "w", encoding="utf-8") as f:
json.dump(hello, f, indent=1)
with open(os.path.join(FIXTURES, "sync.json"), "w", encoding="utf-8") as f:
json.dump([json.loads(r) for r in c.raw[1:]], f, indent=1)
expanded = {
"hash": hello["d"]["sh"],
"types": {str(k): v for k, v in sorted(schema.types.items())},
"fields": {k: {"name": n, "kind": kind, "default": d} for k, (n, kind, d) in sorted(schema.fields.items())},
"errors": {str(k): v for k, v in sorted(schema.errors.items())},
"feeds": {str(k): v for k, v in sorted(schema.feeds.items())},
}
with open(os.path.join(FIXTURES, "schema.json"), "w", encoding="utf-8") as f:
json.dump(expanded, f, indent=1)