Skip to main content

Examples

Complete Kamoji Mini Apps to copy, install and change. Create a folder named after the mini app's id, save the files into it, zip its contents and install the zip (see Sideloading).

Clock: five faces​

Folder clock. A clock with five faces; a tap on the top cycles through them. The time zone, the city name and the accent colour are settings, so a user sets their own UTC offset in the Kamoji app.

  1. Digital, with a ring for the seconds
  2. Analogue, with hands and tick marks made of line widgets
  3. Hours over minutes, big
  4. Time with seconds, weekday and date
  5. Three rings: hours, minutes, seconds

It shows how to share a computed value between many bindings (T() computes the time once per second), build points with polar(), and switch pages with hidden.

Until Kamoji knows the time, the clock shows 00:00 in grey and the line "clock app requires WiFi". Kamoji gets the time from the internet when it is on Wi-Fi, or from the phone when the Kamoji app connects; either one makes time.valid true and the clock starts.

clock/manifest.json

{
"id": "clock",
"name": "Clock",
"version": "1.0.0",
"author": "Your Name",
"api_version": 1,
"permissions": ["touch"],
"params": [
{ "key": "utc_offset", "type": "number", "label": "UTC offset (hours)",
"help": "8 for Singapore, 1 for Paris in winter, -5 for New York in winter",
"default": 8, "min": -12, "max": 14, "step": 0.25 },
{ "key": "label", "type": "string", "label": "City", "default": "Singapore" },
{ "key": "accent", "type": "color", "label": "Accent colour", "default": "#64D2FF" }
]
}

clock/main.lua

-- Clock with 5 faces; a top tap cycles through them.
state.face = 1
FACES = 5
DAYS = { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" }

-- The time at the app's UTC offset, computed once per second (many bindings call it).
-- Until Kamoji knows the time: midnight, with valid = false.
local cache_epoch, cache
function T()
if time.epoch ~= cache_epoch or cache == nil then
cache_epoch = time.epoch
cache = clock(param.utc_offset)
if cache then
cache.valid = true
else
cache = { hour = 0, min = 0, sec = 0, day = 1, month = 1, year = 1970, weekday = 5, valid = false }
end
end
return cache
end

-- Analogue face: a hand from the centre at `deg` (0 = 12 o'clock, clockwise), `len` px long.
function hand(deg, len)
local x, y = polar(120, 120, len, deg - 90)
return { { 120, 120 }, { x, y } }
end

-- Tick mark i (0-11); longer at 12, 3, 6 and 9.
function tick(i)
local inner = (i % 3 == 0) and 88 or 96
local x1, y1 = polar(120, 120, inner, i * 30 - 90)
local x2, y2 = polar(120, 120, 106, i * 30 - 90)
return { { x1, y1 }, { x2, y2 } }
end

function on_top_tap()
state.face = state.face % FACES + 1
end

clock/layout.json

{
"widgets": [
{ "type": "arc", "align": "center", "w": 226, "width": 6, "min": 0, "max": 59,
"start_angle": 270, "end_angle": 269, "track": "#1C1C1E",
"color": "{{ param.accent }}", "value": "{{ T().sec }}", "hidden": "{{ state.face ~= 1 }}" },
{ "type": "label", "font": "huge", "align": "center", "y": -6,
"color": "{{ T().valid and 'white' or '#555555' }}",
"text": "{{ fmt('%02d:%02d', T().hour, T().min) }}", "hidden": "{{ state.face ~= 1 }}" },
{ "type": "label", "font": "small", "align": "center", "y": 52, "color": "grey",
"text": "{{ T().valid and fmt('%s %02d/%02d', param.label, T().day, T().month) or '' }}",
"hidden": "{{ state.face ~= 1 }}" },

{ "type": "circle", "cx": 120, "cy": 120, "r": 110, "fill": "none", "border": "#333333",
"border_width": 2, "hidden": "{{ state.face ~= 2 }}" },
{ "type": "line", "width": 3, "color": "#8E8E93", "points": "{{ tick(0) }}", "hidden": "{{ state.face ~= 2 }}" },
{ "type": "line", "width": 2, "color": "#8E8E93", "points": "{{ tick(1) }}", "hidden": "{{ state.face ~= 2 }}" },
{ "type": "line", "width": 2, "color": "#8E8E93", "points": "{{ tick(2) }}", "hidden": "{{ state.face ~= 2 }}" },
{ "type": "line", "width": 3, "color": "#8E8E93", "points": "{{ tick(3) }}", "hidden": "{{ state.face ~= 2 }}" },
{ "type": "line", "width": 2, "color": "#8E8E93", "points": "{{ tick(4) }}", "hidden": "{{ state.face ~= 2 }}" },
{ "type": "line", "width": 2, "color": "#8E8E93", "points": "{{ tick(5) }}", "hidden": "{{ state.face ~= 2 }}" },
{ "type": "line", "width": 3, "color": "#8E8E93", "points": "{{ tick(6) }}", "hidden": "{{ state.face ~= 2 }}" },
{ "type": "line", "width": 2, "color": "#8E8E93", "points": "{{ tick(7) }}", "hidden": "{{ state.face ~= 2 }}" },
{ "type": "line", "width": 2, "color": "#8E8E93", "points": "{{ tick(8) }}", "hidden": "{{ state.face ~= 2 }}" },
{ "type": "line", "width": 3, "color": "#8E8E93", "points": "{{ tick(9) }}", "hidden": "{{ state.face ~= 2 }}" },
{ "type": "line", "width": 2, "color": "#8E8E93", "points": "{{ tick(10) }}", "hidden": "{{ state.face ~= 2 }}" },
{ "type": "line", "width": 2, "color": "#8E8E93", "points": "{{ tick(11) }}", "hidden": "{{ state.face ~= 2 }}" },
{ "type": "line", "width": 7, "rounded": true, "color": "white",
"points": "{{ hand(T().hour % 12 * 30 + T().min * 0.5, 52) }}", "hidden": "{{ state.face ~= 2 }}" },
{ "type": "line", "width": 4, "rounded": true, "color": "white",
"points": "{{ hand(T().min * 6 + T().sec * 0.1, 82) }}", "hidden": "{{ state.face ~= 2 }}" },
{ "type": "line", "width": 2, "rounded": true, "color": "{{ param.accent }}",
"points": "{{ hand(T().sec * 6, 94) }}", "hidden": "{{ state.face ~= 2 }}" },
{ "type": "circle", "cx": 120, "cy": 120, "r": 6, "fill": "{{ param.accent }}",
"hidden": "{{ state.face ~= 2 }}" },

{ "type": "label", "font": "huge", "align": "center", "y": -42,
"color": "{{ T().valid and 'white' or '#555555' }}",
"text": "{{ fmt('%02d', T().hour) }}", "hidden": "{{ state.face ~= 3 }}" },
{ "type": "label", "font": "huge", "align": "center", "y": 42,
"color": "{{ T().valid and param.accent or '#555555' }}",
"text": "{{ fmt('%02d', T().min) }}", "hidden": "{{ state.face ~= 3 }}" },

{ "type": "label", "font": "large", "align": "center", "y": -18,
"color": "{{ T().valid and 'white' or '#555555' }}",
"text": "{{ fmt('%02d:%02d:%02d', T().hour, T().min, T().sec) }}", "hidden": "{{ state.face ~= 4 }}" },
{ "type": "label", "font": "medium", "align": "center", "y": 26, "color": "{{ param.accent }}",
"text": "{{ T().valid and DAYS[T().weekday] or '' }}", "hidden": "{{ state.face ~= 4 }}" },
{ "type": "label", "font": "small", "align": "center", "y": 56, "color": "grey",
"text": "{{ T().valid and fmt('%02d/%02d/%04d', T().day, T().month, T().year) or '' }}",
"hidden": "{{ state.face ~= 4 }}" },

{ "type": "arc", "align": "center", "w": 226, "width": 10, "min": 0, "max": 24,
"start_angle": 270, "end_angle": 269, "track": "#1C1C1E", "color": "#FF9F0A",
"value": "{{ T().hour }}", "hidden": "{{ state.face ~= 5 }}" },
{ "type": "arc", "align": "center", "w": 194, "width": 10, "min": 0, "max": 60,
"start_angle": 270, "end_angle": 269, "track": "#1C1C1E", "color": "{{ param.accent }}",
"value": "{{ T().min }}", "hidden": "{{ state.face ~= 5 }}" },
{ "type": "arc", "align": "center", "w": 162, "width": 10, "min": 0, "max": 60,
"start_angle": 270, "end_angle": 269, "track": "#1C1C1E", "color": "#34C759",
"value": "{{ T().sec }}", "hidden": "{{ state.face ~= 5 }}" },
{ "type": "label", "font": "medium", "align": "center",
"color": "{{ T().valid and 'white' or '#555555' }}",
"text": "{{ fmt('%02d:%02d', T().hour, T().min) }}", "hidden": "{{ state.face ~= 5 }}" },

{ "type": "label", "font": "small", "align": "bottom_mid", "y": -34, "w": 180, "color": "orange",
"text": "clock app requires WiFi", "hidden": "{{ T().valid }}" },
{ "type": "label", "font": "small", "align": "top_mid", "y": 26, "color": "#555555",
"text": "{{ fmt('%d / %d', state.face, FACES) }}" }
]
}

Things to try: add a face setting of type enum and start on it (state.face = tonumber(param.face) or 1), or a bool setting that hides the seconds.

MQTT value​

Folder mqtt-value. Shows one big number and a subtitle, both taken out of JSON messages on one MQTT topic, with how long ago the last message arrived. Send it a message such as:

{ "value": 42, "subtitle": "Coffee count" }

The broker and topic are settings, so a user points the mini app at their own smart home from the Kamoji app. The default is the public broker broker.hivemq.com, where anyone can read and write the topics, so never send anything private there. Kamoji must be on Wi-Fi.

mqtt-value/manifest.json

{
"id": "mqtt-value",
"name": "MQTT Value",
"version": "1.0.0",
"author": "Your Name",
"api_version": 1,
"permissions": ["net.mqtt"],
"params": [
{ "key": "broker", "type": "string", "label": "Broker", "default": "mqtt://broker.hivemq.com:1883" },
{ "key": "topic", "type": "string", "label": "Topic", "default": "kamoji/mqtt_test/sample_metrics",
"help": "JSON messages like {\"value\": 42, \"subtitle\": \"Coffee count\"}" },
{ "key": "accent", "type": "color", "label": "Number colour", "default": "#E8B23A" }
],
"sources": [
{ "id": "v", "type": "mqtt", "broker": "{{param.broker}}", "topic": "{{param.topic}}", "json_path": "value" },
{ "id": "sub", "type": "mqtt", "broker": "{{param.broker}}", "topic": "{{param.topic}}", "json_path": "subtitle" }
]
}

Both sources listen to the same topic and each takes one field of the JSON message.

mqtt-value/layout.json

{
"widgets": [
{ "type": "label", "font": "huge", "align": "center", "y": -16,
"text": "{{ src.v or '--' }}", "color": "{{ src.mqtt_connected and param.accent or 'grey' }}" },
{ "type": "label", "font": "medium", "align": "center", "y": 48, "w": 200, "color": "white",
"text": "{{ src.sub or '' }}" },
{ "type": "label", "font": "small", "align": "bottom_mid", "y": -26,
"color": "{{ src.mqtt_connected and 'green' or 'orange' }}",
"text": "{{ not src.mqtt_connected and 'connecting...' or (src.v_t and fmt('updated %ds ago', math.floor((device.uptime_s or 0) - src.v_t)) or 'waiting for data') }}" }
]
}

The huge font only has digits and a few symbols, so send numbers as value; put words in subtitle.

To send it a message from a computer, with the Mosquitto clients installed:

mosquitto_pub -h broker.hivemq.com -t kamoji/mqtt_test/sample_metrics \
-m '{"value": 42, "subtitle": "Coffee count"}'

Things to try: add main.lua with on_mqtt(topic, payload) to log every message, or publish back with mqtt.publish from a top tap (add the touch permission).

Excite-O-Meter​

Folder excite-o-meter. The Excite-O-Meter by Originally US, the same mini app as in the catalogue: two gauges, Excitement and Comfort, each with an outer ring for the last 30 seconds and an inner ring for right now, in red, yellow or green. A red flash fills the screen during a hard turn, brake or acceleration, and Kamoji plays its driving sounds. It shows the drive group, a built-in image and built-in fonts, and kamoji.motion_sound.

excite-o-meter/manifest.json

{
"id": "excite-o-meter",
"name": "Excite-O-Meter",
"version": "1.0.0",
"author": "Originally US",
"api_version": 1,
"permissions": ["sensors.imu", "events.drive"],
"params": [
{ "key": "sounds", "type": "bool", "default": true, "label": "Motion sounds",
"help": "Play Kamoji's braking, acceleration and cornering sounds (also follows the Motion Sound setting)" },
{ "key": "vignette", "type": "bool", "default": true, "label": "Red flash on hard moves" }
]
}

excite-o-meter/main.lua

-- Excite-O-Meter: Comfort / Excitement gauges (outer ring = 30 s average, inner ring = right now).

function pct_color(p)
p = p or 0
if p < 34 then return "#E5484D" end -- red
if p < 67 then return "#F2B94A" end -- yellow
return "#3DD68C" -- green
end

-- Red "attacked" flash: full while a hard turn / brake / acceleration lasts, then fades over 1 s.
local flash_until = 0
function vignette_opa()
if not param.vignette then return 0 end
local now = device.uptime_s or 0
if drive.hard then flash_until = now + 1 return 1 end
return clamp(flash_until - now, 0, 1)
end

local function sound(kind) if param.sounds then kamoji.motion_sound(kind) end end
function on_hard_brake() sound("brake") end
function on_hard_accel() sound("accel") end
function on_lean() sound("lean") end

excite-o-meter/layout.json

{
"widgets": [
{ "type": "image", "src": "builtin:exciteometer_vignette", "x": 0, "y": 0,
"opa": "{{ vignette_opa() }}", "hidden": "{{ vignette_opa() <= 0 }}" },

{ "type": "arc", "x": 8, "y": 50, "w": 100, "h": 100, "width": 10, "min": 0, "max": 100,
"track": "#2A2A2A", "value": "{{ drive.excitement or 0 }}", "color": "{{ pct_color(drive.excitement) }}" },
{ "type": "arc", "x": 22, "y": 64, "w": 72, "h": 72, "width": 5, "min": 0, "max": 100,
"track": "#2A2A2A", "value": "{{ drive.excitement_now or 0 }}", "color": "{{ pct_color(drive.excitement_now) }}" },
{ "type": "label", "x": 8, "y": 72, "w": 100, "text_align": "center", "font": "builtin:exciteometer_value",
"color": "white", "text": "{{ fmt('%d', round(drive.excitement or 0)) }}" },
{ "type": "label", "x": 8, "y": 120, "w": 100, "text_align": "center", "font": "builtin:exciteometer_unit",
"color": "#AAAAAA", "text": "%" },
{ "type": "label", "x": 8, "y": 154, "w": 100, "text_align": "center", "font": "small",
"color": "#AAAAAA", "text": "Excitement" },

{ "type": "arc", "x": 132, "y": 50, "w": 100, "h": 100, "width": 10, "min": 0, "max": 100,
"track": "#2A2A2A", "value": "{{ drive.comfort or 0 }}", "color": "{{ pct_color(drive.comfort) }}" },
{ "type": "arc", "x": 146, "y": 64, "w": 72, "h": 72, "width": 5, "min": 0, "max": 100,
"track": "#2A2A2A", "value": "{{ drive.comfort_now or 0 }}", "color": "{{ pct_color(drive.comfort_now) }}" },
{ "type": "label", "x": 132, "y": 72, "w": 100, "text_align": "center", "font": "builtin:exciteometer_value",
"color": "white", "text": "{{ fmt('%d', round(drive.comfort or 0)) }}" },
{ "type": "label", "x": 132, "y": 120, "w": 100, "text_align": "center", "font": "builtin:exciteometer_unit",
"color": "#AAAAAA", "text": "%" },
{ "type": "label", "x": 132, "y": 154, "w": 100, "text_align": "center", "font": "small",
"color": "#AAAAAA", "text": "Comfort" }
]
}

The gauges use the arc's default 270° track (open at the bottom), so no angles are given.

Tap counter​

Folder big-number. One huge number: a top tap adds 1, a top hold resets it, a pinch adds 10, and every bump in the road adds 1. The number turns red from 10.

big-number/manifest.json

{
"id": "big-number",
"name": "Big Number",
"version": "1.0.0",
"author": "Your Name",
"api_version": 1,
"permissions": ["events.drive", "touch"],
"params": [
{ "key": "label", "type": "string", "label": "Caption", "default": "taps and bumps" },
{ "key": "start", "type": "number", "label": "Start at", "default": 0, "min": 0, "max": 1000, "step": 1 },
{ "key": "accent", "type": "color", "label": "Colour", "default": "#E8B23A" }
]
}

big-number/main.lua

-- Runs once when the app opens.
state.n = param.start or 0

function on_bump(magnitude)
state.n = state.n + 1
print("bump", magnitude, "->", state.n)
end

big-number/layout.json

{
"widgets": [
{ "type": "label", "font": "huge", "align": "center", "y": -12,
"text": "{{ state.n }}", "color": "{{ state.n >= 10 and '#FF5050' or param.accent }}" },
{ "type": "label", "font": "small", "align": "center", "y": 58, "color": "grey",
"text": "{{ param.label }}" },
{ "type": "label", "font": "small", "align": "bottom_mid", "y": -28, "color": "#555555",
"text": "tap +1 hold reset pinch +10" }
],
"on": {
"top_tap": "state.n = state.n + 1",
"top_hold": "state.n = 0",
"pinch": "state.n = state.n + 10"
}
}

G-force dot​

Folder gforce-dot. Two views of the forces on the car, switched by a top tap: a live chart of the last 12 seconds (the default; yellow = sideways, blue = forward/back), and a dot in a crosshair that moves sideways in corners, up when you accelerate and down when you brake. It shows the chart widget and how one mini app keeps several views with hidden bindings on a state value.

gforce-dot/manifest.json

{
"id": "gforce-dot",
"name": "G Dot",
"version": "0.2.0",
"author": "Originally US",
"api_version": 1,
"permissions": [
"sensors.imu",
"touch"
],
"features": [
"gyro"
],
"params": [
{
"key": "scale",
"type": "number",
"label": "Dot scale (px per g)",
"default": 100,
"min": 50,
"max": 110,
"step": 10
},
{
"key": "chart_g",
"type": "number",
"label": "Chart full scale (g)",
"default": 1,
"min": 0.25,
"max": 2,
"step": 0.25
}
]
}

gforce-dot/main.lua

-- G Dot: live chart (default) and circular dot view; a top tap switches between them.
state.view = state.view or "chart"

function on_top_tap()
state.view = (state.view == "chart") and "dot" or "chart"
end

function lat() return imu.lat_g or 0 end
function long() return imu.long_g or 0 end

gforce-dot/layout.json

{
"widgets": [
{
"type": "label",
"font": "small",
"align": "top_mid",
"y": 38,
"color": "grey",
"text": "G-force, last 12 s",
"hidden": "{{ state.view ~= \"chart\" }}"
},
{
"type": "chart",
"align": "center",
"w": 180,
"h": 110,
"points": 60,
"every_ms": 200,
"min": -100,
"max": 100,
"grid": [
3,
0
],
"width": 3,
"hidden": "{{ state.view ~= \"chart\" }}",
"series": [
{
"value": "{{ clamp(lat() / param.chart_g * 100, -100, 100) }}",
"color": "#E8B23A"
},
{
"value": "{{ clamp(long() / param.chart_g * 100, -100, 100) }}",
"color": "#64D2FF"
}
]
},
{
"type": "label",
"font": "small",
"align": "bottom_mid",
"y": -40,
"x": -36,
"color": "#E8B23A",
"text": "sideways",
"hidden": "{{ state.view ~= \"chart\" }}"
},
{
"type": "label",
"font": "small",
"align": "bottom_mid",
"y": -40,
"x": 40,
"color": "#64D2FF",
"text": "fwd / back",
"hidden": "{{ state.view ~= \"chart\" }}"
},
{
"type": "circle",
"cx": 120,
"cy": 120,
"r": 100,
"fill": "none",
"border": "#333333",
"border_width": 2,
"hidden": "{{ state.view ~= \"dot\" }}"
},
{
"type": "line",
"width": 1,
"color": "#333333",
"points": [
[
20,
120
],
[
220,
120
]
],
"hidden": "{{ state.view ~= \"dot\" }}"
},
{
"type": "line",
"width": 1,
"color": "#333333",
"points": [
[
120,
20
],
[
120,
220
]
],
"hidden": "{{ state.view ~= \"dot\" }}"
},
{
"type": "line",
"width": 4,
"rounded": true,
"color": "#E8B23A",
"hidden": "{{ state.view ~= \"dot\" }}",
"points": "{{ { {120, 120}, {120 + clamp(lat(), -1, 1) * param.scale, 120 - clamp(long(), -1, 1) * param.scale} } }}"
},
{
"type": "circle",
"r": 9,
"fill": "#E8B23A",
"hidden": "{{ state.view ~= \"dot\" }}",
"cx": "{{ 120 + clamp(lat(), -1, 1) * param.scale }}",
"cy": "{{ 120 - clamp(long(), -1, 1) * param.scale }}"
},
{
"type": "label",
"font": "medium",
"align": "bottom_mid",
"y": -30,
"hidden": "{{ state.view ~= \"dot\" }}",
"text": "{{ fmt('%.2f g', math.sqrt(lat()^2 + long()^2)) }}"
}
]
}

Tilt Paddle: a game​

A one-player paddle game: tilt Kamoji to move the paddle and tap the top to start. It uses a timer as the game loop and kamoji.tone / kamoji.melody for sound. The widgets only draw what the Lua keeps in state.

main.lua:

-- Tilt Paddle: tilt Kamoji left/right to move the paddle, tap the top to start or restart.
-- The ball bounces off the top, left and right walls; past the paddle is a miss.
-- state.autoplay = true steers by itself (the screenshot rig pins it).
L, R, T, B = 0, 240, 40, 200 -- play field: side walls are the screen's edges
PW, PH, PY, BR = 44, 6, 188, 5 -- paddle width/height/top, ball radius

state.mode = "ready" -- ready | play | over
state.best = state.best or 0
state.score = 0
state.px = 120
state.bx, state.by = 120, PY - BR - 1

local vx, vy = 0, 0

local function beep(f, ms) if param.sound then kamoji.tone(f, ms) end end

local function serve()
state.score = 0
state.bx, state.by = 120, 90
local a = math.rad(math.random(35, 55)) * (math.random(2) == 1 and 1 or -1)
vx, vy = 3.2 * math.sin(a), 3.2 * math.cos(a)
state.mode = "play"
beep(660, 60)
end

function on_top_tap()
if state.mode ~= "play" then serve() end
end

local function step()
-- paddle follows the tilt, smoothed
local target = 120 + clamp((imu.lat_g or 0) / param.tilt_g, -1, 1) * ((R - L) / 2 - PW / 2)
if state.autoplay then target = clamp(state.bx + 9, L + PW / 2, R - PW / 2) end -- demo / screenshots
state.px = state.px + (target - state.px) * 0.35
if state.mode ~= "play" then
if state.mode == "ready" then state.bx = state.px end
return
end
local x, y = state.bx + vx, state.by + vy
if x < L + BR then x = 2 * (L + BR) - x; vx = -vx; beep(440, 25) end
if x > R - BR then x = 2 * (R - BR) - x; vx = -vx; beep(440, 25) end
if y < T + BR then y = 2 * (T + BR) - y; vy = -vy; beep(440, 25) end
if vy > 0 and y + BR >= PY and state.by + BR <= PY + 1 and math.abs(x - state.px) <= PW / 2 + BR then
-- hit: the further from the centre, the steeper the bounce; a little faster each time
local off = clamp((x - state.px) / (PW / 2), -1, 1)
local speed = math.min(7, math.sqrt(vx * vx + vy * vy) * 1.05)
local a = off * math.rad(60)
vx, vy = speed * math.sin(a), -speed * math.cos(a)
y = PY - BR
state.score = state.score + 1
beep(880, 30)
end
if y - BR > B then
state.mode = "over"
if state.score > state.best then state.best = state.score end
if param.sound then kamoji.melody("miss:d=8,o=5,b=180:g,e,c,4c4") end
y = B + BR
end
state.bx, state.by = x, y
end

kamoji.every(33, step)

layout.json:

{
"widgets": [
{
"type": "rect",
"x": 0,
"y": 38,
"w": 240,
"h": 2,
"fill": "#3A3A3C"
},
{
"type": "label",
"font": "medium",
"align": "top_mid",
"y": 44,
"color": "#8E8E93",
"text": "{{ state.score }}"
},
{
"type": "rect",
"w": 44,
"h": 6,
"radius": 3,
"fill": "#E8B23A",
"y": 188,
"x": "{{ state.px - 22 }}"
},
{
"type": "circle",
"r": 5,
"fill": "white",
"cx": "{{ state.bx }}",
"cy": "{{ state.by }}",
"hidden": "{{ state.mode == 'over' }}"
},
{
"type": "label",
"font": "medium",
"align": "center",
"y": -10,
"color": "#E8B23A",
"text": "{{ state.mode == 'over' and 'Missed!' or 'Tilt Paddle' }}",
"hidden": "{{ state.mode == 'play' }}"
},
{
"type": "label",
"font": "small",
"align": "center",
"y": 22,
"color": "grey",
"text": "{{ state.mode == 'over' and fmt('Best %d - tap top to retry', state.best) or 'Tilt to move - tap top to start' }}",
"w": 150,
"hidden": "{{ state.mode == 'play' }}"
}
]
}

Snake: two-button controls​

Uses "side_taps": "app": a tap on the left or right turns the snake, holding left or right leaves the game (and the start screen says so). The snake is a single line widget whose points come from the body in Lua.

manifest.json:

{
"id": "snake",
"name": "Snake",
"version": "0.1.1",
"author": "Originally US",
"api_version": 1,
"side_taps": "app",
"permissions": [
"touch"
],
"features": [],
"params": [
{
"key": "speed",
"type": "number",
"label": "Speed",
"default": 3,
"min": 1,
"max": 5,
"step": 1,
"help": "1 = relaxed, 5 = fast. It also speeds up a little as the snake grows"
},
{
"key": "wrap",
"type": "bool",
"label": "Pass through walls",
"default": false,
"help": "On: leaving one side brings the snake in on the other. Off: hitting a wall ends the game"
},
{
"key": "sound",
"type": "bool",
"label": "Sound",
"default": true
},
{
"key": "hide_wifi",
"type": "bool",
"label": "Hide Wi-Fi icon",
"default": true,
"help": "Hide the Wi-Fi icon and network name at the top of the screen while this app is showing"
}
]
}

main.lua:

-- Snake: tap left / right to turn the snake left / right (relative to where it is heading),
-- tap the top to start or restart. Hold left / right to switch to another mode ("side_taps": "app").
N = 16 -- grid N x N cells
CELL = 10 -- px per cell
X0, Y0 = 40, 40 -- top-left of the field (fits inside the round screen)

DIRS = { {1, 0}, {0, 1}, {-1, 0}, {0, -1} } -- right, down, left, up (clockwise)

state.mode = "ready" -- ready | play | over
state.best = state.best or 0
state.score = 0
state.food = { 12, 8 }

local body, dir, turns, acc, grow = {}, 1, {}, 0, 0

local function beep(f, ms) if param.sound then kamoji.tone(f, ms) end end
local function px(c) return X0 + (c - 0.5) * CELL end

local function occupied(x, y, skip_tail)
local n = #body - (skip_tail and 1 or 0)
for i = 1, n do if body[i][1] == x and body[i][2] == y then return true end end
return false
end

local function place_food()
for _ = 1, 200 do
local x, y = math.random(1, N), math.random(1, N)
if not occupied(x, y) then state.food = { x, y }; return end
end
end

local function reset()
body = { {6, 8}, {5, 8}, {4, 8} } -- head first
dir, turns, acc, grow = 1, {}, 0, 0
state.score = 0
place_food()
state.mode = "play"
beep(660, 50)
end

local function turn(d)
if state.mode ~= "play" then return end
if #turns < 2 then turns[#turns + 1] = d end -- queue quick double taps
end

function on_left_tap() turn(-1) end
function on_right_tap() turn(1) end
function on_top_tap()
if state.mode ~= "play" then reset() end
end

-- the move from (x, y) heading d; nil if it hits a wall without wrap
local function ahead(x, y, d)
local nx, ny = x + DIRS[d][1], y + DIRS[d][2]
if param.wrap then
nx, ny = (nx - 1) % N + 1, (ny - 1) % N + 1
elseif nx < 1 or nx > N or ny < 1 or ny > N then
return nil
end
return nx, ny
end

-- demo player for screenshots (state.autoplay = true): greedy towards the food, never into itself
local function autopilot()
local hx, hy = body[1][1], body[1][2]
local best, best_d
for _, t in ipairs({0, -1, 1}) do
local d = (dir - 1 + t) % 4 + 1
local nx, ny = ahead(hx, hy, d)
if nx and not occupied(nx, ny, true) then
local dist = math.abs(nx - state.food[1]) + math.abs(ny - state.food[2])
if not best or dist < best_d then best, best_d = d, dist end
end
end
if best then dir = best end
end

local function step()
if #turns > 0 then
dir = (dir - 1 + table.remove(turns, 1)) % 4 + 1
end
if state.autoplay then autopilot() end
local nx, ny = ahead(body[1][1], body[1][2], dir)
if nx == nil or occupied(nx, ny, grow == 0) then
state.mode = "over"
if state.score > state.best then state.best = state.score end
if param.sound then kamoji.melody("over:d=8,o=5,b=160:e6,c6,a,4f") end
return
end
table.insert(body, 1, { nx, ny })
if nx == state.food[1] and ny == state.food[2] then
state.score = state.score + 1
grow = grow + 2
beep(1046, 40)
place_food()
end
if grow > 0 then grow = grow - 1 else table.remove(body) end
end

-- ms per move: speed 1..5, a little faster as the snake grows
local function interval()
return math.max(70, 260 - param.speed * 35 - math.min(state.score, 30) * 2)
end

kamoji.every(30, function()
if state.mode ~= "play" then return end
acc = acc + 30
if acc >= interval() then acc = 0; step() end
end)

-- widgets
function snake_points()
local pts = {}
for i = #body, 1, -1 do
local c = body[i]
-- with wrap, don't draw a line across the field where the snake passes through a wall
local prev = pts[#pts]
if prev and (math.abs(px(c[1]) - prev[1]) > CELL or math.abs(px(c[2]) - prev[2]) > CELL) then pts = {} end
pts[#pts + 1] = { px(c[1]), px(c[2]) }
end
if #pts == 1 then pts[2] = pts[1] end
return pts
end
function head_x() return body[1] and px(body[1][1]) or -20 end
function head_y() return body[1] and px(body[1][2]) or -20 end

body = { {6, 8}, {5, 8}, {4, 8} }

layout.json:

{
"widgets": [
{
"type": "rect",
"x": 38,
"y": 38,
"w": 164,
"h": 164,
"fill": "none",
"border": "#3A3A3C",
"border_width": 2
},
{
"type": "label",
"font": "small",
"align": "top_mid",
"y": 14,
"color": "#8E8E93",
"text": "{{ fmt('%d best %d', state.score, state.best) }}"
},
{
"type": "circle",
"r": 4,
"fill": "#FF5A4F",
"cx": "{{ 40 + (state.food[1] - 0.5) * 10 }}",
"cy": "{{ 40 + (state.food[2] - 0.5) * 10 }}",
"hidden": "{{ state.mode == 'ready' }}"
},
{
"type": "line",
"width": 8,
"rounded": true,
"color": "#E8B23A",
"points": "{{ snake_points() }}",
"hidden": "{{ state.mode == 'ready' }}"
},
{
"type": "circle",
"r": 5,
"fill": "#FFD66B",
"cx": "{{ head_x() }}",
"cy": "{{ head_y() }}",
"hidden": "{{ state.mode == 'ready' }}"
},
{
"type": "label",
"font": "medium",
"align": "center",
"y": -46,
"color": "#E8B23A",
"text": "{{ state.mode == 'over' and 'Game over' or 'Snake' }}",
"hidden": "{{ state.mode == 'play' }}"
},
{
"type": "label",
"font": "small",
"align": "center",
"y": 6,
"color": "white",
"w": 150,
"text": "{{ state.mode == 'over' and 'Tap top to play again' or 'Tap left / right to turn. Tap top to start' }}",
"hidden": "{{ state.mode == 'play' }}"
},
{
"type": "label",
"font": "small",
"align": "center",
"y": 52,
"color": "#8E8E93",
"w": 150,
"text": "Hold left / right to switch app",
"hidden": "{{ state.mode == 'play' }}"
}
]
}

Turn-by-turn from the phone (nav, permission phone.navigation, "platforms": ["android"]). It splits Google Maps' "distance · instruction" title, colours the arrival time by traffic, keeps every text above the bottom of the screen (covered by the helmet), and switches between four layouts on a tap of the top: Standard (arrow and distance side by side), Big arrow, Text and Trip (arrival, time and distance left, and a 24-cell traffic strip built from rect widgets).

manifest.json:

{
"id": "navigation",
"name": "Navigation",
"version": "0.3.1",
"author": "Originally US",
"api_version": 1,
"permissions": [
"phone.navigation",
"touch"
],
"features": [],
"params": [
{
"key": "layout",
"type": "enum",
"label": "First layout",
"default": "1",
"options": [
"1",
"2",
"3",
"4"
],
"help": "1 Standard, 2 Big arrow, 3 Text, 4 Trip. Tap the top of Kamoji to switch"
},
{
"key": "accent",
"type": "color",
"label": "Accent colour",
"default": "#E8B23A"
},
{
"key": "hide_wifi",
"type": "bool",
"label": "Hide Wi-Fi icon",
"default": true,
"help": "Hide the Wi-Fi icon and network name at the top of the screen while this app is showing"
}
],
"platforms": [
"android"
]
}

main.lua:

-- Navigation: turn-by-turn from the phone. On Android, the Kamoji app forwards Google Maps' or
-- Waze's navigation (their notification) to Kamoji; the Chronos app works too. The firmware fills
-- `nav`: nav.active, nav.routing, nav.has_icon, nav.title, nav.directions, nav.distance,
-- nav.duration, nav.eta; the arrow is builtin:nav_icon(_large). For scripts Kamoji's fonts lack
-- (Khmer, Burmese, Chinese …) the phone draws the instruction itself: nav.has_text_image, builtin:nav_text.
--
-- Google Maps usually puts the distance to the turn in the title ("250 m") and the street in the
-- text, but at the start of a route the title is the whole instruction ("Head towards ..."). So:
-- a title that looks like a distance is shown big; anything else goes with the small text.

function waiting() return not nav.active end

-- Layouts, switched with a tap on the top (remembered while the app is open; the setting picks the
-- first one): 1 Standard, 2 Big arrow, 3 Text, 4 Trip.
LAYOUTS = 4
state.layout = state.layout or math.floor(tonumber(param.layout) or 1)
function L(n) return not waiting() and state.layout == n end
function on_top_tap()
state.layout = state.layout % LAYOUTS + 1
if kamoji.tone then kamoji.tone(1320 + state.layout * 110, 40) end
end

function waiting_text()
if not nav.linked then return "Waiting for your phone..." end
return "Start navigation in Google Maps or Waze on your phone"
end

local function is_distance(s)
-- "250 m", "1.2 km", "0,5 mi", "500 ft"
return s ~= nil and #s <= 12 and s:match("^%s*[%d][%d.,]*%s*%a+%s*$") ~= nil
end

-- At most `max` characters (UTF-8 aware), with "..." when cut.
local function cut(s, max)
if s == nil then return "" end
local n = utf8.len(s)
if n == nil or n <= max then return s end
return s:sub(1, utf8.offset(s, max - 2) - 1) .. "..."
end

-- Google Maps' title is often "8.6 km · At the roundabout, take ..." (distance, then the
-- instruction): split it. Returns the distance ("" when there is none) and the rest of the title.
local function split_title()
local t = (nav.title or ""):gsub("\u{00B7}", "|"):gsub("\u{2022}", "|")
local d, rest = t:match("^%s*([%d][%d.,]*%s*%a+)%s*|%s*(.-)%s*$")
if d then return d, rest end
if is_distance(t) then return t, "" end
return "", t
end

function big_text()
local d = split_title()
return d
end

function instruction(max)
local _, rest = split_title()
local parts = {}
if rest ~= "" then parts[#parts + 1] = rest end
if nav.directions and nav.directions ~= "" and nav.directions ~= rest and nav.directions ~= nav.title then
parts[#parts + 1] = nav.directions
end
return cut(table.concat(parts, " - "), max or 36) -- ~2 lines of the medium font
end

-- Traffic like Google Maps: the ETA in green (clear), orange (slow) or red (heavy). nav.traffic and
-- nav.strip come from Maps' route colours (Android 16); without them the ETA stays green.
TRAFFIC_COLORS = { clear = "#4CD98A", slow = "#FFA000", heavy = "#FF5A4F" }
function traffic_color() return TRAFFIC_COLORS[nav.traffic or ""] or "#4CD98A" end
local STRIP = { c = "#4CD98A", s = "#FFA000", h = "#FF5A4F" }
function strip_color(i)
local ch = (nav.strip or ""):sub(i, i)
return STRIP[ch] or "#2C2C2E"
end
function has_strip() return (nav.strip or "") ~= "" end

function eta_text()
if nav.eta and nav.eta ~= "" then return "ETA " .. nav.eta end
return ""
end

-- Layout 1: arrow and distance side by side, centred as a pair. The large font's width is
-- estimated (digits ~21 px, letters ~19 px, ". , space" ~9 px).
local function est_width(s)
local w = 0
for _, c in utf8.codes(s or "") do
if c >= 48 and c <= 57 then w = w + 21 elseif c == 46 or c == 44 or c == 32 then w = w + 9 else w = w + 19 end
end
return w
end
function pair_x()
local d = big_text()
if d == "" then return 96 end
return math.floor(120 - (48 + 8 + est_width(d)) / 2)
end

function bottom_line()
local parts = {}
for _, s in ipairs({ nav.eta, nav.duration }) do
if s and s ~= "" then parts[#parts + 1] = s end
end
return cut(table.concat(parts, " - "), 24)
end

layout.json:

{
"widgets": [
{
"type": "label",
"font": "medium",
"align": "center",
"y": -22,
"color": "{{ param.accent }}",
"text": "Navigation",
"hidden": "{{ not waiting() }}"
},
{
"type": "label",
"font": "small",
"align": "center",
"y": 18,
"w": 180,
"color": "#8E8E93",
"text": "{{ waiting_text() }}",
"hidden": "{{ not waiting() }}"
},
{
"type": "label",
"font": "medium",
"align": "top_mid",
"y": 22,
"color": "{{ traffic_color() }}",
"text": "{{ eta_text() }}",
"hidden": "{{ not L(1) }}"
},
{
"type": "image",
"src": "builtin:nav_icon",
"x": "{{ pair_x() }}",
"y": 66,
"hidden": "{{ not L(1) or not nav.has_icon }}"
},
{
"type": "label",
"font": "large",
"x": "{{ pair_x() + 56 }}",
"y": 62,
"color": "{{ param.accent }}",
"text": "{{ big_text() }}",
"hidden": "{{ not L(1) or big_text() == '' }}"
},
{
"type": "label",
"font": "medium",
"align": "center",
"w": 200,
"y": 32,
"color": "white",
"text": "{{ instruction(32) }}",
"hidden": "{{ not L(1) or nav.has_text_image }}"
},
{
"type": "image",
"src": "builtin:nav_text",
"align": "center",
"y": 32,
"hidden": "{{ not L(1) or not nav.has_text_image }}"
},
{
"type": "label",
"font": "small",
"align": "top_mid",
"y": 20,
"x": -36,
"color": "{{ traffic_color() }}",
"text": "{{ nav.eta or '' }}",
"hidden": "{{ waiting() or state.layout == 1 or state.layout == 4 }}"
},
{
"type": "label",
"font": "small",
"align": "top_mid",
"y": 20,
"x": 36,
"color": "{{ traffic_color() }}",
"text": "{{ nav.duration or '' }}",
"hidden": "{{ waiting() or state.layout == 1 or state.layout == 4 }}"
},
{
"type": "image",
"src": "builtin:nav_icon_large",
"x": 72,
"y": 42,
"hidden": "{{ not L(2) or not nav.has_icon }}"
},
{
"type": "label",
"font": "large",
"align": "center",
"y": 44,
"w": 220,
"color": "{{ param.accent }}",
"text": "{{ big_text() }}",
"hidden": "{{ not L(2) or big_text() == '' }}"
},
{
"type": "label",
"font": "medium",
"align": "center",
"y": 44,
"w": 200,
"color": "white",
"text": "{{ instruction(16) }}",
"hidden": "{{ not L(2) or big_text() ~= '' }}"
},
{
"type": "label",
"font": "medium",
"align": "center",
"y": -40,
"color": "{{ param.accent }}",
"text": "{{ big_text() }}",
"hidden": "{{ not L(3) or big_text() == '' }}"
},
{
"type": "label",
"font": "medium",
"align": "center",
"w": 204,
"y": "{{ big_text() == '' and 6 or 18 }}",
"color": "white",
"text": "{{ instruction(46) }}",
"hidden": "{{ not L(3) or nav.has_text_image }}"
},
{
"type": "image",
"src": "builtin:nav_text",
"align": "center",
"y": "{{ big_text() == '' and 6 or 18 }}",
"hidden": "{{ not L(3) or not nav.has_text_image }}"
},
{
"type": "label",
"font": "small",
"align": "center",
"y": -58,
"color": "#8E8E93",
"text": "Arrival",
"hidden": "{{ not L(4) }}"
},
{
"type": "label",
"font": "large",
"align": "center",
"y": -22,
"w": 220,
"color": "{{ traffic_color() }}",
"text": "{{ nav.eta ~= '' and nav.eta or '--' }}",
"hidden": "{{ not L(4) }}"
},
{
"type": "label",
"font": "medium",
"align": "center",
"y": 26,
"x": -48,
"color": "{{ traffic_color() }}",
"text": "{{ nav.duration or '' }}",
"hidden": "{{ not L(4) }}"
},
{
"type": "label",
"font": "medium",
"align": "center",
"y": 26,
"x": 48,
"color": "#8E8E93",
"text": "{{ nav.distance or '' }}",
"hidden": "{{ not L(4) }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 24,
"y": 168,
"fill": "{{ strip_color(1) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 32,
"y": 168,
"fill": "{{ strip_color(2) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 40,
"y": 168,
"fill": "{{ strip_color(3) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 48,
"y": 168,
"fill": "{{ strip_color(4) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 56,
"y": 168,
"fill": "{{ strip_color(5) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 64,
"y": 168,
"fill": "{{ strip_color(6) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 72,
"y": 168,
"fill": "{{ strip_color(7) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 80,
"y": 168,
"fill": "{{ strip_color(8) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 88,
"y": 168,
"fill": "{{ strip_color(9) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 96,
"y": 168,
"fill": "{{ strip_color(10) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 104,
"y": 168,
"fill": "{{ strip_color(11) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 112,
"y": 168,
"fill": "{{ strip_color(12) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 120,
"y": 168,
"fill": "{{ strip_color(13) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 128,
"y": 168,
"fill": "{{ strip_color(14) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 136,
"y": 168,
"fill": "{{ strip_color(15) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 144,
"y": 168,
"fill": "{{ strip_color(16) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 152,
"y": 168,
"fill": "{{ strip_color(17) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 160,
"y": 168,
"fill": "{{ strip_color(18) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 168,
"y": 168,
"fill": "{{ strip_color(19) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 176,
"y": 168,
"fill": "{{ strip_color(20) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 184,
"y": 168,
"fill": "{{ strip_color(21) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 192,
"y": 168,
"fill": "{{ strip_color(22) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 200,
"y": 168,
"fill": "{{ strip_color(23) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
},
{
"type": "rect",
"w": 7,
"h": 10,
"radius": 2,
"x": 208,
"y": 168,
"fill": "{{ strip_color(24) }}",
"hidden": "{{ not L(4) or not has_strip() }}"
}
]
}

OBD Dash: car data​

RPM, speed and fuel level from the car (obd, permission sensors.obd). The RPM gauge is an arc scaled 0 to 100; phase() picks what to show: no adapter, reading, an electric car (no RPM or fuel after a few tries), not supported, or the readings. A tap on the top switches between the gauge and tiles.

manifest.json:

{
"id": "obd-dash",
"name": "OBD Dash",
"version": "0.1.0",
"author": "Originally US",
"api_version": 1,
"permissions": ["sensors.obd", "touch"],
"features": [],
"params": [
{ "key": "max_rpm", "type": "number", "label": "Gauge full scale (rpm)", "default": 7000, "min": 3000, "max": 10000, "step": 500 },
{ "key": "accent", "type": "color", "label": "Accent colour", "default": "#E8B23A" },
{ "key": "hide_wifi", "type": "bool", "label": "Hide Wi-Fi icon", "default": true,
"help": "Hide the Wi-Fi icon and network name at the top of the screen while this app is showing" }
]
}

main.lua:

-- OBD Dash: RPM, speed and fuel level from the car's OBD-II port, through the ELM327 Bluetooth
-- adapter paired with Kamoji. Tap the top to switch between the gauge and the tiles.
-- The firmware fills `obd` (permission sensors.obd): available, connected, rpm, speed, fuel,
-- voltage, battery, and per reading has_<x> (the car answered it) / tries_<x> (times asked).
-- Electric cars answer no RPM and no fuel level: after a few tries that is shown as "EV".
state.layout = state.layout or 1
function on_top_tap()
state.layout = state.layout % 2 + 1
if kamoji.tone then kamoji.tone(1320, 30) end
end

local ASKED = 4 -- answers to wait for before deciding a reading isn't supported

-- What to show: "unavailable" (this firmware can't pair an adapter), "disconnected", "reading",
-- "ev", "unsupported" (nothing answers) or "ok".
function phase()
if not obd.available then return "unavailable" end
if not obd.connected then return "disconnected" end
local rpm_tried = (obd.tries_rpm or 0) >= ASKED
local fuel_tried = (obd.tries_fuel or 0) >= 1
local speed_tried = (obd.tries_speed or 0) >= ASKED
if not obd.has_rpm and not obd.has_speed and not obd.has_fuel then
if rpm_tried and speed_tried then
return obd.has_battery and "ev" or "unsupported"
end
return "reading"
end
if rpm_tried and not obd.has_rpm and fuel_tried and not obd.has_fuel then return "ev" end
return "ok"
end
function is(p) return phase() == p end
function L(n) return is("ok") and state.layout == n end

function rpm_text() return obd.has_rpm and fmt("%d rpm", math.floor(obd.rpm or 0)) or "-- rpm" end
function speed_text() return obd.has_speed and fmt("%d", math.floor(obd.speed or 0)) or "--" end
function fuel_text() return obd.has_fuel and fmt("Fuel %d%%", math.floor(obd.fuel or 0)) or "Fuel --" end
function fuel_color()
local f = obd.fuel or 0
if not obd.has_fuel then return "#8E8E93" end
if f < 15 then return "#FF5A4F" elseif f < 30 then return "#FFA000" end
return "#4CD98A"
end
function rpm_value() return clamp((obd.rpm or 0) / param.max_rpm * 100, 0, 100) end -- arc 0..100

function message_title()
local p = phase()
if p == "unavailable" then return "OBD-II" end
if p == "disconnected" then return "No OBD adapter" end
if p == "reading" then return "Reading your car..." end
if p == "ev" then return "Electric car" end
if p == "unsupported" then return "Not supported" end
return ""
end
function message_text()
local p = phase()
if p == "unavailable" then return "OBD-II adapters aren't available on this Kamoji yet" end
if p == "disconnected" then return "Pair your ELM327 Bluetooth adapter in Kamoji's settings and turn on the car" end
if p == "reading" then return "Asking the car for RPM, speed and fuel" end
if p == "ev" then return "Electric cars don't report RPM or fuel level" end
if p == "unsupported" then return "Your car doesn't report RPM, speed or fuel level" end
return ""
end
function ev_line()
local parts = {}
if obd.has_speed then parts[#parts + 1] = fmt("%d km/h", math.floor(obd.speed or 0)) end
if obd.has_battery then parts[#parts + 1] = fmt("Battery %d%%", math.floor(obd.battery or 0)) end
return table.concat(parts, " ")
end

layout.json:

{
"widgets": [
{
"type": "label",
"font": "medium",
"align": "center",
"y": -50,
"color": "{{ is('ev') and '#4CD98A' or param.accent }}",
"text": "{{ message_title() }}",
"hidden": "{{ is('ok') }}"
},
{
"type": "label",
"font": "small",
"align": "center",
"y": 0,
"w": 184,
"color": "#8E8E93",
"text": "{{ message_text() }}",
"hidden": "{{ is('ok') }}"
},
{
"type": "label",
"font": "small",
"align": "center",
"y": 46,
"color": "white",
"text": "{{ ev_line() }}",
"hidden": "{{ not is('ev') }}",
"w": 200
},
{
"type": "arc",
"align": "center",
"y": -6,
"w": 176,
"width": 10,
"min": 0,
"max": 100,
"start_angle": 150,
"end_angle": 30,
"track": "#2C2C2E",
"color": "{{ param.accent }}",
"value": "{{ rpm_value() }}",
"hidden": "{{ not L(1) }}"
},
{
"type": "label",
"font": "small",
"align": "center",
"y": -54,
"color": "{{ param.accent }}",
"text": "{{ rpm_text() }}",
"hidden": "{{ not L(1) }}"
},
{
"type": "label",
"font": "huge",
"align": "center",
"y": -6,
"color": "white",
"text": "{{ speed_text() }}",
"hidden": "{{ not L(1) }}"
},
{
"type": "label",
"font": "small",
"align": "center",
"y": 38,
"color": "#8E8E93",
"text": "km/h",
"hidden": "{{ not L(1) }}"
},
{
"type": "label",
"font": "small",
"align": "center",
"y": 60,
"color": "{{ fuel_color() }}",
"text": "{{ fuel_text() }}",
"hidden": "{{ not L(1) }}"
},
{
"type": "label",
"font": "small",
"align": "center",
"x": -48,
"y": -56,
"color": "#8E8E93",
"text": "RPM",
"hidden": "{{ not L(2) }}"
},
{
"type": "label",
"font": "medium",
"align": "center",
"x": -48,
"y": -30,
"color": "{{ param.accent }}",
"text": "{{ obd.has_rpm and fmt('%d', math.floor(obd.rpm or 0)) or '--' }}",
"hidden": "{{ not L(2) }}"
},
{
"type": "label",
"font": "small",
"align": "center",
"x": 48,
"y": -56,
"color": "#8E8E93",
"text": "km/h",
"hidden": "{{ not L(2) }}"
},
{
"type": "label",
"font": "medium",
"align": "center",
"x": 48,
"y": -30,
"color": "white",
"text": "{{ speed_text() }}",
"hidden": "{{ not L(2) }}"
},
{
"type": "label",
"font": "small",
"align": "center",
"x": -48,
"y": 14,
"color": "#8E8E93",
"text": "Fuel",
"hidden": "{{ not L(2) }}"
},
{
"type": "label",
"font": "medium",
"align": "center",
"x": -48,
"y": 40,
"color": "{{ fuel_color() }}",
"text": "{{ obd.has_fuel and fmt('%d%%', math.floor(obd.fuel or 0)) or '--' }}",
"hidden": "{{ not L(2) }}"
},
{
"type": "label",
"font": "small",
"align": "center",
"x": 48,
"y": 14,
"color": "#8E8E93",
"text": "Battery",
"hidden": "{{ not L(2) }}"
},
{
"type": "label",
"font": "medium",
"align": "center",
"x": 48,
"y": 40,
"color": "#64D2FF",
"text": "{{ obd.has_voltage and fmt('%.1f V', obd.voltage or 0) or '--' }}",
"hidden": "{{ not L(2) }}"
}
]
}