diff --git a/README.md b/README.md index 5bb47d2..46ee0a4 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ A desktop simulator for [matrixbox][matrixbox], the [CircuitPython][circuitpython] app that drives an LED matrix on a Waveshare ESP32-S3-Zero. Runs an app's real, unmodified code on your desktop Python interpreter and streams what -it draws to a terminal renderer, no hardware needed. +it draws to a browser (or terminal) renderer, no hardware needed. -![demo](https://raw.githubusercontent.com/MatrixBOX-dev/simulator/refs/heads/main/asset/sim-image.png) +![demo](https://raw.githubusercontent.com/MatrixBOX-dev/simulator/refs/heads/main/asset/sim-image-web.png) ## Installation @@ -50,8 +50,17 @@ Terminal 2, watch it: matrixbox simulator ``` -Connects to `ws://127.0.0.1:9191` by default, matching `matrixbox app`'s -own default port — use `--connect ` if you changed it. The renderer +and open `http://127.0.0.1:8081/` (`--open` does that for you, `--port` +picks another port). The panel is drawn as round, glowing LEDs, either +flat or mounted in a mock enclosure you can drag around to rotate. + +Prefer staying in the terminal? `matrixbox simulator terminal` draws it +with half-block characters instead. `matrixbox simulator web` is the same +as leaving the renderer out. + +Either one connects to `ws://127.0.0.1:9191` by default, matching +`matrixbox app`'s own default port — use `--connect ` if you changed +it. The renderer waits for the simulator if it isn't up yet, and reconnects automatically if you stop it to switch apps, so you can just leave it running. @@ -85,7 +94,8 @@ reboots whenever it changes, since the wiring can't reconfigure live. This sim does the same: changing width/height from the settings UI, or `z` cycling through sizes from the `matrixbox app` terminal, both reboot the process. Whatever an app already saved survives the restart either -way. +way, and it boots straight back into whichever app was running rather +than the home menu. Selecting XL also rotates the panel 180°, matching real firmware — it doesn't reset the rotation back when you switch away from XL either, @@ -107,16 +117,27 @@ terminal, not a redirected or piped one): - `n`: toggle wifi on/off, to test how an app behaves offline. See "Wifi" below. -Typed into whichever terminal is running `matrixbox simulator` instead: +The same keys also work typed into the simulator page in the browser, +which passes them on to the app. On the device mockup you can also use +the button on top: a click is a short press, holding it past 0.8 seconds +a long one, just like the real button. A click always registers, even +when an app only checks the button now and then. +Only for the browser page itself: + +- `v`: switch between the flat panel and the device mockup. - `t`: toggle a guide line at each panel seam (see "Panel sizes"). Off by default. +- Drag the device to rotate it, double-click to reset the angle. + +Or, with `matrixbox simulator terminal`, typed into its terminal: -A reference for both sets of controls (plus `t`'s current state) is -always shown under the panel in the `matrixbox simulator` window, since -that's the one screen this tool fully redraws itself each frame — the -`matrixbox app` terminal's own controls hint, printed once at startup, -otherwise scrolls out of view under the app's own logs. +- `t`: toggle the seam guide lines, as above. + +A reference for both sets of controls is always shown under the panel, +in the browser and in the `matrixbox simulator terminal` window alike — +the `matrixbox app` terminal's own controls hint, printed once at +startup, otherwise scrolls out of view under the app's own logs. ## Animation speed @@ -245,6 +266,10 @@ uv run ruff check . # lint and fix uv run ty check . # type check ``` +## Demo + +https://github.com/user-attachments/assets/d0655003-ce85-4c79-b668-5602e9f14155 + [matrixbox]: https://www.matrixbox.app [matrixbox-source]: https://github.com/MatrixBOX-dev/matrixbox [circuitpython]: https://circuitpython.org/ diff --git a/asset/sim-image-web.png b/asset/sim-image-web.png new file mode 100644 index 0000000..50d7f6f Binary files /dev/null and b/asset/sim-image-web.png differ diff --git a/matrixbox_simulator/cli.py b/matrixbox_simulator/cli.py index 481bfe7..7dc365b 100644 --- a/matrixbox_simulator/cli.py +++ b/matrixbox_simulator/cli.py @@ -1,10 +1,16 @@ """Single `matrixbox` entrypoint, with `app`, `simulator`, `screenshot`, -and `sandbox` as subcommands.""" +and `sandbox` as subcommands. `simulator` picks a renderer in turn, `web` +or `terminal`, defaulting to `web`.""" import argparse +import sys from matrixbox_simulator.device import run_app, run_sandbox, run_screenshot from matrixbox_simulator.term import run_simulator +from matrixbox_simulator.web import run_web + +RENDERERS = ("web", "terminal") +DEFAULT_RENDERER = "web" def main() -> None: @@ -18,11 +24,28 @@ def main() -> None: description=run_app.__doc__, ) ) + simulator_parser = subparsers.add_parser( + "simulator", + help="renderer that connects to a running app (web by default)", + description="Draws what a running `matrixbox app` shows, either in a " + f"browser or the terminal. Defaults to `{DEFAULT_RENDERER}` when no " + "renderer is named.", + ) + renderers = simulator_parser.add_subparsers(dest="renderer", required=True) + run_web.build_parser( + renderers.add_parser( + "web", + help="browser renderer with glowing LEDs and a 3D device mockup", + description=run_web.__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + ) run_simulator.build_parser( - subparsers.add_parser( - "simulator", - help="terminal renderer that connects to a running app", + renderers.add_parser( + "terminal", + help="terminal renderer using half-block characters", description=run_simulator.__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, ) ) run_screenshot.build_parser( @@ -40,12 +63,14 @@ def main() -> None: ) ) - args = parser.parse_args() + args = parser.parse_args(_with_default_renderer(sys.argv[1:])) if args.command == "app": run_app.run(args) elif args.command == "screenshot": run_screenshot.run(args) - elif args.command == "simulator": + elif args.command == "simulator" and args.renderer == "web": + run_web.run(args) + elif args.command == "simulator" and args.renderer == "terminal": run_simulator.run(args) elif args.command == "sandbox": run_sandbox.run(args) @@ -53,5 +78,18 @@ def main() -> None: raise AssertionError(f"unhandled command: {args.command!r}") +def _with_default_renderer(argv: list[str]) -> list[str]: + # argparse has no default subcommand, so a bare `simulator` (or one + # followed straight by options) gets the default renderer spliced in. + if not argv or argv[0] != "simulator": + return argv + + rest = argv[1:] + if rest and (rest[0] in RENDERERS or rest[0] in ("-h", "--help")): + return argv + + return ["simulator", DEFAULT_RENDERER, *rest] + + if __name__ == "__main__": main() diff --git a/matrixbox_simulator/device/button_input.py b/matrixbox_simulator/device/button_input.py index 21bba74..775cd9e 100644 --- a/matrixbox_simulator/device/button_input.py +++ b/matrixbox_simulator/device/button_input.py @@ -1,17 +1,75 @@ """Simulated front-panel button state. A background keyboard listener calls -press(); the digitalio pin stand-in reads pressed_until on every -button.poll() the same way it'd read a real pin. +press(), a renderer's clickable button calls hold() and release(); the +digitalio pin stand-in calls is_pressed() on every button.poll() the same +way it'd read a real pin. + +Apps only notice a press when two of their polls both see the pin down +(see matrixbox's own debounce), and a mouse click or a fixed-length key +press can come and go between two slow polls. So a release only takes +effect once the app has really observed the press. """ +import threading import time -pressed_until = 0.0 +# A renderer that vanishes mid-hold never sends its release, so a hold +# lets go by itself after this long rather than staying pressed forever. +MAX_HOLD_SECONDS = 10.0 + +# Just over matrixbox's own 40 ms noise filter, so an observed press is +# never thrown away as a bounce. +OBSERVED_SPAN_SECONDS = 0.05 + +_lock = threading.Lock() +_pressed = False +_release_at = 0.0 +_first_read_at: float | None = None +_last_read_at: float | None = None def press(duration_seconds: float) -> None: - global pressed_until - pressed_until = time.monotonic() + duration_seconds + _start(time.monotonic() + duration_seconds) + + +def hold() -> None: + press(MAX_HOLD_SECONDS) + + +def release() -> None: + global _release_at + with _lock: + if _pressed: + _release_at = min(_release_at, time.monotonic()) def is_pressed() -> bool: - return time.monotonic() < pressed_until + global _pressed, _first_read_at, _last_read_at + with _lock: + if not _pressed: + return False + + now = time.monotonic() + if now >= _release_at and _was_observed(): + _pressed = False + return False + + _first_read_at = _first_read_at if _first_read_at is not None else now + _last_read_at = now + + return True + + +def _start(release_at: float) -> None: + global _pressed, _release_at, _first_read_at, _last_read_at + with _lock: + _pressed = True + _release_at = release_at + _first_read_at = None + _last_read_at = None + + +def _was_observed() -> bool: + if _first_read_at is None or _last_read_at is None: + return False + + return _last_read_at - _first_read_at >= OBSERVED_SPAN_SECONDS diff --git a/matrixbox_simulator/device/frame_bridge.py b/matrixbox_simulator/device/frame_bridge.py index d0c20ac..e77692e 100644 --- a/matrixbox_simulator/device/frame_bridge.py +++ b/matrixbox_simulator/device/frame_bridge.py @@ -55,9 +55,14 @@ def __init__(self) -> None: # back off the wire. self.on_publish: Callable[[int, int, bytes], None] | None = None - def start(self, host: str = "127.0.0.1", port: int = 9191) -> wsserver.FrameServer: + def start( + self, + host: str = "127.0.0.1", + port: int = 9191, + on_command: Callable[[str], None] | None = None, + ) -> wsserver.FrameServer: if self._server is None: - self._server = wsserver.FrameServer(host, port) + self._server = wsserver.FrameServer(host, port, on_command=on_command) return self._server diff --git a/matrixbox_simulator/device/run_app.py b/matrixbox_simulator/device/run_app.py index b57346b..ffe008b 100644 --- a/matrixbox_simulator/device/run_app.py +++ b/matrixbox_simulator/device/run_app.py @@ -398,11 +398,12 @@ def _seed_settings( # (ssid, brightness, ...) on a previous run, and those carry over the # same way they would on real hardware. # - # app_name is only set when launched with a single app to autostart. - # Launched against a full checkout instead, autostart is cleared: a - # plain root boot always lands on the home menu, regardless of - # whatever an earlier single-app launch (or a previous root boot's - # own in-UI app pick) left saved here — unlike real firmware, where + # app_name is only set when launched with a single app to autostart, + # or when an internal restart is resuming whatever was running. + # Otherwise autostart is cleared: a fresh root boot always lands on + # the home menu, regardless of whatever an earlier single-app launch + # (or a previous root boot's own in-UI app pick) left saved here — + # unlike real firmware, where # autostart is a sticky user preference, this is a dev sandbox and a # stale autostart from a different, unrelated launch shouldn't leak # into the next one. @@ -486,6 +487,29 @@ def _strip_geometry_flags(argv: list[str]) -> list[str]: return result +# Carries whichever app was on screen across an internal restart, so a +# size change or reload lands back in it rather than on the home menu. +# An environment variable, since that's all that survives os.execv. +RESUME_APP_ENV = "MATRIXBOX_SIMULATOR_RESUME_APP" + + +def running_app_name() -> str | None: + # The kernel's own record of what it launched, kept current across + # every way of switching apps (button, web UI, screensaver). + module = sys.modules.get("load_settings") + running = getattr(module, "app_running", None) + + return running if isinstance(running, str) and running else None + + +def boot_app_name(app_dir: Path | None) -> str | None: + """The app to autostart on this boot: whatever was running before an + internal restart, otherwise the one launched directly, if any.""" + resumed = os.environ.pop(RESUME_APP_ENV, None) + + return resumed or (app_dir.name if app_dir is not None else None) + + def restart_process(reason: str = "to apply the new panel geometry") -> NoReturn: # Real hardware only ever reads panel geometry at boot, and reboots # whenever it changes — pins and tile count can't reconfigure live. @@ -502,6 +526,12 @@ def restart_process(reason: str = "to apply the new panel geometry") -> NoReturn # Re-enter via cli.py, not this module directly: sys.argv[1:] already # starts with the "app" subcommand token cli.py's parser expects, which # this module's own parser would instead misread as the app argument. + running = running_app_name() + if running is not None: + os.environ[RESUME_APP_ENV] = running + else: + os.environ.pop(RESUME_APP_ENV, None) + print(f"matrixbox-simulator: restarting {reason}...") os.execv( sys.executable, @@ -610,6 +640,61 @@ def _toggle_wifi() -> None: print(f"matrixbox-simulator: wifi now {'connected' if new else 'disconnected'}") +# One name per thing a person can do to the running app, shared by the +# terminal's own keys and controls a renderer sends over the frame server. +CONTROL_KEYS: dict[str, str] = { + "s": "short_press", + "l": "long_press", + "r": "reload", + "+": "refresh_fps_up", + "-": "refresh_fps_down", + "]": "gamma_up", + "[": "gamma_down", + "z": "cycle_size", + "n": "toggle_wifi", +} + + +def handle_control(name: str, cycle_size: Callable[[], None] | None = None) -> None: + if name == "short_press": + button_input.press(0.2) + print("matrixbox-simulator: button, short press") + elif name == "long_press": + button_input.press(2.2) + print("matrixbox-simulator: button, long press") + elif name == "button_down": + button_input.hold() + elif name == "button_up": + button_input.release() + elif name == "reload": + restart_process(reason="to reload app and core code") + elif name == "refresh_fps_up": + _bump_refresh_fps(1) + elif name == "refresh_fps_down": + _bump_refresh_fps(-1) + elif name == "gamma_up": + _bump_gamma(1) + elif name == "gamma_down": + _bump_gamma(-1) + elif name == "cycle_size" and cycle_size is not None: + cycle_size() + elif name == "toggle_wifi": + _toggle_wifi() + else: + print(f"matrixbox-simulator: ignoring unknown control {name!r}") + + +def handle_command(message: str, cycle_size: Callable[[], None] | None = None) -> None: + """Runs a control a renderer sent, as {"control": ""}.""" + try: + name = json.loads(message)["control"] + except (ValueError, KeyError, TypeError): + print(f"matrixbox-simulator: ignoring malformed command {message!r}") + return + + handle_control(str(name), cycle_size) + + def _run_kernel( framework_root: Path, args: argparse.Namespace, app_dir: Path | None = None ) -> None: @@ -623,11 +708,12 @@ def _run_kernel( _install_path_sandbox(staged_root) _install_chdir_path_tracking() _install_lenient_bytes_import_hook(staged_root) + boot_app = boot_app_name(app_dir) _seed_settings( staged_root, args.width, args.height, - app_name=app_dir.name if app_dir is not None else None, + app_name=boot_app, overwrite=args.geometry_explicit, rotation=args.rotation_override, ) @@ -637,7 +723,14 @@ def _run_kernel( sys.path.insert(0, str(staged_root / "lib")) sys.path.insert(0, str(STUB_DIR)) - frame_bridge.bridge.start(args.ws_host, args.ws_port) + def cycle_size() -> NoReturn: + _cycle_size(staged_root / "settings.txt") + + frame_bridge.bridge.start( + args.ws_host, + args.ws_port, + on_command=lambda message: handle_command(message, cycle_size), + ) # Read back rather than trusting the launch values directly: after a # restart triggered by a settings-UI change or a size cycle, these # are whatever's actually now saved, which can differ from launch. @@ -657,6 +750,12 @@ def _run_kernel( f"({final_width}x{final_height}) via {framework_root.name}'s " "own kernel (autostart)" ) + elif boot_app is not None: + print( + f"matrixbox-simulator: running {framework_root} " + f"({final_width}x{final_height}) via its own kernel, " + f"resuming {boot_app!r}" + ) else: print( f"matrixbox-simulator: running {framework_root} " @@ -673,9 +772,6 @@ def _run_kernel( os.chdir(staged_root) # goes through tracked_chdir, seeds sys.path[0] source = main_path.read_text() - def cycle_size() -> NoReturn: - _cycle_size(staged_root / "settings.txt") - with _button_listener(cycle_size=cycle_size): try: _exec_as_main(source, main_path) @@ -748,27 +844,9 @@ def listen() -> None: if not ready: continue - char = sys.stdin.read(1) - if char == "s": - button_input.press(0.2) - print("matrixbox-simulator: button, short press") - elif char == "l": - button_input.press(2.2) - print("matrixbox-simulator: button, long press") - elif char == "r": - restart_process(reason="to reload app and core code") - elif char == "+": - _bump_refresh_fps(1) - elif char == "-": - _bump_refresh_fps(-1) - elif char == "]": - _bump_gamma(1) - elif char == "[": - _bump_gamma(-1) - elif char == "z" and cycle_size is not None: - cycle_size() - elif char == "n": - _toggle_wifi() + control = CONTROL_KEYS.get(sys.stdin.read(1)) + if control is not None: + handle_control(control, cycle_size) thread = threading.Thread(target=listen, daemon=True) thread.start() diff --git a/matrixbox_simulator/device/wsserver.py b/matrixbox_simulator/device/wsserver.py index 6ed2303..313050b 100644 --- a/matrixbox_simulator/device/wsserver.py +++ b/matrixbox_simulator/device/wsserver.py @@ -1,7 +1,6 @@ """A minimal, dependency-free WebSocket server (RFC 6455) for pushing binary -frames to renderer clients. Only implements the server to client direction -needed here: handshake plus unmasked binary frame broadcast, with no -incoming message parsing. +frames to renderer clients, and reading back the small, unfragmented text +messages renderers send as controls (button presses and the like). """ import base64 @@ -9,12 +8,23 @@ import socket import struct import threading +from collections.abc import Callable +from urllib.parse import urlsplit _HANDSHAKE_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" +OPCODE_TEXT = 0x1 +OPCODE_BINARY = 0x2 +OPCODE_CLOSE = 0x8 +_LOCAL_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) -class FrameServer: - def __init__(self, host: str = "127.0.0.1", port: int = 9191) -> None: +class Broadcaster: + """Tracks connected, already-handshaken clients and fans messages out to + them. Shared by the device's own frame server and the web renderer's + relay, which accept connections differently but broadcast the same way. + """ + + def __init__(self) -> None: self._clients: list[socket.socket] = [] self._lock = threading.Lock() # Replayed to every newly-connecting client so it never has to wait @@ -26,15 +36,8 @@ def __init__(self, host: str = "127.0.0.1", port: int = 9191) -> None: # otherwise only ever get replayed the stats message. self._last_by_kind: dict[str, bytes] = {} - self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self._sock.bind((host, port)) - self._sock.listen(5) - - threading.Thread(target=self._accept_loop, daemon=True).start() - - def broadcast(self, payload: bytes, *, kind: str) -> None: - frame = _encode_binary_frame(payload) + def broadcast(self, payload: bytes, *, kind: str, text: bool = False) -> None: + frame = encode_frame(payload, text=text) with self._lock: self._last_by_kind[kind] = frame dead: list[socket.socket] = [] @@ -48,6 +51,44 @@ def broadcast(self, payload: bytes, *, kind: str) -> None: self._clients.remove(conn) conn.close() + def forget(self, kind: str) -> None: + with self._lock: + self._last_by_kind.pop(kind, None) + + def add_client(self, conn: socket.socket) -> None: + conn.settimeout(1.0) + with self._lock: + for cached in self._last_by_kind.values(): + conn.sendall(cached) + + self._clients.append(conn) + + def remove_client(self, conn: socket.socket) -> None: + with self._lock: + if conn in self._clients: + self._clients.remove(conn) + + +class FrameServer: + def __init__( + self, + host: str = "127.0.0.1", + port: int = 9191, + on_command: Callable[[str], None] | None = None, + ) -> None: + self._broadcaster = Broadcaster() + self._on_command = on_command + + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._sock.bind((host, port)) + self._sock.listen(5) + + threading.Thread(target=self._accept_loop, daemon=True).start() + + def broadcast(self, payload: bytes, *, kind: str) -> None: + self._broadcaster.broadcast(payload, kind=kind) + def _accept_loop(self) -> None: while True: conn, _addr = self._sock.accept() @@ -57,29 +98,88 @@ def _handshake(self, conn: socket.socket) -> None: try: request = conn.recv(4096).decode("utf-8", "replace") key = _extract_header(request, "sec-websocket-key") - if key is None: + if key is None or not is_local_origin(_extract_header(request, "origin")): conn.close() return - accept = base64.b64encode( - hashlib.sha1((key + _HANDSHAKE_GUID).encode()).digest() - ).decode() response = ( "HTTP/1.1 101 Switching Protocols\r\n" "Upgrade: websocket\r\n" "Connection: Upgrade\r\n" - f"Sec-WebSocket-Accept: {accept}\r\n\r\n" + f"Sec-WebSocket-Accept: {accept_key(key)}\r\n\r\n" ) conn.sendall(response.encode()) - conn.settimeout(1.0) - - with self._lock: - for cached in self._last_by_kind.values(): - conn.sendall(cached) - - self._clients.append(conn) + self._broadcaster.add_client(conn) except OSError: conn.close() + return + + try: + while (message := read_message(conn)) is not None: + opcode, payload = message + if opcode == OPCODE_TEXT and self._on_command is not None: + self._on_command(payload.decode("utf-8", "replace")) + finally: + self._broadcaster.remove_client(conn) + conn.close() + + +def is_local_origin(origin: str | None) -> bool: + # Browsers let any website open a WebSocket to localhost, so a page + # from elsewhere must not be able to drive the app. Non-browser + # clients send no Origin at all. + if origin is None: + return True + + return urlsplit(origin).hostname in _LOCAL_HOSTS + + +def read_message(conn: socket.socket) -> tuple[int, bytes] | None: + """Blocks until one whole client message arrives, returning its opcode + and unmasked payload, or None once the client closes or goes away.""" + try: + first, second = _recv_exact(conn, 2) + length = second & 0x7F + if length == 126: + (length,) = struct.unpack(">H", _recv_exact(conn, 2)) + elif length == 127: + (length,) = struct.unpack(">Q", _recv_exact(conn, 8)) + + mask = _recv_exact(conn, 4) if second & 0x80 else bytes(4) + payload = bytes( + byte ^ mask[index % 4] + for index, byte in enumerate(_recv_exact(conn, length)) + ) + except (ConnectionError, OSError): + return None + + opcode = first & 0x0F + if opcode == OPCODE_CLOSE: + return None + + return opcode, payload + + +def _recv_exact(conn: socket.socket, count: int) -> bytes: + data = bytearray() + while len(data) < count: + try: + chunk = conn.recv(count - len(data)) + except TimeoutError: + continue + + if not chunk: + raise ConnectionError("client went away") + + data += chunk + + return bytes(data) + + +def accept_key(client_key: str) -> str: + digest = hashlib.sha1((client_key + _HANDSHAKE_GUID).encode()).digest() + + return base64.b64encode(digest).decode() def _extract_header(request: str, name: str) -> str | None: @@ -94,8 +194,9 @@ def _extract_header(request: str, name: str) -> str | None: return None -def _encode_binary_frame(payload: bytes) -> bytes: - header = bytearray([0x82]) # FIN + opcode 0x2 (binary) +def encode_frame(payload: bytes, *, text: bool = False) -> bytes: + # FIN bit set: every message goes out as a single, unfragmented frame. + header = bytearray([0x80 | (OPCODE_TEXT if text else OPCODE_BINARY)]) length = len(payload) if length < 126: header.append(length) diff --git a/matrixbox_simulator/term/run_simulator.py b/matrixbox_simulator/term/run_simulator.py index 125f82f..88c72c4 100644 --- a/matrixbox_simulator/term/run_simulator.py +++ b/matrixbox_simulator/term/run_simulator.py @@ -2,12 +2,12 @@ Usage: - matrixbox simulator + matrixbox simulator terminal Connects to ws://127.0.0.1:9191 by default; --connect overrides that. No simulator running? --demo draws an animated demo pattern instead: - matrixbox simulator --demo + matrixbox simulator terminal --demo """ import argparse diff --git a/matrixbox_simulator/term/source.py b/matrixbox_simulator/term/source.py index 1dfa70f..772cd1b 100644 --- a/matrixbox_simulator/term/source.py +++ b/matrixbox_simulator/term/source.py @@ -34,6 +34,19 @@ def __init__(self, url: str) -> None: self._ws = websocket.create_connection(url, timeout=_RECV_TIMEOUT_SECONDS) def next_frame(self) -> "Idle | Disconnected | wire.Frame | wire.Stats": + message = self.next_message() + if not isinstance(message, bytes): + return message + + try: + return wire.decode(message) + except wire.DecodeError as err: + print(f"dropping malformed message: {err}", file=sys.stderr) + return IDLE + + def next_message(self) -> "Idle | Disconnected | bytes": + """Like next_frame(), but hands back the still-encoded message, for + callers that only forward it on.""" try: opcode, data = self._ws.recv_data() except websocket.WebSocketTimeoutException: @@ -47,11 +60,13 @@ def next_frame(self) -> "Idle | Disconnected | wire.Frame | wire.Stats": if opcode != websocket.ABNF.OPCODE_BINARY: return IDLE + return data + + def send_text(self, message: str) -> None: try: - return wire.decode(data) - except wire.DecodeError as err: - print(f"dropping malformed message: {err}", file=sys.stderr) - return IDLE + self._ws.send(message) + except Exception as err: + print(f"websocket error: {err}", file=sys.stderr) def close(self) -> None: try: diff --git a/matrixbox_simulator/term/wire.py b/matrixbox_simulator/term/wire.py index 5867c57..77fb2fc 100644 --- a/matrixbox_simulator/term/wire.py +++ b/matrixbox_simulator/term/wire.py @@ -1,5 +1,6 @@ """Decodes the binary wire messages the frame server sends: Frame (pixel -data) and Stats (app name, FPS, CPU load, memory). +data) and Stats (app name, FPS, CPU load, memory). Also encodes them, for +synthetic sources that have to speak the same protocol as a real app. """ from __future__ import annotations @@ -64,6 +65,37 @@ def decode(data: bytes) -> Frame | Stats: raise DecodeError(f"bad magic byte: {magic:#x}") +def encode_frame(frame: Frame) -> bytes: + header = struct.pack( + " bytes: + flags = 0 + body = bytearray(struct.pack(" Frame: if len(data) < _FRAME_HEADER_LEN: raise DecodeError("frame shorter than its header") diff --git a/matrixbox_simulator/web/run_web.py b/matrixbox_simulator/web/run_web.py new file mode 100644 index 0000000..1fa1ff1 --- /dev/null +++ b/matrixbox_simulator/web/run_web.py @@ -0,0 +1,211 @@ +"""Web renderer for the matrixbox device simulator: draws the panel as +glowing LEDs in a browser, either flat or mounted in a mock enclosure. + +Usage: + + matrixbox simulator + +Then open http://127.0.0.1:8081/. Connects to ws://127.0.0.1:9191 by +default, same as the terminal renderer; --connect overrides that. No app +running? --demo draws an animated demo pattern instead: + + matrixbox simulator --demo +""" + +import argparse +import signal +import sys +import time +import webbrowser +from collections.abc import Callable +from types import FrameType + +from matrixbox_simulator.sizes import SIZE_PRESETS, panel_count_for +from matrixbox_simulator.term import wire +from matrixbox_simulator.term.demo import DemoSource +from matrixbox_simulator.term.run_simulator import ( + DEFAULT_CONNECT_URL, + connect_with_retry, +) +from matrixbox_simulator.term.source import DISCONNECTED +from matrixbox_simulator.web.server import WebServer + +DEFAULT_HOST = "127.0.0.1" +# One above the app's own settings page on 8080, so both can run at once. +DEFAULT_PORT = 8081 + + +def build_parser( + parser: argparse.ArgumentParser | None = None, +) -> argparse.ArgumentParser: + if parser is None: + parser = argparse.ArgumentParser(description=__doc__) + + parser.add_argument( + "--connect", + default=DEFAULT_CONNECT_URL, + help=f"Frame server to connect to (default: {DEFAULT_CONNECT_URL}).", + ) + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help=f"Address to serve the page on (default: {DEFAULT_HOST}).", + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Port to serve the page on (default: {DEFAULT_PORT}).", + ) + parser.add_argument( + "--open", + action="store_true", + help="Open the page in the default browser once it's being served.", + ) + parser.add_argument( + "--demo", + action="store_true", + help="Draw an animated demo pattern instead of connecting to a frame server.", + ) + parser.add_argument( + "--device", + choices=sorted(SIZE_PRESETS), + default=None, + help="Real product size to assume for the demo pattern and placeholder " + "panel. Errors if --width/--height are also given.", + ) + parser.add_argument( + "--width", + type=int, + default=None, + help="Panel width in pixels for the demo pattern and placeholder panel.", + ) + parser.add_argument( + "--height", + type=int, + default=None, + help="Panel height in pixels, see --width.", + ) + parser.add_argument( + "--fps", + type=int, + default=20, + help="Frames per second, demo mode only.", + ) + + return parser + + +def run(args: argparse.Namespace) -> None: + if args.device is not None and (args.width is not None or args.height is not None): + raise SystemExit("--device can't be combined with --width/--height") + + if args.device is not None: + width, height, _tiles = SIZE_PRESETS[args.device] + else: + width = args.width if args.width is not None else 128 + height = args.height if args.height is not None else 32 + + tiles = panel_count_for(width, height) + + running = True + + def handle_sigint(_signum: int, _frame: FrameType | None) -> None: + nonlocal running + running = False + + signal.signal(signal.SIGINT, handle_sigint) + + def is_running() -> bool: + return running + + try: + server = WebServer(args.host, args.port) + except OSError as err: + raise SystemExit(f"can't serve on {args.host}:{args.port}: {err}") from err + + server.start() + page_url = f"http://{args.host}:{server.port}/" + print(f"matrixbox-simulator: serving {page_url}", file=sys.stderr) + if args.open: + webbrowser.open(page_url) + + try: + if args.demo: + run_demo(server, width, height, tiles, args.fps, is_running) + else: + placeholder = wire.Frame.blank(width, height, tiles=tiles) + run_connected(server, args.connect, placeholder, is_running) + finally: + server.stop() + + +def run_demo( + server: WebServer, + width: int, + height: int, + tiles: int, + fps: int, + is_running: Callable[[], bool], +) -> None: + source = DemoSource(width, height, tiles=tiles) + frame_time = 1.0 / fps + stats = wire.Stats( + app="demo pattern", fps=float(fps), cpu_percent=None, rss_kb=None + ) + server.publish_status(connected=True, message="demo pattern") + + while is_running(): + started = time.monotonic() + server.publish(wire.encode_frame(source.next_frame())) + server.publish(wire.encode_stats(stats)) + + elapsed = time.monotonic() - started + if elapsed < frame_time: + time.sleep(frame_time - elapsed) + + +def run_connected( + server: WebServer, + url: str, + placeholder: wire.Frame, + is_running: Callable[[], bool], +) -> None: + while is_running(): + server.publish(wire.encode_frame(placeholder)) + server.publish_status( + connected=False, message=f"waiting for frame server at {url}..." + ) + + source = connect_with_retry(url, is_running) + if source is None: + break + + print(f"matrixbox-simulator: connected to {url}", file=sys.stderr) + server.publish_status(connected=True, message=f"connected to {url}") + server.on_control = source.send_text + + while is_running(): + message = source.next_message() + if isinstance(message, bytes): + try: + server.publish(message) + except wire.DecodeError as err: + print(f"dropping malformed message: {err}", file=sys.stderr) + elif message is DISCONNECTED: + print( + "matrixbox-simulator: lost connection, waiting to reconnect...", + file=sys.stderr, + ) + break + + server.on_control = None + source.close() + + +def main() -> None: + run(build_parser().parse_args()) + + +if __name__ == "__main__": + main() diff --git a/matrixbox_simulator/web/server.py b/matrixbox_simulator/web/server.py new file mode 100644 index 0000000..2d81b87 --- /dev/null +++ b/matrixbox_simulator/web/server.py @@ -0,0 +1,142 @@ +"""HTTP server for the web renderer: serves the page itself and, on the same +port, a WebSocket that relays the app's wire messages to every open tab. +The browser decodes and draws them, so this side never looks at pixels. +Controls flow the other way: whatever a tab sends is handed on to the app. +""" + +import json +import threading +from collections.abc import Callable +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from matrixbox_simulator.device.wsserver import ( + OPCODE_TEXT, + Broadcaster, + accept_key, + read_message, +) +from matrixbox_simulator.term import wire + +STATIC_DIR = Path(__file__).parent / "static" +STATIC_FILES = { + "/": ("index.html", "text/html; charset=utf-8"), + "/index.html": ("index.html", "text/html; charset=utf-8"), + "/app.js": ("app.js", "text/javascript; charset=utf-8"), + "/style.css": ("style.css", "text/css; charset=utf-8"), +} +WEBSOCKET_PATH = "/ws" + + +class WebServer: + def __init__(self, host: str = "127.0.0.1", port: int = 8081) -> None: + self.broadcaster = Broadcaster() + # Set while there's an app to pass controls on to; tabs sending + # controls with nothing connected are simply ignored. + self.on_control: Callable[[str], None] | None = None + self._stopping = threading.Event() + self._http = ThreadingHTTPServer((host, port), self._handler_class()) + self._http.daemon_threads = True + + @property + def port(self) -> int: + return self._http.server_address[1] + + def start(self) -> None: + threading.Thread(target=self._http.serve_forever, daemon=True).start() + + def stop(self) -> None: + self._stopping.set() + self._http.shutdown() + self._http.server_close() + + def publish(self, message: bytes) -> None: + """Forwards one still-encoded wire message to every open page.""" + decoded = wire.decode(message) + kind = "frame" if isinstance(decoded, wire.Frame) else "stats" + self.broadcaster.broadcast(message, kind=kind) + + def publish_status(self, *, connected: bool, message: str) -> None: + if not connected: + # Stale numbers from an app that's gone would read as if it + # were still running. + self.broadcaster.forget("stats") + + payload = json.dumps({"connected": connected, "message": message}) + self.broadcaster.broadcast(payload.encode(), kind="status", text=True) + + def _handler_class(self) -> type[BaseHTTPRequestHandler]: + server = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + path = self.path.split("?", 1)[0] + if path == WEBSOCKET_PATH: + self._upgrade() + return + + static = STATIC_FILES.get(path) + if static is None: + self.send_error(HTTPStatus.NOT_FOUND) + return + + name, content_type = static + body = (STATIC_DIR / name).read_bytes() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + # Always fresh, so editing the page while it's open only + # needs a reload, not a cache bust. + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _upgrade(self) -> None: + key = self.headers.get("Sec-WebSocket-Key") + if key is None: + self.send_error(HTTPStatus.BAD_REQUEST, "expected a WebSocket") + return + + if not self._is_same_origin(): + self.send_error(HTTPStatus.FORBIDDEN, "foreign origin") + return + + self.send_response(HTTPStatus.SWITCHING_PROTOCOLS) + self.send_header("Upgrade", "websocket") + self.send_header("Connection", "Upgrade") + self.send_header("Sec-WebSocket-Accept", accept_key(key)) + self.end_headers() + self.wfile.flush() + + conn = self.connection + server.broadcaster.add_client(conn) + try: + # Also keeps this handler, and so the socket, alive for + # as long as the tab stays open. + while not server._stopping.is_set(): + message = read_message(conn) + if message is None: + return + + opcode, payload = message + if opcode == OPCODE_TEXT and server.on_control is not None: + server.on_control(payload.decode("utf-8", "replace")) + finally: + server.broadcaster.remove_client(conn) + self.close_connection = True + + def _is_same_origin(self) -> bool: + # Any website can open a WebSocket here from the viewer's + # browser, and these sockets can drive the app, so only the + # page this server itself serves gets one. + origin = self.headers.get("Origin") + if origin is None: + return True + + return origin.split("://", 1)[-1] == self.headers.get("Host") + + def log_message(self, format: str, *args: object) -> None: + pass + + return Handler diff --git a/matrixbox_simulator/web/static/app.js b/matrixbox_simulator/web/static/app.js new file mode 100644 index 0000000..78f7fd9 --- /dev/null +++ b/matrixbox_simulator/web/static/app.js @@ -0,0 +1,893 @@ +"use strict"; + +// Mirrors the `matrixbox app` process's own keys and control names, kept in +// sync by hand since that's a separate (Python) process. Each key here is +// sent on to the app, so it works the same as typing it in that terminal. +const APP_CONTROLS = [ + ["n", "toggle wifi on/off", { n: "toggle_wifi" }], + ["s", "short button press", { s: "short_press" }], + ["l", "long button press (usually exits the app)", { l: "long_press" }], + ["r", "reload (restarts)", { r: "reload" }], + ["+/-", "refresh-fps", { "+": "refresh_fps_up", "-": "refresh_fps_down" }], + ["[/]", "gamma", { "[": "gamma_down", "]": "gamma_up" }], + ["z", "cycle panel size", { z: "cycle_size" }], +]; +const CONTROL_KEYS = Object.assign({}, ...APP_CONTROLS.map(([, , controls]) => controls)); + +const FRAME_MAGIC = 0xf3; +const STATS_MAGIC = 0xf4; +const WIRE_VERSION = 1; +const FRAME_HEADER_LENGTH = 8; +const STATS_HAS_CPU_PERCENT = 0b01; +const STATS_HAS_RSS_KB = 0b10; + +// Look of the LEDs, taken from the departures-plus README renderer so both +// read the same: https://github.com/jnbp/matrixbox-departures-plus +const LIT_THRESHOLD = 24; +const GLOW_RADIUS_PER_LED = 0.7; +const GLOW_STRENGTH = 0.55; +const DOT_RADIUS_PER_LED = 0.36; +const FACE_COLOR = "rgb(7, 7, 9)"; +const UNLIT_DOT_COLOR = "rgb(20, 20, 23)"; +const HOUSING_COLOR = "rgb(26, 26, 30)"; +const SEAM_COLOR = "rgba(150, 150, 170, 0.7)"; + +// Enclosure proportions in LEDs, eyeballed from product photos. +const PANEL_HOUSING_LEDS = 3; +const DEVICE_BEZEL_LEDS = 3.5; +const DEVICE_DEPTH_LEDS = 22; + +const BUTTON_SEGMENTS = 32; +const BUTTON_COLLAR = { radius: 4.5, height: 0.6, lightness: 9 }; +const BUTTON_CAP = { radius: 2.6, height: 1.8, lightness: 16 }; +const PLUG_LENGTH_LEDS = 8; + +const CABLE_POINTS = 28; +const CABLE_SLACK = 1.1; +const CABLE_GRAVITY = 0.6; +const CABLE_DAMPING = 0.96; +const CABLE_ITERATIONS = 24; +const CABLE_THICKNESS_LEDS = 1.9; + +const DEFAULT_ROTATION = { x: -16, y: 24 }; +const RECONNECT_DELAY_MS = 500; + +class WireDecoder { + decode(buffer) { + const view = new DataView(buffer); + const magic = view.getUint8(0); + if (view.getUint8(1) !== WIRE_VERSION) { + throw new Error(`unsupported protocol version ${view.getUint8(1)}`); + } + + if (magic === FRAME_MAGIC) { + return this.decodeFrame(view, buffer); + } + + if (magic === STATS_MAGIC) { + return this.decodeStats(view, buffer); + } + + throw new Error(`bad magic byte ${magic}`); + } + + decodeFrame(view, buffer) { + const width = view.getUint16(2, true); + const height = view.getUint16(4, true); + const tiles = Math.max(1, view.getUint8(7)); + const pixels = new Uint8Array(buffer, FRAME_HEADER_LENGTH); + if (pixels.length !== width * height * 3) { + throw new Error(`expected ${width * height * 3} pixel bytes, got ${pixels.length}`); + } + + return { type: "frame", width, height, tiles, pixels }; + } + + decodeStats(view, buffer) { + const flags = view.getUint8(2); + const appLength = view.getUint8(3); + const app = new TextDecoder().decode(new Uint8Array(buffer, 4, appLength)); + let cursor = 4 + appLength; + const fps = view.getFloat32(cursor, true); + cursor += 4; + + let cpuPercent = null; + if (flags & STATS_HAS_CPU_PERCENT) { + cpuPercent = view.getFloat32(cursor, true); + cursor += 4; + } + + let rssKb = null; + if (flags & STATS_HAS_RSS_KB) { + rssKb = view.getUint32(cursor, true); + } + + return { type: "stats", app, fps, cpuPercent, rssKb }; + } +} + +class LedRenderer { + constructor(canvas) { + this.canvas = canvas; + this.context = canvas.getContext("2d"); + this.pixelCanvas = document.createElement("canvas"); + this.litCanvas = document.createElement("canvas"); + this.dotMask = document.createElement("canvas"); + this.background = document.createElement("canvas"); + this.supportsFilter = typeof this.context.filter === "string"; + this.frame = null; + this.housing = true; + this.showSeams = false; + this.ledSize = 0; + this.layoutKey = ""; + } + + setFrame(frame) { + this.frame = frame; + } + + // Fits the panel into the given CSS box, picking a whole number of device + // pixels per LED so every dot comes out the same shape. + layout(maxCssWidth, maxCssHeight) { + if (!this.frame) { + return; + } + + const pixelRatio = window.devicePixelRatio || 1; + const padding = this.housing ? PANEL_HOUSING_LEDS * 2 : 0; + const ledsWide = this.frame.width + padding; + const ledsHigh = this.frame.height + padding; + const ledSize = Math.max( + 2, + Math.floor( + Math.min( + (maxCssWidth * pixelRatio) / ledsWide, + (maxCssHeight * pixelRatio) / ledsHigh, + ), + ), + ); + const key = [this.frame.width, this.frame.height, ledSize, this.housing, pixelRatio].join(); + if (key === this.layoutKey) { + return; + } + + this.layoutKey = key; + this.ledSize = ledSize; + this.canvas.width = ledsWide * ledSize; + this.canvas.height = ledsHigh * ledSize; + this.canvas.style.width = `${this.canvas.width / pixelRatio}px`; + this.canvas.style.height = `${this.canvas.height / pixelRatio}px`; + this.buildLayers(); + } + + cssLedSize() { + return this.ledSize / (window.devicePixelRatio || 1); + } + + buildLayers() { + const { width, height } = this.frame; + const size = this.ledSize; + const faceOffset = this.faceOffset(); + + this.pixelCanvas.width = width; + this.pixelCanvas.height = height; + this.litCanvas.width = width * size; + this.litCanvas.height = height * size; + this.dotMask.width = width * size; + this.dotMask.height = height * size; + this.background.width = this.canvas.width; + this.background.height = this.canvas.height; + + const mask = this.dotMask.getContext("2d"); + mask.fillStyle = "#fff"; + this.traceDots(mask, width, height, size); + mask.fill(); + + const background = this.background.getContext("2d"); + if (this.housing) { + background.fillStyle = HOUSING_COLOR; + background.beginPath(); + background.roundRect(0, 0, this.background.width, this.background.height, size * 2); + background.fill(); + } + + background.fillStyle = FACE_COLOR; + background.fillRect(faceOffset, faceOffset, width * size, height * size); + background.save(); + background.translate(faceOffset, faceOffset); + background.fillStyle = UNLIT_DOT_COLOR; + this.traceDots(background, width, height, size); + background.fill(); + background.restore(); + } + + traceDots(context, width, height, size) { + const radius = size * DOT_RADIUS_PER_LED; + context.beginPath(); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const centerX = x * size + size / 2; + const centerY = y * size + size / 2; + context.moveTo(centerX + radius, centerY); + context.arc(centerX, centerY, radius, 0, Math.PI * 2); + } + } + } + + faceOffset() { + return this.housing ? PANEL_HOUSING_LEDS * this.ledSize : 0; + } + + draw() { + if (!this.frame || !this.ledSize) { + return; + } + + const { width, height, pixels } = this.frame; + const size = this.ledSize; + const faceOffset = this.faceOffset(); + const faceWidth = width * size; + const faceHeight = height * size; + + // Near-black is left fully dark, the same cut-off the reference uses, + // so faint noise doesn't light up dots a real panel would show as off. + const image = new ImageData(width, height); + for (let source = 0, target = 0; source < pixels.length; source += 3, target += 4) { + const red = pixels[source]; + const green = pixels[source + 1]; + const blue = pixels[source + 2]; + if (red + green + blue > LIT_THRESHOLD) { + image.data[target] = red; + image.data[target + 1] = green; + image.data[target + 2] = blue; + } + + image.data[target + 3] = 255; + } + + this.pixelCanvas.getContext("2d").putImageData(image, 0, 0); + + const lit = this.litCanvas.getContext("2d"); + lit.globalCompositeOperation = "copy"; + lit.imageSmoothingEnabled = false; + lit.drawImage(this.pixelCanvas, 0, 0, faceWidth, faceHeight); + lit.globalCompositeOperation = "destination-in"; + lit.drawImage(this.dotMask, 0, 0); + + const context = this.context; + context.save(); + context.globalCompositeOperation = "copy"; + context.drawImage(this.background, 0, 0); + + context.beginPath(); + context.rect(faceOffset, faceOffset, faceWidth, faceHeight); + context.clip(); + + context.globalCompositeOperation = "lighter"; + context.globalAlpha = GLOW_STRENGTH; + if (this.supportsFilter) { + context.filter = `blur(${size * GLOW_RADIUS_PER_LED}px)`; + context.drawImage(this.litCanvas, faceOffset, faceOffset); + context.filter = "none"; + } else { + // Without canvas filters (older Safari), a smoothed upscale of the + // raw pixels is a close enough stand-in for a blur. + context.imageSmoothingEnabled = true; + context.drawImage(this.pixelCanvas, faceOffset, faceOffset, faceWidth, faceHeight); + } + + context.globalAlpha = 1; + context.globalCompositeOperation = "lighten"; + context.drawImage(this.litCanvas, faceOffset, faceOffset); + + if (this.showSeams && this.frame.tiles > 1) { + this.drawSeams(context, faceOffset, faceHeight); + } + + context.restore(); + } + + drawSeams(context, faceOffset, faceHeight) { + const tileWidth = Math.floor(this.frame.width / this.frame.tiles); + context.globalCompositeOperation = "source-over"; + context.strokeStyle = SEAM_COLOR; + context.lineWidth = Math.max(1, Math.round(this.ledSize / 4)); + context.setLineDash([this.ledSize, this.ledSize]); + context.beginPath(); + for (let tile = 1; tile < this.frame.tiles; tile++) { + const x = faceOffset + tile * tileWidth * this.ledSize; + context.moveTo(x, faceOffset); + context.lineTo(x, faceOffset + faceHeight); + } + + context.stroke(); + } +} + +class DeviceMockup { + constructor(box, scene) { + this.box = box; + this.scene = scene; + this.rotation = { ...DEFAULT_ROTATION }; + this.dragStart = null; + this.ledCssSize = 0; + this.onRotate = () => {}; + + const button = document.getElementById("device-button"); + this.buttonCap = buildCylinder("cap", BUTTON_CAP, 0); + this.buttonCapRest = this.buttonCap.style.transform; + button.append(buildCylinder("collar", BUTTON_COLLAR, 0), this.buttonCap); + this.onButton = () => {}; + this.bindButton(button); + this.plug = buildPlug(document.getElementById("plug")); + + scene.addEventListener("pointerdown", (event) => this.startDrag(event)); + scene.addEventListener("pointermove", (event) => this.drag(event)); + scene.addEventListener("pointerup", () => this.endDrag()); + scene.addEventListener("pointercancel", () => this.endDrag()); + scene.addEventListener("dblclick", () => { + this.rotation = { ...DEFAULT_ROTATION }; + this.applyRotation(); + }); + this.applyRotation(); + } + + // Sizes the enclosure around the screen, keeping bezel and depth in LED + // units so every panel size gets a box of believable proportions. + fit(screenCssWidth, screenCssHeight, ledCssSize) { + const bezel = DEVICE_BEZEL_LEDS * ledCssSize; + const style = this.box.style; + this.ledCssSize = ledCssSize; + style.setProperty("--box-width", `${screenCssWidth + bezel * 2}px`); + style.setProperty("--box-height", `${screenCssHeight + bezel * 2}px`); + style.setProperty("--box-depth", `${DEVICE_DEPTH_LEDS * ledCssSize}px`); + style.setProperty("--bezel", `${bezel}px`); + style.setProperty("--led", `${ledCssSize}px`); + } + + // The button follows the real one: pressed for exactly as long as it's + // held, so apps that react to hold length behave the same. + bindButton(button) { + const release = (event) => { + if (!button.classList.contains("pressed")) { + return; + } + + event.stopPropagation(); + button.classList.remove("pressed"); + this.buttonCap.style.transform = this.buttonCapRest; + this.onButton(false); + }; + + button.addEventListener("pointerdown", (event) => { + // Keeps the press from also starting a rotation drag. + event.stopPropagation(); + button.setPointerCapture(event.pointerId); + button.classList.add("pressed"); + this.buttonCap.style.transform = `translateZ(calc(var(--led) * ${-BUTTON_CAP.height * 0.6}))`; + this.onButton(true); + }); + button.addEventListener("pointerup", release); + button.addEventListener("pointercancel", release); + button.addEventListener("dblclick", (event) => event.stopPropagation()); + } + + // Where the cable leaves the plug, projected to the screen, plus a point + // a little further out so the cable starts off in the plug's direction. + plugExit() { + return { tip: centerOf(this.plug.tip), ahead: centerOf(this.plug.ahead) }; + } + + // The plug sits on the left side, which turns away from the viewer once + // the box is rotated past straight on. + plugFacesAway() { + return this.rotation.y < 0; + } + + startDrag(event) { + this.dragStart = { x: event.clientX, y: event.clientY, rotation: { ...this.rotation } }; + this.scene.setPointerCapture(event.pointerId); + this.scene.classList.add("dragging"); + } + + drag(event) { + if (!this.dragStart) { + return; + } + + const deltaX = event.clientX - this.dragStart.x; + const deltaY = event.clientY - this.dragStart.y; + this.rotation = { + x: clamp(this.dragStart.rotation.x - deltaY * 0.3, -50, 15), + y: clamp(this.dragStart.rotation.y + deltaX * 0.3, -75, 75), + }; + this.applyRotation(); + } + + endDrag() { + this.dragStart = null; + this.scene.classList.remove("dragging"); + } + + applyRotation() { + this.box.style.setProperty("--rotate-x", `${this.rotation.x}deg`); + this.box.style.setProperty("--rotate-y", `${this.rotation.y}deg`); + this.onRotate(); + } +} + +// A cable hanging from the plug to beyond the window's left edge, simulated +// as a verlet rope so it sags under gravity and swings as the device turns. +// See https://www.gamedeveloper.com/programming/advanced-character-physics +class CableRope { + constructor(svg, device) { + this.svg = svg; + this.device = device; + this.paths = { + shade: svg.querySelector(".cable-shade"), + body: svg.querySelector(".cable-body"), + highlight: svg.querySelector(".cable-highlight"), + }; + this.points = []; + this.running = false; + this.thickness = 0; + this.endY = 0; + this.restingFrames = 0; + } + + start() { + this.points = []; + this.endY = 0; + this.wake(); + } + + stop() { + this.running = false; + } + + wake() { + this.restingFrames = 0; + if (this.running) { + return; + } + + this.running = true; + window.requestAnimationFrame(() => this.tick()); + } + + tick() { + if (!this.running) { + return; + } + + const ends = this.measureEnds(); + if (ends) { + this.thickness = ends.thickness; + const moved = this.step(ends); + this.draw(); + this.restingFrames = moved < 0.05 ? this.restingFrames + 1 : 0; + } + + // Settled ropes stop simulating until the next rotation or resize wakes + // them, so an idle page isn't burning a frame loop. + if (this.restingFrames > 30) { + this.running = false; + return; + } + + window.requestAnimationFrame(() => this.tick()); + } + + measureEnds() { + const bounds = this.svg.getBoundingClientRect(); + if (!bounds.width) { + return null; + } + + const { tip, ahead } = this.device.plugExit(); + const thickness = CABLE_THICKNESS_LEDS * this.device.ledCssSize; + if (!this.endY) { + // Fixed once per layout, a little below where the plug starts out, + // so the cable leaves the window close by instead of sweeping across it. + this.endY = Math.min(bounds.height - thickness, tip.y - bounds.top + bounds.height * 0.12); + } + + return { + tip: { x: tip.x - bounds.left, y: tip.y - bounds.top }, + ahead: { x: ahead.x - bounds.left, y: ahead.y - bounds.top }, + end: { x: -thickness * 4, y: this.endY }, + thickness, + }; + } + + step({ tip, ahead, end }) { + const free = CABLE_POINTS - 1; + if (this.points.length !== CABLE_POINTS) { + this.points = Array.from({ length: CABLE_POINTS }, (_, index) => { + const share = index / free; + const x = ahead.x + (end.x - ahead.x) * share; + const y = ahead.y + (end.y - ahead.y) * share; + + return { x, y, previousX: x, previousY: y }; + }); + } + + let moved = 0; + for (const point of this.points) { + const velocityX = (point.x - point.previousX) * CABLE_DAMPING; + const velocityY = (point.y - point.previousY) * CABLE_DAMPING; + point.previousX = point.x; + point.previousY = point.y; + point.x += velocityX; + point.y += velocityY + CABLE_GRAVITY; + moved = Math.max(moved, Math.abs(velocityX) + Math.abs(velocityY)); + } + + // Slack follows the current span, so turning the device never stretches + // the cable taut or leaves metres of it piled up. + const span = Math.hypot(end.x - ahead.x, end.y - ahead.y) + Math.hypot(ahead.x - tip.x, ahead.y - tip.y); + const segmentLength = (span * CABLE_SLACK) / free; + const last = this.points.length - 1; + for (let iteration = 0; iteration < CABLE_ITERATIONS; iteration++) { + this.pin(0, tip); + this.pin(1, ahead); + this.pin(last, end); + for (let index = 1; index < last; index++) { + this.constrain(this.points[index], this.points[index + 1], segmentLength, index === 1, index + 1 === last); + } + } + + this.pin(0, tip); + this.pin(1, ahead); + this.pin(last, end); + + return moved; + } + + pin(index, position) { + const point = this.points[index]; + point.x = position.x; + point.y = position.y; + } + + constrain(first, second, length, firstPinned, secondPinned) { + const deltaX = second.x - first.x; + const deltaY = second.y - first.y; + const distance = Math.hypot(deltaX, deltaY) || 1; + const correction = (distance - length) / distance; + const firstShare = firstPinned ? 0 : secondPinned ? 1 : 0.5; + const secondShare = 1 - firstShare; + first.x += deltaX * correction * firstShare; + first.y += deltaY * correction * firstShare; + second.x -= deltaX * correction * secondShare; + second.y -= deltaY * correction * secondShare; + } + + draw() { + const path = smoothPath(this.points); + const thickness = this.thickness || 6; + this.svg.classList.toggle("behind", this.device.plugFacesAway()); + for (const element of Object.values(this.paths)) { + element.setAttribute("d", path); + } + + this.paths.shade.style.strokeWidth = `${thickness}px`; + this.paths.body.style.strokeWidth = `${thickness * 0.8}px`; + this.paths.highlight.style.strokeWidth = `${thickness * 0.28}px`; + this.paths.highlight.setAttribute("transform", `translate(0 ${-thickness * 0.16})`); + } +} + +// Builds a solid cylinder standing up out of its parent face: a ring of +// thin wall strips, shaded by angle as if lit from the front left, plus a +// top disc. Sizes are in LEDs so it scales with the enclosure. +function buildCylinder(className, { radius, height, lightness }, base) { + const cylinder = document.createElement("div"); + cylinder.className = `cylinder ${className}`; + cylinder.style.transform = `translateZ(calc(var(--led) * ${base}))`; + const stripWidth = ((2 * Math.PI * radius) / BUTTON_SEGMENTS) * 1.08; + + for (let segment = 0; segment < BUTTON_SEGMENTS; segment++) { + const angle = (360 * segment) / BUTTON_SEGMENTS; + const shade = lightness + 9 * Math.cos(((angle - 45) * Math.PI) / 180); + const wall = document.createElement("div"); + wall.className = "cylinder-wall"; + wall.style.left = `calc(var(--led) * ${-stripWidth / 2})`; + wall.style.width = `calc(var(--led) * ${stripWidth})`; + wall.style.height = `calc(var(--led) * ${height})`; + wall.style.background = `hsl(240 4% ${shade}%)`; + wall.style.transform = `rotateZ(${angle}deg) translateY(calc(var(--led) * ${radius})) rotateX(90deg)`; + cylinder.append(wall); + } + + const top = document.createElement("div"); + top.className = "cylinder-top"; + top.style.left = `calc(var(--led) * ${-radius})`; + top.style.top = `calc(var(--led) * ${-radius})`; + top.style.width = `calc(var(--led) * ${radius * 2})`; + top.style.height = `calc(var(--led) * ${radius * 2})`; + top.style.transform = `translateZ(calc(var(--led) * ${height}))`; + cylinder.append(top); + + return cylinder; +} + +// Builds the plug as a closed box sticking straight out of the side face, +// with two invisible markers the cable hangs from. +function buildPlug(plug) { + const length = `calc(var(--led) * ${PLUG_LENGTH_LEDS})`; + const sides = [ + ["end", { inset: "0", transform: `translateZ(${length})` }], + ["upper", { left: "0", top: `calc(${length} * -1)`, width: "100%", height: length, transformOrigin: "bottom", transform: "rotateX(-90deg)" }], + ["lower", { left: "0", top: "100%", width: "100%", height: length, transformOrigin: "top", transform: "rotateX(90deg)" }], + ["back", { left: `calc(${length} * -1)`, top: "0", width: length, height: "100%", transformOrigin: "right", transform: "rotateY(90deg)" }], + ["front", { left: "100%", top: "0", width: length, height: "100%", transformOrigin: "left", transform: "rotateY(-90deg)" }], + ]; + + for (const [name, style] of sides) { + const side = document.createElement("div"); + side.className = `plug-side ${name}`; + Object.assign(side.style, style); + plug.append(side); + } + + const tip = document.createElement("div"); + tip.className = "cable-anchor"; + tip.style.transform = `translateZ(${length})`; + const ahead = document.createElement("div"); + ahead.className = "cable-anchor"; + ahead.style.transform = `translateZ(calc(var(--led) * ${PLUG_LENGTH_LEDS + 5}))`; + plug.append(tip, ahead); + + return { tip, ahead }; +} + +function centerOf(element) { + const rect = element.getBoundingClientRect(); + + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; +} + +// Curves through the midpoints between points, so the rope reads as one +// smooth cable rather than a chain of straight segments. +function smoothPath(points) { + let path = `M ${points[0].x} ${points[0].y}`; + for (let index = 1; index < points.length - 1; index++) { + const midX = (points[index].x + points[index + 1].x) / 2; + const midY = (points[index].y + points[index + 1].y) / 2; + path += ` Q ${points[index].x} ${points[index].y} ${midX} ${midY}`; + } + + const last = points[points.length - 1]; + + return `${path} L ${last.x} ${last.y}`; +} + +class Preferences { + read(key, fallback) { + try { + return window.localStorage.getItem(key) ?? fallback; + } catch { + return fallback; + } + } + + write(key, value) { + try { + window.localStorage.setItem(key, value); + } catch { + // Private windows can refuse storage; the page still works without it. + } + } +} + +class SimulatorPage { + constructor() { + this.decoder = new WireDecoder(); + this.preferences = new Preferences(); + this.renderer = new LedRenderer(document.getElementById("leds")); + this.device = new DeviceMockup(document.getElementById("box"), document.getElementById("scene")); + this.cable = new CableRope(document.getElementById("cable"), this.device); + this.device.onRotate = () => this.cable.wake(); + this.device.onButton = (pressed) => this.sendControl(pressed ? "button_down" : "button_up"); + this.socket = null; + this.stage = document.getElementById("stage"); + this.panelView = document.getElementById("panel-view"); + this.deviceView = document.getElementById("device-view"); + this.deviceScreen = document.getElementById("device-screen"); + this.connection = document.getElementById("connection"); + this.stats = document.getElementById("stats"); + this.seamsToggle = document.getElementById("seams"); + this.viewButtons = document.querySelectorAll("[data-view]"); + this.drawPending = false; + this.lastFrameSize = ""; + this.view = + new URLSearchParams(window.location.search).get("view") ?? this.preferences.read("view", "panel"); + + this.renderer.showSeams = this.preferences.read("seams", "off") === "on"; + this.seamsToggle.checked = this.renderer.showSeams; + this.renderAppControls(); + this.bindControls(); + this.setView(this.view); + this.connect(); + } + + renderAppControls() { + const list = document.getElementById("app-controls"); + for (const [key, description] of APP_CONTROLS) { + const term = document.createElement("dt"); + term.textContent = key; + const detail = document.createElement("dd"); + detail.textContent = description; + list.append(term, detail); + } + } + + bindControls() { + for (const button of this.viewButtons) { + button.addEventListener("click", () => this.setView(button.dataset.view)); + } + + this.seamsToggle.addEventListener("change", () => this.setSeams(this.seamsToggle.checked)); + window.addEventListener("resize", () => this.relayout()); + window.addEventListener("keydown", (event) => { + if (event.metaKey || event.ctrlKey || event.altKey) { + return; + } + + if (event.key === "v") { + this.setView(this.view === "panel" ? "device" : "panel"); + } else if (event.key === "t") { + this.setSeams(!this.renderer.showSeams); + } else if (event.key in CONTROL_KEYS && !event.repeat) { + this.sendControl(CONTROL_KEYS[event.key]); + } + }); + } + + setView(view) { + this.view = view === "device" ? "device" : "panel"; + this.preferences.write("view", this.view); + for (const button of this.viewButtons) { + button.setAttribute("aria-pressed", String(button.dataset.view === this.view)); + } + + const isDevice = this.view === "device"; + this.deviceView.hidden = !isDevice; + this.panelView.hidden = isDevice; + (isDevice ? this.deviceScreen : this.panelView).append(this.renderer.canvas); + this.renderer.housing = !isDevice; + this.relayout(); + if (isDevice) { + this.cable.start(); + } else { + this.cable.stop(); + } + } + + setSeams(enabled) { + this.renderer.showSeams = enabled; + this.seamsToggle.checked = enabled; + this.preferences.write("seams", enabled ? "on" : "off"); + this.scheduleDraw(); + } + + relayout() { + const frame = this.renderer.frame; + if (!frame) { + return; + } + + const bounds = this.stage.getBoundingClientRect(); + if (this.view === "device") { + // Leaves room for the bezel, and for the box's depth once rotated. + const enclosureLeds = DEVICE_BEZEL_LEDS * 2 + DEVICE_DEPTH_LEDS; + const ledsWide = frame.width + enclosureLeds; + const ledsHigh = frame.height + enclosureLeds; + const ledCssSize = Math.min((bounds.width * 0.86) / ledsWide, (bounds.height * 0.8) / ledsHigh); + // A hair over, so rounding down to whole device pixels lands on it. + this.renderer.layout(frame.width * ledCssSize + 0.01, frame.height * ledCssSize + 0.01); + const canvas = this.renderer.canvas; + this.device.fit( + parseFloat(canvas.style.width), + parseFloat(canvas.style.height), + this.renderer.cssLedSize(), + ); + this.cable.start(); + } else { + this.renderer.layout(bounds.width - 32, bounds.height - 32); + } + + this.renderer.draw(); + } + + connect() { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const socket = new WebSocket(`${protocol}//${window.location.host}/ws`); + socket.binaryType = "arraybuffer"; + this.socket = socket; + socket.addEventListener("message", (event) => this.handleMessage(event.data)); + socket.addEventListener("close", () => { + this.setConnection(false, "web server gone, retrying..."); + window.setTimeout(() => this.connect(), RECONNECT_DELAY_MS); + }); + } + + sendControl(control) { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify({ control })); + } + } + + handleMessage(data) { + if (typeof data === "string") { + const status = JSON.parse(data); + this.setConnection(status.connected, status.message); + if (!status.connected) { + this.stats.textContent = status.message; + } + + return; + } + + let message; + try { + message = this.decoder.decode(data); + } catch (error) { + console.warn("dropping malformed message", error); + return; + } + + if (message.type === "frame") { + this.handleFrame(message); + } else { + this.stats.textContent = formatStats(message); + } + } + + handleFrame(frame) { + this.renderer.setFrame(frame); + const size = `${frame.width}x${frame.height}`; + if (size !== this.lastFrameSize) { + this.lastFrameSize = size; + document.body.dataset.panelSize = size; + this.relayout(); + return; + } + + this.scheduleDraw(); + } + + // Frames can arrive faster than the screen refreshes; only the newest + // one is worth drawing. + scheduleDraw() { + if (this.drawPending) { + return; + } + + this.drawPending = true; + window.requestAnimationFrame(() => { + this.drawPending = false; + this.renderer.draw(); + }); + } + + setConnection(connected, message) { + this.connection.dataset.connected = String(connected); + this.connection.textContent = connected ? "live" : "offline"; + this.connection.title = message; + } +} + +function formatStats(stats) { + const cpu = stats.cpuPercent === null ? "n/a" : `${stats.cpuPercent.toFixed(0)}%`; + const rss = stats.rssKb === null ? "n/a" : `${(stats.rssKb / 1024).toFixed(1)} MB`; + + return `app: ${stats.app} fps: ${stats.fps.toFixed(1)} cpu: ${cpu} sim rss (peak): ${rss}`; +} + +function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)); +} + +new SimulatorPage(); diff --git a/matrixbox_simulator/web/static/index.html b/matrixbox_simulator/web/static/index.html new file mode 100644 index 0000000..4834a14 --- /dev/null +++ b/matrixbox_simulator/web/static/index.html @@ -0,0 +1,81 @@ + + + + + + matrixbox simulator + + + +
+
+ matrixbox + connecting... +
+
+
+ + +
+ +
+
+ +
+
+ + + + +
+ +
+
waiting for a connected app...
+
+
+

This page

+
+
v
switch panel / device view
+
t
toggle panel seams
+
drag
rotate the device, double-click resets
+
click
the device's button: click for a short press, hold for a long one
+
+
+
+

App (here, or in the matrixbox app terminal)

+
+
+
+
+ + + + diff --git a/matrixbox_simulator/web/static/style.css b/matrixbox_simulator/web/static/style.css new file mode 100644 index 0000000..4dbd99e --- /dev/null +++ b/matrixbox_simulator/web/static/style.css @@ -0,0 +1,448 @@ +:root { + color-scheme: light dark; + --page: #ececef; + --page-edge: #cfcfd5; + --ink: #26262c; + --ink-muted: #6b6b75; + --chrome: rgba(255, 255, 255, 0.7); + --chrome-border: rgba(0, 0, 0, 0.08); + --accent: #e08a1e; + --live: #2f9e5b; + --offline: #b54848; + --floor-shadow: rgba(0, 0, 0, 0.45); + --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --page: #1d1d22; + --page-edge: #0c0c0f; + --ink: #e6e6ea; + --ink-muted: #8d8d98; + --chrome: rgba(30, 30, 36, 0.75); + --chrome-border: rgba(255, 255, 255, 0.08); + --floor-shadow: rgba(0, 0, 0, 0.8); + } +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; +} + +body { + display: grid; + grid-template-rows: auto 1fr auto; + background: radial-gradient(ellipse at 50% 40%, var(--page) 35%, var(--page-edge) 100%); + color: var(--ink); + font-family: var(--sans); + font-size: 14px; +} + +[hidden] { + display: none !important; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 12px 16px; +} + +.brand { + display: flex; + gap: 10px; + align-items: center; +} + +.brand-name { + font-weight: 650; + letter-spacing: 0.02em; +} + +.connection { + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + color: #fff; + background: var(--offline); +} + +.connection[data-connected="true"] { + background: var(--live); +} + +.toggles { + display: flex; + gap: 12px; + align-items: center; +} + +.segmented { + display: inline-flex; + padding: 2px; + border: 1px solid var(--chrome-border); + border-radius: 8px; + background: var(--chrome); +} + +.segmented button { + padding: 4px 12px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--ink-muted); + font: inherit; + cursor: pointer; +} + +.segmented button[aria-pressed="true"] { + background: var(--accent); + color: #fff; +} + +.switch { + display: inline-flex; + gap: 6px; + align-items: center; + color: var(--ink-muted); + cursor: pointer; +} + +.switch input { + accent-color: var(--accent); +} + +.stage { + position: relative; + min-height: 0; + overflow: hidden; +} + +.panel-view, +.device-view { + position: absolute; + inset: 0; + display: grid; + place-items: center; +} + +.leds { + display: block; + image-rendering: pixelated; +} + +.panel-view .leds { + filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.35)); +} + +/* Device mockup: a CSS 3D box sized from JS in LED units. */ + +.scene { + position: relative; + z-index: 1; + display: grid; + place-items: center; + width: 100%; + height: 100%; + perspective: 2400px; + cursor: grab; + touch-action: none; + user-select: none; +} + +.scene.dragging { + cursor: grabbing; +} + +.box { + --box-width: 600px; + --box-height: 200px; + --box-depth: 100px; + --bezel: 20px; + --led: 4px; + --rotate-x: -16deg; + --rotate-y: 24deg; + + position: relative; + width: var(--box-width); + height: var(--box-height); + transform-style: preserve-3d; + transform: rotateX(var(--rotate-x)) rotateY(var(--rotate-y)); +} + +.face { + position: absolute; + backface-visibility: hidden; +} + +.front, +.back { + inset: 0; +} + +.front { + display: grid; + place-items: center; + border-radius: calc(var(--led) * 0.6); + background: linear-gradient(175deg, #232327 0%, #141416 55%, #0f0f11 100%); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05); + transform: translateZ(calc(var(--box-depth) / 2)); +} + +.screen { + position: relative; + line-height: 0; + background: #070709; +} + +/* The panel sits slightly recessed behind the bezel. */ +.screen::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + box-shadow: + inset 0 calc(var(--led) * 0.8) calc(var(--led) * 1.2) rgba(0, 0, 0, 0.7), + inset 0 0 0 1px rgba(0, 0, 0, 0.9); + background: linear-gradient(160deg, rgba(255, 255, 255, 0.035) 0%, transparent 45%); +} + +.back { + background: #0e0e10; + transform: rotateY(180deg) translateZ(calc(var(--box-depth) / 2)); +} + +.top, +.bottom { + left: 0; + top: calc((var(--box-height) - var(--box-depth)) / 2); + width: var(--box-width); + height: var(--box-depth); +} + +.top { + background: linear-gradient(0deg, #2b2b30 0%, #38383e 100%); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04); + transform: rotateX(90deg) translateZ(calc(var(--box-height) / 2)); + transform-style: preserve-3d; +} + +.bottom { + background: #0b0b0d; + transform: rotateX(-90deg) translateZ(calc(var(--box-height) / 2)); +} + +.left, +.right { + top: 0; + left: calc((var(--box-width) - var(--box-depth)) / 2); + width: var(--box-depth); + height: var(--box-height); +} + +.left { + background: linear-gradient(90deg, #17171a 0%, #1e1e22 100%); + transform: rotateY(-90deg) translateZ(calc(var(--box-width) / 2)); + transform-style: preserve-3d; +} + +.right { + background: #111114; + transform: rotateY(90deg) translateZ(calc(var(--box-width) / 2)); +} + +.floor-shadow { + position: absolute; + left: -6%; + top: calc((var(--box-height) - var(--box-depth) * 2) / 2); + width: 112%; + height: calc(var(--box-depth) * 2); + background: radial-gradient(closest-side, var(--floor-shadow), transparent); + filter: blur(calc(var(--led) * 3)); + transform: rotateX(90deg) translateZ(calc(var(--box-height) / -2 - 1px)); + pointer-events: none; +} + +.button { + position: absolute; + right: 9%; + top: 50%; + width: 0; + height: 0; + transform-style: preserve-3d; +} + +.button .cylinder { + cursor: pointer; +} + +.cylinder { + position: absolute; + left: 0; + top: 0; + transform-style: preserve-3d; +} + +.cylinder-wall, +.cylinder-top { + position: absolute; +} + +.cylinder-wall { + transform-origin: 50% 0; +} + +.cylinder-top { + border-radius: 50%; +} + +.collar .cylinder-top { + background: radial-gradient(circle at 40% 35%, #2e2e33, #0d0d0f 75%); +} + +.cap .cylinder-top { + background: radial-gradient(circle at 38% 32%, #55555d 0%, #1c1c20 60%, #08080a 100%); +} + +.port { + position: absolute; + left: 68%; + width: calc(var(--led) * 2.2); + height: calc(var(--led) * 2.2); + border-radius: 50%; + background: radial-gradient(circle, #050506 45%, #3a3a40 60%, #18181b 75%); +} + +.port-upper { + top: 58%; +} + +.port-lower { + top: 84%; +} + +/* The plug's own position on the side face; its box is built in JS. */ +.plug { + position: absolute; + left: calc(68% + var(--led) * 1.1 - var(--led) * 1.8); + top: calc(71% - var(--led) * 1.3); + width: calc(var(--led) * 3.6); + height: calc(var(--led) * 2.6); + transform-style: preserve-3d; +} + +.plug-side { + position: absolute; + background: linear-gradient(90deg, #c9c9cf, #f4f4f6); +} + +.plug-side.end { + border-radius: calc(var(--led) * 0.6); + background: #e9e9ed; +} + +.plug-side.upper { + background: linear-gradient(90deg, #dcdce1, #ffffff); +} + +.plug-side.lower { + background: linear-gradient(90deg, #9d9da5, #c2c2c8); +} + +.cable-anchor { + position: absolute; + left: 50%; + top: 50%; + width: 1px; + height: 1px; +} + +/* Drawn flat over (or, when the plug faces away, under) the 3D scene, so + it can always trail off the edge of the window. */ +.cable { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + z-index: 2; +} + +.cable.behind { + z-index: 0; +} + +.cable-shade { + stroke: #b3b3ba; +} + +.cable-body { + stroke: #e2e2e7; +} + +.cable-highlight { + stroke: #ffffff; +} + +.footer { + display: grid; + gap: 12px; + padding: 12px 16px 16px; +} + +.stats { + font-family: var(--mono); + font-size: 13px; + color: var(--ink); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.controls { + display: flex; + flex-wrap: wrap; + gap: 8px 40px; +} + +.controls h2 { + margin: 0 0 4px; + font-size: 12px; + font-weight: 600; + color: var(--ink-muted); +} + +.controls dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 2px 12px; + margin: 0; + font-size: 12px; +} + +.controls dt { + font-family: var(--mono); + color: var(--accent); +} + +.controls dd { + margin: 0; + color: var(--ink-muted); +} + +.controls code { + font-family: var(--mono); +} diff --git a/tests/test_button_input.py b/tests/test_button_input.py new file mode 100644 index 0000000..c463818 --- /dev/null +++ b/tests/test_button_input.py @@ -0,0 +1,48 @@ +import time + +from matrixbox_simulator.device import button_input + + +def poll_after(seconds: float) -> bool: + time.sleep(seconds) + + return button_input.is_pressed() + + +def test_click_shorter_than_the_poll_interval_is_still_seen() -> None: + button_input.hold() + button_input.release() + + assert poll_after(0.0) is True + assert poll_after(0.06) is True + assert poll_after(0.0) is False + + +def test_release_waits_until_two_polls_have_seen_the_press() -> None: + button_input.press(0.01) + + assert poll_after(0.2) is True + assert poll_after(0.0) is True # too soon after the first poll to count + assert poll_after(0.06) is True + assert poll_after(0.0) is False + + +def test_hold_stays_pressed_until_released() -> None: + button_input.hold() + + assert poll_after(0.0) is True + assert poll_after(0.1) is True + assert poll_after(0.1) is True + + button_input.release() + assert poll_after(0.0) is False + + +def test_untouched_button_reads_released() -> None: + button_input.hold() + button_input.release() + poll_after(0.0) + poll_after(0.06) + poll_after(0.0) + + assert button_input.is_pressed() is False diff --git a/tests/test_controls.py b/tests/test_controls.py new file mode 100644 index 0000000..e93da9a --- /dev/null +++ b/tests/test_controls.py @@ -0,0 +1,41 @@ +import time + +import pytest + +from matrixbox_simulator.device import button_input, run_app + + +def test_every_terminal_key_maps_to_a_handled_control( + capsys: pytest.CaptureFixture[str], +) -> None: + unsafe = {"reload", "cycle_size"} # these restart the process + for control in set(run_app.CONTROL_KEYS.values()) - unsafe: + run_app.handle_control(control) + + assert "unknown control" not in capsys.readouterr().out + + +def test_button_down_holds_until_button_up() -> None: + run_app.handle_command('{"control": "button_down"}') + assert button_input.is_pressed() + time.sleep(0.06) + assert button_input.is_pressed() + + run_app.handle_command('{"control": "button_up"}') + assert not button_input.is_pressed() + + +def test_cycle_size_runs_the_given_callback() -> None: + calls: list[str] = [] + run_app.handle_command('{"control": "cycle_size"}', lambda: calls.append("cycled")) + + assert calls == ["cycled"] + + +@pytest.mark.parametrize("message", ["not json", "[]", '{"other": 1}']) +def test_malformed_commands_are_ignored( + message: str, capsys: pytest.CaptureFixture[str] +) -> None: + run_app.handle_command(message) + + assert "malformed command" in capsys.readouterr().out diff --git a/tests/test_resume.py b/tests/test_resume.py new file mode 100644 index 0000000..9edf80d --- /dev/null +++ b/tests/test_resume.py @@ -0,0 +1,63 @@ +import sys +import types +from pathlib import Path + +import pytest + +from matrixbox_simulator.device import run_app + + +class Restarted(Exception): + pass + + +@pytest.fixture +def fake_execv(monkeypatch: pytest.MonkeyPatch) -> None: + def execv(_path: str, _argv: list[str]) -> None: + raise Restarted + + monkeypatch.setattr(run_app.os, "execv", execv) + monkeypatch.delenv(run_app.RESUME_APP_ENV, raising=False) + + +def set_running_app(monkeypatch: pytest.MonkeyPatch, name: object) -> None: + module = types.ModuleType("load_settings") + module.app_running = name # ty: ignore[unresolved-attribute] + monkeypatch.setitem(sys.modules, "load_settings", module) + + +@pytest.mark.usefixtures("fake_execv") +def test_restart_resumes_the_running_app(monkeypatch: pytest.MonkeyPatch) -> None: + set_running_app(monkeypatch, "clock") + + with pytest.raises(Restarted): + run_app.restart_process() + + assert run_app.boot_app_name(None) == "clock" + + +@pytest.mark.usefixtures("fake_execv") +def test_restart_from_the_menu_boots_to_the_menu( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(run_app.RESUME_APP_ENV, "stale") + set_running_app(monkeypatch, False) + + with pytest.raises(Restarted): + run_app.restart_process() + + assert run_app.boot_app_name(None) is None + + +@pytest.mark.usefixtures("fake_execv") +def test_resumed_app_wins_over_the_launched_one( + monkeypatch: pytest.MonkeyPatch, +) -> None: + set_running_app(monkeypatch, "weather") + + with pytest.raises(Restarted): + run_app.restart_process() + + assert run_app.boot_app_name(Path("/apps/clock")) == "weather" + # Consumed: a later boot without a restart falls back to the launch. + assert run_app.boot_app_name(Path("/apps/clock")) == "clock" diff --git a/tests/test_web_server.py b/tests/test_web_server.py new file mode 100644 index 0000000..b624e9f --- /dev/null +++ b/tests/test_web_server.py @@ -0,0 +1,87 @@ +import json +import queue +import urllib.error +import urllib.request +from collections.abc import Iterator + +import pytest +import websocket + +from matrixbox_simulator.term import wire +from matrixbox_simulator.web.server import WebServer + + +@pytest.fixture +def server() -> Iterator[WebServer]: + web_server = WebServer("127.0.0.1", 0) + web_server.start() + yield web_server + web_server.stop() + + +def test_serves_the_page(server: WebServer) -> None: + with urllib.request.urlopen(f"http://127.0.0.1:{server.port}/") as response: + body = response.read().decode() + + assert response.headers["Content-Type"].startswith("text/html") + assert "app.js" in body + + +def test_unknown_paths_are_not_found(server: WebServer) -> None: + with pytest.raises(urllib.error.HTTPError) as error: + urllib.request.urlopen(f"http://127.0.0.1:{server.port}/../pyproject.toml") + + assert error.value.code == 404 + + +def test_new_page_is_replayed_the_latest_frame_and_status(server: WebServer) -> None: + frame = wire.Frame(width=2, height=1, pixels=bytes(6)) + server.publish(wire.encode_frame(frame)) + server.publish_status(connected=True, message="connected") + + client = websocket.create_connection(f"ws://127.0.0.1:{server.port}/ws", timeout=2) + try: + received = [client.recv(), client.recv()] + finally: + client.close() + + binary = next(message for message in received if isinstance(message, bytes)) + text = next(message for message in received if isinstance(message, str)) + assert wire.decode(binary) == frame + assert json.loads(text) == {"connected": True, "message": "connected"} + + +def test_disconnect_drops_stale_stats(server: WebServer) -> None: + stats = wire.Stats(app="clock", fps=1.0, cpu_percent=None, rss_kb=None) + server.publish(wire.encode_stats(stats)) + server.publish_status(connected=False, message="waiting") + + client = websocket.create_connection(f"ws://127.0.0.1:{server.port}/ws", timeout=2) + try: + status = client.recv() + client.settimeout(0.3) + with pytest.raises(websocket.WebSocketTimeoutException): + client.recv() + finally: + client.close() + + assert json.loads(status)["connected"] is False + + +def test_controls_from_a_page_are_forwarded(server: WebServer) -> None: + controls: queue.Queue[str] = queue.Queue() + server.on_control = controls.put + + client = websocket.create_connection(f"ws://127.0.0.1:{server.port}/ws", timeout=2) + try: + client.send('{"control": "cycle_size"}') + assert controls.get(timeout=2) == '{"control": "cycle_size"}' + finally: + client.close() + + +def test_pages_from_other_origins_are_refused(server: WebServer) -> None: + with pytest.raises(websocket.WebSocketException): + websocket.create_connection( + f"ws://127.0.0.1:{server.port}/ws", timeout=2, origin="https://example.com" + ) diff --git a/tests/test_wire.py b/tests/test_wire.py new file mode 100644 index 0000000..26258c6 --- /dev/null +++ b/tests/test_wire.py @@ -0,0 +1,13 @@ +from matrixbox_simulator.term import wire + + +def test_frame_round_trips_through_encode_and_decode() -> None: + frame = wire.Frame(width=2, height=1, pixels=bytes([1, 2, 3, 4, 5, 6]), tiles=2) + + assert wire.decode(wire.encode_frame(frame)) == frame + + +def test_stats_round_trip_keeps_optional_fields() -> None: + stats = wire.Stats(app="clock", fps=30.0, cpu_percent=None, rss_kb=2048) + + assert wire.decode(wire.encode_stats(stats)) == stats diff --git a/tests/test_wsserver.py b/tests/test_wsserver.py new file mode 100644 index 0000000..fee8e5c --- /dev/null +++ b/tests/test_wsserver.py @@ -0,0 +1,54 @@ +import queue +import socket + +import pytest +import websocket + +from matrixbox_simulator.device.wsserver import FrameServer, is_local_origin + + +def free_port() -> int: + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + + return probe.getsockname()[1] + + +@pytest.mark.parametrize( + ("origin", "allowed"), + [ + (None, True), + ("http://127.0.0.1:8081", True), + ("http://localhost:3000", True), + ("http://[::1]:8081", True), + ("https://example.com", False), + ("http://127.0.0.1.example.com", False), + ], +) +def test_only_local_origins_are_allowed(origin: str | None, allowed: bool) -> None: + assert is_local_origin(origin) is allowed + + +def test_text_messages_from_clients_reach_the_command_handler() -> None: + commands: queue.Queue[str] = queue.Queue() + port = free_port() + FrameServer("127.0.0.1", port, on_command=commands.put) + + client = websocket.create_connection(f"ws://127.0.0.1:{port}", timeout=2) + try: + client.send('{"control": "short_press"}') + client.send("x" * 300) + assert commands.get(timeout=2) == '{"control": "short_press"}' + assert commands.get(timeout=2) == "x" * 300 + finally: + client.close() + + +def test_foreign_origins_are_refused() -> None: + port = free_port() + FrameServer("127.0.0.1", port) + + with pytest.raises(websocket.WebSocketException): + websocket.create_connection( + f"ws://127.0.0.1:{port}", timeout=2, origin="https://example.com" + )