Expressions and Lua
Kamoji Mini Apps are programmed in Lua 5.4, in two
places: short expressions inside {{ }} in layout.json, and an optional
main.lua script for functions, state and event handlers. Both run in the
same sandboxed interpreter, so an expression can call any function your
script defines.
Expressions
A widget property whose string contains {{ … }} is a binding. What is
between the braces is a Lua expression (not statements):
{ "type": "label", "text": "{{ fmt('%.1f', (imu.lat_g or 0) * 9.81) }} m/s²" }
{ "type": "arc", "value": "{{ clamp(abs(imu.lat_g or 0) / param.max_g * 100, 0, 100) }}" }
{ "type": "label", "text": "{{ state.n }}", "color": "{{ state.n >= 10 and 'red' or param.accent }}" }
- One expression alone (
"{{ x }}") gives the expression's own value: a number forvalueorx, a table forpoints, a boolean forhidden, a string or number for colours. - Text with expressions (
"Speed {{ v }} km/h") is joined into one string. Numbers are written without trailing zeros (3,2.5,0.33, at most two decimals; usefmtfor anything else),nilbecomes empty, and booleans becometrue/false. - Kamoji evaluates every binding 10 times a second, and again right after a gesture, event or MQTT message, and touches the widget only when the result changed.
- If an expression fails (for example arithmetic on a
nil), a label shows—, any other property keeps its last value, and the error is logged. The mini app keeps running. Sensor values arenilfor the first moment after the mini app opens, so writeimu.lat_g or 0. - Lua's
a and b or cis the usual way to write a condition:"{{ src.mqtt_connected and 'green' or 'orange' }}". Remember that in Lua onlynilandfalseare false:0is true.
Expressions also work in some manifest strings (MQTT broker, topic,
username, password), where they are evaluated once when the mini app opens:
"{{param.broker}}".
Helper functions
Available in expressions and in main.lua:
| Function | Returns |
|---|---|
fmt(format, ...) | string.format: fmt('%02d:%02d', h, m), fmt('%.2f g', g) |
abs(x) | Absolute value |
clamp(x, lo, hi) | x limited to lo…hi |
map(x, a, b, c, d) | x mapped from the range a…b to c…d (not clamped) |
lerp(a, b, t) | a + (b - a) * t |
round(x [, n]) | x rounded to n decimals (default 0) |
polar(cx, cy, r, deg) | Two values, x, y: the point at radius r and angle deg from (cx, cy). 0° is 3 o'clock, clockwise; subtract 90 for 0° at 12 o'clock |
clock(utc_offset_hours) | The current date and time at that UTC offset (for example 8, -5, 5.5), as a table { year, month, day, hour, min, sec, weekday } (weekday 1 = Sunday), or nil while Kamoji does not know the time. See Time |
json.decode(text) | The Lua value of a JSON text, or nil and an error message |
json.encode(value) | JSON text. A table with items 1..n becomes an array, any other table an object |
print(...) | Writes a line to Kamoji's log (tag app.lua) |
kamoji.play(name [, volume]) | Plays one of Kamoji's own sounds (below), at volume 0–1 (default: Kamoji's normal volume) |
kamoji.motion_sound(kind) | Plays Kamoji's own driving sound for "brake", "accel" or "lean", exactly as the built-in modes do: only when the user's Motion Sound setting allows it, and spaced out like Kamoji's own |
kamoji.play takes the name of a sound in Kamoji's sound set, with or without
.wav: for example touch_single, tap_head, tap_head_giggle_1,
pinch_cheek, zap, kamoji, wifi_connected, motion_bumpy,
motion_braking_1, motion_accelerate_1, motion_lean_1.
kamoji.sounds() lists them all. Use kamoji.motion_sound rather than kamoji.play
for driving reactions, so the user's Motion Sound setting is respected.
Sound
| Function | What it does |
|---|---|
kamoji.play(name [, volume]) | One of Kamoji's own sounds, by name (above) |
kamoji.sounds() | A list of the names kamoji.play accepts |
kamoji.play_file(path [, volume]) | A WAV file from your mini app's own folder, e.g. "assets/coin.wav". Returns false if the file isn't there. Paths can't start with / or contain .. |
kamoji.tone(freq, ms [, volume]) | A beep: a square wave at freq Hz (up to 8000; 0 is silence) for ms milliseconds |
kamoji.melody(rtttl [, volume]) | A tune in RTTTL, the classic mobile-ringtone text format. Returns true, or nil and an error message |
kamoji.stop_sound() | Stops whatever is playing |
volume is 0–1 and defaults to Kamoji's normal volume. A new sound replaces the
one playing, and sound stops when the mini app leaves the screen.
WAV files must be 16-bit mono PCM.
Tones and melodies are generated on Kamoji, up to 20 seconds per call.
RTTTL is name:defaults:notes. The defaults are d (default note length, 4 =
a quarter note), o (default octave, 4–7) and b (tempo in beats per minute).
Each note is [length]note[#][.][octave], with p for a pause and . for
one and a half times the length:
kamoji.tone(880, 40) -- a short blip
kamoji.melody("win:d=8,o=5,b=180:c6,e6,g6,4c7") -- a little fanfare
kamoji.melody("miss:d=8,o=5,b=180:g,e,c,4c4") -- and the opposite
Timers
For anything that moves on its own, such as a game, run your code on a timer:
| Function | What it does |
|---|---|
kamoji.every(ms, fn) | Calls fn() every ms milliseconds (at least 20). Returns a timer id |
kamoji.after(ms, fn) | Calls fn() once, after ms milliseconds. Returns a timer id |
kamoji.cancel(id) | Stops a timer |
The screen is updated right after each timer callback, so a 33 ms timer gives about 30 frames per second. Up to 16 timers per mini app; they run only while the mini app is on screen and stop when it leaves. A timer that falls behind skips ticks rather than catching up in a burst. Each callback has the same time limit as other handlers.
state.x = 0
kamoji.every(33, function()
state.x = (state.x + 3) % 240 -- "cx": "{{ state.x }}" moves a circle across
end)
Plus Lua's standard math, string, table, utf8 and coroutine
libraries and the basic functions (tostring, tonumber, pairs,
ipairs, pcall, select, type and so on).
Global tables
| Table | Holds |
|---|---|
imu, drive, light, env, time, device | Live sensor values: see Sensors and Events |
src | Data from the mini app's sources: see MQTT and Data |
param | The mini app's settings: see Settings |
state | Empty, for your own values. Use it for anything bindings read (state.n) |
mqtt | mqtt.publish, mqtt.subscribe |
json | json.encode, json.decode |
kamoji | Sound (play, play_file, tone, melody, sounds, stop_sound, motion_sound) and timers (every, after, cancel) |
main.lua
main.lua runs once, from top to bottom, each time the mini app comes on screen,
before the widgets are built. Use it to set initial state and define
functions:
-- Runs once when the mini app opens.
state.n = param.start or 0
DAYS = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }
-- Global functions can be called from bindings: "{{ speed_text() }}"
function speed_text()
return fmt("%d", state.n)
end
Globals you define (functions, tables like DAYS) are visible to every
binding. Keep heavy work out of functions that bindings call 10 times a
second: compute once and cache, as the
clock example does.
Handlers
Define any of these global functions and Kamoji calls them:
| Handler | When |
|---|---|
on_enter() | After main.lua ran and the widgets were built, just before the first evaluation |
on_exit() | When the mini app leaves the screen. Its MQTT connection is still open |
on_top_tap() | A tap on the top of Kamoji |
on_top_hold() | A hold on the top |
on_pinch() | A short squeeze of left and right together |
on_left_tap(), on_right_tap() | A single tap on the left / right side. Only for mini apps with "side_taps": "app" in the manifest: see Side taps |
on_bump(magnitude) | A bump in the road |
on_hard_brake(magnitude) | Hard braking |
on_hard_accel(magnitude) | Hard acceleration |
on_lean(magnitude) | Hard cornering |
on_mqtt(topic, payload) | Every MQTT message the mini app receives |
The events and their magnitudes are described in
Sensors and Events. After every
handler, all bindings are evaluated right away, so a change to state shows
at once.
Gestures. When your mini app has a handler for a gesture (a function above or
an entry in the layout's on), Kamoji gives the gesture to your mini app and skips
its normal action for it. Gestures without a handler keep their normal
behaviour. Left and right taps (previous / next mode) and holding left and
right for 3 seconds (Settings) always keep theirs, so a user can always leave
your mini app.
When both a function on_top_tap and an on entry top_tap exist, both run.
Sandbox and limits
Anyone can sideload a mini app, so mini apps run in a sandbox:
| Limit | Value | What happens |
|---|---|---|
| Memory | 512 KB of Lua memory per mini app | An allocation over the cap fails with a "not enough memory" error |
| CPU, bindings | 20 ms for all bindings together, per evaluation | The binding running when time is up fails with CPU budget exceeded, and so do the rest of that round |
| CPU, scripts | 200 ms for main.lua, and for each handler call | The call fails with CPU budget exceeded |
| Errors | 30 errors in a row | The mini app is stopped with an error card |
Not available: io, os, debug, package, require, load, loadfile,
dofile, collectgarbage and string.dump. A mini app cannot read or write
files, run other code, or reach the network except through the
MQTT functions its permissions allow. Use
time / clock() instead of os.time / os.date.
The error card. When main.lua has a syntax error or fails while running,
layout.json is not valid JSON, or the mini app hits 30 errors in a row, Kamoji
stops the mini app and shows its name and the error in red in place of the mini app.
The details are in the log. Fix the mini app and install it again.