Here you can see the API Tulip currently ships with.
NOTE: This page represents the APIs in the latest commit of our main branch. Builds for the Tulip hardware (tulip.upgrade()) and the macOS build of Tulip Desktop may lag behind these changes. Tulip Web should always be up to date with our main branch. For the M5Stack Tab5, this page represents the latest commit of the dev_tab5 branch instead.
Tulip boots right into a Python prompt and all interaction with the system happens there. You have your own space to store code and files in /user and we keep system examples and programs in /sys. (On Tulip Desktop or Web, the sys folder is actually ../sys from where it boots.)
You can make your own Python programs with Tulip's built in editor and execute them, or just experiment on the Tulip REPL prompt in real time.
# Interact with the filesystem.
# Supported: ls, head, cat, newfile, cp, mv, rm, pwd, cd, mkdir, rmdir
ls
mkdir('directory')
cd('directory')
# Clears the REPL screen and formatting
clear
# If you want something to run when Tulip boots, add it to boot.py
edit("boot.py")
# You can upgrade the firmware over-the-air over wifi
tulip.upgrade()
# Takes a screenshot and saves to disk. The screen will blank for a moment
# If no filename given will upload to Tulip World (needs wifi)
tulip.screenshot("screenshot.png")
tulip.screenshot()
# You can optionally pass x,y,w,h to screenshot to only capture part of the screen
tulip.screenshot("middle.png", x=400,y=200,w=200,h=200)
# Return the current CPU usage (% of time spent on CPU tasks like Python code, sound, some display)
usage = tulip.cpu() # or use tulip.cpu(1) to show more detail in a connected UART
ms = tulip.ticks_ms() # returns the milliseconds since epoch, aka Arduino millis()
ms = tulip.amy_ticks_ms() # returns the audio engine's ms since boot
board = tulip.board() # Returns the board type, e.g. "TDECK", "N16R8" etcTulip can run different types of programs that you make or you can download from Tulip World. They range from simple Python scripts or modules, to full-screen "apps" with multitasking and UIs. You can edit and create these apps on Tulip itself using our editor, and upload them to Tulip World for others to use.
You can run any Python script in your current directory with execfile:
>>> execfile("hello.py")
Hello worldYou can also create Python libraries and import them from your current directory:
>>> import my_library
>>> my_library.do_something()
Doing itIf you have a program that relies on mulitple files (graphics, sounds or multiple Python files) you'll want to create a Tulip package. A package is just a folder with your files in it, like:
rabbit_game/
... rabbit_game.py # the main script should have the same name as the package
... extra.py # can put any other python files in here
... rabbit_pic.png
... rabbit_pic1.png
... rabbit_sample.wav
The main Python script must be the name of the package. This script needs to explicitly import tulip or amy or others if you are using those. Then, you and your users can start the package by run('rabbit_game') from the directory that has the folder in it. The package will be cleaned up after when they exit.
By default, a package is imported (for example, import rabbit_game.) If your rabbit_game.py has code that runs on import, it will run. If it has a def run(app): method, a UIScreen full screen window will be created that the user can switch to or quit.
We ship a couple of game-like examples, check them out:
bunny_bounceplanet_boingparallaxstarfall- a late-70s style fixed shooter, drawn entirely on the BG planegeoglyph- an early-80s style vertical scroller with separate air and ground targets, on a hardware-scrolled BG planesolitaire- Klondike patience; drag the cards with a finger, or play it in taps, and the board sizes itself to the screen
The Tulip World BBS supports uploading and downloading packages as tar files: just world.upload('package', username) or world.download('package').
We put a few examples in /sys/ex, and if you run('app'), it will look in your current folder and the /sys/ex folder.
If you want your package to run alongside other apps, and show a task bar with a quit and app-switch button, you need to use a package that implements UIScreen. UIScreen's API is detailed below, but a simplest example is:
# my switchable program, program.py
def run(app):
# Setup my app
app.present() # I'm ready, show my appPut that in a package called program, and when run('program') is called, your app will start and show a task bar. Multitasking apps have to return immediately after setup (the run function) and rely on callbacks to process data and user input. We have callbacks for everything you'd need: keyboard input, MIDI input, music sequencer ticks and touch input. UIScreen also sets up callbacks for "activating" (switching to the app or first run), "deactivating" (switching away from the app) or quitting.
If you set your UIScreen up as a game (by setting app.game = True in your def run(app): before app.present()), it will handle things like clearing the sprites and BG, and making sure keypresses only go to the full screen window.
You can also hide the task bar for games by setting app.hide_task_bar=True. That means users will have to know to use control-Tab and control-Q to switch and quit from your game.
UIScreen apps should use LVGL/tulip.UIX classes for their UI, so that the UI appears and disappears automatically during switching. This is especially important on Tulip CC hardware, where we ensure the UI switching drawing does not interrupt music or other time sensitive callbacks. You can also use other Tulip drawing commands for the UI, but be mindful that the BG (and often TFB) will be cleared on switching away from your app, so you'll have to redraw those on your activate callback. If you have a game mode on, the deactivate callback will clear the BG and sprite layer for you.
The REPL itself is treated as a (special) multitasking app, always first in the list and cannot be quit.
You can switch apps with the keyboard: control-tab, and quit apps with control-Q.
We ship a few examples of multitasking apps, please check them out here:
On your Tulip, you can find these in editable form as my_X, for example, /sys/ex/my_drums.py. This lets you edit the drum machine. The original one is read-only and always baked into Tulip, so it can't be harmed.
Please see the music tutorial for a tutorial on UIScreen.
Still very much early days, but Tulip supports a native chat and file sharing BBS called TULIP ~ WORLD where you can hang out with other Tulip owners. You're able to pull down the latest messages and files and send messages and files yourself.
Try it out with run('worldui'). You'll first want to run world.username="my_name" to choose a username.
You can also call the underlying Tulip World APIs:
# On Tulip Web, you should use world_web
if(tulip.board()=="WEB"):
import world_web as world
else:
import world
messages = world.messages(n=500, mtype='files') # returns a list of latest files (not unique ones)
messages = world.messages(n=100, mtype='text') # returns a list of latest chat messages
# On Tulip web, you can't assign the output of messages.
# If you want to do somethign other than print them, use your own done callback:
world.messages(n=25, done=do_something)
# When posting messages or files you set a username, minimum 1 character, maximum 10
world.post_message("hello!!") # Sends a message to Tulip World. username required. will prompt if not set.
world.upload(filename, description) # Uploads a file to Tulip World. username required. description optional (25 characters)
world.upload(folder, description) # Packages a folder and uploads it to Tulip World as a package
world.download(filename) # Downloads the latest file named filename from Tulip World if it exists
world.download(filename, username) # Downloads the latest file named filename from username from Tulip World if it exists
world.download(package_name) # Downloads a package and extracts it
world.ls() # lists most recent unique filenames/usernames
world.ls(100) # optional count (most recent)
# AMYboard World: sketches shared on amyboard.com also run on Tulip.
# download() fetches the latest sketch.py into user/current/ and starts it the
# AMYboard way: synths reset, the sketch's saved knob state applied, and its
# loop() scheduled on the sequencer. CV in/out calls no-op on Tulip; I2C
# accessories (OLED display, rotary encoders) work.
world.amyboard.download(sketch_name) # e.g. world.amyboard.download("eno_ambient")
world.amyboard.download(sketch_name, username) # latest version by a specific user
world.amyboard.download(sketch_name, username, start=False) # just download, don't run
world.amyboard.ls() # lists most recent AMYboard World sketches
import amyboard
amyboard.stop_sketch() # stops a running sketch's loop()Big note: Tulip World is hosted by a bot running on the Tulip/AMY/Alles Discord. If there's any abuse of the system, I'll revoke the key. I'd love more help making Tulip World a more stable and fun experience for everyone.
Tulip ships with a text editor, based on pico/nano. It supports syntax highlighting, search, save/save-as.
# Opens the Tulip editor to the given filename.
# Control-X saves the file, if no filename give will prompt for one.
# Control-O is save as -- will write to new filename given
# Control-W searches
# Control-R prompts for a filename to read into the current buffer
edit("game.py")
edit() # no filenameWe include LVGL 9 for use in making your own user interface. LVGL is optimized for constrained hardware like Tulip. You can build nice UIs with simple Python commands. You can use LVGL directly by simply import lvgl and setting up your own widgets. Please check out LVGL's examples page for inspiration. (As of this writing, their Python examples have not been ported to our version of LVGL (9.0.0) but most things should still work.)
It's best to build user interfaces inside a UIScreen multitasking Tulip package. Our UIScreen will handle placing elements on your app and dealing with multitasking.
For more simple uses of LVGL, like buttons, sliders, checkboxes and single line text entry, we provide wrapper classes like UICheckbox, UIButton, UISlider, UIText, and UILabel. See our fully Python implementation of these in ui.py for hints on building your own UIs. Also see our buttons.py example, or more complete examples like drums, juno6, wordpad etc in /sys/ex.
Tulip apps that support multitasking are called UIScreens and they wrap functionality for adding UI elements and switching between apps. A Tulip package tries to run(screen) in your main Python file, and if it exists, will expect the run(screen) function to exit quickly and hand over control to various callbacks. This allows multiple apps to work at the same time. It's especially useful for music apps that share the sequencer and MIDI callbacks.
By default a UIScreen is created for you when you run(app), presuming app.py in the package has a def run(screen): function. The UIScreen object is passed into screen. You can treat that object as your app's global state, and also set and get various parameters of the app:
def run(screen):
# These are all the defaults:
screen.bg_color = 0 # tulip color of the screen BG
screen.keep_tfb = False # whether to hide the TFB while running the app
screen.offset_y = 100 # by default, screens "start" at 0,100 to leave room for the task bar
screen.activate_callback = None # called when the app starts and when it is switched to
screen.deactivate_callback = None # called when you switch away from the app
screen.quit_callback = None # called when the quit button is pressed. Note: deactivate_callback is called first on quit
screen.handle_keyboard = False # if you set up UI components that accept keyboard input
screen.group.set_style_text_font(lv.font_tulip_11,0) # Set the default font for the entire app if you want
# Set up your UI with screen.add(), adding UIElement objects
screen.add(tulip.UILabel("hello there"), x=500,y=100)
# You can use LVGL alignment to add objects in relation to the last object added
# See https://docs.lvgl.io/master/widgets/obj.html for a listing of aligns
screen.add(tulip.UILabel("under that one"), direction=lv.ALIGN.BOTTOM_MID)
# When you're ready, do
screen.present()
def quit(screen):
# your quit callback gets a screen object, use it to shut down
def activate(screen):
# use this to re-draw anything explicitly. LVGL components added with add() will automatically appearTulip UIScreen apps should never wait in a loop or call sleep. They should rely on callbacks to do all their work. For example, our drum machine waits for the sequencer callback to play the next note. Our editor app relies on the keyboard callback for the next keypress. This allows Tulip to run multiple programs at once.
See some examples of more complex UIs using UIScreen:
juno6drumsvoicesloopstudio- an FL Studio Mobile style pattern workstation, built for the Tab5kanplay- a one-finger chord instrument after KANTAN Play
If you want to edit these programs on Tulip, find editable versions in /sys/ex/my_X.py, like /sys/ex/my_drums.py.
You can see running multitasking apps with tulip.running_apps, which is a dict by app name. This lets you set or inspect parameters of running apps. tulip.repl_screen always returns the REPL UIScreen. You can programtically switch apps with e.g. tulip.app('drums'). The current running UIScreen is tulip.current_uiscreen().
>>> tulip.running_apps['voices'].piano_y
320
>>> tulip.repl_screen.bg_color
9You can summon a touch keyboard with tulip.keyboard(). Tapping the keyboard icon dismisses it, or you can use tulip.keyboard() again to remove it.
It types into the LVGL text field that has the keyboard focus, and follows the focus as you tap from field to field, so a form on a touch screen can be filled in with no keyboard attached. With no field focused it types into the console instead, as it always has -- that is the REPL. Pass a field to type into it from the start: a button that opens the keyboard has taken the focus itself by the time it is pressed, so an app with a keyboard button of its own should say which field it means.
We boot a launcher for common operations. It's available via the small grey icon on the bottom right.
For LVGL fonts, you can use default LVGL montserrat fonts, e.g. font=lv.font_montserrat_12, or the built in Tulip BG fonts, e.g. font=lv.tulip_font_13.
tulip.keyboard() # open or close the soft keyboard
tulip.keyboard(field) # open it typing into an LVGL text area
tulip.launcher() # open or close our launcher
# You're free to use any direct LVGL calls. It's a powerful library with a lot of functionality and customization, all accessible through Python.
import lvgl as lv
# our tulip.lv_scr is the base screen on bootup, to use as a base screen in LVGL.
calendar = lv.calendar(lv.current_screen())
calendar.set_pos(500,100)
# use our tulip.UIX classes to add simple UI elements to your app.
# UISlider: draw a slider
# bar_color - the color of the whole bar, or just the set part if using two colors
# unset_bar_color - the color of the unset side of the bar, if None will just be all one color
# handle_v_pad, h_pad -- how many px above/below / left/right of the bar it extends
# handle_radius - 0 for square
screen.add(tulip.UISlider(val=0, w=None, h=None, bar_color=None, unset_bar_color=None,
handle_color=None, handle_radius=None, handle_v_pad=None, handle_h_pad=None, callback=None))
# UIButton: push button with text
screen.add(tulip.UIbutton(text=None, w=None, h=None, bg_color=None, fg_color=None,
font=None, radius=None, callback=None))
# UILabel: text
screen.add(tulip.UILabel(text="", fg_color=None, w=None, font=None))
# UIText: text entry
screen.add(tulip.UIText(text=None, placeholder=None, w=None, h=None,
bg_color=None, fg_color=None, font=None, one_line=True, callback=None))
# UICheckbox
# Optionally draw a label next to the checkbox
screen.add(tulip.UICheckbox(text=None, val=False, bg_color=None, fg_color=None, callback=None))See our buttons.py example for UIX class use.
You can set up a tabbed UI in a UIScreen with our TabView class. It's set up to act like a mini UIScreen, where you can add elements.
def run(screen):
# This will create a TabView in the UIScreen, on the left, with three tabs
tabview = ui.TabView(screen, ["tab1", "tab2", "tab3"], size=80, position = lv.DIR.LEFT)
# Create any UIElement
bpm_slider = tulip.UISlider(tulip.seq_bpm()/2.4, w=300, h=25,
callback=bpm_change, bar_color=123, handle_color=23)
# Add it to the tab you want, same API as UIScreen.add()
tabview.add("tab2", bpm_slider, x=300,y=200)
screen.present()Tulip supports USB keyboard input, USB mouse input, and touch input. It also supports a software on-screen keyboard, and any I2C connected keyboard or joystick on Tulip CC. On Tulip Desktop and Tulip Web, mouse clicks act as touch points, and your computers' keyboard works.
If you have a USB mouse connected to Tulip (presumably through a hub) it will, by default, show a mouse pointer and treat clicks as touch downs.
# Returns a mask of joystick-like presses from the keyboard, from arrow keys, Z, X, A, S, Q, W, enter and '
tulip.joyk()
# test for joy presses. Try UP, DOWN, LEFT, RIGHT, X, Y, A, B, SELECT, START, R1, L1
if(tulip.joyk() & tulip.Joy.UP):
print("up")
# Returns the current held keyboard scan codes, up to 6 and the modifier mask (ctrl, shift etc)
(modifiers, scan0, scan1... scan5) = tulip.keys()
# Gets a key ascii code
(char, scan, modifier) = tulip.key_wait() # waits for a key press, returns scan code and modifier too
ch = tulip.key() # returns immediately, returns -1 if nothing held
# If scanning key codes in a program, you may want to turn on "key scan" mode so that
# keys are not sent to the underlying python process
tulip.key_scan(1)
tulip.key_scan(0) # remember to turn it back off or you won't be able to type into the REPL
# If you need to remap keys on your keyboard (we default to US)
tulip.remap() # interactive, can write to your boot.py for you
tulip.key_remap(scan_code, modifier, target_cp437_code)
# You can also register a keyboard callback. Useful for full screen apps that share with others
# there can only be one keyboard callback running.
tulip.keyboard_callback(key)
def key(k):
print("got key: %d" % (key))
tulip.keyboard_callback() # removes callbacks.
# Brightness of the indicator LEDs on the Tab5's built-in keyboard, 0-100 (Tab5 only)
tulip.keyboard_brightness(5)
tulip.keyboard_brightness() # returns the current setting
# Return the last touch panel coordinates, up to 3 fingers at once
(x0, y0, x1, y1, x2, y2) = tulip.touch()
# Modify the touch screen calibration if needed (on Tulip CC only)
# Run ex/calibrate.py to determine this for your panel
tulip.touch_delta(-20, 0, 0.8) # -20 x, 0 y, 0.8 y scale
tulip.touch_delta() # returns current delta
# Set up a callback to receive raw touch events. up == 1 when finger / mouse lifted up
def touch_callback(up):
t = tulip.touch()
print("up %d points x1 %d y1 %d" % (up, t[0], t[1]))
tulip.touch_callback(cb)ime is a Japanese input method: romaji in, kana and kanji out. It needs the
Japanese console font, so it is on the boards that have that -- Tab5, Tulip
Desktop and Tulip Web -- and not on the ESP32-S3 ones. Put this in boot.py:
import ime
ime.start()Four keys hand the keyboard to the IME and take it back, so that every keyboard
has one it can actually press: 変換, かな, Ctrl-Space and Ctrl-\.
Ctrl-Space is the one every other Japanese input method uses, and it is two
dedicated keys everywhere. Ctrl-\ is not: the Tab5's own 70-key keyboard reaches
backslash through its Sym layer, where ctrl cannot be held as well. 変換 and かな
are free on a JIS keyboard -- they used to decode to nothing at all -- and
switching input is exactly what they mean.
The 半角/全角 key sits where a US keyboard keeps its backtick, so it is left alone by default. Point it at the toggle yourself if you want it:
tulip.key_remap(0x35, 0, 0x1c) # 半角/全角 key, JIS positionFor a keyboard that can press none of the four, find out what it does send and bind that:
ime.keytest() # press keys; prints the code, the modifier and the scan code
ime.toggle(<code>) # use that key as the toggle from now on
ime.keytest(False) # stopime.start() is cheap -- it only arms the toggle. The dictionary is read the
first time you switch the IME on, which takes about two seconds and happens once.
While the IME holds the keyboard the cursor is orange, so you can tell which language the next keystroke is in without typing one -- in the console, in the editor, and on the focused LVGL text area. What you type appears on a 変換 strip along the bottom console row rather than going straight into the line -- committed text is what reaches the editor, an LVGL text area, or the REPL. The strip is only there while something is being composed; between one word and the next the bottom row goes back to the console, and the cursor is what says the IME still has the keyboard. Typing:
| key | while typing a reading | while a segment is converted |
|---|---|---|
a-z |
romaji, becoming kana as it resolves | commits, then starts a new reading |
| space | convert the longest reading the dictionary knows | next candidate |
| ↑ / ↓ | — | previous / next candidate |
| → | — | accept this segment, convert what is left |
| ← | — | shrink the segment by one kana |
| return | commit the kana as typed | commit the candidate |
| backspace | delete one kana | back to the unconverted reading |
| escape | throw the whole thing away | back to the unconverted reading |
Ctrl-I (= tab) |
the whole reading as カタカナ | this segment as カタカナ |
Ctrl-U |
the whole reading as ひらがな | this segment as ひらがな |
, . - [ ] / |
become 、 。 ー 「 」 ・ | |
A-Z, digits |
commit, then pass through | commit, then pass through |
nn is not ん. n before anything that cannot continue な行 is, so kanji is
かんじ and konnichiwa is こんにちわ; n' is the explicit single ん, which is also
how you type ほんや (hon'ya, since honya is ほにゃ).
The conversion is per-segment, not per-sentence: there is no morphological
analyser here, so space converts the longest reading the dictionary has from where
you are and → moves on to the rest. にほんごにゅうりょく + space + → + return gives
日本語入力.
Ctrl-I and Ctrl-U are F7 and F6 from MS-IME, ATOK and mozc, which is also
where the Ctrl- spelling of them comes from -- the Tab5's own keyboard has no
function row. They cover the whole reading and ignore the dictionary, which is the
point: space converts the longest reading the dictionary has, so aisukuri-mu +
space is 愛すくりーむ, while aisukuri-mu + Ctrl-I is アイスクリーム. Tab only
means this while something is being composed; with nothing composed it is still
tab. There is no 半角カナ, because the console font has no halfwidth katakana, and
no 英数 key, because a capital letter and switching the IME off already do that.
Katakana and hiragana are also the last two candidates space cycles through, but only over the segment the dictionary matched -- for a whole word, use the keys above.
ime.start() # arm the toggle; the dictionary waits until first use
ime.start(True) # read the dictionary now instead (about 2 seconds)
ime.stop() # disarm; the keyboard goes back to normal
ime.target(my_textarea) # send committed text to this instead of guessing
ime.target(None) # back to guessing: LVGL focus, else editor, else REPL
ime.toggle() # the key code that switches it on and off
ime.toggle(code) # use a different key
ime.keytest() # print what each key sends, to find that code
tulip.ime() # is the IME holding the keyboard right now?The dictionary is /sys/ime/jdic.z: 59073 readings, built from the Google mozc
OSS dictionary (BSD-3-Clause, vocabulary from IPAdic) by
tulip/shared/gen_jdict.py. It is not SKK-JISYO, which is the obvious choice and
is GPL. Without it the IME still does kana.
Tulip hardware has a I2C port on the side for connecting a variety of input or output devices. We currently support the following:
- Mabee DAC (up to 10V) - use
import mabeedac; mabeedac.set(volts, channel)- see the CV control section in the sound documentation as well - ADC (up to 12V) - use
import m5adc; m5adc.get() - DAC (single channel, up to 3.3V) - use
import m5dac; m5dac.set(volts) - DAC2 (dual channel, up to 10V) - use
import m5dac2; m5dac.set2(volts, channel) - CardKB keyboard - use
import m5cardkb, which will automatically let your cardKB be a keyboard in Tulip. Put this in yourboot.pyfor using it at startup. - 8-encoder knobs - use
import m5_8encoder, see the m5_8encoder.py file for more - 8-angle knobs - use
import m58angle; m58angle.get(ch) - Digiclock 7-segment clock - use
import m5digiclock; m5digiclock.set('ABCD') - Joystick - use
import m5joy; m5joy.get() - Extend GPIO - use
import m5extend; m5extend.write_pin(pin, val); m5extend.read_pin(pin)
Tulip CC has the capability to connect to a Wi-Fi network, and Python's native requests library will work to access TCP and UDP. We ship a few convenience functions to grab data from URLs as well.
# Join a wifi network (not needed on Tulip Desktop or Web)
tulip.wifi("ssid", "password")
# Set the Wi-Fi regulatory domain as you join. The default "01" (world safe mode)
# leaves channels 12-14 closed, so an AP parked up there -- routers in Japan often
# are -- stays invisible until you name its country. (Tab5 only for now.)
tulip.wifi("ssid", "password", country="JP")
# Read or set the regulatory domain on its own. Wi-Fi has to be started first,
# so this is for after a tulip.wifi() call. A second argument of False pins the
# country instead of letting the AP's own beacon override it.
tulip.wifi_country() # -> "JP"
tulip.wifi_country("JP")
# Get IP address or check if connected
ip_address = tulip.ip() # returns None if not connected
# Save the contents of a URL to disk (needs wifi)
bytes_read = tulip.url_save("https://url", "filename.ext")
# Get the contents of a URL to memory (needs wifi, and be careful of RAM use)
content = tulip.url_get("https://url")
# Upload a URL to a PUT API. Used in our file_server.py
tulip.url_put(url, "filename.ext")
# Set the time from an NTP server (needs wifi)
tulip.set_time() ssh is an SSH-2 client written in Python. It logs into another machine on the
network and puts its shell on the Tulip text console.
import ssh
# Run one command and get its output back
print(ssh.run("192.168.1.10", "me", "uname -a", password="secret"))
# An interactive shell. It ends when the remote shell does, so type `exit`
ssh.shell("192.168.1.10", "me", password="secret")
# A key instead of a password. It has to be an unencrypted OpenSSH RSA key --
# copy one onto Tulip and give its path. `ssh-keygen -p` takes a passphrase off
ssh.shell("192.168.1.10", "me", key="/user/id_rsa")
# The pieces, if you want to drive a channel yourself
c = ssh.connect("192.168.1.10", "me", key="/user/id_rsa")
c.exec_command("ls /tmp")
while not c.closed: print(c.read())
c.close()There is one cipher suite and it is not negotiable: curve25519-sha256 key
exchange, an rsa-sha2-256 host key, aes128-ctr and hmac-sha2-256. That is
enough for a stock OpenSSH server, which still ships an RSA host key alongside
its Ed25519 one -- but a server configured for Ed25519 only cannot be checked
here and will be refused rather than trusted blindly. (mbedTLS has no EdDSA and
this build's hashlib has no SHA-512, so verifying an Ed25519 host key would
mean writing both from scratch in Python.)
Host keys are remembered the first time you connect, in /user/known_hosts,
and a later mismatch raises ssh.HostKeyError instead of connecting. Pass
accept_new=False to refuse unknown hosts too.
The slow parts are the ones that only happen once: about 0.7s for the handshake (two X25519 scalar multiplications and one RSA signature check) and another 1.6s if you authenticate with a key rather than a password.
A session switches the console into a real terminal -- tulip.term_start(),
below -- so full-screen programs work: vi, top, less, tmux. It is an
xterm subset with cursor addressing, a scroll region, insert and delete of both
lines and characters, an alternate screen, autowrap, tab stops, the DEC
line-drawing set, and the answers a program expects back when it asks the
terminal what it is. The pty is opened at the console's own size, so stty size
agrees with what you can see, and the app sends a window-change if that size ever
moves under it -- switching TFB fonts mid-session changes the column count, and a
remote still wrapping at the old width lays out every line after that wrongly.
Japanese arriving in the middle of a session no longer moves it on a Tab5: the
console promotes itself to font 5, which is the 12x16 console with Japanese in
it rather than a different size (see tulip.tfb_font()).
What it cannot act on it swallows rather than prints, including the window-title sequence a shell sends at every prompt, and it will pick a sequence up again on the far side of a write boundary -- ssh hands over whatever the network gave it, so a sequence can be cut in half anywhere.
The arrows, Delete, Home, End, Insert and the function keys are sent as the
terminal's own sequences, in whichever form the program asked for: a program
that turns on application cursor keys gets ESC O A where a shell gets
ESC [ A. Page Up and Page Down reach the remote as Ctrl-Y and Ctrl-V, which
is what this keyboard has always decoded them to.
Drawing is the limit rather than the link. The screen keeps up with about 30
KB/s of output against the connection's 77, so a full-screen repaint takes
roughly a tenth of a second and a cat of something large is slower than the
network could have been.
Ctrl-C is sent to the remote shell rather than interrupting Python, which is what you want inside a session; the keyboard goes back to the REPL when the session ends.
A session borrows the screen and gives it back. Whatever the console had on it is put away when the session starts and is back when it ends, so quitting does not leave a remote shell sitting on the REPL's screen -- and switching to another app mid-session swaps the two, so the REPL is not looking at the session either. The session's screen is still there when you switch back to it.
sftp moves files over the same kind of connection. It speaks to the server's
SFTP subsystem rather than to scp, whose wire protocol OpenSSH has been
walking away from since 9.0, so it needs nothing on the far end that an OpenSSH
server does not already ship.
import sftp
# One file each way, and a listing. Each call is its own connection.
sftp.get("192.168.1.10", "me", "/etc/hostname", "/user/hostname", password="secret")
sftp.put("192.168.1.10", "me", "/user/song.wav", "music/", key="/user/id_rsa")
print(sftp.listdir("192.168.1.10", "me", "/var/log", key="/user/id_rsa"))
# Or hold one open, which is what you want for more than one file
s = sftp.connect("192.168.1.10", "me", key="/user/id_rsa")
print(s.realpath(".")) # where relative paths start
for name, longname, attrs in s.ls("."): # longname is the server's own ls -l line
print(longname)
s.get("notes.txt") # keeps the name, lands in the cwd
s.get("notes.txt", "/user/") # a directory: the name is kept
s.write_file("hello.txt", "from tulip\n")
print(s.read_file("hello.txt"))
print(s.stat("hello.txt")) # {'size': 11, 'mode': 33188, ...}
s.mkdir("new"); s.rename("hello.txt", "new/hi.txt")
s.remove("new/hi.txt"); s.rmdir("new")
s.close()get() and put() return the number of bytes moved and take a
progress(done, total) callback, called once per 16KB chunk, with total set
to None if the server did not say how big the file was. Anything the server
refuses raises sftp.SFTPError -- an ssh.SSHError carrying the server's own
message and its status code in .code, so except sftp.SFTPError as e: if e.code == sftp.FX_NO_SUCH_FILE is the way to tell a missing file from a
refused one.
Measured on a Tab5 over wifi: a 120KB file moves in one to two seconds each
way. Fetching sits at about 62 KB/s; sending ran between 65 and 105 KB/s from
one run to the next, which is the air rather than the code. For anything small
the connection costs more than the file does -- about two seconds of handshake
when authenticating with a key -- which is why connect() is there next to the
one-shot calls.
There is an app version of all this, SSH in the launcher (or run('sshterm')).
It puts up a form for the host, user, password or key file and port, remembers
everything but the password in /user/sshterm.conf, and then hands the console
over to the session. It is a normal switchable app: the task bar keeps working
while you are connected, so you can switch to another app and come back to the
session still running, and quitting from the task bar hangs up. Typing ~. at
the start of a line hangs up too, the way OpenSSH's escape does.
The form has a keyboard button, for a Tab5 with nothing plugged into it: the on-screen keyboard types into the field you last tapped, and the form is laid out in two columns so that the keyboard, which takes the bottom half of the screen, does not cover the fields or the Connect button. Enter in any field connects, as well as the Connect button -- the on-screen keyboard's return key included, so you never have to reach past it to submit.
The Files button turns the form into a transfer page: a remote path, a local
one, and Get, Put and List. List puts the remote directory in the box on the
right, with a / after the names that are directories, so the name you are
about to type is in front of you. A transfer opens its own connection with the
host, user and credentials from the form -- a typed password is kept in RAM for
as long as the app runs, and still never written to sshterm.conf, so a
transfer works after connecting has cleared the field. Both paths are
remembered in the config file along with the host. The page is part of the
form, so it is where you are before a shell session or after one.
We ship asyncio and also provide a simpler tulip.defer() callback to schedule code in the future.
import asyncio
async def sleep(sec):
await asnycio.sleep(sec)
print("done")
asyncio.run(sleep(5))
def hello(t):
print("hello called with arg %d" % (t))
tulip.defer(hello, 123, 1500) # will be called 1500ms laterTulip comes with the AMY synthesizer, a very full featured 250-oscillator synth that supports FM, PCM, subtractive and additive synthesis, partial synthesis, filters, and much more. See the AMY documentation for more information, Tulip's version of AMY comes with stereo sound, chorus and reverb. It includes a "small" version of the PCM patch set (29 patches) alongside all the Juno-6 and DX7 patches. It also has support for loading WAVE files in Tulip as samples.
Tulip can drive an Alles
mesh over Wi-Fi -- any number of remote speakers running AMY, all controlled from one
Tulip -- through the alles module. alles.mesh() redirects AMY's output to the mesh's
multicast group, so everything that already makes sound here plays there instead: a
sketch calling amy.send(), synth.py, the sequencer. Pass local=False to silence
Tulip's own speaker and make it a pure controller -- worth doing for anything busy, since
rendering here competes with the scheduler that is sending to the mesh. Measured on a
Tab5 playing alles_demo: driving the speakers alone costs 0.02 render load and keeps
exact time, while playing the same piece locally as well ran it a fifth slow, and at full
polyphony it overran the render block and dragged to half speed.
import alles
alles.mesh() # also keeps playing locally; local=False for mesh only
alles.map() # [(client, ip, clock_ms), ...] -- who is out there
alles.send(client=1, osc=0, wave=amy.SINE, freq=440, vel=1) # one node
alles.send(osc=0, vel=0) # no client= -> everyone
alles.local(False) # stop playing here, keep driving the mesh
alles.sync() # {ip: {client, clock_ms, offset_ms, rtt_ms}}
alles.off() # back to normalThere is a piece written for two of them in
alles_demo
-- put the speakers apart, import alles_demo, and the melody comes off one wall and
echoes back from the other. It addresses each speaker by client number and stamps
every note with the moment it should sound, so the two stay together.
A client number is a label the nodes negotiate between each other, so it is not a stable
identity -- two nodes fresh out of a reboot will both answer as client 0 until they
settle, which is why alles.sync() is keyed by address. Addressing is the node's own client
number, the one it reports in alles.map(): client= rides the wire as g, and AMY's
own parser does not read it at all -- the node firmware does, and the Alles firmware
does. A client= above 255 was historically a group: a node joined if
client_id % (client - 255) == 0. to= is there for firmware that does not read
client=; it sends unicast to one address instead, at the cost of a datagram per node.
The nodes play in step, and the module does that for you: every message carries a t
prefix naming the host time it should sound at, and each node keeps an offset between
that clock and its own and schedules against ours. AMY itself dropped the absolute time
field (parse.c still carries the line "t no longer used (was time=)"), but the Alles
firmware still reads t, which is what makes it work. Pass at_ms= to alles.send() to
name the moment; leave it out for "as soon as you can".
Everything a node is told to play happens alles.ALLES_LATENCY_MS (1 s) after the stamp.
That is the budget the network has to deliver inside, so a packet a few milliseconds late
still lands on the beat -- and it is why setting up takes a moment: a reset is scheduled
against that latency while a patch load takes effect the moment it arrives, so defining
instruments straight after a reset lets the reset land on top of them and the nodes lose
their synths partway through. Wait the latency out first. alles.sync() reports each
node's clock, offset and round trip if you want to see the skew for yourself.
Tulip can also route AMY signals to CV outputs connected over Tulip CC's I2C port. You will need one or two Mabee DACs or similar GP8413 setup. This lets you send accurate LFOs over CV to modular or other older analog synthesizers.
See the music tutorial for a LOT more information on music in Tulip.
We provide a wrapper on AMY that manages synthesizers you can allocate. These handle voice stealing and finding oscillators for the underlying synth patches. They're recommended to use for most use cases. If you need more direct control, you can use AMY.
You can use synth.PatchSynth to create a synthesizer based on our built-in patches. 0-127 are Juno-6 patches, 128-255 are DX-7 patches, 256 is a piano. You can create your own patches as well.
syn = synth.PatchSynth(num_voices=2, patch=143) # two note polyphony, patch 143 is DX7 BASS 2If you want to play multimbral tones, like a Juno-6 bass alongside a DX7 pad:
synth1 = synth.PatchSynth(num_voices=1, patch=0) # Juno
synth2 = synth.PatchSynth(num_voices=1, patch=128) # DX7
synth1.note_on(50, 1)
synth2.note_on(50, 0.5)
synth1.note_off(50)The OscSynth synth lets yo directly control parameters of an AMY oscillator as a managed synth:
syn = synth.OscSynth(wave=amy.PCM, preset=10) # PCM wave type, preset=10 (808 Cowbell)You can use OscSynth and amy.load_sample to load samples from WAV files on Tulip storage:
amy.load_sample('sample.wav', preset=50)
s = synth.OscSynth(wave=amy.PCM, preset=50)
s.note_on(60, 1.0)Use syn.release() to free up the resources for a synth.
Once you have your synths set up the way you like, you can save their state so it comes back on the next boot. tulip.save_synth_state() reads the current configuration of every AMY synth and appends the commands that restore it to the bottom of your boot.py (or any file you give it). Re-saving replaces the previously saved state.
tulip.save_synth_state() # adds current synth state to your boot.py
tulip.save_synth_state('my_setup.py') # or save it to some other file to execfile() laterYou can use amy.py to control the AMY synthesizer directly.
amy.send(volume=4) # change volume
amy.send(reset=amy.RESET_ALL_NOTES) # stops everything that is sounding
amy.reset() # stops the sound AND clears every synth -- see the warning below
amy.send(synth=1, patch=129, num_voices=1) # set up a DX7 patch on synth 1
amy.send(synth=1, note=45, vel=1) # plays a tone
amy.send(synth=1, pan=0) # set to the left channel
amy.send(synth=1, pan=1) # set to the right channelamy.reset() and the amy.examples demos clear every synth. amy.reset() runs
AMY's instruments_reset(), which destroys every synth midi.py set up -- and the
amy.examples.example_* demos begin with amy.send(reset=amy.RESET_ALL_OSCS), so they
do the same thing. AMY has no way to tell Python it happened, and a Python object holding
a synth number goes on looking perfectly valid while the synth it names is gone, so notes
sent to it are dropped in silence -- no error, no warning.
Anything in midi.config puts itself back: amy.instrument_generation counts these
resets, and a synth.PatchSynth that sees it has moved re-creates its synth on the next
note. What comes back is how the synth was set up -- patch, polyphony, flags -- so
anything you changed afterwards with update_oscs() is not replayed. Synths you drive by
sending synth= numbers yourself are not tracked and stay wiped; re-send their setup, or
start over from:
midi.add_default_synths() # Juno on channel 1, drums on 10, bleeper on 0To stop the sound without clearing anything, use amy.send(reset=amy.RESET_ALL_NOTES)
instead of amy.reset().
To load your own WAVE files as samples you can play like an instrument, use amy.load_sample:
# To save space / RAM, you may want to downsample your WAVE files to 11025 or 22050Hz. We detect SR automatically.
amy.load_sample("flutea4.wav", preset=50) # samples are converted to mono if they are stereo. preset # can be anything
# You can optionally tell us the loop start and end point (in samples), and base MIDI note of the sample.
# We can detect this in WAVE file metadata if it exists! (Many sample packs include this.)
amy.load_sample("flutea4.wav", midinote=81, loopstart=1020, loopend=1500, preset=50)
# The preset number can now be used in AMY's PCM sample player.
amy.send(osc=20, wave=amy.PCM, preset=50, vel=1, note=50)
# You can unload already allocated presets:
amy.unload_sample(50) # frees the RAM and the preset slot
amy.reset() # frees all allocated PCM presetsOn Tulip Desktop or Web, or with an AMYboard / AMYchip connected to a hardware Tulip over I2C, you can use audio input as well. This is brand new and we're still working out a good API for it. For now, you can set any oscillator to be fed by the L or R channel of an audio input.
amy.send(osc=0, wave=amy.AUDIO_IN0, vel=1)
amy.echo(1, 250, 500, 0.8) # echo effect on the audio inputTo send signals over CV on Tulip CC (hardware only):
amy.send(osc=100, wave=amy.SAW_DOWN, freq=2.5, vel=1)
tulip.amy_set_external_channel(100, 1) # osc, channel
# external_channel = 0 - no CV output, will route to audio (default)
# external_channel = 1 - 1st channel of the first connected GP8413 / dac
# external_channel = 2 - 2nd channel of the first connected GP8413
# external_channel = 3 - 1st channel of the second connected GP8413
# external_channel = 4 - 2st channel of the second connected GP8413
# Or just send CV signals directly using the mabeedac library:
import mabeedac
mabeedac.send(volts, channel=0)Tulip also ships with our own music.py, which lets you create chords, progressions and scales through code:
import music
chord = music.Chord("F:min7")
for i,note in enumerate(chord.midinotes()):
amy.send(wave=amy.SINE,osc=i*9,note=note,vel=0.25)Tulip is always running AMY's live sequencer, which allows you to have multiple music programs running sharing a common clock. You can use seq = sequence.AMYSequence(length, divider) and then seq.add(offset, function, args) to control an AMY sequence.
A sequence in AMY is defined as a length and divider. The divider is set as the musical note length's denominator. If you want this sequence to be a pattern of events, you can specify that in length, which indicates how many of those events happen in a loop. For an example of a 16 position 1/8th note drum machine, length is 16 and divider is 8. For a 8 note long quarter note pattern, length is 8 and divider is 4.
If you want repeating events but don't care about a pattern, you can set length to 1. The sequence will just repeat at the given divider note length. For example, if you want a thing to happen every 32nd note, you'd choose a length of 1 and a divider of 32.
You can also set length to 0, which lets you address ticks in absolute time. This is useful for non-repeating sequencing, like a MIDI event recorder: just set the divider to whatever note length you want, and set length to 0: AMYSequence(0,8). Then you can add events to the sequence in absolute note lengths from the start.
You can set divider from 1 up to 192 and length can be any number you want. You can have multiple sequences running at once, each with different dividers and lengths.
You can only sequence AMY music events (MIDI, note ons, synth, amy.send, parameter changes) with the AMY sequencer.
To use the music sequencer, use seq = sequencer.AMYSequence(length, divider). Then add new events using seq.add(position, function, [args]). position is the position within the pattern (or any future position, if length is 0) to schedule function in. In the drum machine example, you set up a pattern of 16 1/8th notes, so index 0 would be the first hit, and 15 the last). You lastly pass whatever arguments you want to give to that function. synth.note_on takes 2 - a note number and a velocity. You can optionally pass other parameters like pan=0.1 as keyword arguments. seq.add() returns the event that was added. You can keep this event around to later update or remove an individual event. e = seq.add(0, func) can then be used to update the sequence with a new function: e.update(0, new_func) or remove it with: e.remove().
To schedule any Python function in time with the music sequencer, for example, if you want to update the display to show a LED animation as a drum pattern plays, you can use sequence.TulipSequence(divider). You can only have up to 8 TulipSequences overall in Tulip, so your app should only use one -- if your app wants to sequence arbitrary Python, set up a single sequence_callback at the divider you want. The clock is shared between TulipSequence and AMYSequence. For example, if your drum machine is AMYSequence(16, 8), use TulipSequence(8) for your graphical update code -- it will be called every 1/8th note, in time with the drum pattern.
See how we do this in the drums app.
To use the Tulip sequencer, use seq = sequence.TulipSequence(divider, func). func will be called every divider, in time with the AMY sequencer. You can stop it with seq.clear().
Here's an example of using both sequencers:
import sequencer
syn = synth.PatchSynth(num_voices=1, patch=0) # make a synthesizer to control
arp_notes = [48,50,52,49,56,58,60,57]
def print_every_other_note(x):
print("hit! %d" %(x))
music_seq= sequencer.AMYSequence(16, 8) # 1/8th notes, 16 of them
# Every 1/8th note print the current tick
print_seq= sequencer.TulipSequence(8, print_every_other_note) # every 1/8th note
for i in range(16):
# At index i, schedule a note on for the synth, with parameters (arp_notes[i%8], 1)
music_seq.add(i, syn.note_on, [arp_notes[i%8], 1])
def stop():
music_seq.clear() # Removes all scheduled notes from this sequence
print_seq.clear() # Removes all scheduled events from this sequence
syn.release() # Stops the synthYou can set or see the system-wide BPM (beats, or quarters per minute) with AMY's sequencer.tempo(120)
See the music tutorial for a LOT more information on music in Tulip.
Via AMY, Tulip supports MIDI in and out to connect to external music hardware. You can set up a python callback to respond immediately to any incoming MIDI message. You can also send messages out to MIDI out.
You can use MIDI over serial (the 3.5mm connectors on Tulip CC) or USB as well, using the USB-KB connector. Note this USB is meant as a host connector: you can connect USB MIDI keyboards or USB MIDI interfaces to Tulip. You cannot connect Tulip directly to a computer as a "USB MIDI gadget". If you want your Tulip to control your computer, use a MIDI interface on your computer and wire Tulip's MIDI out to it.
If you have a USB MIDI adapter connected, MIDI out from Tulip will go to both USB and TRS MIDI connectors. MIDI in can come into either TRS or USB.
By default, Tulip boots into AMY's live MIDI synthesizer mode. Any note-ons, note-offs, program changes or pitch bend messages will be processed automatically with polyphony and voice stealing, and Tulip will play the tones with no other user intervention needed.
By default, MIDI notes on channel 1 will map to Juno-6 patch 0. And MIDI notes on channel 10 will play the PCM samples (like a drum machine).
You can adjust which voices are sent with midi.config.add_synth(channel=channel, synth=synth). For example, you can have Tulip play DX7 patch 129 on channel 2 with midi.config.add_synth(channel=2, synth=synth.PatchSynth(patch=129, num_voices=1)). channel=2 is a MIDI channel (we use 1-16 indexing), patch=129 is an AMY patch number, num_voices=1 is the number of voices (polyphony) you want to support for that channel and patch.
(A good rule of thumb is Tulip CC can support about 6 simultaneous total voices for Juno-6, 8-10 for DX7, and 20-30 total voices for PCM and more for other simpler oscillator patches.)
These mappings will get reset to default on boot. If you want to save them, put add_synth commands in your boot.py.
You can set up your own MIDI callbacks in your own programs. You can call midi.add_callback(function), which will call your function with a list of a (2 or 3-byte) MIDI message. These callbacks will get called alongside the default MIDI callback (that plays synth notes on MIDI in).
On Tulip Desktop, MIDI works on macOS 11.0 (Big Sur, released 2020) and later ports using the "IAC" MIDI bus. (It does not yet work at all on Linux or Windows.) This lets you send and receive MIDI with Tulip to any program running on the same computer. If you don't see "IAC" in your MIDI programs' list of MIDI ports, enable it by opening Audio MIDI Setup, then showing MIDI Studio, double click on the "IAC Driver" icon, and ensure it is set to "Device is online."
Tulip Desktop macOS's SYSEX handling only works on macOS 14.0 (Sonoma, released 2023) and later.
On Tulip Web, MIDI (including SYSEX) "just works" in many browsers, but not Safari.
You can also send MIDI messages "locally", e.g. to a running Tulip program that is expecting hardware MIDI input, via tulip.midi_local()
def callback(m):
if(m[0]==144):
print("Note on, note # %d velocity # %d" % (m[1], m[2]))
midi.add_callback(callback)
midi.remove_callback(callback) # turns off callback
def callback(message):
print(message[0]) # first byte of MIDI in message
tulip.midi_out((144,60,127)) # sends a note on message
# tulip.midi_out(bytes) # Can send bytes or list
tulip.midi_local((144, 60, 127)) # send note on to local busMIDI realtime sync is off by default — Tulip's sequencer keeps its own tempo, and no realtime messages are sent.
Use tulip.external_midi_sync(x) to control this:
tulip.external_midi_sync(False) # default: internal clock; ignore and don't send realtime messages
tulip.external_midi_sync(True) # follow external MIDI realtime sync (Tulip is a clock slave)
tulip.external_midi_sync(send=True) # send MIDI realtime sync (Tulip is the clock master)When following (True, or mode 1):
- MIDI
F8(Timing Clock) drives external tempo sync for the sequencer. - MIDI
FA(Start) starts the sequencer. - MIDI
FC(Stop) stops the sequencer.
When sending (send=True, or mode 2):
- Tulip sends
F8(Timing Clock) at 24 PPQ derived from its owntulip.seq_bpm()tempo, out the configured MIDI interface. Clock keeps flowing even while the sequencer transport is stopped, so downstream gear stays tempo-locked. FA(Start) /FC(Stop) are sent when the sequencer transport starts or stops (e.g.sequencer.start()/sequencer.stop()).
Tulip has special handling for MIDI sysex messages. Because of Tulip's memory constraints, we do not return SYSEX messages in tulip.midi_in(). We do always parse SYSEX messages for AMY-over-SYSEX, this allows you to send AMY wire messages over MIDI.
If you want to receive and parse MIDI sysex messages in Tulip, set a midi.sysex_callback. Like so:
def scb(message):
print("Received sysex message of %d bytes" % (len(message)))
midi.sysex_callback = scbThen, any MIDI SYSEX message will call this function with the message as the parameter. We limit SYSEX messages to 16KB at a time.
If you do not set a sysex_callback, we will not parse any SYSEX messages other than AMY-over-SYSEX. Your midi_in function will never receive SYSEX messages.
To send SYSEX messages, just use midi_out like normal: tulip.midi_out([0xf0, 0x01, 0x02, 0x03, 0xf7]).
See the music tutorial for a LOT more information on music in Tulip.
You can send an AMY message over MIDI on Tulip. This allows you to control another AMY device over a MIDI connection (USB or UART). You can easily route any AMY messages over MIDI SYSEX using midi.sysex_amy:
amy.override_send = midi.sysex_amy
amy.reset()
amy.send(osc=0, vel=1, freq=440) # will send this message over SYSEXAny connected AMY device (AMYboard, Tulip, Python on a computer) will respond to this message.
The Tulip GPU consists of 3 subsystems, in drawing order:
- A bitmap graphics plane (BG), a margin larger than the screen (1024+128 x 600+100 on a Tulip CC, 1280+160 x 720+120 on a Tab5), with scrolling x- and y- speed registers. Drawing shape primitives and UI elements draw to the BG.
- A text frame buffer (TFB) that draws 8x12 fixed width text on top of the BG, with 256 colors
- A sprite layer on top of the TFB (which is on top of the BG). The sprite layer is fast, doesn't need to have a clear screen, is drawn per scanline, can draw bitmap color sprites.
The Tulip GPU runs at a fixed FPS depending on the resolution and display clock. You can change the display clock but will limit the amount of room for sprites and text tiles per line. The default for Tulip CC is 28Mhz, which is 34FPS. This is a great balance of speed and stability for text -- the editor and REPL.
You can set a python callback for the frame done interrupt, for use in games or animations.
# returns current GPU usage computed on the last 100 frames, as a percentage of max
usage = tulip.gpu()
# returns current FPS, based on the display clock
fps = tulip.fps()
# resets all 3 GPU systems back to their starting state, clears all BG and sprite ram and clears the TFB.
tulip.gpu_reset()
# Get or set the display clock in MHz. Current default is 18.
# Higher clocks mean smoother animations, but less time for the CPU to prepare things to draw
clock = tulip.display_clock()
tulip.display_clock(mhz)
# Convenience function for getting the screen width and height,
# which are just the first two values returned by tulip.timing()
(WIDTH, HEIGHT) = tulip.screen_size()
# if the display clock gets in a strange state, you can restart it by just
tulip.display_restart() # does not clear any data like gpu_reset()
# You can also manually stop and start the display. This is useful if you want to do something intensive
# that requires the resources of the GPU as well as the CPU, or want to do faster disk access
tulip.display_stop() # Tulip will still run
tulip.display_start()
# Sets a frame callback python function to run every frame
# See the game mode in UIScreen for an easier way to make games
game_data = {"frame_count": 0, "score": 0}
def game_loop(data):
update_inputs(data)
check_collisions(data)
do_animation(data)
update_score(data) # etc
data["frame_count"] += 1
tulip.frame_callback(game_loop, game_data) # starts calling the callback every frame
tulip.frame_callback() # disables the callback
# Sets the screen brightness, from 1-9 (9 max brightness.) 5 is default.
tulip.brightness(5)
# Show the GPU usage (frames per second, time spent in GPU) at the next GPU epoch (100 frames) in stderr
tulip.gpu_log()The background plane (BG) is the screen plus a margin of an eighth of its width and a sixth of its height, never less than 128 x 100. On a Tulip CC that is 1024 + 128 x 600 + 100 with the visible portion 1024x600; on a Tab5 it is 1280 + 160 x 720 + 120 around a visible 1280x720. (You can change the visible portion with tulip.timing().) Use the extra for double buffering, hardware scrolling or for storing bitmap data "offscreen" for later blitting (you can treat it as fixed bitmap RAM.) The BG is drawn first, with the TFB and sprite layers drawn on top.
Horizontal scrolling only reaches into that margin: an x_offset past the margin width runs a line's read off the end of its row in the plane and into the next one, which shows up as the picture shearing by a row. To loop a background seamlessly, copy the leftmost margin-width of columns to the far right of the plane and reset the offset to 0 when it reaches the margin width. Vertical scrolling has no such limit — y_offset picks a whole row, so it wraps cleanly over the full height of the plane.
The UI operations (LVGL or anything in tulip.UI) also draw to the BG. Be careful if you're using both BG drawing operations and LVGL as they may draw on top of one another.
Tulip uses RGB332, with 256 colors. Here's the palette:
On the Tab5 the BG plane is RGB565 (65,536 colors). Everything below still
takes the 0-255 palette index, and a palette index draws the same color it does
elsewhere, so programs written for the palette run unchanged. In addition, any
color argument -- pal_idx in the calls below, and the fg / bg of
tfb_str -- can be an (r, g, b) tuple of 0-255 values, which picks the color
directly. Ints outside 0-255 are refused. bg_png keeps a PNG's full color, so
there is no need to reduce it to 255 colors first; bg_pixel(x, y) still
returns the nearest palette index, and bg_pixel_rgb(x, y) reads the pixel
exactly; the raw bytes of bg_bitmap and sprite_bitmap are two bytes a pixel
(RGB565, little-endian) instead of one; and screenshot() writes an RGB PNG.
The transparent color is still palette entry 0x55.
# Tab5 only: true color, alongside the palette
tulip.bg_rect(10, 10, 100, 100, (255, 128, 0), 1)
tulip.bg_pixel(x, y, (30, 144, 255))
(r, g, b) = tulip.bg_pixel_rgb(x, y)
tulip.tfb_str(0, 0, "hello", 0, (255, 255, 0), (0, 0, 80))# Set or get a pixel on the BG
pal_idx = tulip.bg_pixel(x,y)
tulip.bg_pixel(x,y,pal_idx) # pal_idx is 0-255 for 8-bit RGB332 mode
# Convert between packed palette color and r,g,b
pal_idx = tulip.color(r,g,b)
(r,g,b) = tulip.rgb(pal_idx)
# Set the contents of a PNG file on the background.
# To save RAM and disk space, I recommend converting your PNG to 255 colors before moving to Tulip
# Imagemagick does this with: convert input.png -colors 255 output.png
# Or with dithering: convert input.png -dither FloydSteinberg -colors 255 output.png
png_file_contents = open("file.png", "rb").read()
tulip.bg_png(png_file_contents, x, y)
# Or use the png filename directly
tulip.bg_png(png_filename, x, y)
# Copy bitmap area from x,y of width,height to x1, y1
tulip.bg_blit(x,y,w,h,x1, y1)
# If you give blit an extra parameter it will not copy over alpha color (0x55), good for blending BG images
tulip.bg_blit(x,y,w,h,x1, y1, 1)
# Sets or gets a rect of the BG with bitmap data (RGB332 pal_idxes)
tulip.bg_bitmap(x, y, w, h, bitmap)
bitmap = tulip.bg_bitmap(x, y, w, h)
# Clear the BG with a color or default
tulip.bg_clear(pal_idx)
tulip.bg_clear() # uses default
# Drawing primitives. These all write to the BG.
# If you want to use them for sprites, you can use bg_bitmap after drawing offscreen.
# set filled to 1 if you want the shape filled, 0 or omit otherwise
tulip.bg_line(x0,y0, x1,y1, pal_idx, [width])
tulip.bg_bezier(x0,y0, x1,y1, x2,y2, pal_idx)
tulip.bg_circle(x,y,r, pal_idx, filled) # x and y are the center
tulip.bg_roundrect(x,y, w,h, r, pal_idx, filled)
tulip.bg_rect(x,y, w,h, pal_idx, filled)
tulip.bg_triangle(x0,y0, x1,y1, x2,y2, pal_idx, filled)
tulip.bg_fill(x,y,pal_idx) # Flood fill starting at x,y
tulip.bg_str(string, x, y, pal_idx, font) # same as char, but with a string. x and y are the bottom left. font is a number, 0-18
tulip.bg_str(string, x, y, pal_idx, font, w, h) # Will center the text inside w,h
"""
Set scrolling registers for the BG.
line is visible line number (0-599).
x_offset sets x position pixels to offset for that line (default is 0)
y_offset sets y position pixels to offset for that line (default is the line number)
x_speed is how many pixels a frame to add to x_offset (default is 0)
y_speed is how many pixels a frame to add to y_offset (default is 0)
For example, to scroll the BG up two pixels a frame
for i in range(600):
tulip.bg_scroll(i, 0, i, 0, -2)
"""
tulip.bg_scroll(line, x_offset, y_offset, x_speed, y_speed)
tulip.bg_scroll() # resets to default
# Change individual registers
tulip.bg_scroll_x_speed(line, x_speed)
tulip.bg_scroll_y_speed(line, y_speed)
tulip.bg_scroll_x_offset(line, x_offset)
tulip.bg_scroll_y_offset(line, y_offset)
# "Swap" the visible BG with the one to its right, using the scrolling registers
# This would make 1024,0 the top left BG pixel after the first call to swap, and 0,0 after the second call to swap
tulip.bg_swap()There are three types of fonts built into Tulip.
- TFB fonts: we ship 6 fixed-size fonts for the TFB (see below). You can switch them at runtime with
tulip.tfb_font(). - LVGL fonts: LVGL ships a few fonts like
lv.font_montserrat_12. These fonts have more glyphs, can handle some unicode characters, and also ship with symbols (like the ones we show in the Tulip launcher). - Tulip fonts: we ship 20 fonts to use with
bg_stretc, and they can also be used in LVGL widgets by referencing them likelv.tulip_font_13.
Font 19 is the Japanese one, and the only Tulip font that is not ASCII-only. It is
efont Biwidth 16, a public domain bitmap face descended from the Japanese 東雲
(Shinonome) fonts, in which halfwidth Latin at 8x16 and fullwidth Japanese at 16x16
are one design -- so a line mixing the two shares a box, a baseline and a stroke
weight rather than looking like two fonts glued together. It carries all of ASCII,
hiragana, katakana, and 3449 kanji: the 常用漢字 and 人名用漢字 and then some, but
not all of JIS X 0208. A character it does not have is drawn as 〓, the Japanese
convention for a character that could not be set, so the column count stays right.
Board support is compiled in: Tab5, Tulip Desktop and Tulip Web have it; the
ESP32-S3 boards do not, and tulip.tfb_font(3) raises there.
The TFB supports 6 built-in fixed-width fonts, switchable at runtime with tulip.tfb_font(x):
0: default 8x12 font1: small 6x8 font2: big 12x16 font3: Japanese 16 dot -- 8x16 halfwidth cells, fullwidth characters spanning two of them4: the same face pixel doubled -- 16x32 halfwidth cells, 32x32 fullwidth5: font2with Japanese in it -- 12x16 halfwidth cells, 24x16 fullwidth
Fonts 3, 4 and 5 are the ones that can show Japanese. A fullwidth character
takes two TFB cells, so tulip.tfb_str(x,y) reads the same character back from
either of them, and everything else -- scrolling, ANSI codes, the editor -- keeps
working on a grid of uniform cells. Font 4 is font 3 doubled rather than a
second face at 32 dot, which is what keeps the exact 1:2 half-to-fullwidth ratio.
Font 5 is font 2's console with Japanese added: same 12x16 cells, same column
count, and everything CP437 can hold -- all of English, the box drawing, the
accents -- still drawn from the 12x16 face, so a line of English looks identical in
2 and 5. Only the Japanese comes from the Japanese face, at its own 16x16 in
the 24x16 box a cell pair gives it, centred rather than stretched half a pixel per
column: fullwidth Japanese is square, and widening it by half would thicken some
strokes of a kanji and not others. It is emboldened on the way through, because the
Japanese face strokes at 1px and the 12x16 Latin next to it at 2, and side by side
the Japanese otherwise reads a shade lighter than the English. Every stroke grows
one pixel to the right except where another stroke is immediately beyond it -- at
16px a dense kanji separates its strokes by a single pixel, and a smear that took
those would turn 電 and 器 into blocks. The cost is a little extra tracking between
Japanese characters; what it buys is a console that can start printing Japanese
without changing size.
You do not have to switch fonts by hand to see Japanese. The first time the console
is asked to print a character none of the CP437 fonts can draw, it switches itself
to whichever Japanese font has the cell size it is already using: font 5 from
font 2, font 3 from anywhere else. Nothing on screen moves when there is a
match, which matters most in an ssh session -- the far end was told a column count
at the start, and a console that silently changed its own would lay out every line
after that wrongly. Calling tulip.tfb_font() yourself turns the automatic switch
off for the rest of the session -- a font you chose out loud is never
second-guessed.
The TFB is a character plane for fast text drawing. The visible row/column count depends on the selected font size. It supports 256 ANSI colors for foreground and background, and supports formatting. TFB is used by the text editor and Python REPL.
# Sets a string / gets a character and/or format to the text frame buffer (TFB)
# (default geometry is 128x50 with font 0; changes with other TFB font sizes)
# Format has ANSI codes for reverse (0x80), underline (0x40), flash (0x20), bold (0x10)
# fg color is palette index, 0-255, same for bg color
# Note that the REPL and editor use the TFB
tulip.tfb_str(x,y, "string", [format], [fg], [bg])
(char, format, fg, bg) = tulip.tfb_str(x,y)
# ANSI color and formatting codes have convenience functions
print(tulip.Colors.LIGHT_RED + "this is red " + tulip.Colors.GREEN + tulip.Colors.INVERSE + " and then green inverse")
# To reset ANSI formatting
print(tulip.Colors.DEFAULT)
# Tulip REPL supports ANSI 256 color modes as well
print(tulip.ansi_fg(56))
# You can also stop or start the TFB. It will maintain what is on screen in memory, and you can still read/write it
tulip.tfb_stop()
tulip.tfb_start()
# If you want to keep the existing TFB around, you can save it to a temporary buffer and recall it
tulip.tfb_save()
tulip.tfb_restore()
# The console can also be driven as a terminal rather than as a printer, which
# is what the ssh app does with a remote shell: cursor addressing, a scroll
# region, insert and delete, an alternate screen, and the DEC line-drawing set.
# The session borrows the screen: whatever was on the console is put away at
# the start and comes back at the end.
tulip.term_start()
tulip.term_stop()
# Pass False when the console is only changing hands for a while -- the task bar
# switching apps -- rather than a session starting or ending. The two screens
# trade places and the terminal keeps its scroll region, modes and alternate
# screen to come back to.
tulip.term_stop(False) # something else gets the console
tulip.term_start(False) # and the session gets it back
# What the terminal has been told about how to encode keys, as a bitmask:
# 1 application cursor keys, 2 application keypad, 4 bracketed paste, 8 mouse
# reporting. ssh.key_bytes(key, flags) turns a Tulip key code into the bytes to
# send with that in mind.
flags = tulip.term_flags()
# What the terminal owes the other end -- an answer to "what are you" or "where
# is your cursor". It has no idea where to send them, so whatever is running the
# session collects them and writes them back.
tulip.term_reply()
# In terminal mode a \n moves down and keeps the column, the way a terminal's
# line feed does; it is the pty on the other end that puts the \r in front of
# it. Outside terminal mode the console behaves as it always has.
# Set/get TFB font number
# 0=8x12, 1=6x8, 2=12x16, 3=Japanese 16 dot, 4=Japanese 16 dot at 2x,
# 5=Japanese in font 2's 12x16 cells
# 3, 4 and 5 raise ValueError on a board built without the Japanese font.
tulip.tfb_font(x)
font_num = tulip.tfb_font()
# Japanese needs no setup -- printing it switches the console to a font that can
# draw it, keeping the cell size it already had where there is one that matches.
print("日本語と English が混在する行。ABCdefg 0123")
tulip.tfb_font(4) # same face, twice the size: 80x22 on a Tab5
tulip.tfb_font(5) # Japanese at font 2's size: 106x45 on a Tab5You can have up to 32 bitmap sprites on screen at once, and have 32KB of bitmap data to store them in. Sprites have collision detection built in. Sprites are drawn in order of sprite index, so sprite index 5 will draw on top of sprite index 3 if they share pixel space.
# Load the data from a PNG file into sprite RAM at the memory position (0-32767).
# Returns w, h, and number of bytes used
# Alpha is used if given
(w, h, bytes) = tulip.sprite_png(png_data, mem_pos)
(w, h, bytes) = tulip.sprite_png("filename.png", mem_pos)
# Or load sprites in from a bitmap in memory (packed pallete indexes for RGB332)
# The bitmap can be made from code you wrote, or from bg_bitmap to sample the background
# Use pal idx 0x55 to denote alpha when generating your own sprites
bytes = tulip.sprite_bitmap(bitmap, mem_pos)
# Read bitmap data from sprite ram if you need to modify sprites or copy them to BG
bitmap = tulip.sprite_bitmap(mem_pos, length)
# "Register" a piece of sprite RAM into a sprite handle index for later use.
# Creates sprite handle #12 referencing sprite data starting at mem_pos and w,h pixels
tulip.sprite_register(12, mem_pos, w, h)
# Turn on a sprite to draw on screen
tulip.sprite_on(12)
# And off
tulip.sprite_off(12)
# Set a sprite x and y position
tulip.sprite_move(12, x, y)
# Every frame, we update a collision list of things that collided that frame
# Collisions are evaluated every scanline (left to right and top to bottom),
# and only on pixels that are written to the screen (not ALPHA, and must be visible)
# See world.download("collide") for an example
# Calling collisions() clears the memory of collisions we've kept up to that point.
for c in tulip.collisions():
(a,b) = c # a and b are sprite #s that collided. a will always < b.
# Check if a touch or mouse click hit a sprite by looking for sprite #31
if(b==31):
print("Touch/click on sprite %d" % (a))
# Clear all sprite RAM, reset all sprite handles
tulip.sprite_clear()You can access sprites using the tulip_sprite_X commands, or use our convenience Sprite class to manage memory and IDs for you:
class Bullet(tulip.Sprite):
def __init__(self, copy_from=None):
super().__init__(copy_from=copy_from)
self.load("bullet.png", 32, 32)
b.on()
b.move_to(20,20)
b = Bullet()
b.off() # turn off
b.on() # turns on
b.x = 1025
b.clamp() # ensure it is within screen range
b.move() # move to its most recent position
b.move_to(x,y) # sets x and y and moves
b2 = Bullet(copy_from=b) # will use the image data from b but make a new sprite handle
b2.move_to(25,25) # Can have many sprites of the same image data on screen this wayA Player class comes with a quick way to move a sprite from the keyboard:
p = tulip.Player(speed=5) # 5px per movement
p.load("me.png", 32, 32)
p.joy_move() # will update the position based on the joystickThe Tab5 has a 2 megapixel camera on its front (an SC2356 on the ESP32-P4's
MIPI-CSI port). Tulip reads it at 1280x720, which is also the size of the
screen, as RGB565 -- the same 16-bit layout LVGL uses on the Tab5, so a frame
can go straight into an lv.image. The sensor's own auto exposure and white
balance run in the background once the camera is started.
tulip.camera_start() # powers the sensor and starts streaming at 30 fps
tulip.camera_running() # True while streaming
tulip.camera_info() # {'sensor': 'SC202CS', 'width': 1280, 'height': 720,
# 'format': 'RGB565', 'fps': 30, 'frames': 1289,
# 'errors': 0, 'dropped': 12, 'hflip': False, 'vflip': False, ...}
# dropped: frames handed back unread because you were still
# reading the newest one (a long camera_bg() does that)
# gain / exposure / red_balance / blue_balance: what the ISP's
# auto exposure and white balance have settled on (-1 when
# stopped). Sensor defaults are 10 / 750 / 1000 / 1000; if they
# stay there the picture is dark and green because the ISP
# controller never ran, not because of the colour conversion.
tulip.camera_stop() # stops streaming and frees the frame buffers (5.5 MB)The cheap way to see the picture is to draw it on the BG plane, converted to Tulip's RGB332. This is the live preview: call it from a frame callback and the BG follows the camera.
tulip.camera_bg() # the whole frame, full screen
tulip.camera_bg(x=640, y=0, w=640, h=360) # scaled into the top-right quartercamera_frame() gives you the pixels. With no arguments it returns a new
bytearray of 1280x720x2 bytes; pass w and h for a nearest-neighbour
scaled copy, and buf to fill a buffer you already have (it must be exactly
w*h*2 bytes) instead of allocating a new one each time. The result is what
an lv.image_dsc_t with cf=lv.COLOR_FORMAT.RGB565 wants:
buf = bytearray(640 * 360 * 2)
tulip.camera_frame(640, 360, buf=buf)
dsc = lv.image_dsc_t({'header': {'w': 640, 'h': 360, 'cf': lv.COLOR_FORMAT.RGB565}, 'data_size': len(buf), 'data': buf})
img = lv.image(tulip.current_uiscreen().group)
img.set_src(dsc)camera_wait() blocks until a frame newer than the last one you saw arrives
(up to timeout_ms, default 1000) and returns its sequence number, or None
on a timeout -- for a loop that wants every frame rather than the newest one
at the time it asks:
seq = None
while True:
seq = tulip.camera_wait(seq)
tulip.camera_bg(0, 0, 640, 360)Stills come out of camera_capture(). A filename ending in .jpg (through
the P4's hardware JPEG encoder, about 150 ms plus the flash write) or .png
(lodepng, 24-bit, and slow -- around 17 seconds for a full frame) writes the
file and returns the byte count; no filename returns the JPEG as bytes,
ready to upload or send:
tulip.camera_capture("/user/photo.jpg") # quality defaults to 80
tulip.camera_capture("/user/photo.jpg", quality=95)
tulip.camera_capture("/user/photo.png")
jpeg = tulip.camera_capture() # bytescamera_flip(hflip=, vflip=) mirrors the image on the sensor and returns the
current (hflip, vflip); the settings persist across camera_stop() /
camera_start(). camera_test_pattern(True) replaces the scene with the
sensor's built-in ramp -- black at the left edge to white at the right -- which
is how to check that frames arrive, in the right orientation and byte order,
without pointing the camera at anything. Its colour is not a reference: the
ramp passes through the ISP like a scene, and the auto white balance settles
on a tint for it (magenta, on the units measured). camera_test_pattern(False)
puts the scene back.
Errors come back as RuntimeError (camera is not running, or the ESP-IDF
error name for a failure to start, e.g. when no sensor answers) and ValueError
for a bad size, rectangle, quality or filename extension.
The Tab5 has two built-in microphones, fed through an ES7210 ADC that shares the
ESP32-P4's I2S bus with the speaker. Because the two run full-duplex off one
clock, the microphone is fixed at 44100 Hz, 16-bit, stereo -- the same rate
AMY plays at -- and each read gives you both mics interleaved as (left, right, left, right, ...) 16-bit samples. Recording and playback happen at once, so you
can capture while AMY is making sound.
tulip.mic_start() # program the ADC and start capturing (gain=30 dB by default)
tulip.mic_running() # True while capturing
tulip.mic_info() # {'running': True, 'sample_rate': 44100, 'channels': 2,
# 'bits': 16, 'gain': 30, 'blocks': 512, 'available': 3840,
# 'capacity': 22050, 'overruns': 0, 'read_errors': 0,
# 'peak_left': 284, 'peak_right': 273, 'initialized': True}
# available/capacity: frames waiting in the ring / its size
# (half a second). overruns counts frames dropped because
# nobody read them in time -- normal if you stop reading.
tulip.mic_stop() # stop capturing (the speaker keeps working)mic_read() pulls samples out of the ring as bytes (four bytes per frame: L
then R, little-endian int16). With no argument it returns whatever is buffered,
waiting up to timeout_ms (default 1000) for the first block if the ring is
empty, or None on a timeout. Pass frames to cap how many you take:
pcm = tulip.mic_read(frames=1024) # up to 1024 stereo frames as 4096 bytes
import array
samples = array.array('h', pcm) # signed 16-bit
left = samples[0::2]; right = samples[1::2]mic_level() returns the last block's peak for each mic as (left, right),
each 0.0-1.0 -- cheap, no data copied, for a VU meter or "is anyone
talking":
while True:
l, r = tulip.mic_level()
tulip.bg_rect(0, 100, int(l * 300), 20, (0, 255, 0), 1) # a simple level barmic_gain(db) sets the ADC input gain (0-37 dB, clamped) and returns it;
mic_gain() with no argument just reads it.
mic_record(seconds, filename=None, mono=False) blocks for seconds (up to 30)
and collects the audio. With a filename ending .wav it writes a finished WAV
file and returns the byte count; with no filename it returns the raw PCM as
bytes. mono=True averages the two mics into one channel:
tulip.mic_start()
tulip.mic_record(3.0, "/user/memo.wav") # 3 s stereo WAV
tulip.mic_record(3.0, "/user/memo.wav", mono=True) # 3 s mono WAV
pcm = tulip.mic_record(0.5) # raw stereo bytes
tulip.mic_stop()The rate is fixed, so for a lower-rate file (voice memos, speech recognition) decimate a copy in Python -- e.g. take every third frame for ~14.7 kHz.
While the mic is running, AMY also gets the newest block of it, so an
oscillator with wave=amy.AUDIO_EXT0 (left mic) or amy.AUDIO_EXT1 (right)
plays what that mic hears -- through AMY's envelopes, filters and effects like
any other oscillator, and at the level amp/vel ask for, same as any other
oscillator. (This is the Tab5's version of the audio-in the ESP32-S3 boards get
from AMY's own I2S capture; the Tab5 renders audio itself, so the samples are
handed over instead.)
Nothing reaches the speaker until you ask for such an oscillator, and it costs
the recording nothing: mic_read() and mic_record() still get every frame.
tulip.mic_start()
amy.send(osc=200, wave=amy.AUDIO_EXT0, vel=1) # live, left mic
amy.send(osc=200, filter_freq=800, resonance=4, filter_type=amy.FILTER_LPF)
amy.send(osc=200, vel=0) # stop passing it through
tulip.mic_stop() # the oscillator goes silentWatch out for feedback: the speaker sits a few centimetres from the mics, so
a loud pass-through howls. Keep amp low, or wear headphones -- measured on a
Tab5, amp=16 was already enough to take off.
Errors come back as RuntimeError (microphone is not running, or the ESP-IDF
error name for a failure to start) and ValueError for a bad frames,
seconds or filename extension.
The Tab5 has a built-in microSD slot. tulip.sd_mount() brings it up and mounts
it at /sd, after which it is a normal part of the filesystem -- use os,
open(), etc. against /sd/...:
tulip.sd_mount() # mount at /sd (4-bit mode); returns the mount path
tulip.sd_mounted() # True while mounted
import os
os.listdir("/sd")
open("/sd/hello.txt", "w").write("hi\n")
tulip.sd_info() # {'mount': '/sd', 'sectors': 61175808, 'sector_size': 512,
# 'capacity': 31322013696, 'total': ..., 'free': ...}
tulip.sd_unmount() # unmount and power the card downsd_mount(path="/sd", width=4, freq=20_000_000) -- width=1 is a slower but more
forgiving fallback if a card won't enumerate at 4-bit. Calling it again while
already mounted just returns the existing mount path (it does not remount).
sd_info() returns None when nothing is mounted; capacity is the raw card
size, free/total come from the mounted filesystem.
The card must be inserted before sd_mount() -- an empty slot raises
OSError("no SD card / init failed"). Under the hood this uses MicroPython's
machine.SDCard block device (pins CLK=43/CMD=44/D0-3=39-42, powered by the P4
on-chip LDO channel 4), not the BSP's FAT mount, so it coexists with Tulip's own
flash filesystem.
The Tab5 has a built-in Bosch BMI270 6-axis IMU (accelerometer + gyroscope).
tulip.imu() reads one sample and returns six floats:
ax, ay, az, gx, gy, gz = tulip.imu() # accel in g, gyro in degrees/secondThe accelerometer values are in g (at rest one axis reads about 1.0 from
gravity, so sqrt(ax*ax + ay*ay + az*az) is ~1.0); the gyroscope values are in
degrees/second (near zero when the board is still). The sensor is brought up on
the first call (accel and gyro at 100 Hz, ranges +/-4 g and +/-2000 dps) and
then polled on demand -- nothing runs until you ask for a sample. Read it as
often as you like, e.g. from a frame_callback, to detect tilt or motion:
import math
ax, ay, az, _, _, _ = tulip.imu()
# rough screen-tilt angle, degrees from flat
pitch = math.degrees(math.atan2(ay, math.sqrt(ax*ax + az*az)))If the IMU can't be reached (e.g. a board without it populated) imu() raises
RuntimeError.
Tab5 firmware bakes in AMY's TR-808 ROM sample set, so the GM drum synth on
channel 10 is a real 808 kit out of the box and needs nothing extra. The other
136 Gamma9001 bank presets (numbers 256-391: 909, LinnDrum, CR-78 and so on)
live in a separate 3.7 MB drums.bin, which the Tab5 loads into PSRAM:
frames = tulip.gamma9001_load("/sd/drums.bin")
amy.send(osc=0, wave=amy.PCM, preset=256, note=60, vel=1) # 909 bass drumBuild drums.bin with python3 -m amy.headers gamma9001 in the
amy repo -- it appears in amy/build/ --
and copy it to the board. At 3.56 MB a microSD card is the natural place for it.
Until you call this, presets 256+ fall back to preset 0; nothing is allocated
and nothing else changes. The blob loads once -- a second call returns the same
frame count rather than replacing the buffer, since a note may still be playing
out of it. Raises OSError if the file can't be opened, ValueError if it is
the wrong size, and MemoryError if PSRAM can't hold it.
See planet_boing in /sys/ex/ for a fleshed out example of using the Game and Sprite classes.
Things we've thought of we'd love your help on:
- Sprite editor in Tulip
- Tile / Map editor in Tulip



Chat about Tulip on our Discord!