Bluetooth Protocol
The Kamoji companion app talks to Kamoji over Bluetooth Low Energy (BLE): it reads its firmware version, changes its settings and mode, plays its sounds and animations, shows your own photos, GIFs and videos on its screen, streams its motion sensor, downloads its Black Box recordings and controls its RGB lights. You can do the same from your own code. This page documents that channel, with Python examples you can run from a laptop.
BLE is Kamoji's control channel: short text commands and answers. Firmware updates and the asset files are sent over Wi-Fi instead, and are not covered here. Audio recording also travels over Wi-Fi, but is unlocked over BLE, so it is covered below.
The photo and animation commands (IMG_*) need firmware 0.1.56 or later;
Diagnostics and the Black Box
need 0.1.57 or later, and the RGB lights 0.1.58 or later.
Audio recording as described here needs
0.1.75 or later.
From firmware 0.1.60, Kamoji only obeys devices it trusts: see
Authorization (older firmware skips that step).
Everything else on this page works on earlier firmware too.
Check your version with GET_INFO (below), or on the device's
Info page.
Kamoji accepts a single BLE connection. Disconnect it in the Kamoji app (or close the app) before connecting from your own code, otherwise it will not show up in a scan.
Finding and connecting
Kamoji advertises as KMJ-XXXX (older firmware: KM-XXXX), where XXXX
comes from its MAC address, with this GATT service:
| UUID | Use | |
|---|---|---|
| Service | 12345678-1234-5678-1234-56789abcdef0 | Kamoji control |
CMD characteristic | 12345678-1234-5678-1234-56789abcdef1 | Write your commands here |
TX characteristic | 12345678-1234-5678-1234-56789abcdef3 | Notify: Kamoji's answers arrive here |
The scan response carries manufacturer data (company id 0xFFFF), so you can
tell which Kamoji it is before connecting:
| Bytes (after the company id) | Content |
|---|---|
| 0-5 | Bluetooth MAC address |
| 6 | Hardware revision: 7 = R7, 8 = R8, 0 = unknown |
| 7 onwards | Firmware version as ASCII, e.g. 0.1.54 |
Very old firmware has no revision byte: byte 6 is then the first character of
the version (always 0x20 or above), which is how you tell the two layouts
apart.
Kamoji takes one Bluetooth connection at a time. While a phone is connected,
newer firmware keeps advertising, but not connectable, so you can tell a
Kamoji that is in use from one that is out of range (read the connectable flag
of the advertisement: kCBAdvertisementDataIsConnectable on iOS,
ScanResult.isConnectable() on Android). That advertisement is a single packet
with the name and the same manufacturer data, without the service UUID and
without a scan response. Older firmware stops advertising while connected.
Authorization
Kamoji only obeys phones and computers it trusts, so nobody outside your car can take it over. There is no pairing screen and no code: Kamoji trusts a new device if it is close by the first time it connects (inside the car, not outside it), and from then on that device works from any distance.
- Kamoji measures the distance itself, from the signal strength of the connection (by default it needs -72 dBm or stronger from a new device, a few metres at most).
- It remembers 10 devices. When an 11th joins, it forgets the one it heard from least recently.
- Each device has a secret key (32 random bytes) and a public id: the first 8 bytes of SHA-256(key). The key goes over the air only once, when the device joins; after that it proves it has the key instead of sending it.
Run this right after connecting (after enabling TX notifications), before
any other command:
You Kamoji
── AUTH_HELLO:<id hex> ──────────────────────────▶
a device it trusts:
◀──────────────────────────── AUTH:CHALLENGE:<nonce hex>
── AUTH_PROOF:<HMAC-SHA256(key, nonce) hex> ─────▶
◀──────────────────────────── AUTH:OK:0:<trusted devices>
a new device, close by:
◀──────────────────────────── AUTH:ENROLL:<signal dBm>
── AUTH_ENROLL:<key hex> ────────────────────────▶
◀──────────────────────────── AUTH:OK:1:<trusted devices>
a new device, too far:
◀──────────────────────────── AUTH:ERR:FAR:<signal dBm>:<minimum dBm>
The HMAC is computed over the nonce's bytes (decode the hex first). A
wrong proof is answered AUTH:ERR:BAD_PROOF and the connection is dropped.
Other errors are AUTH:ERR:ARGS (malformed) and AUTH:ERR:STATE (a proof or
key without its AUTH_HELLO).
Until AUTH:OK, Kamoji answers every command except GET_INFO with
AUTH:REQUIRED:<NAME>, and it drops a connection that has not
authorized within 30 seconds. Firmware from before authorization answers
AUTH_HELLO with UNKNOWN_CMD:AUTH_HELLO (or not at all): just carry on.
To make Kamoji forget a device (no AUTH_HELLO needed), send
AUTH_FORGET:<id hex>:<HMAC-SHA256(key, "forget") hex>, the HMAC taken over
the 6 ASCII bytes forget. Kamoji answers AUTH:FORGOT:<devices left>, or
AUTH:ERR:UNKNOWN if it did not trust that device.
Keep the key private: whoever has it can control every Kamoji that trusts it.
Commands and answers
- A command is plain ASCII text written to
CMD, one command per write. Never split a command across several writes. A write holds up to the negotiated MTU minus 3 bytes (MTU is usually 185-256 on phones and laptops). - Commands with a value use
NAME:value, e.g.SET_MODE:3. - Answers come back as notifications on
TX, one message per notification, in the sameTOPIC:field:fieldstyle. - A command the firmware does not know is answered with
UNKNOWN_CMD:<NAME>(firmware R7 0.1.46 / R8 0.1.28 and later; older firmware stays silent). Always use a timeout when waiting for an answer. - The link slows down after about 5 seconds without traffic (to save power),
which delays commands by up to a second. Send
LINK_FASTbefore a burst of commands; it keeps the link fast for 8 seconds, so repeat it every 5 seconds while you are active. - Send
LINK_ALIVEevery 5 seconds while you stay connected (newer firmware). Once you have sent it, Kamoji drops your link after 60 seconds without any command from you, so a script or app that crashes cannot keep Kamoji from other phones. If you never send it, your link is not dropped for being quiet.
Common commands
| Command | Answer | What it does |
|---|---|---|
AUTH_HELLO:<id>, AUTH_PROOF:<hmac>, AUTH_ENROLL:<key> | See Authorization | Prove this device is trusted (first) |
GET_INFO | FW:<version>, AT:<asset count>, AMD5:<asset md5>, NICK:<nickname>, UPTIME:<seconds> | Device information; UPTIME is Kamoji's lifetime uptime, all power-ons together (newer firmware) |
GET_SETTINGS | SETTING:<key>=<value> for each setting, then SETTINGS:END | Every setting of the Settings menu, and more |
SET_SETTING:<key>=<value> | SETTING:<key>=<value>, or SETTING:ERR:<key>:<code> | Change a setting (saved right away) |
SET_MODE:<n> | SETTING:ui_mode=<n> | Switch mode, like a swipe on the device (refused while it plays with another Kamoji) |
SET_NICKNAME:<name> | NICK:<name>, or NICK:ERR:<code> (TOO_LONG, BAD_CHAR) | Rename it (SET_NICKNAME: alone = back to the default). Any UTF-8 text without control characters: spaces, punctuation, accents, emoji; not /, .. or a leading ERR:; at most 20 characters and 63 bytes (older firmware: printable ASCII without : only) |
PLAY_FILE:<file> | PLAY:OK:<file>, or PLAY:ERR:<reason>:<file>; later PLAY:END:<file> | Play a stored animation (.gif) or sound (.wav) once |
PLAY_LOOP:<file> | Same as PLAY_FILE | Play it repeatedly until STOP_PLAY |
STOP_PLAY | None | Stop what PLAY_FILE / PLAY_LOOP started |
GET_DRIVE_STATS | DRIVE_TOTAL:…, DRIVE_TRIP:… per trip, then DRIVE_STATS:END:<n> | Drive statistics (R8) |
IMU_STREAM:<hz>, IMU_STOP | IMU:ON:<hz>, then IMU:D:…; IMU:OFF | Live motion-sensor readings: see Diagnostics |
BBOX_LIST, BBOX_GET:<trip>:<seq>, BBOX_TAIL:<gen>:<offset> | See Black Box | List and download Kamoji's own recordings |
TIME_SET:<epoch ms> | TIME:OK:<source> | Give Kamoji the time, if it has none yet: see Setting the clock |
WIFI_INFO:<json>, WIFI_AP_DISCONNECT | WIFI_CHECK:… or WIFI_AP:… | Find Kamoji on Wi-Fi: see Reaching Kamoji over Wi-Fi |
GET_MIC_TOKEN | MIC_TOKEN:<token> | Get this phone's key for audio recording |
LED_SET:…, LED_GET, LED_AUTO | LED:STATE:… (LED_SET: none) | Set or read the RGB lights: see RGB lights |
APPS_LIST, APP_PUT:…, APP_COMMIT:<id>, APP_ABORT:<id>, APP_REMOVE:<id>, APP_PARAMS:<id>, APP_SET:… | APP:… | List, install, remove and configure Kamoji Mini Apps: see Kamoji Mini Apps |
LINK_FAST | None | Keep the BLE link fast for 8 s |
LINK_ALIVE | None | Heartbeat: after the first one, 60 s without any command drops the link |
CHECK_UPDATE | None | Check the update server for new firmware now |
REBOOT | None | Restart Kamoji |
GET_SETTINGS lists the setting keys and their current values. Mode numbers
are the values of the ui_mode setting. To reach Kamoji over Wi-Fi, see
Reaching Kamoji over Wi-Fi. The app also uses
firmware-update commands, which are not covered here.
Do not invent command names that begin with START or END: firmware from
before mid-2026 treated those as a Bluetooth firmware update, and START
erases the update partition.
Showing your own photo or animation
Kamoji can show a photo (a JPEG) or an animation (an animated GIF) over everything on its screen for a number of seconds, then go back to what it was doing. The file is streamed over BLE in chunks.
What Kamoji accepts
| Photo | Animation | |
|---|---|---|
| Format | Baseline JPEG (not progressive) | GIF (animated or not) |
| Size on screen | 240 × 240 (larger pictures are scaled down 1/2, 1/4 or 1/8 by the decoder, smaller ones are centred) | 240 × 240 canvas, looped |
| File size | Up to 512 KB (aim for 10-30 KB: it takes about 1 s per 12 KB) | Up to 512 KB (the app keeps GIFs at 250 KB or less) |
| How long it stays up | 1 to 600 seconds | 1 to 600 seconds |
Kamoji tells the two apart by the file's first bytes (GIF8 = a GIF). Ask
before preparing a file: IMG_CAPS answers IMG:CAPS:<max bytes>:<formats>,
e.g. IMG:CAPS:524288:jpeg,gif. A GIF is drawn at its own size from the top-left
corner (not scaled), so make it exactly 240 × 240.
Prepare the file on your side: crop it square and resize it to 240 × 240. For
an animation, fewer frames and colours, and frames that only change a little
from one to the next, make a much smaller GIF.
The transfer
You Kamoji
── IMG_BEGIN:<bytes>:<seconds> ─────────────▶
◀──────────────────────────────── IMG:READY:<N> (or IMG:ERR:<code>)
── IMG_DATA <offset 0> ───────────────────────▶
── IMG_DATA <offset 240> ──────────────────────▶ … as fast as the link takes
◀──────────────────────────────── IMG:ACK:<received> every N bytes
── IMG_DATA … ────────────────────────────────▶
◀──────────────────────────────── IMG:ACK:<bytes> the last byte is in
◀──────────────────────────────── IMG:OK on screen
… <seconds> later …
◀──────────────────────────────── IMG:END:finished
-
IMG_BEGIN:<bytes>:<seconds>announces the file's size and how long it stays up. Kamoji answersIMG:READY:<N>(it will acknowledge everyNbytes, currently 4096), orIMG:ERR:<code>. -
IMG_DATAwrites carry the file, in order. They are binary, not text: the 9 ASCII bytesIMG_DATA:, then the chunk's offset in the file as a 4-byte little-endian unsigned integer, then the bytes themselves, filling the write (MTU − 3 − 13 bytes of data each):49 4D 47 5F 44 41 54 41 3A | 00 10 00 00 | FF D8 FF E0 …"IMG_DATA:" offset 4096 dataSend them as writes without response, so many fit in each radio exchange. A write with response works too, but is several times slower.
-
Kamoji answers
IMG:ACK:<received>each time anotherNbytes are in, and once the whole file is in. Stay at most 2 × N bytes ahead of the last ack. -
Writes without response can be dropped by your Bluetooth stack when its queue is full. When a chunk arrives at the wrong offset, Kamoji ignores it and answers
IMG:RESEND:<received>once: resend from that offset. If no ack comes for about 3 seconds, resend from the last acked offset. -
Once the last byte is in, Kamoji decodes the file:
IMG:OK= on screen, orIMG:ERR:<code>. When it leaves the screen, Kamoji sendsIMG:END:<why>:finished(its time is up),stopped, orreplaced(another photo, or something else took the screen).
IMG_STOP ends the photo on screen now, or drops a transfer in progress.
A new IMG_BEGIN while a photo is up replaces it without a gap. A transfer
that receives nothing for 15 seconds is dropped (IMG:ERR:timeout).
IMG:ERR: code | Meaning |
|---|---|
size | Size is 0, over the limit, or the data overran it. Sent as IMG:ERR:size:<max bytes> |
duration | Seconds outside 1-600 |
busy | A firmware or asset upload is running: try again afterwards |
mem | Not enough memory right now: try again |
state | IMG_DATA without a transfer in progress |
timeout | The transfer stalled |
decode | The file could not be decoded (a progressive JPEG, a broken GIF…) |
unavailable | The screen could not be taken over |
While a photo or animation is up, Kamoji ignores touches and its mood animations, closes its settings menu, and pauses the current mode. All of them come back when it ends.
Updating Kamoji's files
Kamoji's animations and sounds are files in its storage (its "inventories").
A few changed files can be sent straight over BLE, with no Wi-Fi at all; the
app does this by itself on connect when fewer than 10 files changed. A larger
update goes over Wi-Fi. Firmware without it answers
UNKNOWN_CMD:ASSET_LIST.
You Kamoji
── ASSET_LIST ──────────────────────────────▶
◀──────────────────────── ASSET:FILE:<size>:<md5>:<name> one per file
◀──────────────────────── ASSET:LIST_END:<files>
── ASSET_PUT:<size>:<md5>:<name> ───────────▶
◀──────────────────────────────── ASSET:READY:<N> (or ASSET:ERR:<code>)
── ASSET_DATA <offset 0> ─────────────────────▶ … acked like IMG_DATA
◀──────────────────────────────── ASSET:SAVED:<name>
── ASSET_SET:<set md5> ─────────────────────▶ once every file is in
◀──────────────────────────────── ASSET:SET_OK:<files>
ASSET_LISTlists every file with its size and MD5 (lowercase hex). Kamoji reads every file to hash it, so the list takes a few seconds. Compare it with your own files: only send the ones it lacks or holds with another MD5.ASSET_PUT:<size>:<md5>:<name>announces one file (at most 1 MB; names use onlyA-Z a-z 0-9 _ - .). AfterASSET:READY:<N>, send the file exactly like a photo: 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>when a write was lost.- Once the last byte is in, Kamoji checks the MD5 and replaces the old file in
one step:
ASSET:SAVED:<name>. On any error the old file stays. ASSET_SET:<md5>stores the MD5 of the whole set, the valueGET_INFOreports asAMD5, so the app sees the Kamoji as up to date. It answersASSET:SET_OK:<file count>.
ASSET_STOP drops a transfer in progress. A transfer that receives nothing
for 15 seconds is dropped (ASSET:ERR:timeout). Expect about 12 KB per second.
ASSET:ERR: code | Meaning |
|---|---|
busy | Kamoji is updating itself (files or firmware): try again in a moment |
args / size / name | A malformed command, a size of 0 or over 1 MB, or a name it does not accept |
md5 | The file that arrived does not match its MD5 |
space | Not enough room in its storage |
mem | Not enough memory right now: try again |
state | ASSET_DATA without a transfer in progress |
timeout | The transfer stalled |
write / rename / fs | The file could not be written: try again |
Diagnostics: live motion data
Kamoji can stream its motion sensor (accelerometer and gyroscope), its fused tilt angles and its light sensor, live. The app's Diagnostics page shows this stream.
You Kamoji
── IMU_STREAM:<hz> ─────────────────────────▶
◀──────────────────────────────── IMU:ON:<hz> (or IMU:ERR:rate)
◀──────────────────────────────── IMU:D:… every ~40 ms
── IMU_STREAM:<hz> ─────────────────────────▶ again within 10 s
◀──────────────────────────────── IMU:D:…
── IMU_STOP ────────────────────────────────▶
◀──────────────────────────────── IMU:OFF
IMU_STREAM:<hz>starts the stream at<hz>readings per second: a whole number, 1 or more. Rates above 100 become 100, andIMU:ON:<hz>echoes the rate you get (IMU_STREAM:200→IMU:ON:100). Anything else (empty,0,10.5) is answeredIMU:ERR:rate.- Send
IMU_STREAMagain at least every 10 seconds (the app does it every 4). Otherwise the stream stops by itself 10 seconds after the last one. It also stops when you disconnect. Neither sends a notification. SendingIMU_STREAMwhile it runs changes the rate. IMU_STOPstops it now and answersIMU:OFF.
Readings arrive in batches, about every 40 ms (4 readings per notification at 100 Hz, 1 at 10 Hz):
IMU:D:<t_ms>:<interval_ms>:<reading>;<reading>;…;
IMU:D:812345:10:12,-34,9810,5,-3,0,120,-50,3,-1,0,1650;14,-30,9808,4,-2,1,121,-50,4,-1,0,1650;
<t_ms> is Kamoji's clock (milliseconds since it started) at the first
reading; the next ones follow <interval_ms> apart (1000 ÷ rate, rounded
down). The stream is lossy: a notification the Bluetooth link has no room
for is dropped, not queued, so look for gaps in <t_ms>.
Each reading is 12 whole numbers, ax,ay,az,gx,gy,gz,pitch,roll,gfx,gfy,gfz,light:
| Fields | What | Unit |
|---|---|---|
ax, ay, az | Acceleration, gravity included, in the sensor's axes | mm/s² |
gx, gy, gz | Rotation rate | 0.1 °/s |
pitch, roll | Tilt angle, fused from both sensors | 0.01 ° |
gfx, gfy, gfz | G-force: acceleration with gravity removed, in the sensor's axes | 1/1000 g |
light | Light sensor | mV (0-5000), -1 = no reading yet |
Black Box
Kamoji records its own motion sensor all the time, like a flight recorder, and keeps about the last hour. Every time it starts (on R7, every time the car is switched on) begins a new trip, numbered from a counter that survives power cuts. The recording stays on Kamoji until you download it.
What it records
Each sample holds 9 values: acceleration ax ay az (0.01 m/s², gravity
included), rotation gx gy gz (0.1 °/s), tilt pitch roll (0.01 °) and the
light sensor (mV, -1 = no reading). Note the units differ from the
Diagnostics stream, and there is no G-force: work it out from the
acceleration if you need it.
- Continuously, 25 samples per second.
- Bursts of 100 samples per second around sharp events: when the
acceleration moves more than 2 m/s² away from its average over the last
second, or the rotation passes 60 °/s on any axis. A burst includes the
second before the event and runs until 2 seconds after the last sharp
sample, 6 seconds at most, with at most 6 bursts a minute. Kamoji also takes
a short burst every 4-8 minutes while moving, to tune its detectors (reason
3below: not an event). - Idle: after 60 seconds without motion, Kamoji marks the recording idle and pauses the continuous samples until something moves. Bursts can still happen.
- Driving events from Kamoji's own detectors: hard braking, hard acceleration, a hard lean either way, a bump.
- The time, once per trip, as soon as Kamoji knows the real time (from
Wi-Fi, or from the app with
TIME_SET).
Recordings are collected in memory and written to Kamoji's storage as chunks when the car has stopped for 3 seconds, and at the latest every 10 minutes. Whatever is still in memory is lost if the power goes. Kamoji keeps at most about 1 hour of recording (and at most about 1.1 MB), deleting the oldest chunks first. From firmware 0.1.74, an animation or sound update that needs the room also deletes the oldest chunks.
Listing and downloading
Send one Black Box request at a time and wait for its last line
(BBOX:END, BBOX:DONE, BBOX:TEND or BBOX:ERR) before the next. From
firmware 0.1.75, a BBOX_GET sent while a BBOX_TAIL is still streaming is
refused with BBOX:ERR:busy; older firmware accepts it and mixes the two
answers' BBOX:DATA lines.
BBOX_LIST describes the current trip, then every chunk on storage, oldest
first:
BBOX:NOW:<trip>:<uptime ms>:<unix time s>:<pending ms>
BBOX:F:<trip>:<seq>:<bytes>:<first ms>:<last ms>:<recorded ms> one per chunk
BBOX:END:<chunks>
BBOX:NOW: the current trip, Kamoji's clock now, the Unix time now (0if it does not know the time) and how much recording is still in memory only.BBOX:F: one chunk,<trip>and<seq>name it.<bytes>is the whole file,<first ms>and<last ms>are Kamoji's clock in that trip.
In the first seconds after Kamoji starts, the list can be empty (BBOX:END:0):
ask again.
BBOX_GET:<trip>:<seq> sends one chunk file:
BBOX:BEGIN:<trip>:<seq>:<bytes>:<crc32>
BBOX:DATA:<offset>:<base64> … as many as it takes
BBOX:DONE:<trip>:<seq>:<bytes>
Unlike a photo, the data comes from Kamoji, as text notifications:
<offset> is where the piece goes in the file, followed by the bytes in
standard base64. There are no acks to send: the lines arrive in order. Check
the length and the CRC-32 (8 hex digits, the standard zlib CRC-32, so Python's
zlib.crc32() matches) once BBOX:DONE arrives. There is no resume: after a
CRC mismatch or a disconnect, ask for the chunk again.
BBOX:ERR: code | Meaning |
|---|---|
args | A malformed command |
busy | Another BBOX_GET or BBOX_TAIL is running: wait for it |
missing | No such chunk (it may just have been deleted to make room), or it could not be read |
io | Not enough memory right now: try again |
Recording not yet on storage
BBOX_TAIL:<gen>:<offset> sends the part of the current trip that is still
in memory. The app polls it for its live view. Start with BBOX_TAIL:0:0:
BBOX:TAIL:<trip>:<gen>:<offset>:<pending bytes>:<pending ms>
BBOX:DATA:<offset>:<base64> …
BBOX:TEND:<gen>:<next offset>
Then ask again with the <gen> and <next offset> from BBOX:TEND to get
only what is new. Once Kamoji writes the memory out as a chunk, <gen>
changes and it starts again from offset 0 (the old part is now a chunk in
BBOX_LIST), so always continue from the values Kamoji echoes. One answer
carries at most 48 KB: ask again from <next offset> for the rest. The data
is records without the 40-byte header, and a piece can
end in the middle of a record, so add it to what you have before decoding.
Setting the clock
TIME_SET:<unix time ms> gives Kamoji the phone's time. The app sends it on
every connect. Kamoji only takes it if it does not know the time yet (Wi-Fi
time, or an earlier TIME_SET, wins), and ignores times before November 2023.
The answer, TIME:OK:<source>, tells you where Kamoji's time comes from,
not that it took yours: 1 Wi-Fi, 2 the app, or 0 Kamoji still does not
know the time (for example, yours was before November 2023 and it has no
Wi-Fi time either). A malformed time is answered TIME:ERR:args.
On firmware before 0.1.75, the answer is never 0: it says 1 even when the
clock was never set, and a malformed time is answered BBOX:ERR:args.
Chunk file format
All numbers are little-endian. The file starts with a 40-byte header:
| Bytes | Content |
|---|---|
| 0-3 | KBX1 |
| 4 | Format version (1) |
| 5, 6 | Continuous and burst rate in Hz (25, 100) |
| 7 | Values per sample (9) |
| 8-11, 12-15 | Trip, sequence number |
| 16-19, 20-23 | Kamoji's clock (ms) at the first and last record |
| 24-27 | Milliseconds of recording |
| 28-31, 32-35 | The Unix time (s, 0 = unknown) and Kamoji's clock (ms) at the moment the chunk was written |
| 36-39 | 0 |
Then records, back to back, each starting with a letter and Kamoji's clock in
ms (u32):
| Record | Fields after the letter | Meaning |
|---|---|---|
C | u32 ms, u8 interval ms, u16 count, samples | Continuous samples (one second) |
B | u32 ms, u8 interval ms, u16 count, u8 reason, samples | A burst; reason 1 acceleration, 2 rotation, 3 routine sample |
E | u32 ms, u8 event | Driving event: 1 hard braking, 2 hard acceleration, 3 / 4 hard lean one way / the other, 5 bump |
I | u32 ms | Kamoji went idle |
T | u32 ms, u16 id | Its face animation changed (the id is internal to the firmware and can change between versions) |
S | u32 ms, u32 Unix time (s), u16 ms, u8 source | Kamoji learned the time (1 Wi-Fi, 2 the app): the trip began at that time minus the first field |
Samples are compressed: the first is 9 × int16; each following one gives,
per value, an int8 difference from the previous sample, or the byte 0x80
followed by the new value as an int16. Stop decoding at a letter you do not
know (newer firmware).
To put a real time on a record: if the header's Unix time is not 0, add the
difference between the record's clock and the header's clock to it.
Otherwise use the trip's S record, which may be in another chunk of the same
trip.
RGB lights
Kamoji has small RGB lights: four on R7 (two on top, two underneath), two on R8 (top only). Normally Kamoji drives them itself, pulsing with the sound. You can take them over with an effect and your own colours.
LED_SET:<effect>:<brightness>:<speed>:<c0>,<c1>,<c2>,<c3>
| Field | Values |
|---|---|
effect | 0 Kamoji's own lights (auto), 1 solid, 2 breathe, 3 rainbow (ignores the colours), 4 police (double flashes, each side in its own colour), 5 ping-pong (a light bouncing left and right), 6 strobe, 7 music (the colours pulse with the sound) |
brightness | 0-100 (%) |
speed | 1-100: one cycle of the effect lasts from about 3 s (1) down to 0.3 s (100) |
c0-c3 | Colours as hex RRGGBB: top-left, top-right, bottom-left, bottom-right. All four are required, even on R8, which has no bottom lights |
Brightness and speed outside their range are brought into it. LED_SET
sends no answer when it works (the app sends many while you drag a
colour); only a malformed command gets one, LED:ERR:args (fewer than 7
fields, or an effect above 7). Read the result back with LED_GET.
LED_GETanswersLED:STATE:<effect>:<brightness>:<speed>:<c0>,<c1>,<c2>,<c3>:<lights>, colours in lowercase hex,<lights>= 4 on R7, 2 on R8. SinceLED_SETis silent, useLED_GETto check whether the firmware has the lights commands at all (older firmware answersUNKNOWN_CMD:LED_GET).LED_AUTOgives the lights back to Kamoji and answersLED:STATE:with effect0. Your colours stay stored for next time.
Your choice is kept: Kamoji saves it (3 seconds after the last change, so
dragging a colour costs one save) and restores it when it starts. It does not
revert when you disconnect; only LED_AUTO (or LED_SET with effect 0)
hands the lights back. Kamoji's own lights still take over while it starts up
and during a firmware update.
async def set_lights(client, answers, effect, brightness, speed, colors):
"""colors: four 'RRGGBB' strings (top-left, top-right, bottom-left, bottom-right)."""
cmd = f"LED_SET:{effect}:{brightness}:{speed}:{','.join(colors)}"
await client.write_gatt_char(CMD, cmd.encode(), response=True) # no answer when it works
await client.write_gatt_char(CMD, b"LED_GET", response=True)
while True:
msg = await asyncio.wait_for(answers.get(), 5)
if msg.startswith(("LED:STATE:", "LED:ERR:")):
return msg
# await set_lights(client, answers, 2, 80, 40, ["ff0000", "ff0000", "0000ff", "0000ff"]) # breathe
# await client.write_gatt_char(CMD, b"LED_AUTO", response=True) # back to Kamoji's own lights
Audio recording (over Wi-Fi)
The app's Aural Tuning records a short clip of the sound in the car, to tune how Kamoji's sound-reactive features filter out background noise. The clip is recorded by Kamoji and downloaded as a WAV file from its built-in web server (HTTP on port 80), over Wi-Fi, so your phone or computer must be on the same network as Kamoji: its own Wi-Fi hotspot, or the Wi-Fi network it has joined. Bluetooth is used to find Kamoji on Wi-Fi and to unlock it. In order:
- Authorize over Bluetooth.
- Send
WIFI_INFO, and join Kamoji's hotspot if it tells you to. - Get the key with
GET_MIC_TOKEN. - Make the HTTP requests below, with the key, while the window is open.
Reaching Kamoji over Wi-Fi
WIFI_INFO:<json> tells Kamoji which Wi-Fi network you are on, and Kamoji
answers with where to reach its web server:
WIFI_INFO:{"ssid":"<your Wi-Fi name>","bssid":"<your access point's MAC>","lan":"<ip>"}
ssid and bssid may be empty strings when your device cannot read them (on
phones, without location permission); an empty value simply never matches.
lan is optional: an IP address at which you have already reached this Kamoji
on your network, which counts as "same network" even without a Wi-Fi name
(older firmware ignores it).
| Answer | Meaning |
|---|---|
WIFI_CHECK:SAME:<ip>:<port> | You are on the same network as Kamoji (its Wi-Fi name and access point both match, or lan is its address), or already on its hotspot (<ip> is then 192.168.4.1 by default). Use http://<ip>:<port>. If you matched the network Kamoji has joined and its hotspot was on, it turns the hotspot off |
WIFI_AP:STARTED:<ssid>:<password>:<port> | A different network: Kamoji starts its own hotspot (or already has it on) and gives you its name and password. Join it, then use http://192.168.4.1:<port>. If the hotspot was off, this answer comes a moment later |
WIFI_CHECK:ERR:NO_STA_IP | Same network, but Kamoji has no IP address on it yet: try again |
WIFI_AP:ERR:START_FAILED | The hotspot could not start |
WIFI_CHECK:ERR:BAD_JSON | The JSON did not parse |
CHECK_BUSY:ERR:FW_BUSY, …:AS_BUSY, …:BUSY | Kamoji is updating its firmware, its files, or both: nothing happens. Try again later |
Every answer except CHECK_BUSY also opens the app window that audio
recording needs: 60 seconds, and each recording extends it by its own length
plus 20 seconds. Only a trusted phone gets the hotspot's password, since
WIFI_INFO needs authorization.
WIFI_AP_DISCONNECT drops every device on Kamoji's hotspot and closes the
app window; it answers WIFI_AP:DISCONNECTED:<devices dropped>. Kamoji also
drops the hotspot's devices by itself (the hotspot stays on) after 5 minutes
with no Bluetooth command and no web-server traffic, so send something, such
as GET_INFO, every couple of minutes while you are on it.
The app waits up to 15 seconds for an answer, sends WIFI_INFO once more if
nothing has come after 5 seconds, and stays on Kamoji's hotspot until you
disconnect. Those are good defaults, not rules.
Recording
Every request needs both:
- The app window that
WIFI_INFOopens. - This phone's key, sent as the header
X-Kamoji-Mic-Token: <key>. Get it over Bluetooth, after authorizing:GET_MIC_TOKENanswersMIC_TOKEN:<key>(32 lowercase hex digits). Only a trusted phone can ask, and eachGET_MIC_TOKENreplaces the previous key, so only the phone that asked last can record.
A request without both is answered 403 with "error":"no_app_session".
Firmware before 0.1.75 answers UNKNOWN_CMD:GET_MIC_TOKEN and does not check
a key. The Kamoji app sends the key from version 1.0.1, so earlier app
versions cannot record on firmware 0.1.75 or later.
From firmware 0.1.76 the key can also go in the address, ?token=<key>, which
is handy for a quick test in a browser. In your own code, prefer the header:
an address is far more likely to end up in logs and browser history.
| Request | Answer |
|---|---|
GET /mic/status | The recorder's state (below) |
GET /mic/record?seconds=<n> | Start a recording of 1 to 30 seconds. It replaces the previous clip |
GET /mic/recording.wav | The finished clip, as a WAV file |
Every request except the WAV download answers the same JSON:
{"ok": true, "error": "", "state": "recording", "seconds": 10, "samples": 48000,
"sample_rate": 16000, "bits": 32, "channels": 1, "mic": "INMP441",
"gain": 2, "max_seconds": 30}
state is idle, recording or ready (a clip is ready to download).
seconds is the length asked for, samples how many are recorded so far,
mic the microphone model and gain the software gain in use.
| HTTP status | error | Meaning |
|---|---|---|
| 403 | no_app_session | No app window open, or a missing or wrong key |
| 400 | bad_seconds | seconds missing, or outside 1-30 |
| 409 | busy | A recording is already running |
| 409 | not_ready | No finished clip to download yet |
| 507 | no_memory | Not enough memory for the clip right now |
There is no notification when a recording ends: wait for its length, then ask
/mic/status until state is ready. Stay off the network while Kamoji
listens if you can, as Wi-Fi traffic adds noise. The clip is 16 kHz, mono,
32-bit PCM (samples × 4 bytes after a 44-byte WAV header), exactly what
Kamoji's sound-reactive features hear. It stays only in Kamoji's memory,
until the next recording or a restart, and is never saved to its storage.
Kamoji never records on its own, and shows nothing on screen while it
records.
import json
import time
import urllib.request
def record_clip(host, key, seconds=10, path="kamoji.wav"):
"""host: Kamoji's IP address. key: from GET_MIC_TOKEN. Run within the window WIFI_INFO opened."""
def get(path):
req = urllib.request.Request(f"http://{host}{path}", headers={"X-Kamoji-Mic-Token": key})
return urllib.request.urlopen(req, timeout=10).read() # 403/400/409/507 raise HTTPError
get(f"/mic/record?seconds={seconds}")
time.sleep(seconds) # stay quiet while it listens
while json.loads(get("/mic/status"))["state"] != "ready":
time.sleep(0.5)
with open(path, "wb") as f:
f.write(get("/mic/recording.wav"))
Kamoji Mini Apps
Kamoji Mini Apps are installed, listed, removed and configured
over this same channel: APPS_LIST, APP_PUT / APP_COMMIT / APP_ABORT (an
install, file by file, with the same transfer as
ASSET_PUT), APP_REMOVE, and APP_PARAMS /
APP_SET for a mini app's settings. They are documented, with a Python example,
in Sideloading.
Firmware without mini apps answers UNKNOWN_CMD:APPS_LIST. Mini apps are
modes 6 and up, so SET_MODE and the ui_mode setting accept those numbers
too.
DIY with Python
The examples use bleak (Bluetooth on macOS, Windows and Linux) and Pillow (images):
pip install bleak pillow
Download kamoji_ble.py, a complete script built from the snippets below:
python kamoji_ble.py info # firmware version, assets, nickname
python kamoji_ble.py photo cat.jpg --seconds 30
python kamoji_ble.py gif dance.gif --seconds 60
python kamoji_ble.py stop
The first time, run it with your laptop close to Kamoji (in the car), so
Kamoji starts trusting it. The script keeps its key in ~/.kamoji_ble_key.
The first run asks for Bluetooth access for your terminal app (System Settings → Privacy & Security → Bluetooth). Until it is granted, bleak reports "Bluetooth device is turned off".
Connect and send a command
import asyncio
import hashlib
import hmac
from pathlib import Path
from bleak import BleakClient, BleakScanner
SERVICE = "12345678-1234-5678-1234-56789abcdef0"
CMD = "12345678-1234-5678-1234-56789abcdef1"
TX = "12345678-1234-5678-1234-56789abcdef3"
KEY = bytes.fromhex(Path("~/.kamoji_ble_key").expanduser().read_text()) # 32 bytes
async def authorize(client, answers):
"""AUTH_HELLO, then the proof (a trusted device) or the key (a new one, close by)."""
async def send(text):
await client.write_gatt_char(CMD, text.encode(), response=True)
await send("AUTH_HELLO:" + hashlib.sha256(KEY).digest()[:8].hex())
msg = await asyncio.wait_for(answers.get(), 5)
if msg.startswith("AUTH:CHALLENGE:"):
nonce = bytes.fromhex(msg.split(":")[2])
await send("AUTH_PROOF:" + hmac.new(KEY, nonce, hashlib.sha256).hexdigest())
msg = await asyncio.wait_for(answers.get(), 5)
elif msg.startswith("AUTH:ENROLL:"):
await send("AUTH_ENROLL:" + KEY.hex())
msg = await asyncio.wait_for(answers.get(), 5)
print("<-", msg) # AUTH:OK:… (or UNKNOWN_CMD:AUTH_HELLO on older firmware)
async def main():
device = await BleakScanner.find_device_by_filter(
lambda d, ad: (d.name or "").startswith("KMJ-")
or SERVICE in (ad.service_uuids or []),
timeout=10,
)
async with BleakClient(device) as client:
answers = asyncio.Queue()
await client.start_notify(TX, lambda _, d: answers.put_nowait(d.decode().strip()))
await authorize(client, answers)
# Print every answer Kamoji sends from now on.
await client.start_notify(TX, lambda _, data: print("<-", data.decode()))
await client.write_gatt_char(CMD, b"LINK_FAST", response=True)
await client.write_gatt_char(CMD, b"GET_INFO", response=True)
await asyncio.sleep(2) # FW:…, AT:…, AMD5:…, NICK:…
asyncio.run(main())
Create the key once with
python -c "import secrets, pathlib; pathlib.Path('~/.kamoji_ble_key').expanduser().write_text(secrets.token_hex(32))"
(the downloadable script does that by itself). Try GET_SETTINGS or
SET_MODE:1 in place of GET_INFO.
Prepare a photo
import io
from PIL import Image
def prepare_photo(path, quality=75):
"""Centre-crop, 240x240, baseline JPEG."""
img = Image.open(path).convert("RGB")
side = min(img.size)
left, top = (img.width - side) // 2, (img.height - side) // 2
img = img.crop((left, top, left + side, top + side)).resize((240, 240), Image.LANCZOS)
out = io.BytesIO()
img.save(out, "JPEG", quality=quality, progressive=False, optimize=True)
return out.getvalue() # typically 6-20 KB
For an animated GIF, do the same crop and resize on every frame (Pillow's
ImageSequence.Iterator) and save with one shared palette. If the GIF is
still too big, keep every other frame or use fewer colours.
prepare_gif() in the script does exactly that.
Send it
import struct
async def show(client, data, seconds, answers):
"""`answers`: an asyncio.Queue the TX notifications are put on."""
async def next_answer(*prefixes, timeout=5):
while True:
msg = await asyncio.wait_for(answers.get(), timeout)
if msg.startswith(prefixes):
return msg
await client.write_gatt_char(CMD, f"IMG_BEGIN:{len(data)}:{seconds}".encode(), response=True)
ready = await next_answer("IMG:READY:", "IMG:ERR:")
if ready.startswith("IMG:ERR:"):
raise RuntimeError(ready)
window = 2 * int(ready.split(":")[2])
chunk = client.mtu_size - 3 - 13 # "IMG_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"IMG_DATA:" + struct.pack("<I", offset) + part, response=False)
offset += len(part)
try:
msg = await next_answer("IMG:ACK:", "IMG:RESEND:", "IMG:ERR:", timeout=3)
except asyncio.TimeoutError:
offset = acked # no ack: resend from the last one
continue
if msg.startswith("IMG:ERR:"):
raise RuntimeError(msg)
acked = max(acked, int(msg.split(":")[2]))
if msg.startswith("IMG:RESEND:"):
offset = int(msg.split(":")[2]) # a write was lost: resend from there
print(await next_answer("IMG:OK", "IMG:ERR:", timeout=8))
Use it inside the async with BleakClient(...) block, with a queue that the
notifications fill:
answers = asyncio.Queue()
await client.start_notify(TX, lambda _, d: answers.put_nowait(d.decode().strip()))
await show(client, prepare_photo("cat.jpg"), 30, answers)
On Linux (BlueZ), client.mtu_size can report the default 23 until the MTU
is exchanged, which makes the chunks tiny and the transfer slow. It still
works. For full speed, use a chunk of 244 - 13 bytes if your adapter
negotiates MTU 247 (most do).
Download the Black Box
Using the same answers queue, this lists the chunks, downloads each one,
checks it and decodes it:
import base64
import struct
import zlib
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 list_chunks(client, answers):
"""BBOX_LIST: the chunks on storage, oldest first, plus the BBOX:NOW line."""
await client.write_gatt_char(CMD, b"BBOX_LIST", response=True)
now, chunks = None, []
while True:
msg = await next_answer(answers, "BBOX:NOW:", "BBOX:F:", "BBOX:END:", "BBOX:ERR:")
if msg.startswith("BBOX:ERR:"):
raise RuntimeError(msg)
if msg.startswith("BBOX:END:"):
return now, chunks
fields = [int(x) for x in msg.split(":")[2:]]
if msg.startswith("BBOX:NOW:"):
now = dict(zip(("trip", "uptime_ms", "epoch_s", "pending_ms"), fields))
else:
chunks.append(dict(zip(("trip", "seq", "bytes", "t_first_ms", "t_last_ms", "recorded_ms"), fields)))
async def get_chunk(client, answers, trip, seq):
"""BBOX_GET: the whole chunk file, checked against its CRC-32."""
await client.write_gatt_char(CMD, f"BBOX_GET:{trip}:{seq}".encode(), response=True)
begin = await next_answer(answers, "BBOX:BEGIN:", "BBOX:ERR:")
if begin.startswith("BBOX:ERR:"):
raise RuntimeError(begin)
size, crc = int(begin.split(":")[4]), int(begin.split(":")[5], 16)
data = bytearray(size)
while True:
msg = await next_answer(answers, "BBOX:DATA:", "BBOX:DONE:", "BBOX:ERR:")
if msg.startswith("BBOX:ERR:"):
raise RuntimeError(msg)
if msg.startswith("BBOX:DONE:"):
break
_, _, offset, b64 = msg.split(":", 3)
part = base64.b64decode(b64)
data[int(offset):int(offset) + len(part)] = part
if zlib.crc32(data) != crc:
raise ValueError("CRC mismatch: ask for the chunk again")
return bytes(data)
HEADER = struct.Struct("<4s4B8I") # 40 bytes
def decode_samples(b, p, n, channels):
"""First sample: int16 per value. Then per value an int8 difference, or 0x80 + the int16."""
rows, prev = [], None
for i in range(n):
row = []
for c in range(channels):
if i == 0:
v = struct.unpack_from("<h", b, p)[0]; p += 2
else:
d = b[p]; p += 1
if d == 0x80:
v = struct.unpack_from("<h", b, p)[0]; p += 2
else:
v = prev[c] + (d - 256 if d > 127 else d)
row.append(v)
rows.append(row)
prev = row
return rows, p
def decode_chunk(b):
(magic, version, cont_hz, burst_hz, channels, trip, seq, t_first, t_last,
recorded_ms, epoch_s, uptime_at_epoch, _) = HEADER.unpack_from(b, 0)
if magic != b"KBX1":
raise ValueError("not a Black Box chunk")
out = {"trip": trip, "seq": seq, "epoch_s": epoch_s, "uptime_at_epoch": uptime_at_epoch, "records": []}
p = HEADER.size
while p < len(b):
kind = chr(b[p])
if kind in "CB":
t_ms, interval_ms, n = struct.unpack_from("<IBH", b, p + 1)
reason = b[p + 8] if kind == "B" else 0
samples, p = decode_samples(b, p + (9 if kind == "B" else 8), n, channels)
out["records"].append((kind, t_ms, interval_ms, reason, samples))
elif kind == "T":
t_ms, anim = struct.unpack_from("<IH", b, p + 1); p += 7
out["records"].append(("T", t_ms, anim))
elif kind == "I":
(t_ms,) = struct.unpack_from("<I", b, p + 1); p += 5
out["records"].append(("I", t_ms))
elif kind == "E":
t_ms, event = struct.unpack_from("<IB", b, p + 1); p += 6
out["records"].append(("E", t_ms, event))
elif kind == "S":
t_ms, epoch, epoch_ms, source = struct.unpack_from("<IIHB", b, p + 1); p += 12
out["records"].append(("S", t_ms, epoch, epoch_ms, source))
else:
break # a record from newer firmware: stop here
return out
After authorize():
now, chunks = await list_chunks(client, answers)
for c in chunks:
trip = decode_chunk(await get_chunk(client, answers, c["trip"], c["seq"]))
# each sample: [ax, ay, az (0.01 m/s²), gx, gy, gz (0.1 °/s), pitch, roll (0.01 °), light (mV)]