| 961b3c61 Andy McCall |
1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
from __future__ import annotations |
| 18 |
|
| 19 |
import io |
| 20 |
import json |
| 21 |
import time |
| 22 |
import urllib.error |
| 45a475bb Andy McCall ↶ |
23 |
import urllib.parse |
| 961b3c61 Andy McCall |
24 |
import urllib.request |
| 25 |
|
| 26 |
CONTRACT_MAJOR = 0 |
| 27 |
|
| 28 |
|
| 29 |
class EmuControlError(RuntimeError): |
| 30 |
pass |
| 31 |
|
| 32 |
|
| 8ac19619 Andy McCall ↶ |
33 |
def parse_ppm_header(data: bytes): |
| 34 |
|
| 35 |
|
| 36 |
|
| 37 |
if data[:2] != b"P6": |
| 38 |
raise EmuControlError(f"not a P6 PPM (magic {data[:2]!r})") |
| 39 |
fields, i, n = [], 2, len(data) |
| 40 |
while len(fields) < 3 and i < n: |
| 41 |
while i < n and data[i:i + 1].isspace(): |
| 42 |
i += 1 |
| 43 |
if i < n and data[i:i + 1] == b"#": |
| 44 |
while i < n and data[i:i + 1] not in (b"\n", b"\r"): |
| 45 |
i += 1 |
| 46 |
continue |
| 47 |
start = i |
| 48 |
while i < n and not data[i:i + 1].isspace(): |
| 49 |
i += 1 |
| 50 |
fields.append(int(data[start:i])) |
| 51 |
if len(fields) < 3: |
| 52 |
raise EmuControlError("truncated PPM header") |
| 53 |
return fields[0], fields[1], fields[2], i + 1 |
| 54 |
|
| 55 |
|
| 961b3c61 Andy McCall |
56 |
class EmuControl: |
| 57 |
def __init__(self, port: int, host: str = "127.0.0.1", timeout: float = 5.0): |
| 58 |
self.base = f"http://{host}:{port}" |
| 59 |
self.timeout = timeout |
| 60 |
|
| 61 |
|
| 62 |
def _get(self, path: str) -> tuple[bytes, str]: |
| 63 |
try: |
| 64 |
with urllib.request.urlopen(self.base + path, timeout=self.timeout) as r: |
| 65 |
return r.read(), r.headers.get_content_type() |
| 66 |
except urllib.error.HTTPError as e: |
| 67 |
raise EmuControlError(f"GET {path} -> {e.code}: {e.read()!r}") from e |
| 68 |
|
| 69 |
def _post(self, path: str) -> bytes: |
| 70 |
req = urllib.request.Request(self.base + path, method="POST", data=b"") |
| 71 |
try: |
| 72 |
with urllib.request.urlopen(req, timeout=self.timeout) as r: |
| 73 |
return r.read() |
| 74 |
except urllib.error.HTTPError as e: |
| 75 |
raise EmuControlError(f"POST {path} -> {e.code}: {e.read()!r}") from e |
| 76 |
|
| 77 |
|
| 78 |
def status(self) -> dict: |
| 79 |
body, _ = self._get("/status") |
| 80 |
st = json.loads(body) |
| 81 |
major = int(str(st.get("contract", "0")).split(".")[0]) |
| 82 |
if major != CONTRACT_MAJOR: |
| 83 |
raise EmuControlError( |
| 84 |
f"contract major mismatch: emulator {st.get('contract')} " |
| 85 |
f"vs client {CONTRACT_MAJOR}.x" |
| 86 |
) |
| 87 |
return st |
| 88 |
|
| 89 |
def frame(self) -> int: |
| 90 |
return int(self.status()["frame"]) |
| 91 |
|
| 92 |
def regs(self) -> dict: |
| 93 |
body, _ = self._get("/regs") |
| 94 |
return json.loads(body) |
| 95 |
|
| 96 |
def mem(self, addr: int, length: int, bank: int | None = None) -> bytes: |
| 97 |
path = f"/mem?addr={addr}&len={length}" |
| 98 |
if bank is not None: |
| 99 |
path += f"&bank={bank}" |
| 100 |
body, _ = self._get(path) |
| 101 |
return body |
| 102 |
|
| 103 |
def step(self, frames: int = 1) -> int: |
| 104 |
body = self._post(f"/step?frames={frames}") |
| 105 |
return int(json.loads(body)["frame"]) |
| 106 |
|
| 107 |
def pause(self) -> None: |
| 108 |
self._post("/pause") |
| 109 |
|
| 110 |
def resume(self) -> None: |
| 111 |
self._post("/resume") |
| 8ac19619 Andy McCall ↶ |
112 |
|
| d44319b1 Andy McCall ↶ |
113 |
def key(self, text: str | None = None, code: int | None = None, |
| 114 |
down: bool | None = None) -> None: |
| 115 |
|
| 116 |
|
| 117 |
|
| 118 |
|
| 119 |
parts = [] |
| 120 |
if text is not None: |
| 45a475bb Andy McCall ↶ |
121 |
|
| 122 |
|
| 123 |
parts.append(f"text={urllib.parse.quote(text, safe='')}") |
| d44319b1 Andy McCall ↶ |
124 |
if code is not None: |
| 125 |
parts.append(f"code={code}") |
| 126 |
if down is not None: |
| 127 |
parts.append(f"down={1 if down else 0}") |
| 128 |
if not parts: |
| 129 |
raise EmuControlError("key() needs text= or code=") |
| 130 |
self._post("/key?" + "&".join(parts)) |
| 131 |
|
| 132 |
def reset(self) -> None: |
| 133 |
|
| 134 |
self._post("/reset") |
| 135 |
|
| 8ac19619 Andy McCall ↶ |
136 |
def screenshot_ppm(self) -> bytes: |
| 137 |
|
| 138 |
body, _ = self._get("/screenshot") |
| 139 |
return body |
| 961b3c61 Andy McCall |
140 |
|
| 141 |
def screenshot(self): |
| 142 |
|
| 8ac19619 Andy McCall ↶ |
143 |
body = self.screenshot_ppm() |
| 961b3c61 Andy McCall |
144 |
try: |
| 145 |
from PIL import Image |
| 146 |
except ImportError as e: |
| 147 |
raise EmuControlError("screenshot() needs Pillow (pip install pillow)") from e |
| 148 |
return Image.open(io.BytesIO(body)) |
| 149 |
|
| 150 |
|
| 151 |
def wait_ready(self, tries: int = 100, delay: float = 0.05) -> dict: |
| 152 |
|
| 153 |
last = None |
| 154 |
for _ in range(tries): |
| 155 |
try: |
| 156 |
return self.status() |
| 157 |
except (EmuControlError, urllib.error.URLError, ConnectionError) as e: |
| 158 |
last = e |
| 159 |
time.sleep(delay) |
| 160 |
raise EmuControlError(f"emulator control port never came up: {last}") |
| 161 |
|
| 162 |
def run_to_frame(self, target: int) -> int: |
| 163 |
|
| 164 |
cur = self.frame() |
| 165 |
if target > cur: |
| 166 |
self.step(frames=target - cur) |
| 167 |
return self.frame() |