Skip to main content

Sideloading

Anyone can install a Kamoji Mini App on their own Kamoji without going through the catalogue: a mini app you are writing, or one someone sent you. This page covers the Kamoji app's Install from file and, for tool builders, the Bluetooth commands behind every install.

Only install mini apps you trust

Sideloaded mini apps run in a sandbox: they cannot touch Kamoji's files or settings, and can only reach the network over MQTT when they declare it. They can still show anything on the screen and send what they read from Kamoji's sensors to an MQTT broker. Install mini apps from people you trust.

Packaging a mini app​

A mini app travels as a .zip of the mini app folder's contents, with manifest.json at the root of the zip (not inside a sub-folder):

hello-counter.zip
manifest.json
layout.json
main.lua

On macOS or Linux, from inside the folder:

cd hello-counter
zip -r -X ../hello-counter.zip . -x '.*' -x '__MACOSX*'

Avoid Finder's or Explorer's "Compress": they put the folder itself at the root, and macOS adds hidden ._ files.

Bundle rules:

RuleLimit
Total size2 MB
Files64
Path of each fileAt most 63 bytes, at most 3 levels deep, /-separated
NamesNo .., no absolute paths, nothing starting with .
Manifestid equal to the folder name the mini app is installed as, api_version 1

:::tip Share it with everyone The same .zip can be submitted to the Mini Apps store at developer.kamoji.bot: see Publishing to the Mini Apps store. :::

Install from file​

  1. Put the .zip on your phone (AirDrop, a file share, a download).
  2. Connect to Kamoji in the Kamoji app and open the Apps screen.
  3. Choose Install from file and pick the .zip.

The Kamoji app sends it to Kamoji over Bluetooth and tells you when it is installed. Installing a new version of a mini app with the same id replaces the old one; if that mini app is on screen, it restarts.

Bluetooth install protocol​

The rest of this page is for people building their own tools. It uses the command channel described in Bluetooth Protocol: connect and authorize first. No older command starts with AP, so firmware without mini apps answers UNKNOWN_CMD:APPS_LIST: that Kamoji needs a firmware update to use mini apps.

CommandAnswer
APPS_LISTAPP:ITEM:<ok>:<version>:<id>:<name> per mini app, then APP:LIST_END:<count>
APP_PUT:<size>:<md5>:<app_id>:<path>ASSET:READY:<N>, then the file transfer, ending in ASSET:SAVED:<app_id>:<path>
APP_COMMIT:<app_id>APP:INSTALLED:<app_id>:<version> or APP:ERR:<code>
APP_ABORT:<app_id>APP:ABORTED:<app_id>
APP_REMOVE:<app_id>APP:REMOVED:<app_id> or APP:ERR:<code>
APP_PARAMS:<app_id>APP:PARAM:<app_id>:<json> per setting, then APP:PARAMS_END:<app_id>:<count>
APP_SET:<app_id>:<key>:<json value>APP:SET_OK:<app_id>:<key> or APP:ERR:<code>

Listing mini apps: APPS_LIST​

── APPS_LIST ─────────────────────────────────▶
◀────────────── APP:ITEM:1:0.3.0:clock:Clock
◀────────────── APP:ITEM:0:1.0.0:broken-app:Broken App
◀────────────── APP:LIST_END:2

ok is 1 for a mini app that can run and 0 for one that cannot (a broken manifest, an unsupported api_version). The name is the last field and may contain :.

The list is in alphabetical order of the ids, and the mini apps that can run (ok = 1) are modes 6, 7, 8… in that order. SET_MODE:<n> (or the ui_mode setting) with one of those numbers switches Kamoji to that mini app.

Installing: APP_PUT and APP_COMMIT​

An install sends every file of the mini app into a staging area, then commits it in one step. A failed or abandoned install leaves the installed version, if any, untouched.

You Kamoji
── APP_PUT:<size>:<md5>:<app_id>:manifest.json ──▶
◀────────────────────────────────── ASSET:READY:<N>
── ASSET_DATA <offset 0> … ─────────────────────▶ acked like ASSET_PUT
◀────────────────── ASSET:SAVED:<app_id>:manifest.json
── APP_PUT:… layout.json, main.lua, … ─────────▶ one file at a time
── APP_COMMIT:<app_id> ─────────────────────────▶
◀────────────────────── APP:INSTALLED:<app_id>:<version>
  1. APP_PUT:<size>:<md5>:<app_id>:<path> announces one file: its size in bytes, its MD5 (lowercase hex), the mini app's id and the file's path inside the mini app folder (main.lua, assets/icon.png). The path follows the bundle rules above. The file is written to /storage/apps/.staging/<app_id>/<path>.
  2. After ASSET:READY:<N>, send the file exactly as for ASSET_PUT: binary ASSET_DATA writes (the 11 ASCII bytes ASSET_DATA:, a 4-byte little-endian offset, then the bytes), at most 2 × N bytes ahead of the last ASSET:ACK:<received>, resending from ASSET:RESEND:<received>. Kamoji checks the MD5 and answers ASSET:SAVED:<app_id>:<path>.
  3. Repeat for every file, then send APP_COMMIT:<app_id>. Kamoji checks the staged manifest.json (its id and api_version), replaces /storage/apps/<app_id> with the staged folder in one step, and looks for mini apps again. The installed copy's config.json (the user's settings) and data/ folder are carried over, unless the new bundle ships its own. If that mini app is on screen, it restarts with the new version. The answer is APP:INSTALLED:<app_id>:<version>.
  4. To give up half-way, send APP_ABORT:<app_id>: Kamoji deletes the staging folder and answers APP:ABORTED:<app_id>. Do the same after an error.

A refused command is answered with APP:ERR:<code>; errors during the transfer itself are the ASSET:ERR: codes of ASSET_PUT.

APP:ERR: codeMeaning
busyAnother transfer or update is running: try again in a moment
bad_idThe app id does not follow the rules (lowercase letters, digits and -, 2–40 characters)
bad_pathThe path breaks the bundle rules
too_bigThe file or the mini app is over the size limit
no_spaceNot enough room in Kamoji's storage
md5A file does not match its MD5
no_manifestAPP_COMMIT without a staged manifest.json
bad_manifestThe staged manifest is not valid JSON, its id is not the app id, or its api_version is not supported
not_foundNo such mini app (or nothing staged)
fsKamoji could not write to its storage: try again
bad_key, bad_valueAPP_SET only: see below

Removing: APP_REMOVE​

APP_REMOVE:<app_id> deletes the mini app with its settings and answers APP:REMOVED:<app_id>, or APP:ERR:not_found. If the mini app is on screen, Kamoji goes back to mode 0 first.

Settings: APP_PARAMS and APP_SET​

The Kamoji app's settings form for a mini app is built entirely from these two commands; it needs no copy of the manifest.

APP_PARAMS:<app_id> answers one line per entry of the manifest's params, then APP:PARAMS_END:<app_id>:<count>. Each line carries the manifest entry plus its current value (from config.json, else the default):

APP:PARAM:clock:{"key":"utc_offset","type":"number","label":"UTC offset (hours)","default":8,"min":-12,"max":14,"step":0.25,"value":8}
APP:PARAM:clock:{"key":"accent","type":"color","label":"Accent colour","default":"#64D2FF","value":"#FF9F0A"}
APP:PARAM:clock:{"key":"label","type":"string","label":"City","default":"Singapore","value":"London"}
APP:PARAMS_END:clock:3

A secret never sends its value or its default, only whether it is set:

APP:PARAM:mqtt-value:{"key":"pass","type":"secret","label":"Password","optional":true,"set":true}

APP_SET:<app_id>:<key>:<json value> changes one setting. The value is JSON: APP_SET:clock:utc_offset:5.5, APP_SET:clock:label:"London", APP_SET:excite-o-meter:sounds:false. Kamoji checks it against the setting:

TypeAccepted
numberA number within min / max
booltrue or false
stringA string
enumOne of options
color"#RGB" or "#RRGGBB"
secretA string

null clears a secret or an optional setting, so its default applies again; for any other setting it is refused.

It saves the value to the mini app's config.json in one step (written to a temporary file, then renamed into place) and answers APP:SET_OK:<app_id>:<key>. If that mini app is on screen, it restarts so the change shows at once. Errors: APP:ERR:bad_id, APP:ERR:not_found (no such mini app), APP:ERR:bad_key (no such setting), APP:ERR:bad_value (the value does not fit), APP:ERR:fs.

Keep each setting's manifest entry short (labels, help, options): every APP:PARAM line has to fit in one notification, about 400 bytes.

Python example​

This installs a mini app folder, using authorize() and the connection code from DIY with Python:

import asyncio
import hashlib
import struct
from pathlib import Path


async def next_answer(answers, *prefixes, timeout=10):
while True:
msg = await asyncio.wait_for(answers.get(), timeout)
if msg.startswith(prefixes):
return msg


async def put_file(client, answers, app_id, rel, data):
md5 = hashlib.md5(data).hexdigest()
await client.write_gatt_char(CMD, f"APP_PUT:{len(data)}:{md5}:{app_id}:{rel}".encode(), response=True)
ready = await next_answer(answers, "ASSET:READY:", "ASSET:ERR:", "APP:ERR:")
if not ready.startswith("ASSET:READY:"):
raise RuntimeError(ready)
window = 2 * int(ready.split(":")[2])
chunk = client.mtu_size - 3 - 15 # "ASSET_DATA:" + 4-byte offset
acked = offset = 0
while acked < len(data):
while offset < len(data) and offset < acked + window:
part = data[offset:offset + chunk]
await client.write_gatt_char(
CMD, b"ASSET_DATA:" + struct.pack("<I", offset) + part, response=False)
offset += len(part)
try:
msg = await next_answer(answers, "ASSET:ACK:", "ASSET:RESEND:", "ASSET:SAVED:",
"ASSET:ERR:", "APP:ERR:", timeout=3)
except asyncio.TimeoutError:
offset = acked
continue
if msg.startswith("ASSET:SAVED:"):
return
if "ERR:" in msg:
raise RuntimeError(msg)
acked = max(acked, int(msg.split(":")[2]))
if msg.startswith("ASSET:RESEND:"):
offset = int(msg.split(":")[2])
msg = await next_answer(answers, "ASSET:SAVED:", "ASSET:ERR:", "APP:ERR:")
if "ERR:" in msg:
raise RuntimeError(msg)


async def install_app(client, answers, folder):
folder = Path(folder)
app_id = folder.name
files = [p for p in sorted(folder.rglob("*"))
if p.is_file() and not any(part.startswith(".") for part in p.relative_to(folder).parts)]
try:
for p in files:
rel = p.relative_to(folder).as_posix()
print("->", rel)
await put_file(client, answers, app_id, rel, p.read_bytes())
await client.write_gatt_char(CMD, f"APP_COMMIT:{app_id}".encode(), response=True)
msg = await next_answer(answers, "APP:INSTALLED:", "APP:ERR:")
if msg.startswith("APP:ERR:"):
raise RuntimeError(msg)
print("<-", msg)
except Exception:
await client.write_gatt_char(CMD, f"APP_ABORT:{app_id}".encode(), response=True)
raise

Call it inside the async with BleakClient(...) block, after authorize():

await client.write_gatt_char(CMD, b"LINK_FAST", response=True)
await install_app(client, answers, "hello-counter")