Files
Adolfo Reyna 0fb3c22f79
Main / Build (Brainfuck) (push) Has been cancelled
Main / Build (Breakout) (push) Has been cancelled
Main / Build (Calculator) (push) Has been cancelled
Main / Build (Diceware) (push) Has been cancelled
Main / Build (EpubReader) (push) Has been cancelled
Main / Build (GPIO) (push) Has been cancelled
Main / Build (GraphicsDemo) (push) Has been cancelled
Main / Build (HelloWorld) (push) Has been cancelled
Main / Build (M5UnitTest) (push) Has been cancelled
Main / Build (Magic8Ball) (push) Has been cancelled
Main / Build (MediaKeys) (push) Has been cancelled
Main / Build (MystifyDemo) (push) Has been cancelled
Main / Build (SerialConsole) (push) Has been cancelled
Main / Build (Snake) (push) Has been cancelled
Main / Build (TamaTac) (push) Has been cancelled
Main / Build (TodoList) (push) Has been cancelled
Main / Build (TwoEleven) (push) Has been cancelled
Main / Bundle (push) Has been cancelled
feat: add McpScreen phase 2 display tools
2026-06-25 12:43:08 -04:00

229 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Host smoke test for the Tactility MCP Screen Phase 2 API."""
import argparse
import base64
import json
import socket
import struct
import urllib.request
def discover(timeout: float = 2.0) -> str | None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.settimeout(timeout)
try:
sock.sendto(b"DISCOVER_SCREEN", ("255.255.255.255", 5000))
data, address = sock.recvfrom(128)
if data == b"SCREEN_IP_80":
return address[0]
except TimeoutError:
return None
finally:
sock.close()
return None
def rpc(ip: str, request_id: int, method: str, params: dict | None = None) -> dict:
payload = {
"jsonrpc": "2.0",
"id": request_id,
"method": method,
}
if params is not None:
payload["params"] = params
request = urllib.request.Request(
f"http://{ip}/api/mcp",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as response:
return json.loads(response.read())
def require_result(response: dict, label: str) -> None:
if "error" in response:
raise RuntimeError(f"{label} failed: {response['error']}")
if "result" not in response:
raise RuntimeError(f"{label} returned no result: {response}")
def make_bmp() -> bytes:
width = 4
height = 4
row_size = width * 3
pixels = bytearray()
colors = [
(0, 0, 255),
(0, 255, 0),
(255, 0, 0),
(255, 255, 255),
]
for y in range(height):
for x in range(width):
blue, green, red = colors[(x + y) % len(colors)]
pixels.extend((blue, green, red))
pixels.extend(b"\0" * ((4 - row_size % 4) % 4))
offset = 54
file_size = offset + len(pixels)
header = (
b"BM"
+ struct.pack("<IHHI", file_size, 0, 0, offset)
+ struct.pack("<IIIHHIIIIII", 40, width, height, 1, 24, 0, len(pixels), 0, 0, 0, 0)
)
return header + pixels
def raw_endpoint(ip: str) -> None:
pixels = bytes(
[
0xF8, 0x00,
0x07, 0xE0,
0x00, 0x1F,
0xFF, 0xFF,
]
)
request = urllib.request.Request(
f"http://{ip}/api/screen/raw?x=2&y=2&w=2&h=2",
data=pixels,
headers={"Content-Type": "application/octet-stream"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as response:
if response.read() != b"OK":
raise RuntimeError("Raw endpoint returned an unexpected response")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--ip", help="Device IP; omit to use UDP discovery")
parser.add_argument(
"--draw",
action="store_true",
help="Also clear the app canvas and draw visible test text",
)
args = parser.parse_args()
ip = args.ip or discover()
if not ip:
raise SystemExit("MCP Screen was not discovered. Pass --ip DEVICE_IP to test directly.")
print(f"Testing MCP Screen at {ip}")
tools = rpc(ip, 1, "tools/list")
require_result(tools, "tools/list")
names = [tool["name"] for tool in tools["result"]["tools"]]
expected = {"get_capabilities", "clear_screen", "draw_text"}
if not expected.issubset(names):
raise RuntimeError(f"Missing tools: {sorted(expected - set(names))}")
print(f"tools/list: {', '.join(names)}")
capabilities = rpc(
ip,
2,
"tools/call",
{"name": "get_capabilities", "arguments": {}},
)
require_result(capabilities, "get_capabilities")
print("get_capabilities:", capabilities["result"]["content"][0]["text"])
if args.draw:
cleared = rpc(
ip,
3,
"tools/call",
{"name": "clear_screen", "arguments": {"color": 1}},
)
require_result(cleared, "clear_screen")
drawn = rpc(
ip,
4,
"tools/call",
{
"name": "draw_text",
"arguments": {
"text": "Tactility MCP is alive",
"x": 20,
"y": 30,
"size": 2,
},
},
)
require_result(drawn, "draw_text")
raw_rpc = rpc(
ip,
5,
"tools/call",
{
"name": "draw_raw_rgb565",
"arguments": {
"rgb565_base64": base64.b64encode(
bytes([0xFF, 0xE0, 0xF8, 0x1F, 0x07, 0xFF, 0x00, 0x00])
).decode(),
"x": 20,
"y": 70,
"w": 4,
"h": 1,
},
},
)
require_result(raw_rpc, "draw_raw_rgb565")
bmp = rpc(
ip,
6,
"tools/call",
{
"name": "draw_color_bmp",
"arguments": {
"bmp_base64": base64.b64encode(make_bmp()).decode(),
"x": 30,
"y": 90,
},
},
)
require_result(bmp, "draw_color_bmp")
pbm_bytes = b"P4\n8 2\n" + bytes([0b10101010, 0b01010101])
pbm = rpc(
ip,
7,
"tools/call",
{
"name": "draw_image",
"arguments": {
"pbm_base64": base64.b64encode(pbm_bytes).decode(),
"x": 50,
"y": 110,
},
},
)
require_result(pbm, "draw_image")
raw_endpoint(ip)
screenshot = rpc(
ip,
8,
"tools/call",
{"name": "get_screenshot", "arguments": {}},
)
require_result(screenshot, "get_screenshot")
screenshot_text = screenshot["result"]["content"][0]["text"]
if not screenshot_text.startswith("__PBM_BASE64__:"):
raise RuntimeError("Screenshot did not return PBM data")
base64.b64decode(screenshot_text.split(":", 1)[1], validate=True)
print("Visible Phase 2 draw and screenshot tests sent.")
print("Phase 2 smoke test passed.")
if __name__ == "__main__":
main()