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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/target
matrixbox_simulator/device/sandbox_fs/
__pycache__/
*.pyc
.venv/
45 changes: 36 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ It's two halves that talk over a local WebSocket:
`bitmaptools`, `wifi`, and so on), backed by plain Python instead of real
hardware. It runs an app's actual code, and every `display.refresh()`
gets pushed out as a frame. An app runs from a staged copy of its own
code, kept in a gitignored folder so settings it saves (brightness,
Wi-Fi, whatever it writes to `settings.txt`) survive between runs, the
same way they'd survive a reflash on real hardware.
code, kept in a per-user cache directory (see "Sandbox" below) so
settings it saves (brightness, Wi-Fi, whatever it writes to
`settings.txt`) survive between runs, the same way they'd survive a
reflash on real hardware.
- **`matrixbox_simulator.term`**: a terminal renderer, built on `rich`, that
connects, decodes those frames, and draws them with Unicode half-blocks
and truecolor. Framed in a white border, with a stats line underneath
Expand Down Expand Up @@ -121,12 +122,9 @@ needs a real terminal, not a redirected or piped one):

- `s` / `l`: short or long front-panel button press. Long usually exits
the app, same as holding the real button.
- `r`: reload the running app, only offered when booted at a checkout's
root (not a single app). Copies that app's code fresh from your
checkout, then triggers the same exit a long press would, so it comes
back running your changes without a full restart.
- `R`: restart the whole process, picking up a core code change (not
just an app's own) rather than requiring a manual stop and rerun.
- `r`: reload by restarting the whole process. Staging always re-syncs
the entire checkout fresh on boot, so this alone picks up both app and
core code changes, then boots straight back into whatever was running.
- `+` / `-`: adjust refresh pacing live. See "Animation speed" below.
- `[` / `]`: adjust color gamma live. See "Colors" below.
- `z`: cycle through panel sizes live. See "Panel sizes" above.
Expand Down Expand Up @@ -274,6 +272,33 @@ sets the output PNG's pixel scale factor (default 8, so a 128x32 panel
becomes a 1024x256 image). `--size` / `--width` / `--height` pick the
panel size, same as `matrixbox app` (see "Panel sizes" above).

## Sandbox

Every `matrixbox app`/`screenshot` run stages a fresh copy of the
checkout (or one app) into a per-user cache directory, never inside this
package's own install — a global install's site-packages often isn't
writable at all, and installed code should stay read-only regardless.
That's also where saved settings (brightness, Wi-Fi, whatever an app
writes to `settings.txt`) persist between runs.

Location, in priority order:

1. `$XDG_CACHE_HOME/matrixbox-simulator/sandbox_fs`, if `XDG_CACHE_HOME`
is set.
2. `~/Library/Caches/matrixbox-simulator/sandbox_fs` on macOS.
3. `%LOCALAPPDATA%\matrixbox-simulator\sandbox_fs` on Windows.
4. `~/.cache/matrixbox-simulator/sandbox_fs` otherwise (the POSIX
default, and also what `XDG_CACHE_HOME` itself defaults to when
unset).

Nothing precious lives there — it's all re-derived from the real
checkout on the next run. Inspect or clear it with:

```sh
uv run matrixbox sandbox info # prints its location and on-disk size
uv run matrixbox sandbox clean # deletes it entirely
```

## Useful flags

`uv run matrixbox app`: `--size` or `--width` / `--height` for panel
Expand All @@ -287,6 +312,8 @@ and start fresh, `--refresh-fps` / `--gamma` (see above).

`uv run matrixbox screenshot`: see "Screenshots" above.

`uv run matrixbox sandbox`: see "Sandbox" above.

## Limitations

- `Group(scale=...)` is accepted but not honored: nothing renders
Expand Down
15 changes: 12 additions & 3 deletions matrixbox_simulator/cli.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""Single `matrixbox` entrypoint, with `app`, `simulator`, and `screenshot`
as subcommands."""
"""Single `matrixbox` entrypoint, with `app`, `simulator`, `screenshot`,
and `sandbox` as subcommands."""

import argparse

from matrixbox_simulator.device import run_app, run_screenshot
from matrixbox_simulator.device import run_app, run_sandbox, run_screenshot
from matrixbox_simulator.term import run_simulator


Expand Down Expand Up @@ -32,6 +32,13 @@ def main() -> None:
description=run_screenshot.__doc__,
)
)
run_sandbox.build_parser(
subparsers.add_parser(
"sandbox",
help="inspect or clear the simulator's staged sandbox",
description=run_sandbox.__doc__,
)
)

args = parser.parse_args()
if args.command == "app":
Expand All @@ -40,6 +47,8 @@ def main() -> None:
run_screenshot.run(args)
elif args.command == "simulator":
run_simulator.run(args)
elif args.command == "sandbox":
run_sandbox.run(args)
else:
raise AssertionError(f"unhandled command: {args.command!r}")

Expand Down
19 changes: 17 additions & 2 deletions matrixbox_simulator/device/run_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,23 @@

REPO_ROOT = Path(__file__).resolve().parent.parent.parent
MATRIXBOX_ROOT = REPO_ROOT.parent / "matrixbox"
STUB_DIR = Path(__file__).resolve().parent / "cpstubs"
SANDBOX_ROOT = Path(__file__).resolve().parent / "sandbox_fs"
STUB_DIR = Path(__file__).resolve().parent / "cpstubs" # bundled, read-only


def _default_sandbox_root() -> Path:
if xdg_cache := os.environ.get("XDG_CACHE_HOME"):
cache_root = Path(xdg_cache)
elif sys.platform == "darwin":
cache_root = Path.home() / "Library" / "Caches"
elif sys.platform == "win32" and (local_app_data := os.environ.get("LOCALAPPDATA")):
cache_root = Path(local_app_data)
else:
cache_root = Path.home() / ".cache"

return cache_root / "matrixbox-simulator" / "sandbox_fs"


SANDBOX_ROOT = _default_sandbox_root()

# The matrixbox device profile this sim pretends to be. Only the board
# name has to match matrixbox's own board-detection logic; the pins it
Expand Down
78 changes: 78 additions & 0 deletions matrixbox_simulator/device/run_sandbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Inspect or clear the simulator's entire staged sandbox: every staged
checkout and app, plus whatever settings.txt/wifi creds got saved along
the way. Nothing precious lives there — it's all re-derived from the
real checkout on the next `matrixbox app`/`screenshot` run.

Usage:

matrixbox sandbox info
matrixbox sandbox clean
"""

import argparse
import shutil
from pathlib import Path

from matrixbox_simulator.device import run_app


def build_parser(
parser: argparse.ArgumentParser | None = None,
) -> argparse.ArgumentParser:
if parser is None:
parser = argparse.ArgumentParser(description=__doc__)

subparsers = parser.add_subparsers(dest="sandbox_command", required=True)
subparsers.add_parser("info", help="show the sandbox's location and on-disk size")
subparsers.add_parser("clean", help="delete the sandbox entirely")

return parser


def _dir_size(path: Path) -> int:
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())


def _format_size(num_bytes: int) -> str:
size = float(num_bytes)
for unit in ("B", "KB", "MB", "GB"):
if size < 1024 or unit == "GB":
return f"{size:.0f}{unit}" if unit == "B" else f"{size:.1f}{unit}"
size /= 1024

raise AssertionError("unreachable")


def _run_info() -> None:
if not run_app.SANDBOX_ROOT.exists():
print(f"matrixbox-simulator: sandbox: {run_app.SANDBOX_ROOT} (doesn't exist)")
return

size = _format_size(_dir_size(run_app.SANDBOX_ROOT))
print(f"matrixbox-simulator: sandbox: {run_app.SANDBOX_ROOT} ({size})")


def _run_clean() -> None:
if not run_app.SANDBOX_ROOT.exists():
print(f"matrixbox-simulator: nothing to clean at {run_app.SANDBOX_ROOT}")
return

shutil.rmtree(run_app.SANDBOX_ROOT)
print(f"matrixbox-simulator: cleaned {run_app.SANDBOX_ROOT}")


def run(args: argparse.Namespace) -> None:
if args.sandbox_command == "info":
_run_info()
elif args.sandbox_command == "clean":
_run_clean()
else:
raise AssertionError(f"unhandled sandbox command: {args.sandbox_command!r}")


def main() -> None:
run(build_parser().parse_args())


if __name__ == "__main__":
main()