| … | … | @@ -0,0 +1,137 @@ |
| 1 | #!/usr/bin/env python3 |
| 2 | """Shared control end to end, on REAL hardware: Blender's sender + the web UI |
| 3 | (cthuloid-web) + a proto-3 unit. |
| 4 | |
| 5 | python3 blender-addon/tests/e2e_shared_control.py <unit-ip> <web-ui-url> |
| 6 | |
| 7 | Uses the addon's real net.py/protocol.py (no Blender needed) and the web UI's |
| 8 | HTTP API. Values stay dim; the unit's colours are restored at the end. Leaves |
| 9 | the web UI connected (Connect switch on). |
| 10 | """ |
| 11 | |
| 12 | import json |
| 13 | import os |
| 14 | import socket |
| 15 | import sys |
| 16 | import time |
| 17 | import urllib.request |
| 18 | |
| 19 | HERE = os.path.dirname(os.path.abspath(__file__)) |
| 20 | sys.path.insert(0, os.path.join(HERE, "..", "cthuloid_lights")) |
| 21 | import net # noqa: E402 |
| 22 | import protocol # noqa: E402 |
| 23 | |
| 24 | UNIT = sys.argv[1] if len(sys.argv) > 1 else "192.168.50.89" |
| 25 | WEB = (sys.argv[2] if len(sys.argv) > 2 else "http://stortron.local:4050").rstrip("/") |
| 26 | failures = [] |
| 27 | |
| 28 | |
| 29 | def check(name, ok, detail=""): |
| 30 | print(("PASS " if ok else "FAIL ") + name + ("" if ok else " " + str(detail))) |
| 31 | if not ok: |
| 32 | failures.append(name) |
| 33 | |
| 34 | |
| 35 | def wait(pred, timeout=4.0): |
| 36 | end = time.monotonic() + timeout |
| 37 | while time.monotonic() < end: |
| 38 | if pred(): |
| 39 | return True |
| 40 | time.sleep(0.05) |
| 41 | return pred() |
| 42 | |
| 43 | |
| 44 | def api(path, body=None): |
| 45 | req = urllib.request.Request(WEB + path, data=None if body is None else json.dumps(body).encode(), |
| 46 | headers={"content-type": "application/json", "x-cthuloid": "1"}, |
| 47 | method="GET" if body is None else "POST") |
| 48 | try: |
| 49 | with urllib.request.urlopen(req, timeout=5) as r: |
| 50 | return r.status, json.loads(r.read() or b"null") |
| 51 | except urllib.error.HTTPError as e: |
| 52 | return e.code, json.loads(e.read() or b"null") |
| 53 | |
| 54 | |
| 55 | def unit_state(): |
| 56 | st = api("/api/state")[1] |
| 57 | return next(u for u in st["units"] if u["addr"] == UNIT) |
| 58 | |
| 59 | |
| 60 | def lamp_values(): |
| 61 | s = socket.create_connection((UNIT, 1337), timeout=4) |
| 62 | time.sleep(0.3) |
| 63 | s.sendall(b"state\n") |
| 64 | time.sleep(0.5) |
| 65 | data = s.recv(8192).decode() |
| 66 | s.close() |
| 67 | line = next(l for l in data.splitlines() if l.startswith("state ")) |
| 68 | return [int(x) for x in line.split()[6:]] |
| 69 | |
| 70 | |
| 71 | # --- the web UI on, with this unit included --------------------------------------- |
| 72 | u = unit_state() |
| 73 | UID = u["id"] |
| 74 | api("/api/units/" + UID, {"enabled": True, "fadeMs": 0}) |
| 75 | api("/api/connect", {"on": True}) |
| 76 | check("web UI connected to the unit", wait(lambda: unit_state()["status"] == "connected", 8)) |
| 77 | |
| 78 | before = lamp_values() |
| 79 | print("lamp before:", before) |
| 80 | |
| 81 | blender = net.Sender() |
| 82 | blender.min_interval = 0.0 |
| 83 | blender.start() |
| 84 | HOLDER = blender.holder |
| 85 | |
| 86 | |
| 87 | def frame(vals): |
| 88 | return protocol.Frame(tuple(vals), 0) |
| 89 | |
| 90 | |
| 91 | # Connect (force): Blender takes every slot to set the scene -- here the lamp's |
| 92 | # own values, so nothing visibly changes -- then lets go when quiet. |
| 93 | blender.send(UNIT, 1337, frame(before), force=True) |
| 94 | check("Blender connected with proto 3", wait(lambda: blender.status_of(UNIT, 1337)[0] == "connected")) |
| 95 | time.sleep(2.0) |
| 96 | |
| 97 | # 1. Blender changes slot 1 only. |
| 98 | scene = list(before) |
| 99 | scene[0:3] = [1500, 0, 0] |
| 100 | blender.send(UNIT, 1337, frame(scene)) |
| 101 | check("web UI shows slot 1 held by Blender", |
| 102 | wait(lambda: (unit_state()["holds"][0] or {}).get("holder") == HOLDER), unit_state()["holds"]) |
| 103 | check("lamp slot 1 = Blender's value", wait(lambda: lamp_values()[0:3] == [1500, 0, 0])) |
| 104 | check("Blender lets go after ~1.5 s quiet", wait(lambda: unit_state()["holds"][0] is None, 4)) |
| 105 | |
| 106 | # 2. The web UI grabs slot 2; Blender's change to slot 2 is refused and reported. |
| 107 | code, r = api("/api/units/%s/grab" % UID, {"index": 1, "session": "e2e", "name": "webtester"}) |
| 108 | check("web grabs slot 2", code == 200 and r == {"granted": True}, (code, r)) |
| 109 | scene[3:6] = [0, 1500, 0] |
| 110 | blender.send(UNIT, 1337, frame(scene)) |
| 111 | check("Blender reports slot 2 held by webtester", |
| 112 | wait(lambda: "slot 2 held by webtester" in blender.status_of(UNIT, 1337)[1]), blender.status_of(UNIT, 1337)) |
| 113 | check("Blender did not change slot 2", lamp_values()[3:6] == before[3:6]) |
| 114 | |
| 115 | # 3. The web user sets their colour and lets go; Blender must not stomp on it. |
| 116 | code, _ = api("/api/units/%s/slot" % UID, {"index": 1, "session": "e2e", "r": 0, "g": 0, "b": 0.02, "level": 1}) |
| 117 | check("web sets slot 2", code == 200, code) |
| 118 | api("/api/units/%s/release" % UID, {"index": 1, "session": "e2e"}) |
| 119 | check("web's release frees the slot", wait(lambda: unit_state()["holds"][1] is None)) |
| 120 | want = [int(round(0.02 * 65535)), 0, 0] |
| 121 | check("lamp slot 2 = the web user's colour", wait(lambda: lamp_values()[3:6] == want), lamp_values()[3:6]) |
| 122 | time.sleep(1.0) |
| 123 | check("Blender left it alone after the web user let go", lamp_values()[3:6] == want) |
| 124 | check("web UI stored the released value", abs(unit_state()["slots"][1]["level"] - 0.02) < 0.001, |
| 125 | unit_state()["slots"][1]) |
| 126 | |
| 127 | blender.stop() |
| 128 | |
| 129 | # Restore the lamp (legacy pwm only touches unheld slots; red clamped to the cap). |
| 130 | time.sleep(0.5) |
| 131 | s = socket.create_connection((UNIT, 1337), timeout=4) |
| 132 | s.sendall(("pwm " + " ".join(str(min(v, 45000) if i % 3 == 2 else v) for i, v in enumerate(before)) + "\n").encode()) |
| 133 | time.sleep(0.5) |
| 134 | s.close() |
| 135 | |
| 136 | print("\n%d failure(s)" % len(failures) if failures else "\nALL PASS") |
| 137 | sys.exit(1 if failures else 0) |