Sensors: Tilt & Orientation¶
Everything else in ScrollKit is output. This is the one part that reads the physical world back in: the Adafruit MatrixPortal S3 carries an onboard LIS3DH accelerometer, so a sign built on one knows which way is down — and can do something about it.
from scrollkit.sensors.tilt import TiltSensor
tilt = TiltSensor(display)
if tilt.available and tilt.orientation != "bottom":
print("someone has turned the sign on its side")
That same code runs on the desktop, where the simulator supplies a virtual gravity vector you steer with the arrow keys. You develop against the identical API you ship.
The address gotcha¶
The S3's LIS3DH lives at 0x19, not 0x18
Adafruit's own CircuitPython libraries default to I2C address 0x18. On the MatrixPortal S3 the accelerometer answers at 0x19. Adafruit documents this explicitly, and getting it wrong does not raise anything useful — it simply finds nothing, which is indistinguishable from "this board has no accelerometer".
ScrollKit keeps the address in the board registry
(src/scrollkit/display/boards.py), not in the driver, so it is declared once per
board alongside everything else that varies between them.
Panel coordinates, not chip coordinates¶
Everything TiltSensor reports is in panel space: +X toward the panel's
right edge, +Y toward its bottom edge (the displayio convention, where y grows
downward). You never have to think about how the chip is soldered on.
| Property | What it gives you |
|---|---|
available |
True if a real or simulated accelerometer is answering |
read() |
Smoothed (x, y, z) acceleration in g |
gravity_angle |
Degrees clockwise from "bottom edge down", [0, 360) |
orientation |
Which edge is down: "bottom", "right", "top", "left", or "flat" |
magnitude |
Strength of in-plane gravity — ~1.0 hanging, ~0.0 lying flat |
is_flat |
True when the panel is face up or down |
describe() |
A JSON-able snapshot of all of the above |
gravity_angle is the continuous one — use it when you want a response that
varies smoothly with angle. orientation is the snapped one, for deciding which
way something should happen.
tilt = TiltSensor(display)
tilt.orientation # 'bottom' — hanging normally
tilt.gravity_angle # 0.0
# ... stand the box on its right-hand end ...
tilt.orientation # 'right'
tilt.gravity_angle # 90.0
It never raises¶
A board with no accelerometer (the Pimoroni Interstate 75 W), a busy I2C bus, a
desktop run with no display attached — all of them give you available == False,
orientation == "flat", and a read() of (0.0, 0.0, 1.0). Write the interesting
path and let the boring one degrade:
edge = tilt.orientation if tilt.available else "bottom"
A single dropped I2C transaction is swallowed too, keeping the last good vector — one flaky read must not blank an effect mid-animation.
Hysteresis, because signs wobble¶
A sign resting near a 45° diagonal would otherwise flip between two orientation
names every frame, and anything keyed to it would thrash. orientation only
changes once the angle is past the boundary by the hysteresis margin (8° by
default), so it commits to an answer and stays there.
Reads are throttled¶
The display loop runs at ~20 fps and I2C is not free while the panel is being
driven. read() does real bus work at most every min_interval seconds (0.1 s by
default — far quicker than anyone can turn a sign) and returns the cached vector in
between, so calling it every frame costs essentially nothing. read(force=True)
bypasses the throttle.
Tilting a laptop¶
You can't. So the simulator carries a virtual accelerometer instead, and
TiltSensor reads it automatically when there's no real chip:
| Key | Effect |
|---|---|
| ++left++ / ++right++ | Swing gravity 15° |
| ++down++ | Snap back upright |
| ++up++ | Lay the panel flat on its back |
For tests and headless runs, set it exactly:
display.set_virtual_tilt(angle=90) # right edge down
display.set_virtual_tilt(flat=True) # lying face up
Because the simulated and real sensors expose the same class, a tilt-driven app is written, debugged and regression-tested on the desktop before it ever reaches a board.
Making something happen: GravityDripAnimator¶
The payoff effect. Whatever is on screen lets go and pours toward whichever edge is now the floor — turn the box mid-fall and the pile changes direction with you. It's an image animator, following the usual start/step/detach contract:
from scrollkit.effects.image_animators import GravityDripAnimator
drip = GravityDripAnimator(edge=tilt.orientation)
drip.start(display, tile, bitmap, palette, base_colors)
for frame in range(drip.HOLD_FRAMES):
drip.set_gravity(edge=tilt.orientation) # follow the box, live
drip.step(frame)
await display.show()
if drip.is_complete:
break
drip.detach()
set_gravity() also accepts a continuous angle= (snapped to the nearest edge)
and a speed=, so you can drive the fall rate from tilt.magnitude and have things
tumble more slowly as the box comes back to level.
The runnable version is demos/medium/tilt_drip.py:
PYTHONPATH=src python demos/medium/tilt_drip.py
What it can and can't melt¶
The animator needs the lit pixels of what's on screen, and displayio cannot be
read back on hardware — there is no way to ask the panel what it is showing. So
it works from an indexed Bitmap the library already owns: a
BitmapText banner, or an image TileGrid. Plain StaticText
labels have no readable bitmap behind them; convert them to BitmapText if you
want them to melt.
The lifted pixels move onto a full-panel overlay and the source is blanked, so a
7-row BitmapText strip still falls all the way to the panel floor rather than
piling up inside its own strip.
Budget¶
start() refuses (raises ValueError) past max_pixels — 400 by default — so an
over-dense image falls back to the still frame instead of blowing the frame budget.
Per frame it touches only the pixels that actually moved: one write for the new
cell, one erase for the vacated one. 400 lit pixels is ~800 writes ≈ 5.6 ms at the
device-measured ~7 µs/write, well inside the 50 ms (20 fps) budget — and it drops to
zero as the pile settles.
Verifying on real hardware¶
The simulator has no I2C bus, so the address, the register writes, the burst read, and above all which way the chip's axes actually point can only be confirmed with a board on the end of a cable:
make copy-to-circuitpy
PYTHONPATH=src python test/claude/tilt_probe_s3.py --port /dev/cu.usbmodemXXXX
PYTHONPATH=src python test/claude/tilt_probe_s3.py --port /dev/cu.usbmodemXXXX --watch 30
The probe writes nothing to the board. --watch streams live readings while you
physically turn it — check that orientation names the edge actually facing the
floor. If it's consistently rotated or mirrored, fix AXIS_MAP in
src/scrollkit/sensors/tilt.py. That single constant is the only place the
chip-to-panel mounting is encoded, and every angle, orientation and tilt-driven
effect follows from it.
Which boards have what¶
Ask the library rather than hard-coding it, so a future board flows through automatically:
from scrollkit.dev import capabilities
capabilities()["sensors"]["by_board"]
# {'adafruit_matrixportal_s3': {'tilt': True},
# 'pimoroni_interstate75_w': {'tilt': False}}