feat: command ops for socket protocol 2

shadeCommand, groupCommand, tiltCommand, repeatCommand, setPositions,
setSensor, setMyPosition, unpairShade, setRollingCode (types 16-24) are
read on async_tcp by ProtoOps and handed to loop() through the
LoopQueue, which replies once the request has run: ok, not found or
radio unavailable. A bad payload, a paused radio or a full queue is
answered straight away. sendRemoteCommand (25) touches nothing of the
shade model, so it goes to the radio from async_tcp like its route.

ShadeRequests::run() now says how a request went, not only whether
the radio took it. An op reads only the fields that were sent, so
setPositions can leave the tilt as it is. The shadeCommand event is
renamed shadeCommanded, since the op has the route's name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-24 11:21:55 +02:00
parent 9fe05105ff
commit 0b54264ee3
18 changed files with 812 additions and 50 deletions

View file

@ -10,8 +10,9 @@ Runs against a real device on your network, by hand, never in CI. It checks sock
|---|---|
| `--device` | the device's address (required) |
| `--password`, `--username` / `--pin` | the credentials of a Secured device |
| `--radio --shade <id>` | also send real commands: that shade moves a little and 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. 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 it already has. 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

@ -15,6 +15,8 @@ def pytest_addoption(parser):
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")
@pytest.fixture(scope="session")
@ -82,3 +84,14 @@ def schema(host, token):
@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

112
test/e2e/test_commands.py Normal file
View file

@ -0,0 +1,112 @@
"""The command ops (docs/PROTOCOL.md). Without --radio nothing goes on air:
the ops are refused before they reach the radio, or send nothing anyway."""
import time
import pytest
from proto_client import run_sync
from test_messages import error_name, reply_to
COMMAND_OPS = ("shadeCommand", "groupCommand", "tiltCommand", "repeatCommand", "setPositions", "setSensor",
"setMyPosition", "unpairShade", "setRollingCode", "sendRemoteCommand")
def pack(schema, fields):
"""A payload by field names, in short keys."""
return {schema.keys[name]: value for name, value in fields.items()}
def synced_model(connect, schema):
"""A synced client and the shades and groups it got, by name."""
c = connect(schema=schema.hash)
_, frames, _ = run_sync(c)
shades, groups = [], []
for f in frames:
shades.extend(schema.expand(s) for s in f["d"].get(schema.keys["shades"], []))
groups.extend(schema.expand(g) for g in f["d"].get(schema.keys["groups"], []))
return c, shades, groups
def op(c, schema, name, fields):
"""Sends the op and returns its reply."""
msg_id = c.send(schema.type_numbers[name], pack(schema, fields), with_id=True)
return reply_to(c, msg_id)
def is_ok(schema, reply):
return reply["d"].get(schema.keys["ok"]) == 1
def test_every_command_op_is_in_the_schema(schema):
for name in COMMAND_OPS:
assert 16 <= schema.type_numbers[name] <= 127, name
def test_an_op_without_an_id_is_a_bad_request(connect, schema):
c, _, _ = synced_model(connect, schema)
c.send(schema.type_numbers["shadeCommand"], pack(schema, {"shadeId": 1, "command": "My"}))
reply = c.recv_until(lambda f: f["t"] == 2)[-1]
assert "id" not in reply
assert error_name(schema, reply) == "badRequest"
@pytest.mark.parametrize("name, fields, why", [
("shadeCommand", {}, "shadeId is missing"),
("shadeCommand", {"shadeId": 1, "command": "Upp"}, "Not a command"),
("shadeCommand", {"shadeId": 1}, "Give command or target"),
("shadeCommand", {"shadeId": 1, "target": 101}, "target must be 0-100"),
("tiltCommand", {"shadeId": 1, "command": "Up", "target": 5}, "Give command or target"),
("groupCommand", {"groupId": 1}, "command is missing"),
("repeatCommand", {"command": "Up"}, "Give shadeId or groupId"),
("setPositions", {"shadeId": 1}, "Give position, tiltPosition or both"),
("setSensor", {"shadeId": 1}, "Give sunny, windy or both"),
("setMyPosition", {"shadeId": 1, "myPos": 101}, "myPos must be 0-100"),
("unpairShade", {}, "shadeId is missing"),
("setRollingCode", {"shadeId": 1}, "lastRollingCode is missing"),
("sendRemoteCommand", {"command": "Up", "lastRollingCode": 1}, "remoteAddress is missing"),
])
def test_a_bad_payload_is_refused_with_why(connect, schema, name, fields, why):
c, _, _ = synced_model(connect, schema)
reply = op(c, schema, name, fields)
assert error_name(schema, reply) == "badRequest"
assert reply["d"][schema.keys["desc"]] == why
def test_a_shade_or_group_that_does_not_exist_is_not_found(connect, schema):
c, shades, groups = synced_model(connect, schema)
shade = next(i for i in range(254, 0, -1) if i not in {s["shadeId"] for s in shades})
group = next(i for i in range(254, 0, -1) if i not in {g["groupId"] for g in groups})
reply = op(c, schema, "shadeCommand", {"shadeId": shade, "command": "Up"})
assert error_name(schema, reply) == "notFound"
assert reply["d"][schema.keys["desc"]] == "No shade with that id"
reply = op(c, schema, "groupCommand", {"groupId": group, "command": "Up"})
assert error_name(schema, reply) == "notFound"
assert reply["d"][schema.keys["desc"]] == "No group with that id"
def test_set_positions_where_the_shade_already_is(connect, schema):
"""setPositions sends nothing on air. Setting the position the sync gave
moves the shade's believed position by less than 1%, its fraction."""
c, shades, _ = synced_model(connect, schema)
if not shades:
pytest.skip("the device has no shades")
shade = shades[0]
reply = op(c, schema, "setPositions", {"shadeId": shade["shadeId"], "position": shade.get("position", 0)})
assert is_ok(schema, reply)
def test_a_command_moves_the_shade_and_replies_ok(connect, schema, radio_shade):
"""Moves the --shade a little and back to where it was."""
c, shades, _ = synced_model(connect, schema)
shade = next((s for s in shades if s["shadeId"] == radio_shade), None)
assert shade, "no shade %s on the device" % radio_shade
start = shade.get("position", 0)
reply = op(c, schema, "shadeCommand", {"shadeId": radio_shade, "command": "Down" if start < 50 else "Up"})
assert is_ok(schema, reply)
state = schema.type_numbers["shadeState"]
moving = c.recv_until(lambda f: f["t"] == state and f["d"].get(schema.keys["shadeId"]) == radio_shade
and f["d"].get(schema.keys["direction"], 0) != 0, timeout=10)[-1]
assert moving
time.sleep(2)
reply = op(c, schema, "shadeCommand", {"shadeId": radio_shade, "target": start})
assert is_ok(schema, reply)