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.
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:
| Rule | Limit |
|---|---|
| Total size | 2 MB |
| Files | 64 |
| Path of each file | At most 63 bytes, at most 3 levels deep, /-separated |
| Names | No .., no absolute paths, nothing starting with . |
| Manifest | id 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
- Put the
.zipon your phone (AirDrop, a file share, a download). - Connect to Kamoji in the Kamoji app and open the Apps screen.
- 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.
| Command | Answer |
|---|---|
APPS_LIST | APP: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>
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>.- After
ASSET:READY:<N>, send the file exactly as forASSET_PUT: binaryASSET_DATAwrites (the 11 ASCII bytesASSET_DATA:, a 4-byte little-endian offset, then the bytes), at most 2 × N bytes ahead of the lastASSET:ACK:<received>, resending fromASSET:RESEND:<received>. Kamoji checks the MD5 and answersASSET:SAVED:<app_id>:<path>. - Repeat for every file, then send
APP_COMMIT:<app_id>. Kamoji checks the stagedmanifest.json(itsidandapi_version), replaces/storage/apps/<app_id>with the staged folder in one step, and looks for mini apps again. The installed copy'sconfig.json(the user's settings) anddata/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 isAPP:INSTALLED:<app_id>:<version>. - To give up half-way, send
APP_ABORT:<app_id>: Kamoji deletes the staging folder and answersAPP: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: code | Meaning |
|---|---|
busy | Another transfer or update is running: try again in a moment |
bad_id | The app id does not follow the rules (lowercase letters, digits and -, 2–40 characters) |
bad_path | The path breaks the bundle rules |
too_big | The file or the mini app is over the size limit |
no_space | Not enough room in Kamoji's storage |
md5 | A file does not match its MD5 |
no_manifest | APP_COMMIT without a staged manifest.json |
bad_manifest | The staged manifest is not valid JSON, its id is not the app id, or its api_version is not supported |
not_found | No such mini app (or nothing staged) |
fs | Kamoji could not write to its storage: try again |
bad_key, bad_value | APP_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:
| Type | Accepted |
|---|---|
number | A number within min / max |
bool | true or false |
string | A string |
enum | One of options |
color | "#RGB" or "#RRGGBB" |
secret | A 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")