Code the Sushis
Once you've finished setting up your Qualia S3 with CircuitPython, you can access the code and necessary libraries by downloading the Project Bundle.
To do this, click on the Download Project Bundle button in the window below. It will download to your computer as a zipped folder.
# SPDX-FileCopyrightText: 2026 Liz Clark for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""Sushi conveyor belt on a Qualia S3 with
either 320x820 or 240x960 bar display
graphics code in turbo (lib/sushi_viper.mpy).
"""
import os
import random
import time
import board
import busio
import displayio
import dotclockframebuffer
import sushi_viper
from panels import PANELS
# "bar320x820" (3.2") or "bar240x960" (3.7").
DISPLAY = "bar320x820"
# The TFT's I/O expander address None uses the board default (0x3F).
IO_EXPANDER_ADDRESS = None
# Belt speed. Positive moves the plates right in the landscape art,
# negative left
PIXELS_PER_FRAME = 1
# None runs at the panel's own refresh rate
MAX_FPS = None
ASSET_FOLDER = "/sushi"
# The palette index that is see-through in the plates and the belt slat.
TRANSPARENT_INDEX = 1
# How far the belt's top edge is below the top of the landscape background,
# in pixels.
BELT_TOP = {"bar320x820": 156, "bar240x960": 90}
# Slats repeat every SLAT_PITCH pixels along the belt. None puts them end to
# end at the slat's own width
SLAT_PITCH = None
# Each plate is centered on its slat
PLATE_OFFSET = (0, 0)
displayio.release_displays()
panel = PANELS[DISPLAY]
i2c = busio.I2C(board.SCL, board.SDA, frequency=panel["i2c_frequency"])
io_expander = dict(board.TFT_IO_EXPANDER)
if IO_EXPANDER_ADDRESS is not None:
io_expander["i2c_address"] = IO_EXPANDER_ADDRESS
dotclockframebuffer.ioexpander_send_init_sequence(
i2c, panel["init_sequence"], **io_expander
)
i2c.deinit()
framebuffer_settings = dict(board.TFT_PINS)
framebuffer_settings.update(panel["timings"])
framebuffer = dotclockframebuffer.DotClockFramebuffer(**framebuffer_settings)
screen_width, screen_height = framebuffer.width, framebuffer.height
first_pixel = framebuffer.first_pixel_offset // 2
row_stride = framebuffer.row_stride // 2
framebuffer_pixels = len(memoryview(framebuffer))
print(
f"{DISPLAY}: {screen_width}x{screen_height}, row stride {row_stride} px, "
f"first pixel {first_pixel}, panel refresh {framebuffer.refresh_rate} Hz"
)
load_start = time.monotonic()
background = sushi_viper.IndexedBMP(
"%s/sushi_background_%dx%d.bmp" % (ASSET_FOLDER, screen_height, screen_width)
)
if (background.width, background.height) != (screen_height, screen_width):
raise ValueError(
"background is %dx%d, needs to be %dx%d"
% (background.width, background.height, screen_height, screen_width)
)
background.draw_rotated(framebuffer, first_pixel, row_stride, framebuffer_pixels)
del background
def plate_number(name):
"""The number in sushi_<number>.bmp, or None for any other file."""
if name.startswith("sushi_") and name.endswith(".bmp") and name[6:-4].isdigit():
return int(name[6:-4])
return None
plate_paths = [
"%s/sushi_%d.bmp" % (ASSET_FOLDER, number)
for number in sorted(
plate_number(name)
for name in os.listdir(ASSET_FOLDER)
if plate_number(name) is not None
)
]
tiles, tile_width, tile_height, belt_x = sushi_viper.load_tiles(
framebuffer,
first_pixel,
row_stride,
screen_width,
BELT_TOP[DISPLAY],
ASSET_FOLDER + "/sushi_belt.bmp",
plate_paths,
TRANSPARENT_INDEX,
SLAT_PITCH,
PLATE_OFFSET,
)
print(
"%d plates on %dx%d tiles, loaded in %.2f s"
% (len(plate_paths), tile_width, tile_height, time.monotonic() - load_start)
)
# belt
slots, params, seen = sushi_viper.new(
screen_width,
screen_height,
first_pixel,
row_stride,
framebuffer_pixels,
len(plate_paths),
tile_width,
tile_height,
belt_x,
)
loop_length = sushi_viper.loop_length(params)
sushi_viper.recycle(
slots, params, seen, len(plate_paths), random.randrange, everything=True
)
print(f"{len(slots)} slots, belt loop {loop_length} px")
frame_ns = 1_000_000_000 // (MAX_FPS or framebuffer.refresh_rate)
next_frame_ns = time.monotonic_ns()
frames = draw_ns = flush_ns = 0
report_ns = next_frame_ns + 2_000_000_000
scroll = 0
while True:
params[sushi_viper.PARAM_SCROLL] = scroll
# swap in new plates for slots that have gone off screen
sushi_viper.recycle(slots, params, seen, len(plate_paths), random.randrange)
draw_start_ns = time.monotonic_ns()
sushi_viper.draw_belt(framebuffer, tiles, slots, params)
draw_end_ns = time.monotonic_ns()
framebuffer.refresh()
flush_end_ns = time.monotonic_ns()
scroll = (scroll + PIXELS_PER_FRAME) % loop_length
frames += 1
draw_ns += draw_end_ns - draw_start_ns
flush_ns += flush_end_ns - draw_end_ns
if flush_end_ns >= report_ns:
print(
"%d fps, draw %d us, refresh %d us"
% (frames // 2, draw_ns // frames // 1000, flush_ns // frames // 1000)
)
frames = draw_ns = flush_ns = 0
report_ns += 2_000_000_000
next_frame_ns += frame_ns
wait_ns = next_frame_ns - time.monotonic_ns()
if wait_ns > 0:
time.sleep(wait_ns / 1e9)
else:
next_frame_ns = time.monotonic_ns()
Upload the Code, Sushis and Libraries to the Qualia S3
After downloading the Project Bundle, plug your Qualia S3 into your computer's USB port with a known good USB data+power cable. You should see a new flash drive appear in the computer's File Explorer or Finder (depending on your operating system) called CIRCUITPY. Unzip the folder and copy the following items to the Qualia S3's CIRCUITPY drive.
- lib folder
- sushi folder
- code.py
- panels.py
- sushi_viper.mpy
Your Qualia S3 CIRCUITPY drive should look like this after copying the files and folders:
Sushi Viper
The sushi_viper.mpy file is a CircuitPython Turbo helper file that was compiled with mpy-cross for the ESP32-S3 on the Qualia S3. The original source Python file is also available on GitHub:
# SPDX-FileCopyrightText: 2026 Liz Clark for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""Sushi conveyor belt drawn straight into a DotClockFramebuffer, the inner
loops as @micropython.viper.
At startup, load_tiles() builds one opaque tile per plate. The belt is a
loop of slots, one tile each.
Four caller-owned buffers:
framebuffer the DotClockFramebuffer itself, RGB565, via its buffer protocol
tiles bytearray of every tile, back to back, RGB565, portrait
slots array('i'), per slot: index in tiles of its tile's first pixel
params array('i'), settings in, one int per PARAM_* slot below
new() builds slots and params
"""
import array
import struct
import micropython
from micropython import const
PARAM_FIRST_PIXEL = const(0) # framebuffer index of screen pixel (0, 0)
PARAM_ROW_STRIDE = const(1) # framebuffer pixels from one row to the next
PARAM_BELT_X = const(2) # belt's left edge on screen, pixels
PARAM_SCREEN_HEIGHT = const(3) # rows to draw
PARAM_TILE_WIDTH = const(4) # tile size in the framebuffer's orientation, pixels
PARAM_TILE_HEIGHT = const(5) # the belt pitch: one tile per slat
PARAM_SLOT_COUNT = const(6) # slots in the belt loop
PARAM_SCROLL = const(7) # 0 .. slot count * tile height - 1; more moves the belt down
PARAM_COUNT = const(8)
# pylint: disable=undefined-variable, too-many-locals, misplaced-comparison-constant, unused-argument
# load bitmap
class IndexedBMP:
"""An uncompressed 8-bit indexed BMP file: its size and its palette as
RGB565"""
def __init__(self, path, transparent_index=None):
self.path = path
with open(path, "rb") as bmp:
header = bmp.read(54)
if len(header) < 54 or header[:2] != b"BM":
raise ValueError(path + ": not a BMP file")
(
self._data_offset,
info_size,
self.width,
height,
_planes,
bits,
compression,
_image_size,
_x_density,
_y_density,
color_count,
_important,
) = struct.unpack_from("<IIiiHHIIiiII", header, 10)
if bits != 8 or compression != 0 or info_size < 40:
raise ValueError(
path + ": needs to be an 8-bit indexed BMP without compression"
)
# A positive height stores the rows bottom to top.
self._bottom_up = height > 0
self.height = abs(height)
color_count = color_count or 256
bmp.seek(14 + info_size)
entries = bmp.read(4 * color_count) # blue, green, red, unused
self.palette = array.array("H", [0] * 256)
for index in range(color_count):
blue, green, red = entries[4 * index], entries[4 * index + 1], entries[4 * index + 2]
self.palette[index] = ((red & 0xF8) << 8) | ((green & 0xFC) << 3) | (blue >> 3)
self.transparent = -1
if transparent_index is not None:
used = set(
self.palette[index] for index in range(color_count) if index != transparent_index
)
self.transparent = 0x0821 # nearly black, and nearly never used
while self.transparent in used:
self.transparent += 1
self.palette[transparent_index] = self.transparent
def draw_rotated(self, dest, start, stride, dest_pixels):
"""Write the image into dest (RGB565, dest_pixels long) turned 90
degrees clockwise: row y of the image becomes column height - 1 - y,
and moving right along the image moves down dest, stride pixels a
step. start is the dest index of the turned image's top left."""
if start < 0 or start + (self.width - 1) * stride + self.height > dest_pixels:
raise ValueError(self.path + ": doesn't fit where it's being drawn")
row_bytes = (self.width + 3) & ~3 # BMP rows are padded to 4 bytes
row = bytearray(row_bytes)
args = array.array("i", [self.width, 0, stride])
with open(self.path, "rb") as bmp:
bmp.seek(self._data_offset)
for file_row in range(self.height):
bmp.readinto(row)
y = self.height - 1 - file_row if self._bottom_up else file_row
args[1] = start + self.height - 1 - y
expand_row(dest, row, self.palette, args)
def overlay_rect(dest, dest_start, dest_stride, src, src_start,
src_stride, width, height, transparent=-1):
"""Copy a width x height rectangle of RGB565 from src to dest, skipping
pixels equal to transparent"""
overlay(
dest,
src,
array.array(
"i", [dest_start, dest_stride, src_start, src_stride, width, height, transparent]
),
)
def load_tiles(
framebuffer,
first_pixel,
row_stride,
screen_width,
belt_top,
belt_path,
plate_paths,
transparent_index,
pitch=None,
plate_offset=(0, 0),
):
"""Build one tile per plate, return (tiles, tile_width, tile_height,
belt_x)
Call after the background is in the framebuffer"""
belt = IndexedBMP(belt_path, transparent_index)
slat_length, tile_width = belt.width, belt.height
belt_x = screen_width - belt_top - tile_width
if not 0 <= belt_x <= screen_width - tile_width:
raise ValueError("belt_top puts the belt off the background")
pitch = pitch or slat_length
tile_pixels = tile_width * pitch
slat = bytearray(2 * tile_width * slat_length)
belt.draw_rotated(slat, 0, tile_width, tile_width * slat_length)
plate_center = slat_length // 2 + plate_offset[0]
window = plate_center - pitch // 2
period = bytearray(2 * tile_pixels)
# first the background under the belt, repeated
overlay_rect(period, 0, tile_width, framebuffer,
first_pixel + belt_x, 0, tile_width, pitch)
# then every slat
copy = (window - slat_length + 1) // pitch
while copy * pitch < window + pitch:
copy_start = copy * pitch # belt position of this slat's first row
top = max(window, copy_start)
bottom = min(window + pitch, copy_start + slat_length)
if top < bottom:
overlay_rect(
period,
(top - window) * tile_width,
tile_width,
slat,
(top - copy_start) * tile_width,
tile_width,
tile_width,
bottom - top,
belt.transparent,
)
copy += 1
del slat
tiles = bytearray(2 * tile_pixels * len(plate_paths))
for number, path in enumerate(plate_paths):
tiles[2 * tile_pixels * number : 2 * tile_pixels * (number + 1)] = period
plate = IndexedBMP(path, transparent_index)
# Landscape position of the plate's top left on the slat...
left = plate_center - plate.width // 2
top = (tile_width - plate.height) // 2 + plate_offset[1]
# ...and where that puts the turned plate in the tile.
row = left - window
column = tile_width - top - plate.height
if not (0 <= row and row + plate.width <= pitch and 0 <= column
and column + plate.height <= tile_width):
raise ValueError(
path + ": plate doesn't fit on one belt pitch; check its size, "
"the pitch and plate_offset"
)
turned = bytearray(2 * plate.width * plate.height)
plate.draw_rotated(turned, 0, plate.height, plate.width * plate.height)
overlay_rect(
tiles,
number * tile_pixels + row * tile_width + column,
tile_width,
turned,
0,
plate.height,
plate.height,
plate.width,
plate.transparent,
)
return tiles, tile_width, pitch, belt_x
@micropython.viper
def expand_row(dest: ptr16, row: ptr8, palette: ptr16, args: ptr32):
"""dest[args[1] + i * args[2]] = palette[row[i]] for i below args[0]."""
count = args[0]
dest_index = args[1]
step = args[2]
i = 0
while i < count:
dest[dest_index] = palette[row[i]]
dest_index += step
i += 1
@micropython.viper
def overlay(dest: ptr16, src: ptr16, args: ptr32):
"""Copy a rectangle, skipping the transparent color: args are dest
start, dest stride, src start, src stride, width, height, transparent."""
dest_row = args[0]
dest_stride = args[1]
src_row = args[2]
src_stride = args[3]
width = args[4]
height = args[5]
transparent = args[6]
y = 0
while y < height:
i = 0
while i < width:
pixel = src[src_row + i]
if pixel != transparent:
dest[dest_row + i] = pixel
i += 1
dest_row += dest_stride
src_row += src_stride
y += 1
# belt
def new(
screen_width,
screen_height,
first_pixel,
row_stride,
framebuffer_pixels,
tile_count,
tile_width,
tile_height,
belt_x,
):
"""Return (slots, params, seen) for a belt at belt_x on a screen of the
given size. first_pixel and row_stride are the framebuffer's
first_pixel_offset and row_stride, in pixels
Slots start pointing at tile 0, call recycle(..., everything=True) to
fill them with random plates."""
if tile_count < 3:
raise ValueError("need at least 3 plates so neighbours can differ")
if not 0 <= belt_x <= screen_width - tile_width:
raise ValueError("belt_x puts the belt off the screen")
if row_stride < screen_width:
raise ValueError("row_stride is narrower than the screen")
last_pixel = first_pixel + (screen_height - 1) * row_stride + belt_x + tile_width
if last_pixel > framebuffer_pixels:
raise ValueError("framebuffer is too small for this screen size")
# Enough slots to cover the screen, plus one that is always fully hidden.
slot_count = (screen_height + tile_height - 1) // tile_height + 1
slots = array.array("i", [0] * slot_count)
params = array.array("i", [0] * PARAM_COUNT)
params[PARAM_FIRST_PIXEL] = first_pixel
params[PARAM_ROW_STRIDE] = row_stride
params[PARAM_BELT_X] = belt_x
params[PARAM_SCREEN_HEIGHT] = screen_height
params[PARAM_TILE_WIDTH] = tile_width
params[PARAM_TILE_HEIGHT] = tile_height
params[PARAM_SLOT_COUNT] = slot_count
seen = [False] * slot_count
return slots, params, seen
def loop_length(params):
"""The belt loop's length in pixels"""
return params[PARAM_SLOT_COUNT] * params[PARAM_TILE_HEIGHT]
def recycle(slots, params, seen, tile_count, randrange, everything=False):
"""Give a new plate to each slot that has been on screen and is now
fully off it, never the same plate touching. everything=True
refills every slot, for the first frame. Returns how many slots changed"""
slot_count = params[PARAM_SLOT_COUNT]
tile_height = params[PARAM_TILE_HEIGHT]
screen_height = params[PARAM_SCREEN_HEIGHT]
tile_pixels = params[PARAM_TILE_WIDTH] * tile_height
length = slot_count * tile_height
top = -params[PARAM_SCROLL] % length # belt position at screen row 0
changed = 0
for slot in range(slot_count):
# where the slot starts
start = (slot * tile_height - top) % length
if start < screen_height or start > length - tile_height:
seen[slot] = True
if not everything:
continue
elif not (seen[slot] or everything):
continue
seen[slot] = False
before = slots[slot - 1] // tile_pixels
after = slots[(slot + 1) % slot_count] // tile_pixels
if everything: # filling in order
if slot == 0:
before = -1
if slot < slot_count - 1:
after = -1
tile = randrange(tile_count)
while tile in (before, after):
tile = randrange(tile_count)
slots[slot] = tile * tile_pixels
changed += 1
return changed
@micropython.viper
def draw_belt(framebuffer: ptr16, tiles: ptr16, slots: ptr32, params: ptr32):
"""Copy the belt strip, scrolled by PARAM_SCROLL, into the framebuffer."""
first_pixel = params[PARAM_FIRST_PIXEL]
row_stride = params[PARAM_ROW_STRIDE]
belt_x = params[PARAM_BELT_X]
screen_height = params[PARAM_SCREEN_HEIGHT]
tile_width = params[PARAM_TILE_WIDTH]
tile_height = params[PARAM_TILE_HEIGHT]
slot_count = params[PARAM_SLOT_COUNT]
scroll = params[PARAM_SCROLL]
quad_width = tile_width - (tile_width & 3)
position = slot_count * tile_height - scroll
if position >= slot_count * tile_height:
position -= slot_count * tile_height
slot = position // tile_height
tile_row = position - slot * tile_height
source = slots[slot] + tile_row * tile_width
row_start = first_pixel + belt_x
y = 0
while y < screen_height:
dest = row_start
quad_end = dest + quad_width
while dest < quad_end:
framebuffer[dest] = tiles[source]
framebuffer[dest + 1] = tiles[source + 1]
framebuffer[dest + 2] = tiles[source + 2]
framebuffer[dest + 3] = tiles[source + 3]
dest += 4
source += 4
row_end = row_start + tile_width
while dest < row_end:
framebuffer[dest] = tiles[source]
dest += 1
source += 1
row_start += row_stride
tile_row += 1
if tile_row == tile_height: # on to the next slot's tile
tile_row = 0
slot += 1
if slot == slot_count:
slot = 0
source = slots[slot]
y += 1
The file contains a few graphical functions that are a combination of non-Viper:
-
IndexedBitmap.__init__()- reads the sushi bitmap files and converts them to RGB565 look-up tables. It also tracks the transparent palette index that allows for the background to be removed from the sushi plates -
IndexedBitmap.draw_rotated()- rotates the graphics so that they can be displayed on the display horizontally rather than the default vertical. A Viper module,expand_row(described below), is called inside of it. -
overlay_rect()- creates an array of the RGB565 table's attributes -
load_tiles()- loads the sushi plate graphics and conveyor belt graphics from the filesystem -
new()- calculates how many sushi plates can fit across the belt. Then, it creates theslotsarray (tracks the plates going across the belt), fills outparamsand tracks which plate has been put on screen so that nothing repeats -
loop_length()- the length in pixels for the conveyor belt. This lets code.py track when the conveyor belt wraps around -
recycle()- checks every frame to see if a plate is fully off screen. If it is, then a new sushi plate is queued
and Viper modules:
-
expand_row()- handles the palette index rotation inside ofIndexedBitmap.draw_rotated -
overlay()- draws the conveyor belt graphic followed by the sushi plates on top of the background image -
draw_belt()- draws each frame in the animation, moving the plate and belt position, based on theparamsandslotsarrays
You'll notice that the three Viper modules handle the actual drawing of pixels to the Framebuffer, whereas everything else is handled in Python. Tim C. wrote an excellent explainer for how Viper modules are written in his PixelDust Digital Sand for CircuitPython Guide:
Viper functions are written as Python, but they are their own weird flavor of Python with an extra set of rules that must be followed.
Local variables and arguments must be
int,uint(32-bit only),bool,ptr,ptr8,ptr16,ptr32, orobject. They cannot usefloatslike normal Python numbers do. Parameters get passed to the Viper function as a list of values, and are accessed via their index within that list. There is no overflow protection. Integers will silently wrap, out-of-range indexes can corrupt memory or crash instead of raising a cleanIndexError. Variables cannot change types. Mixing of typed values withobjectvalues will fall back to slower runtime calls.
To compile this file for the ESP32-S3, you would run this command with mpy-cross:
mpy-cross -march=xtensawin sushi_viper.py
# SPDX-FileCopyrightText: 2026 Liz Clark for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""Init sequences and timings for the Qualia bar displays."""
PANELS = {
"bar320x820": {
"i2c_frequency": 400_000,
"init_sequence": (
b'\x11\x80d'
b'\xff\x05w\x01\x00\x00\x13'
b'\xef\x01\x08'
b'\xff\x05w\x01\x00\x00\x10'
b'\xc0\x02\xe5\x02'
b'\xc1\x02\x0c\n'
b'\xc2\x02\x07\x0f'
b'\xc3\x01\x02'
b'\xcc\x01\x10'
b'\xcd\x01\x08'
b'\xb0\x10\x00\x08Q\r\xce\x06\x00\x08\x08\x1d\x02\xd0\x0fo6?'
b'\xb1\x10\x00\x10O\x0c\x11\x05\x00\x07\x07\x1f\x05\xd3\x11n4?'
b'\xff\x05w\x01\x00\x00\x11'
b'\xb0\x01M'
b'\xb1\x01\x1c'
b'\xb2\x01\x87'
b'\xb3\x01\x80'
b'\xb5\x01G'
b'\xb7\x01\x85'
b'\xb8\x01!'
b'\xb9\x01\x10'
b'\xc1\x01x'
b'\xc2\x01x'
b'\xd0\x81\x88d'
b'\xe0\x03\x80\x00\x02'
b'\xe1\x0b\x04\xa0\x00\x00\x05\xa0\x00\x00\x00``'
b'\xe2\r00``<\xa0\x00\x00=\xa0\x00\x00\x00'
b'\xe3\x04\x00\x0033'
b'\xe4\x02DD'
b'\xe5\x10\x06>\xa0\xa0\x08@\xa0\xa0\nB\xa0\xa0\x0cD\xa0\xa0'
b'\xe6\x04\x00\x0033'
b'\xe7\x02DD'
b'\xe8\x10\x07?\xa0\xa0\tA\xa0\xa0\x0bC\xa0\xa0\rE\xa0\xa0'
b'\xeb\x07\x00\x01NN\xeeD\x00'
b"\xed\x10\xff\xff\x04Vr\xff\xff\xff\xff\xff\xff'e@\xff\xff"
b'\xef\x06\x10\r\x04\x08?\x1f'
b'\xff\x05w\x01\x00\x00\x13'
b'\xe8\x02\x00\x0e'
b'\xff\x05w\x01\x00\x00\x00'
b'\x11\x80x'
b'\xff\x05w\x01\x00\x00\x13'
b'\xe8\x82\x00\x0c\n'
b'\xe8\x02\x00\x00'
b'\xff\x05w\x01\x00\x00\x00'
b'6\x01\x00'
b':\x01f'
b'\x11\x80x'
b')\x80x'
),
"timings": {
"frequency": 16000000,
"width": 320,
"height": 820,
"hsync_pulse_width": 2,
"hsync_back_porch": 44,
"hsync_front_porch": 50,
"hsync_idle_low": False,
"vsync_pulse_width": 2,
"vsync_back_porch": 18,
"vsync_front_porch": 16,
"vsync_idle_low": False,
"pclk_active_high": False,
"pclk_idle_high": False,
"de_idle_high": False,
},
},
"bar240x960": {
"i2c_frequency": 100_000,
"init_sequence": (
b'\xff\x05w\x01\x00\x00\x13'
b'\xef\x01\x08'
b'\xff\x05w\x01\x00\x00\x10'
b'\xc0\x02w\x00'
b'\xc1\x02\x11\x0c'
b'\xc2\x02\x07\x02'
b'\xcc\x010'
b'\xb0\x10\x06\xcf\x14\x0c\x0f\x03\x00\n\x07\x1b\x03\x12\x10%6\x1e'
b'\xb1\x10\x0c\xd4\x18\x0c\x0e\x06\x03\x06\x08#\x06\x12\x100/\x1f'
b'\xff\x05w\x01\x00\x00\x11'
b'\xb0\x01s'
b'\xb1\x01|'
b'\xb2\x01\x83'
b'\xb3\x01\x80'
b'\xb5\x01I'
b'\xb7\x01\x87'
b'\xb8\x013'
b'\xb9\x02\x10\x1f'
b'\xbb\x01\x03'
b'\xc1\x01\x08'
b'\xc2\x01\x08'
b'\xd0\x01\x88'
b'\xe0\x06\x00\x00\x02\x00\x00\x0c'
b'\xe1\x0b\x05\x96\x07\x96\x06\x96\x08\x96\x00DD'
b'\xe2\x0c\x00\x00\x03\x03\x00\x00\x02\x00\x00\x00\x02\x00'
b'\xe3\x04\x00\x0033'
b'\xe4\x02DD'
b'\xe5\x10\r\xd4(\x8c\x0f\xd6(\x8c\t\xd0(\x8c\x0b\xd2(\x8c'
b'\xe6\x04\x00\x0033'
b'\xe7\x02DD'
b'\xe8\x10\x0e\xd5(\x8c\x10\xd7(\x8c\n\xd1(\x8c\x0c\xd3(\x8c'
b'\xeb\x06\x00\x01\xe4\xe4D\x00'
b'\xed\x10\xf3\xc1\xba\x0ffwDUUDwf\xf0\xab\x1c?'
b'\xef\x06\x10\r\x04\x08?\x1f'
b'\xff\x05w\x01\x00\x00\x13'
b'\xe8\x02\x00\x0e'
b'\x11\x80x'
b'\xe8\x82\x00\x0c\n'
b'\xe8\x02@\x00'
b'\xff\x05w\x01\x00\x00\x00'
b'6\x01\x00'
b':\x01f'
b')\x80\x14'
b'\xff\x05w\x01\x00\x00\x10'
b'\xe5\x02\x00\x00'
),
"timings": {
"frequency": 16000000,
"width": 240,
"height": 960,
"overscan_left": 120,
"hsync_pulse_width": 8,
"hsync_back_porch": 20,
"hsync_front_porch": 20,
"hsync_idle_low": False,
"vsync_pulse_width": 8,
"vsync_back_porch": 20,
"vsync_front_porch": 20,
"vsync_idle_low": False,
"pclk_active_high": True,
"pclk_idle_high": False,
"de_idle_high": False,
},
},
}
# "bar320x820" (3.2") or "bar240x960" (3.7").
DISPLAY = "bar320x820"
# The TFT's I/O expander address None uses the board default (0x3F).
IO_EXPANDER_ADDRESS = None
# Belt speed. Positive moves the plates right in the landscape art,
# negative left
PIXELS_PER_FRAME = 1
# None runs at the panel's own refresh rate
MAX_FPS = None
ASSET_FOLDER = "/sushi"
# The palette index that is see-through in the plates and the belt slat.
TRANSPARENT_INDEX = 1
# How far the belt's top edge is below the top of the landscape background,
# in pixels.
BELT_TOP = {"bar320x820": 156, "bar240x960": 90}
# Slats repeat every SLAT_PITCH pixels along the belt. None puts them end to
# end at the slat's own width
SLAT_PITCH = None
# Each plate is centered on its slat
PLATE_OFFSET = (0, 0)
Then, the display and framebuffer are initialized.
displayio.release_displays()
panel = PANELS[DISPLAY]
i2c = busio.I2C(board.SCL, board.SDA, frequency=panel["i2c_frequency"])
io_expander = dict(board.TFT_IO_EXPANDER)
if IO_EXPANDER_ADDRESS is not None:
io_expander["i2c_address"] = IO_EXPANDER_ADDRESS
dotclockframebuffer.ioexpander_send_init_sequence(
i2c, panel["init_sequence"], **io_expander
)
i2c.deinit()
framebuffer_settings = dict(board.TFT_PINS)
framebuffer_settings.update(panel["timings"])
framebuffer = dotclockframebuffer.DotClockFramebuffer(**framebuffer_settings)
screen_width, screen_height = framebuffer.width, framebuffer.height
first_pixel = framebuffer.first_pixel_offset // 2
row_stride = framebuffer.row_stride // 2
framebuffer_pixels = len(memoryview(framebuffer))
print(
f"{DISPLAY}: {screen_width}x{screen_height}, row stride {row_stride} px, "
f"first pixel {first_pixel}, panel refresh {framebuffer.refresh_rate} Hz"
)
Load the Sushi
Next, the graphics are loaded, first with the background that matches the size of the display. plate_number() finds the plates of sushi in the folder based on the number in the file name. These bitmaps are passed, along with the belt graphic, to the load_tiles() helper from sushi_viper.mpy. Finally, new() is called to populate the first run of plates across the belt.
background = sushi_viper.IndexedBMP(
"%s/sushi_background_%dx%d.bmp" % (ASSET_FOLDER, screen_height, screen_width)
)
if (background.width, background.height) != (screen_height, screen_width):
raise ValueError(
"background is %dx%d, needs to be %dx%d"
% (background.width, background.height, screen_height, screen_width)
)
background.draw_rotated(framebuffer, first_pixel, row_stride, framebuffer_pixels)
del background
def plate_number(name):
"""The number in sushi_<number>.bmp, or None for any other file."""
if name.startswith("sushi_") and name.endswith(".bmp") and name[6:-4].isdigit():
return int(name[6:-4])
return None
plate_paths = [
"%s/sushi_%d.bmp" % (ASSET_FOLDER, number)
for number in sorted(
plate_number(name)
for name in os.listdir(ASSET_FOLDER)
if plate_number(name) is not None
)
]
tiles, tile_width, tile_height, belt_x = sushi_viper.load_tiles(
framebuffer,
first_pixel,
row_stride,
screen_width,
BELT_TOP[DISPLAY],
ASSET_FOLDER + "/sushi_belt.bmp",
plate_paths,
TRANSPARENT_INDEX,
SLAT_PITCH,
PLATE_OFFSET,
)
print(
"%d plates on %dx%d tiles, loaded in %.2f s"
% (len(plate_paths), tile_width, tile_height, time.monotonic() - load_start)
)
# belt
slots, params, seen = sushi_viper.new(
screen_width,
screen_height,
first_pixel,
row_stride,
framebuffer_pixels,
len(plate_paths),
tile_width,
tile_height,
belt_x,
)
loop_length = sushi_viper.loop_length(params)
sushi_viper.recycle(
slots, params, seen, len(plate_paths), random.randrange, everything=True
)
print(f"{len(slots)} slots, belt loop {loop_length} px")
The Loop
In the loop, the belt scroll position is updated. recycle() is called to check if any plates are off screen, and if so, swap in new plates. draw_belt() draws the new frame and framebuffer.refresh() refreshes the display to show it. For debugging, the FPS is printed to the serial console every 2 seconds.
while True:
params[sushi_viper.PARAM_SCROLL] = scroll
# swap in new plates for slots that have gone off screen
sushi_viper.recycle(slots, params, seen, len(plate_paths), random.randrange)
draw_start_ns = time.monotonic_ns()
sushi_viper.draw_belt(framebuffer, tiles, slots, params)
draw_end_ns = time.monotonic_ns()
framebuffer.refresh()
flush_end_ns = time.monotonic_ns()
scroll = (scroll + PIXELS_PER_FRAME) % loop_length
frames += 1
draw_ns += draw_end_ns - draw_start_ns
flush_ns += flush_end_ns - draw_end_ns
if flush_end_ns >= report_ns:
print(
"%d fps, draw %d us, refresh %d us"
% (frames // 2, draw_ns // frames // 1000, flush_ns // frames // 1000)
)
frames = draw_ns = flush_ns = 0
report_ns += 2_000_000_000
next_frame_ns += frame_ns
wait_ns = next_frame_ns - time.monotonic_ns()
if wait_ns > 0:
time.sleep(wait_ns / 1e9)
else:
next_frame_ns = time.monotonic_ns()
Page last edited October 02, 2026
Text editor powered by tinymce.