Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 36 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <url>` 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 <url>` 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.

Expand Down Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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/
Expand Down
Binary file added asset/sim-image-web.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 44 additions & 6 deletions matrixbox_simulator/cli.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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(
Expand All @@ -40,18 +63,33 @@ 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)
else:
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()
70 changes: 64 additions & 6 deletions matrixbox_simulator/device/button_input.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 7 additions & 2 deletions matrixbox_simulator/device/frame_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading