Python SDK reference
Every call a Python app can make, with parameters, return values, remarks and a working
snippet for each. The app model itself is on Python apps; the machine-readable
stub ships on the device as SDK\uno.pyi.
The application object
A Python app is one .py file that defines a class and a module-global instance named exactly
app. The runtime (PYRT.UNO) executes your file top to bottom, looks up app,
and binds whichever of the six callbacks you defined. There is no event loop to write and no main():
the desktop calls you.
import uno
class MyApp(uno.App):
def build(self, cv): # once, when the window opens
self.w, self.h = cv.width(), cv.height()
def draw(self, cv): # every repaint
cv.clear(uno.rgb(16, 18, 34))
cv.text(8, 8, "hello", uno.rgb(214, 218, 232))
def tick(self): # ~60 times a second
return False # nothing changed - let the shell sleep
def key(self, uni, scan, ctrl):
return False # not ours - let the shell have it
app = MyApp() # the runtime looks for this exact name
app is missing the run fails with No `app` object. Define: app = MyApp() in Studio's output pane.Callbacks
| Callback | When it runs | Return |
|---|---|---|
build(self, cv) | Once, when the window opens. The canvas is already valid, so
cv.width()/cv.height() give your drawable size - read them here, never hardcode
a size (the shell picks your window). | ignored |
draw(self, cv) | Every repaint. | ignored |
tick(self) | ~60 times a second while the app is open. | None
(or nothing) repaints; an explicit falsey value such as False skips the repaint - the
idle idiom below. |
key(self, uni, scan, ctrl) | A key was pressed while your window is focused.
uni is the character's codepoint (0 if none), scan the special-key code (0 if none),
ctrl nonzero while Ctrl is held. Exactly one of uni/scan is nonzero
per event. | truthy = you consumed it |
action(self, id) | A toolkit widget action (only relevant to apps hosted with widgets). | truthy = consumed |
opened(self) / closed(self) | The window was shown / is closing. Only bound if they exist at load. | ignored |
Key codes
| Key | scan | uni |
|---|---|---|
| Up / Down / Right / Left | 1 / 2 / 3 / 4 | 0 |
| Delete (forward) | 8 | 0 |
| F1..F12 | 0x0B..0x16 | 0 |
| Esc | 0x17 | 0 |
| Enter / Backspace / Tab / Space | 0 | 13 / 8 / 9 / 32 |
| Letters, digits, symbols | 0 | the ASCII code, shift-aware |
Keys the shell owns never reach you: Ctrl+Esc, Alt+Tab, Ctrl+W, Ctrl+M, Ctrl+Tab, F2, Alt+arrows, and Esc while a popup or fullscreen app is up.
The idle idiom
An app that returns None from tick() repaints sixty times a second whether anything
changed or not. A static app should keep a dirty flag instead:
def key(self, uni, scan, ctrl):
...change something...
self.dirty = True
return True
def tick(self):
if self.dirty:
self.dirty = False
return None # repaint this frame
return False # idle - skip the repaint
Canvas
The drawing surface handed to build and draw. Coordinates are canvas-relative -
(0, 0) is the top-left of your content area, below the title bar - and draws are clipped to your window.
All coordinates must be ints: pass int(x) if you animate with floats, or the call
raises TypeError.
| Method | Effect |
|---|---|
cv.width() / cv.height() | Drawable size in pixels. Read it every draw if you want live resize behaviour. |
cv.clear(color) | Fill the whole canvas. |
cv.fill_rect(x, y, w, h, color) | Filled rectangle. |
cv.rect(x, y, w, h, color) | 1-pixel outline. |
cv.pixel(x, y, color) | One pixel. |
cv.hline(x, y, w, color) / cv.vline(x, y, h, color) | Horizontal / vertical line. |
cv.text(x, y, s, color) | Draw a string, transparent background. |
The remaining Canvas methods (wall_col, wall_span,
mask_span, flat_span, duum_frame, seg_cols) are the
textured-column fast paths built for Duum's renderer; their exact contracts are in
SDK\uno.pyi.
uno.rgb
uno.rgb(r, g, b) -> int packs three 0-255 channels into the colour value every
drawing call takes. Build your palette once at module level, not per frame.
RED = uno.rgb(255, 96, 96) # channels are 0-255, masked & 0xFF
cv.fill_rect(10, 10, 40, 40, RED)
Time and held keys
uno.ticks() -> int - the Toolbox tick counter. Honesty first: this is a
call counter shared with every C app (it advances once per call, from anywhere), so treat it as
frame pacing only - call it exactly once per tick() and use deltas - or skip it and count
your own tick() invocations. For real time, use unoauto.uptime():
milliseconds since boot, ungated in every build - the clock the automation samples pace on.
import uno, unoauto
def build(self, cv):
self.frames = 0 # your own frame counter...
self.t0 = unoauto.uptime() # ...and the real clock, in ms
def tick(self):
self.frames += 1 # ~60/s: fine for animation pacing
elapsed_s = (unoauto.uptime() - self.t0) // 1000 # honest seconds
uno.keys_down() -> int - the navigation/action keys held right now, as a
bitmask: 1 Up, 2 Down, 4 Right, 8 Left, 16 fire (F or Ctrl), 32 use (Space/E), 64 comma, 128 period.
For movement, polling this beats buffering key() events.
held = uno.keys_down()
if held & 1: self.y -= 2 # Up is held right now
if held & 4: self.x += 2 # Right
# 0 on the firmware input path (no key-up events there):
# fall back to timing out your own key() events, like Duum does.
Sound
uno.beep(midi, ticks) plays a square-wave note at a MIDI pitch (60 = middle C,
69 = A440) for ticks sixtieths of a second; uno.quiet() stops it. These two
always work, on any machine.
uno.beep(69, 30) # A440 for half a second
uno.quiet() # cut it short
The sampled-audio calls need a DAC (HD Audio or AC'97) and raise OSError when the
machine has none - always wrap them and fall back to beep:
| Call | Effect |
|---|---|
uno.sfx_load(slot, pcm, rate) -> bool | Load unsigned 8-bit mono PCM into an effect slot. |
uno.sfx_play(slot, vol, sep) -> bool | Play a loaded slot: volume 0-255, stereo
position 0 left / 128 centre / 255 right. False means that one play didn't start. |
uno.mus_play(smf, loop=0) -> bool | Play a standard MIDI file from bytes. |
uno.mus_stop() | Stop music. |
try:
ok = uno.sfx_load(0, pcm_bytes, 11025) # slot, 8-bit PCM, rate
uno.sfx_play(0, 200, 128) # slot, volume 0-255, pan 0/128/255
uno.mus_play(smf_bytes) # standard MIDI file; loop=1 repeats
except OSError: # no DAC on this machine
uno.beep(60, 10) # the square wave always works
Files
Volumes are numbered; 0 is the boot volume and the one-argument forms default to it. Names are
8.3 on FAT volumes and case-insensitive. There is no listdir and no file objects - the API is
whole-file and offset reads, which is what a small app actually needs.
| Call | Returns | Remarks |
|---|---|---|
uno.read(name) / uno.read(vol, name) | bytes, or
None if the file does not exist | Reads the whole file - fine for documents, wrong for a
4 MB WAD (stream those with read_at). |
uno.read_at(vol, name, off, n) | bytes | Reads n
bytes at offset off. vol is required here. |
uno.size(name) / uno.size(vol, name) | int, -1 if
missing | The cheap existence test. |
uno.write(name, data) / uno.write(vol, name, data) | bool |
False on a read-only volume - surface that state to the user, don't swallow it. |
uno.mkdir(path) / uno.mkdir(vol, path) | bool |
One level at a time; the parent must exist. |
data = uno.read("NOTES.TXT") # the boot volume; None if missing
big = uno.read(1, "DATA.BIN") # an explicit volume index
# stream a large file instead of loading it whole:
hdr = uno.read_at(0, "GAME.WAD", 0, 12) # vol, name, offset, length
n = uno.size("NOTES.TXT") # -1 if it does not exist
ok = uno.write("SCORES.TXT", text.encode())
if not ok:
... # read-only volume - tell the user
uno.mkdir("SAVES") # one level; the parent must exist
uno.write("SAVES/SLOT1.DAT", blob)
Preferences and key bindings
Small per-app settings without inventing a file format:
uno.pref_get(name) -> str|None and uno.pref_set(name, value) -> bool
persist strings (values capped at 32 bytes) in the shell's preference store.
uno.bind_name(action), uno.bind_set(action, uni, scan) and
uno.bind_reset() read and remap the shared game-action key bindings.
name = uno.pref_get("player") or "YOU" # None on first run
uno.pref_set("player", name) # values are capped at 32 bytes
The system log
uno.log(sev, fac, text) writes a line to the system log (severities follow syslog;
fac is a numeric facility - apps are 6). The viewer app, the log file sink and the remote link all see it -
prefer this to inventing your own trace file. The reading half
(log_read/log_span/log_stat/...) is documented in
SDK\uno.pyi.
uno.log(6, 6, "level loaded") # severity, facility, text - both ints
# severities follow syslog: 3 err, 4 warning, 5 notice, 6 info, 7 debug.
# facilities: 0 kernel, 1 net, 2 storage, 3 browser, 4 ui, 5 security,
# 6 app (yours), 7 remote. Read it back in the System Log viewer.
Hardware introspection
uno.devices() -> str and uno.pci() -> list expose the device
tree (location, IDs, class, claiming driver) - the same data the devices remote verb reports.
Read-only.
Language and library limits
The runtime is MicroPython at its core feature level, plus the modules above. What that means in practice:
| You have | You don't have |
|---|---|
classes, generators, comprehensions, set, bytearray, big ints, floats
(single precision), % and .format() formatting, walrus, async/await |
f-strings, frozenset, memoryview, __del__, reverse special
methods, NotImplemented |
math, struct, array, collections
(namedtuple), sys, gc, micropython |
time, random, json, os, io,
re, typing - and math.pi (write 3.14159265) |
one .py file up to 192 KB, a 16 MB heap, an 8 KB
print() buffer shown in Studio |
importing a second .py file, threads, and the @micropython.native/viper
decorators (they fault on real hardware - never use them) |
unoauto: drive the machine
import unoauto is the automation half of unoautomate, available
to any Python app in every build: each call checks the caller's privilege and, when the capability is
absent, returns its inert value (False, [], None) instead of raising -
so the same script degrades gracefully on a machine that trusts it less. uptime() and
deadline_left() are ungated.
| Function | Needs | Returns / effect |
|---|---|---|
available() | observe | True when observation is granted. |
log(text) | observe | Emit on the script log channel (streams to the dev PC). |
probe() | observe | System snapshot: (name, kind, state, v1, v2)
rows - kind 0 module, 1 window, 2 subsystem. |
key(scan, uni, ctrl=0) | drive | Inject a key. Argument order is
scan-first - the mirror image of the key() callback. Lands on the next frame. |
pointer(x, y, btn) | drive | Inject a pointer event (btn 1 press, 0 release). |
apps() / launch(i) / close_top() | drive | Count, open, close apps. Never close_top() your own window from an automation
script. |
uptime() | - | Milliseconds since boot. Pace scripts on this. |
deadline_left() | - | ms left in the current test budget, -1 if none. |
poweroff() | system | Shut down (unattended runs end themselves). |
remote_active() / remote_send(s) / remote_recv() /
remote_stop() | observe/system | Talk to the dev PC over the URC link. |
The load-bearing pattern - actions land on the next shell frame, so run your steps from a generator
and yield between step and check:
import uno, unoauto
class Bot(uno.App):
def build(self, cv):
self.steps = self.script()
self.until = 0
def script(self):
unoauto.key(0x17, 0, 1) # Ctrl+Esc: open the Start menu...
yield 30 # ...which lands on the NEXT frame
unoauto.key(0, 13) # Enter
yield 30
wins = [r[0] for r in unoauto.probe() if r[1] == 1]
unoauto.log("open now: %s" % ", ".join(wins))
def tick(self):
if self.steps is None:
return False
now = unoauto.uptime()
if now >= self.until:
try:
self.until = now + next(self.steps)
except StopIteration:
self.steps = None
app = Bot()
unoscript: the permission-gated OS surface
import unoscript is the production automation surface: UI, apps, files, processes, memory,
ports and power, each call gated by a capability tier (0 ambient, 1 user, 2 admin, 3 kernel). Denied calls
raise OSError; request() asks for a grant (consent sheet, role, or signed manifest)
and answers False rather than raising. The model, the manifest story and the full catalog are on
Remote control & automation.
| Call | Tier | Effect |
|---|---|---|
available() / whoami() / secured() | - | Presence, acting uid (0xFFFFFFFF when nobody is signed in), whether the real adjudicator is in. |
cap_tier(name) / request(name[, scope[, ttl]]) | - | Look up a capability's tier; ask for it. |
ui.click(x, y[, btn]) / ui.move(x, y) / ui.key(scan[, uni[, mods]]) |
0 | Synthetic input on the real device path. |
ui.screen() | 0 | The window tree as text, focused window marked. |
ui.clip_get() / ui.clip_set(s) | 0 / 1 | Clipboard. |
app.count() / app.launch(i) / app.close_top() /
app.message(i, verb) | 0 / 1 | App control; verb is
"info", "focus" or "close". |
fs.read(path) / fs.write(path, data) | 1 / 2 | A bare relative path is your home (USERS\<uid>\..., parents auto-created,
reads capped at 4 KB); an absolute /label/rest names a volume and is tier 2.
.. is rejected. |
proc.list() | 2 | (pid, tid, state, name, owner) rows;
state bit 0 = focused. |
mem.read/write, io.in_/out | 2-3 | Raw memory and port I/O; always audited. |
sys.power(n) | 2 | 0 shutdown, 1 reboot. |
import unoscript as u
u.cap_tier("power") # -> 2: needs an explicit grant
if u.request("power"): # consent sheet, role, or manifest
u.sys.power(0) # clean shutdown
u.fs.write("notes/todo.txt", b"buy milk") # tier 1: USERS/<uid>/notes/...
print(u.fs.read("notes/todo.txt")) # b'buy milk' (4 KB read cap)
for pid, tid, state, name, owner in u.proc.list(): # tier 2
print(pid, name, "focused" if state & 1 else "")
Worked examples: the sample programs. The app model: Python apps. The permission model and unattended grants: Remote control & automation.