Solana Badge v1 · rev lcd

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.

Lua 5.4 320×240 display API v1 Example apps ↗

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/<id>/
/apps/gm/
  app.ini        metadata (optional)
  main.lua       entry point
  ...            images, extra Lua modules, data

The 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.

app.ini
name=GM
version=1.0.0
author=you
description=Says gm, in brand colours.
entry=main.lua
FieldDefaultMeaning
namedirectory idDisplay name in the launcher.
versionFree-form version string.
authorWho wrote it.
descriptionOne line describing the app.
entrymain.luaThe 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.

CallbackWhen
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:

espnow / ble events on_button (per edge) on_update(dt) on_draw() flush

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
-- 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
end

Three 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:

persisting a value
-- 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
end

SDK 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 levelMeaning
badge.versionOS version string, currently "0.1.0".
badge.api_versionInteger; bumps when a binding changes shape. Currently 1.
badge.device_nameThe 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.

FunctionReturnsDescription
gfx.width()intCanvas width in pixels (320).
gfx.height()intCanvas 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])intPixel width the string would occupy.
gfx.text_height([size=1])intPixel height of a line at this size.
gfx.color(r, g, b)intBuild an RGB565 colour from 8-bit components (each clamped 0..255).
gfx.hsv(h [, s=1] [, v=1])intRGB565 from hue in degrees and s/v in 0..1. Handy for cycling colour.
gfx.gradient(t)intA 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, reasonRead just the header to measure an image without decoding it.
gfx.brightness([value])intScreen 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.

FunctionReturnsDescription
input.down(key)boolTrue while the key is held (level).
input.pressed(key)boolTrue on the frame the key went down (edge).
input.released(key)boolTrue on the frame the key came up (edge).
input.repeated(key)boolTrue on each auto-repeat tick while held.
input.held_ms(key)intMilliseconds the key has been held, 0 if up.
input.any()boolTrue if any button is currently held. A cheap "press any key".
input.keys()arrayThe six key names, in order.
input.label(key)stringThe silkscreen word for a key, e.g. "SELECT" — what to print in an on-screen hint.
input.present()boolWhether 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.

FunctionReturnsDescription
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])intGlobal 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.

FunctionReturnsDescription
system.millis()intMilliseconds since boot.
system.uptime()numberSeconds 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()intFree internal-SRAM heap, in bytes.
system.psram()intFree PSRAM, in bytes.
system.lua_memory()used, limitBytes of the app's own Lua heap cap in use, and the cap.
system.chip()tableTable of { model, revision, mhz, flash_bytes, psram_bytes }.
system.name([new_name])stringGet, 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()arrayEvery installed app as { id, name, version, author, description, bytes }.
system.current_app()stringThe running app id.
system.random_bytes([n=32])stringn 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.

FunctionReturnsDescription
os.time()intSeconds since the epoch. With no RTC and usually no NTP this is seconds since boot, unless something has set the clock.
os.clock()numberSeconds since boot (millis()/1000).
os.date([format] [, time])string | tableA 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])numberDifference 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>/.

FunctionReturnsDescription
storage.read(path)string | nil, errRead 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)boolWhether the file or directory exists.
storage.size(path)int | nilFile size in bytes, or nil if it is missing.
storage.remove(path)boolDelete a file.
storage.mkdir(path)boolCreate a directory.
storage.list([subdir])array | nil, errSorted names in the app directory (or a subdirectory); directories carry a trailing slash. Up to 64 entries.
storage.space()used, totalBytes 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.

FunctionReturnsDescription
kv.get(key [, default])string | defaultRead a value. Returns the default (or nil) if the key was never set — which is how you detect first run.
kv.set(key, value)boolStore a string value.
kv.remove(key)boolDelete 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.

FunctionReturnsDescription
battery.volts()numberPack voltage.
battery.percent()numberEstimated charge, 0..100.
battery.charging()boolWhether 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.

FunctionReturnsDescription
mic.enable([on=true])boolTurn the microphones on or off.
mic.enabled()boolWhether audio capture is running.
mic.level()left, rightSmoothed levels 0..100 — meant for meters.
mic.db()left, rightUnsmoothed level in dBFS, roughly −58..0.
mic.read([n=256])tableA 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.

FunctionReturnsDescription
se050.present()boolWhether the part answered at all.
se050.atr()string | nilThe element's ATR as hex, or nil.
se050.test()boolRe-run the link test (~20 ms). A deliberate call, not something to poll each frame.
se050.random([n=32])string | nil, reasonn bytes (1..64) from the secure element, or nil + reason. No fallback — failure is the answer.
se050.random_available()boolAn 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().

FunctionReturnsDescription
wifi.connect(ssid [, password])boolStart 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()boolWhether the current association is enterprise.
wifi.disconnect()Drop the connection.
wifi.connected()boolWhether the badge has an association and an IP.
wifi.status()stringHuman-readable status text.
wifi.ip()stringCurrent IP address.
wifi.ssid()stringThe SSID currently joined.
wifi.rssi()intSignal strength of the current link.
wifi.mac()stringThis badge's Wi-Fi MAC, lowercase (matches the ESP-NOW convention).
wifi.channel()intCurrent Wi-Fi channel.
wifi.scan()boolKick off an async scan.
wifi.scanning()boolWhether a scan is still running.
wifi.networks()arrayScan results as { ssid, rssi, encrypted }.
wifi.hotspot([password])boolBring 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.

FunctionReturnsDescription
http.get(url [, timeout_ms=5000])status, body | nil, errGET 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, errPOST 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.

FunctionReturnsDescription
espnow.enable([on=true] [, channel])boolStart or stop ESP-NOW. Channel defaults to the badge setting.
espnow.enabled()boolWhether ESP-NOW is running.
espnow.channel()intThe active channel.
espnow.broadcast(data)boolSend to every badge in range. Payload up to MAX_PAYLOAD (240) bytes.
espnow.send(mac, data)boolSend to one peer by MAC ("aa:bb:cc:dd:ee:ff").
espnow.peers()arrayKnown peers as { mac, name, rssi, age_ms, packets }, strongest signal first.
espnow.signal(mac)rssi, age_ms | nilSignal for one peer, or nil if it has not been heard.
espnow.beacon([on])boolWhether 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.

FunctionReturnsDescription
ble.enable([on=true] [, name])boolStart or stop the BLE UART. Name defaults to the device name.
ble.enabled()boolWhether BLE is advertising.
ble.connected()boolWhether a central is connected.
ble.send(text)boolSend a line of text.
ble.listen([on=true])boolRoute incoming lines to on_ble() (true), or hand them back to the app-push protocol (false).
ble.listening()boolWhether the app currently owns the incoming line stream.
ble.address()string | nilThe 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.

LimitValueNotes
Lua heap cap1 MBDrawn from PSRAM. Exceeding it surfaces as an ordinary "not enough memory" Lua error you can catch.
Callback budget250 msPer on_update / on_draw / on_button / event callback. Past it the app errors with "exceeded its time budget".
on_start budget5 sThe startup callback and the top-level script get the longer budget.
Blocking extension cap12 sHow far blocking bindings (sleep, http, se050.random) may push a single callback's deadline in total.
storage.read limit64 KBThe most a single read returns; larger files must be paged.
gfx.image limit256 KBLargest image file the decoder will load.
File path length96 charsRelative paths only — no leading slash, no "..".
kv key length8 charsAfter the per-app namespace prefix, out of the NVS 15-character key limit.
ESP-NOW payload240 bytesPer 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.

Solana Badge · DEF CON Generated from pcb/v1, pinout/ and firmware/testkit/testkit.ino