Developer reference
Write an app for Solana OS
Solana OS runs one badge app at a time as a Lua script, with the whole device hanging off a
single badge global — graphics, the six buttons, the LEDs, storage, Wi-Fi, HTTP,
ESP-NOW, BLE and the on-board sensors. This is the reference for that runtime and its SDK.
Try it on the virtual badge → Solana OS in this tab, with the same runtime and the same limits. No hardware needed to write an app and watch it run.
Overview
An app is a directory of files pushed onto the badge. The runtime builds a fresh Lua state
for it at launch and tears that state down at stop, so nothing one app leaves behind can
reach the next. You write Lua callbacks — on_start, on_update, on_draw and a handful of event handlers — and the OS calls them; you never
run your own main loop.
Everything the badge can do is reached through the badge table. There is no require of a device library and no imports to wire up — badge.gfx, badge.storage, badge.wifi and the rest are already there when your
script starts.
- Runtime
- Lua 5.4, one app at a time
- Display
- 320×240, shared framebuffer
- Buttons
- up · down · left · right · a · b
- Heap per app
- 1 MB, from PSRAM
Anatomy of an app
An app is a directory on the badge filesystem:
/apps/gm/
app.ini metadata (optional)
main.lua entry point
... images, extra Lua modules, dataThe directory name is the app id — restricted to [a-z0-9._-],
1 to 32 characters. It is the handle the launcher, the push API and badge.system.launch() all use. Everything else about the app is optional: a
directory that is nothing but a main.lua is a valid app, named after its folder.
The app.ini manifest
app.ini is key=value, one per line; # and ; start a comment. Every field is optional.
name=GM
version=1.0.0
author=you
description=Says gm, in brand colours.
entry=main.lua| Field | Default | Meaning |
|---|---|---|
| name | directory id | Display name in the launcher. |
| version | — | Free-form version string. |
| author | — | Who wrote it. |
| description | — | One line describing the app. |
| entry | main.lua | The script the runtime loads. Validated as a safe relative path; an unsafe value falls back to main.lua. |
require works inside an app. package.path is set per app to /littlefs/apps/<id>/?.lua, then /littlefs/apps/<id>/?/init.lua, then a shared /littlefs/lib/?.lua — so require("util") finds util.lua next to your main.lua. There is no package.cpath; no dynamic C loading exists on the badge.
Lifecycle & frames
Your entry points are globals in your script. All of them are optional — the top level of
the script itself runs once before on_start(), which is enough for a script
that only prints something.
| Callback | When |
|---|---|
| on_start() | Runs once, right after the script loads. Set up state, claim the LEDs, probe hardware. |
| on_update(dt) | Every frame, before drawing. dt is seconds since the previous frame — advance timers and state here. |
| on_draw() | Every frame, after on_update. Draw the whole scene; the runtime pushes the framebuffer when it returns. |
| on_button(key, pressed) | One call per button edge. key is "up".."b"; pressed is true on press, false on release. |
| on_espnow(mac, data, rssi) | An incoming ESP-NOW application message. |
| on_ble(line) | A line of BLE text, once you have called ble.listen(). |
| on_stop() | Runs before the app is torn down. Release anything you want cleaned up gracefully. |
Within a frame the runtime works in a fixed order:
You never call a present or swap. Everything draws into a shared PSRAM
framebuffer and the runtime pushes it to the panel once on_draw() returns,
which is why apps are flicker-free without doing anything about it. To end an app and
return to the launcher, call badge.system.exit() — it is deferred to the next
frame, so it is safe from anywhere, including deep inside on_draw().
Your first app
Here is a complete, installable app. Save it as main.lua in a folder named gm, add the app.ini above, and push the folder to a badge. It
draws a pulsing greeting in the brand gradient and quits on CANCEL.
-- apps/gm/main.lua
local g = badge.gfx
local t = 0
function on_start()
badge.led.take() -- stop the launcher's LED animation
badge.log("gm from " .. badge.device_name)
end
function on_update(dt)
t = t + dt
end
function on_draw()
g.clear(g.BG)
-- a gradient rule across the top: the cheapest way to look native
for x = 0, g.width() - 1 do
g.line(x, 0, x, 3, g.gradient(x / g.width()))
end
local pulse = (math.sin(t * 3) + 1) / 2
g.text_center("gm", g.width() // 2, 96, g.gradient(pulse), 4)
g.text_center("CANCEL to quit", g.width() // 2, 200, g.MUTED)
end
function on_button(key, pressed)
if key == "b" and pressed then
badge.system.exit() -- back to the launcher
end
endThree things are worth noticing. on_draw() clears and repaints the whole
scene every frame — there is no partial redraw to manage. gfx.gradient(t) walks the Solana purple-to-green ramp, which is the quickest way to make an app look like
it belongs on the badge. And on_button switches on the logical key name
("b"), while anything shown on screen uses the silkscreen label — input.label("b") is "CANCEL", because nothing on the board is
marked b.
To persist anything across reboots, reach for the namespaced key/value store rather than a file — it survives a filesystem reflash:
-- remember a high score across reboots
local best = tonumber(badge.storage.kv.get("best", "0"))
function record(score)
if score > best then
best = score
badge.storage.kv.set("best", tostring(best))
end
endSDK reference
Everything hangs off the badge global. A few shortcuts sit at the top level;
the rest is grouped into the namespaces below. Optional arguments are shown in [brackets] with their defaults.
| Top level | Meaning |
|---|---|
| badge.version | OS version string, currently "0.1.0". |
| badge.api_version | Integer; bumps when a binding changes shape. Currently 1. |
| badge.device_name | The badge's current name, the same value badge.system.name() returns. |
| badge.log(...) | Like print(), but every line is tagged with the app id in the console. |
| badge.millis() | Shortcut for badge.system.millis(). |
| badge.sleep(ms) | Shortcut for badge.system.sleep(ms). |
badge.gfx
Drawing into the shared 320×240 framebuffer. The runtime pushes it to the panel once on_draw() returns, so you never flush or double-buffer yourself.
| Function | Returns | Description |
|---|---|---|
| gfx.width() | int | Canvas width in pixels (320). |
| gfx.height() | int | Canvas height in pixels (240). |
| gfx.clear([color]) | — | Fill the whole screen. Defaults to the theme background (BG). |
| gfx.pixel(x, y [, color]) | — | Set one pixel. |
| gfx.line(x1, y1, x2, y2 [, color]) | — | A straight line. |
| gfx.rect(x, y, w, h [, color]) | — | Rectangle outline. |
| gfx.fill_rect(x, y, w, h [, color]) | — | Filled rectangle. |
| gfx.round_rect(x, y, w, h [, radius=4] [, color]) | — | Rounded-rectangle outline. Radius is clamped to the canvas. |
| gfx.fill_round_rect(x, y, w, h [, radius=4] [, color]) | — | Filled rounded rectangle. |
| gfx.circle(x, y, r [, color]) | — | Circle outline. Radius clamped to the canvas. |
| gfx.fill_circle(x, y, r [, color]) | — | Filled circle. |
| gfx.triangle(x1, y1, x2, y2, x3, y3 [, color]) | — | Triangle outline. |
| gfx.fill_triangle(x1, y1, x2, y2, x3, y3 [, color]) | — | Filled triangle. |
| gfx.text(str, x, y [, color] [, size=1]) | — | Draw text with its top-left at (x, y). Size is an integer scale. |
| gfx.text_center(str, x, y [, color] [, size=1]) | — | Text centred horizontally on x. |
| gfx.text_right(str, x, y [, color] [, size=1]) | — | Text right-aligned to x. |
| gfx.text_width(str [, size=1]) | int | Pixel width the string would occupy. |
| gfx.text_height([size=1]) | int | Pixel height of a line at this size. |
| gfx.color(r, g, b) | int | Build an RGB565 colour from 8-bit components (each clamped 0..255). |
| gfx.hsv(h [, s=1] [, v=1]) | int | RGB565 from hue in degrees and s/v in 0..1. Handy for cycling colour. |
| gfx.gradient(t) | int | A point on the Solana ramp: t=0 is brand purple, t=1 is brand green. |
| gfx.image(path, x, y [, scale=1]) | ok [, reason] | Draw a PNG or JPEG from the app directory. Returns false + reason on any failure (max 256 KB). |
| gfx.image_size(path) | w, h | nil, reason | Read just the header to measure an image without decoding it. |
| gfx.brightness([value]) | int | Screen backlight 0..255. Reads the current value when called with no argument. |
| gfx.flush() | — | Push the framebuffer to the panel immediately. Only needed when you draw outside on_draw(), e.g. a loading screen inside on_start(). |
Constants
- SOLANA_PURPLE, SOLANA_GREEN, SOLANA_TEAL, SOLANA_MAGENTAThe brand palette.
- BLACK, WHITE, BG, PANEL, BORDER, MUTEDTheme colours the OS uses for its own chrome.
- RED, ORANGE, YELLOW, GREEN, CYAN, BLUEThe usual primaries.
- Colours are RGB565 integers. Use gfx.color(r, g, b) to build one, or the named constants above.
- Image paths resolve inside the app directory and cannot escape it — the same rule badge.storage follows.
badge.input
The six buttons. Keys are named "up", "down", "left", "right", "a", "b" — the numeric constants are accepted too, but the names are what on_button() delivers.
| Function | Returns | Description |
|---|---|---|
| input.down(key) | bool | True while the key is held (level). |
| input.pressed(key) | bool | True on the frame the key went down (edge). |
| input.released(key) | bool | True on the frame the key came up (edge). |
| input.repeated(key) | bool | True on each auto-repeat tick while held. |
| input.held_ms(key) | int | Milliseconds the key has been held, 0 if up. |
| input.any() | bool | True if any button is currently held. A cheap "press any key". |
| input.keys() | array | The six key names, in order. |
| input.label(key) | string | The silkscreen word for a key, e.g. "SELECT" — what to print in an on-screen hint. |
| input.present() | bool | Whether the button hardware is wired on this board. |
Constants
- UP, DOWN, LEFT, RIGHT, A, BNumeric key indices (0..5).
- COUNTNumber of buttons (6).
- "a" and "b" name nothing printed on the board — the silkscreen reads SELECT and CANCEL. Switch on the key name in code, but show input.label(key) on screen.
- Polling (input.down) and the on_button(key, pressed) callback both work; a game usually wants polling, a menu usually wants the callback.
badge.led
The two WS2812B RGB LEDs. Writes are buffered — led.set()/all() stage a colour and led.show() latches both LEDs in one pass.
| Function | Returns | Description |
|---|---|---|
| led.set(index, r, g, b) | — | Stage a colour for LED 0 or 1 (0..255 per channel). |
| led.all(r, g, b) | — | Stage the same colour on both LEDs. |
| led.gradient(index, t [, intensity=1]) | — | Stage a point on the Solana ramp (t=0 purple, t=1 green), scaled by intensity. |
| led.show() | — | Latch the staged colours to the LEDs. |
| led.off() | — | Turn both LEDs off. |
| led.pulse(r, g, b [, ms=400]) | — | A self-decaying flash driven by the OS animation tick — no per-frame work needed. |
| led.brightness([value]) | int | Global LED brightness 0..255; reads when called with no argument. |
| led.take() | — | Cancel any OS animation (boot sequence, idle breathing) so the app owns the LEDs. |
Constants
- COUNTNumber of RGB LEDs (2).
- The launcher runs an idle animation on the LEDs. Call led.take() in on_start() before you drive them yourself, or the OS animation will fight your writes.
badge.system
Device info, timing, entropy and the app lifecycle. millis() and sleep() are also mirrored on badge directly.
| Function | Returns | Description |
|---|---|---|
| system.millis() | int | Milliseconds since boot. |
| system.uptime() | number | Seconds since boot, as a float. |
| system.sleep(ms) | — | Yield the CPU for ms (capped at 2000). Pushes the callback deadline out so a deliberate wait is not killed as a runaway loop. |
| system.heap() | int | Free internal-SRAM heap, in bytes. |
| system.psram() | int | Free PSRAM, in bytes. |
| system.lua_memory() | used, limit | Bytes of the app's own Lua heap cap in use, and the cap. |
| system.chip() | table | Table of { model, revision, mhz, flash_bytes, psram_bytes }. |
| system.name([new_name]) | string | Get, or set, the device name. A new name must be 1..23 printable-ASCII characters or the call errors. |
| system.reboot() | — | Restart the badge. |
| system.exit() | — | End the app and return to the launcher. Deferred to the next frame, so it is safe to call from anywhere. |
| system.launch(id) | — | Hand control to another installed app. Deferred; errors if there is no such app. |
| system.apps() | array | Every installed app as { id, name, version, author, description, bytes }. |
| system.current_app() | string | The running app id. |
| system.random_bytes([n=32]) | string | n bytes (1..256) from the ESP32-S3 hardware TRNG. For a certified source use badge.se050.random(). |
- system.name() feeds BLE, mDNS, the SoftAP SSID and the ESP-NOW beacon, which is why the character set is restricted.
- exit() and launch() are requests applied between frames — the current callback runs to completion first.
os (curated)
A trimmed os table is installed as a global. The stock os library is not opened — execute/remove/rename/exit are gone — but the timekeeping calls that library code relies on are kept.
| Function | Returns | Description |
|---|---|---|
| os.time() | int | Seconds since the epoch. With no RTC and usually no NTP this is seconds since boot, unless something has set the clock. |
| os.clock() | number | Seconds since boot (millis()/1000). |
| os.date([format] [, time]) | string | table | A curated strftime. "*t" returns a table of fields; a leading "!" formats in UTC. Unknown specifiers are rejected rather than passed to C. |
| os.difftime(t2 [, t1=0]) | number | Difference between two os.time() values, in seconds. |
- io, os (beyond these four) and debug are not available — see the Sandbox section.
badge.storage
Files and a key/value store, both scoped to the app's own directory. Paths are relative — no leading slash, no ".." — and cannot leave /apps/<your id>/.
| Function | Returns | Description |
|---|---|---|
| storage.read(path) | string | nil, err | Read a whole file. Returns nil + reason if missing, or if it is larger than 64 KB (read big files in chunks with storage.size). |
| storage.write(path, data) | ok [, err] | Write (truncating). Creates any missing parent directory. |
| storage.append(path, data) | ok [, err] | Append to the end of a file, creating it if needed. |
| storage.exists(path) | bool | Whether the file or directory exists. |
| storage.size(path) | int | nil | File size in bytes, or nil if it is missing. |
| storage.remove(path) | bool | Delete a file. |
| storage.mkdir(path) | bool | Create a directory. |
| storage.list([subdir]) | array | nil, err | Sorted names in the app directory (or a subdirectory); directories carry a trailing slash. Up to 64 entries. |
| storage.space() | used, total | Bytes used and total across the whole filesystem (not just this app). |
- Path length is capped at 96 characters. A path that starts with "/" or contains ".." is rejected before it touches the filesystem.
- A single storage.read() returns at most 64 KB; for anything larger, page through it with storage.size() and your own offsets.
badge.storage.kv
A small NVS-backed key/value store, namespaced per app so two apps using the same key never collide. It survives a reflash of the filesystem, which makes it the right home for settings.
| Function | Returns | Description |
|---|---|---|
| kv.get(key [, default]) | string | default | Read a value. Returns the default (or nil) if the key was never set — which is how you detect first run. |
| kv.set(key, value) | bool | Store a string value. |
| kv.remove(key) | bool | Delete a key. |
- NVS caps a full key at 15 characters and the per-app namespace prefix eats 7 of them, so your key may be at most 8 characters. An over-long key raises an error naming the limit.
- The namespace is a hash of the full app id, so one app cannot read another app's keys.
- Values are strings. Serialise numbers or tables yourself (tostring, or a small encoder).
badge.battery
The battery gauge.
| Function | Returns | Description |
|---|---|---|
| battery.volts() | number | Pack voltage. |
| battery.percent() | number | Estimated charge, 0..100. |
| battery.charging() | bool | Whether the charger is active. |
badge.mic
The two PDM microphones. They are off until an app asks for them — the I2S DMA ring is idle otherwise — and the runtime turns them off again when the app stops.
| Function | Returns | Description |
|---|---|---|
| mic.enable([on=true]) | bool | Turn the microphones on or off. |
| mic.enabled() | bool | Whether audio capture is running. |
| mic.level() | left, right | Smoothed levels 0..100 — meant for meters. |
| mic.db() | left, right | Unsmoothed level in dBFS, roughly −58..0. |
| mic.read([n=256]) | table | A table of interleaved L/R 16-bit samples (n clamped 1..1024). |
badge.se050
The NXP SE050 secure element. Its RNG is certified hardware, and this namespace never silently falls back to anything else — a caller who asks for the secure element gets it or gets an error.
| Function | Returns | Description |
|---|---|---|
| se050.present() | bool | Whether the part answered at all. |
| se050.atr() | string | nil | The element's ATR as hex, or nil. |
| se050.test() | bool | Re-run the link test (~20 ms). A deliberate call, not something to poll each frame. |
| se050.random([n=32]) | string | nil, reason | n bytes (1..64) from the secure element, or nil + reason. No fallback — failure is the answer. |
| se050.random_available() | bool | An end-to-end probe: asks the part for one real byte. Costs a full exchange, so probe once and remember. |
badge.wifi
Wi-Fi. connect() returns immediately — joining takes seconds, and blocking the main loop would stall the display and the other radios — so you poll wifi.connected() from on_update().
| Function | Returns | Description |
|---|---|---|
| wifi.connect(ssid [, password]) | bool | Start joining a network. Does not overwrite the badge's saved network. |
| wifi.connect_enterprise{ ssid=, username=, password=, ca=, domain=, method=, identity=, phase2= } | ok [, reason] | WPA2-Enterprise, taken as a table. method defaults to "peap", phase2 to "mschapv2". |
| wifi.enterprise() | bool | Whether the current association is enterprise. |
| wifi.disconnect() | — | Drop the connection. |
| wifi.connected() | bool | Whether the badge has an association and an IP. |
| wifi.status() | string | Human-readable status text. |
| wifi.ip() | string | Current IP address. |
| wifi.ssid() | string | The SSID currently joined. |
| wifi.rssi() | int | Signal strength of the current link. |
| wifi.mac() | string | This badge's Wi-Fi MAC, lowercase (matches the ESP-NOW convention). |
| wifi.channel() | int | Current Wi-Fi channel. |
| wifi.scan() | bool | Kick off an async scan. |
| wifi.scanning() | bool | Whether a scan is still running. |
| wifi.networks() | array | Scan results as { ssid, rssi, encrypted }. |
| wifi.hotspot([password]) | bool | Bring up a SoftAP so a laptop or phone can reach the badge. |
- An app can join a network for its own use, but it cannot repoint the badge — the saved network is the user's.
- wifi.connect_enterprise names a CA certificate installed on the badge; an unknown name is the usual cause of a refused association.
badge.http
Blocking HTTP. There is no sane non-blocking shape for a request inside a callback, so get/post block — but they extend the runtime deadline by their own timeout so a slow request is not mistaken for a hung app. Timeout is capped at 10 s.
| Function | Returns | Description |
|---|---|---|
| http.get(url [, timeout_ms=5000]) | status, body | nil, err | GET a URL. Returns the HTTP status and body, or nil + a transport error. |
| http.post(url [, body=""] [, content_type="text/plain"] [, timeout_ms=5000]) | status, body | nil, err | POST a body. Same return shape as get(). |
- Both return nil + "wifi not connected" if there is no link — check wifi.connected() first.
- timeout_ms is clamped to 100..10000. The deadline extension only stretches so far (12 s per callback), so one request per callback is the safe pattern.
badge.espnow
Badge-to-badge messaging with no access point in between. Application messages arrive as on_espnow(mac, data, rssi); the presence beacons behind espnow.peers() are handled by the OS and never surface as app messages.
| Function | Returns | Description |
|---|---|---|
| espnow.enable([on=true] [, channel]) | bool | Start or stop ESP-NOW. Channel defaults to the badge setting. |
| espnow.enabled() | bool | Whether ESP-NOW is running. |
| espnow.channel() | int | The active channel. |
| espnow.broadcast(data) | bool | Send to every badge in range. Payload up to MAX_PAYLOAD (240) bytes. |
| espnow.send(mac, data) | bool | Send to one peer by MAC ("aa:bb:cc:dd:ee:ff"). |
| espnow.peers() | array | Known peers as { mac, name, rssi, age_ms, packets }, strongest signal first. |
| espnow.signal(mac) | rssi, age_ms | nil | Signal for one peer, or nil if it has not been heard. |
| espnow.beacon([on]) | bool | Whether this badge announces itself. Turn it off to see others without being seen. |
| espnow.clear() | — | Forget the peer table. |
Constants
- MAX_PAYLOADLargest broadcast/send payload in bytes (240).
- MAX_PEERSPeer table capacity (20).
- MACs from peers() and on_espnow() are lowercase and match wifi.mac(), so you can filter out your own broadcasts by comparing them.
badge.ble
The Nordic UART link. Calling ble.listen() claims it: incoming lines start arriving as on_ble(line) instead of being read as app-push commands. The runtime releases the claim when the app stops, which is what lets a phone push a new app again.
| Function | Returns | Description |
|---|---|---|
| ble.enable([on=true] [, name]) | bool | Start or stop the BLE UART. Name defaults to the device name. |
| ble.enabled() | bool | Whether BLE is advertising. |
| ble.connected() | bool | Whether a central is connected. |
| ble.send(text) | bool | Send a line of text. |
| ble.listen([on=true]) | bool | Route incoming lines to on_ble() (true), or hand them back to the app-push protocol (false). |
| ble.listening() | bool | Whether the app currently owns the incoming line stream. |
| ble.address() | string | nil | The BLE address, or nil. |
- Traffic is line-oriented text. For binary, base64-encode it on both ends.
Sandbox & limits
Each app is a single Lua state, built fresh at launch and closed at stop. Three things keep a buggy app from taking the badge down with it: a hard heap cap, a per-callback time budget that kills runaway loops, and a file sandbox chrooted to the app's own directory.
| Limit | Value | Notes |
|---|---|---|
| Lua heap cap | 1 MB | Drawn from PSRAM. Exceeding it surfaces as an ordinary "not enough memory" Lua error you can catch. |
| Callback budget | 250 ms | Per on_update / on_draw / on_button / event callback. Past it the app errors with "exceeded its time budget". |
| on_start budget | 5 s | The startup callback and the top-level script get the longer budget. |
| Blocking extension cap | 12 s | How far blocking bindings (sleep, http, se050.random) may push a single callback's deadline in total. |
| storage.read limit | 64 KB | The most a single read returns; larger files must be paged. |
| gfx.image limit | 256 KB | Largest image file the decoder will load. |
| File path length | 96 chars | Relative paths only — no leading slash, no "..". |
| kv key length | 8 chars | After the per-app namespace prefix, out of the NVS 15-character key limit. |
| ESP-NOW payload | 240 bytes | Per broadcast or send. |
Standard library — available
- base
- package
- coroutine
- table
- string
- math
- utf8
Not available
- io
- os (except the curated time functions)
- debug
Text source only. Only text Lua source runs. Precompiled bytecode is rejected everywhere — load, loadfile, dofile and require accept source only, and string.dump is disabled. A binary chunk could read arbitrary memory, so untrusted apps ship as .lua source.
Curated os. A trimmed os with time, clock, date and difftime is
installed; the destructive calls (execute, remove, rename, exit) are gone.
What this is not. These limits stop accidents — a typo'd loop, a runaway table, a stray path. They are not a defence against a deliberately hostile app: it can still spin the CPU inside a single C binding, flood a radio, or fill the filesystem. Apps arrive semi-trusted; treat pushing one as running code you trust, and keep the pairing code on.