Skip to main content

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 for value or x, a table for points, a boolean for hidden, 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; use fmt for anything else), nil becomes empty, and booleans become true / 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 are nil for the first moment after the mini app opens, so write imu.lat_g or 0.
  • Lua's a and b or c is the usual way to write a condition: "{{ src.mqtt_connected and 'green' or 'orange' }}". Remember that in Lua only nil and false are false: 0 is 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:

FunctionReturns
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​

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

FunctionWhat 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​

TableHolds
imu, drive, light, env, time, deviceLive sensor values: see Sensors and Events
srcData from the mini app's sources: see MQTT and Data
paramThe mini app's settings: see Settings
stateEmpty, for your own values. Use it for anything bindings read (state.n)
mqttmqtt.publish, mqtt.subscribe
jsonjson.encode, json.decode
kamojiSound (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:

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

LimitValueWhat happens
Memory512 KB of Lua memory per mini appAn allocation over the cap fails with a "not enough memory" error
CPU, bindings20 ms for all bindings together, per evaluationThe binding running when time is up fails with CPU budget exceeded, and so do the rest of that round
CPU, scripts200 ms for main.lua, and for each handler callThe call fails with CPU budget exceeded
Errors30 errors in a rowThe 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.