Adding New Hardware¶
Setting up the supported MatrixPortal S3?
This page is for porting ScrollKit to another controller. To physically connect a MatrixPortal S3 to a 64×32 HUB75 panel, follow the photographed hardware assembly guide.
ScrollKit runs the same app on more than one HUB75 driver board. Two boards are registered today:
| Board | Chip | Tilt sensor | Notes |
|---|---|---|---|
| Adafruit MatrixPortal S3 | ESP32-S3 | LIS3DH @ I2C 0x19 |
The default. Calibrated from a real device. |
| Pimoroni Interstate 75 W | RP2350 | — | Registered; profile not yet calibrated. |
A board differs from the others in only a few places: how the RGB matrix is constructed on CircuitPython, the default panel geometry, the calibrated performance profile the feasibility model uses, and which onboard sensors it carries. Everything else (the content types, effects, transitions, the web/OTA stack) is board-agnostic. This page covers how the abstraction works and how to add a board.
How board selection works¶
UnifiedDisplay resolves which board it's on at construction time, in this order:
- An explicit
board=argument. - The
SCROLLKIT_HW_BOARDenvironment variable. - Auto-detection on CircuitPython, reading
board.board_id. - Falling back to the MatrixPortal S3.
from scrollkit.display.unified import UnifiedDisplay
UnifiedDisplay() # auto-detect; S3 on the desktop
UnifiedDisplay(board="adafruit_matrixportal_s3") # force a board
On the real device step 3 means a flashed board "just works" with no code change.
On the desktop there is no board, so auto-detect returns the S3 and you select a
different board explicitly (or via SCROLLKIT_HW_BOARD) when you want to model its
performance in the simulator. The registry and resolver live in
src/scrollkit/display/boards.py; that module is import-safe on the device (all
hardware imports are function-local), so it never drags rgbmatrix onto a desktop
or test import.
Estimate vs. calibrated
A board's performance profile is calibrated once real timing is captured
from the device (a *_baseline.json ships in the package); until then it uses
a clearly-labeled ROUGH_ESTIMATE_UNCALIBRATED profile. Feasibility reports
built from an estimate say so and round to one significant figure. See the
Performance guide for the cost model behind these
profiles.
Adding a board, step by step¶
-
Register the board in
BOARDSinsrc/scrollkit/display/boards.py. Add aBoardSpecwith its geometry, LED pitch, HUB75 address-pin count (4 for a 64-row panel, 5 for 64-tall), and amake_matrixbuilder that constructs the panel on-device. Prefer the board's own RGBMatrix aliases so no GPIO numbers are hard-coded; fall back to an explicit pin list:def _make_matrix_myboard(spec, width, height, bit_depth): import board, rgbmatrix, framebufferio matrix = rgbmatrix.RGBMatrix( width=width, height=height, bit_depth=bit_depth, addr_pins=board.MTX_ADDRESS[:spec.addr_pin_count], **board.MTX_COMMON) # rgb_pins, clock/latch/oe display = framebufferio.FramebufferDisplay(matrix, auto_refresh=False) return matrix, display, matrix # (hardware, display, matrix) -
Map its on-device id by adding the board's
board.board_idstring(s) to_ONDEVICE_ID_MAPso auto-detection resolves it to your canonical id.Declare its onboard sensors on the same
BoardSpecwhile you're there. A board with an accelerometer setshas_accelerometer=Trueand the actualaccel_i2c_address— read the vendor's pinout rather than trusting a driver default, which is exactly the trap the MatrixPortal S3 sets (0x19, not the0x18Adafruit's own libraries assume). Boards left at the default are simply reported as having no tilt sensor, andTiltSensordegrades instead of probing a bus for nothing. -
Add an estimate profile in
src/scrollkit/simulator/core/hardware_profile.py(an*_estimate()function returning aHardwareProfilewithconfidence=CONFIDENCE_ESTIMATE). Set the RAM and timing fields to the chip's honest ballpark; this is what the feasibility gate uses until you calibrate. Register it in_estimate_for()and give the board a baseline filename in_BASELINE_FILENAMES. -
Calibrate once you have the board. Flash CircuitPython, wire the panel, and first inspect the board's raw identifier and matrix-pin API over USB:
PYTHONSAFEPATH=1 python test/claude/cpy_repl.py --port /dev/cu.usbmodemXXXXConfirm that the reported
BOARD_ID/MACHINEmaps to the new board and that eitherMTX_COMMON+MTX_ADDRESSorNAMED_MATRIX_PINSis true. Then capture real numbers:PYTHONSAFEPATH=1 python test/claude/calibrate_device.py \ --board <id> --port /dev/cu.usbmodemXXXX --cp 10.2.1 PYTHONSAFEPATH=1 python test/claude/device_benchmarks.py \ --board <id> --port /dev/cu.usbmodemXXXX --cp 10.2.1These write
<id>_baseline.jsonand<id>_benchmarks.jsonintosrc/scrollkit/simulator/core/. The profile then auto-upgrades toCALIBRATED_FROM_DEVICEwith no further code change. -
Verify. On the desktop, model the board and prove your app stays in budget:
from scrollkit.dev import run_headless result = run_headless(my_app, frames=120, strict=True) # board via SCROLLKIT_HW_BOARD assert result.okThen flash the real board and confirm the panel drives correctly.
The calibration baseline drives feasibility reports; the benchmark table also
feeds scrollkit.dev.performance_guide("<id>").
Confirm the board id and pin aliases on real hardware
The exact board.board_id string and whether a given CircuitPython build
exposes board.MTX_COMMON / board.MTX_ADDRESS (vs. individual pin names)
should be checked on the device. The make_matrix builder falls back to an
explicit pin list when those aliases are absent.
See also the Performance guide for what the feasibility budget
means, and the Display guide for the UnifiedDisplay API your app
talks to.