Skip to main content

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.

Firmware version

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.

One connection at a time

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:

UUIDUse
Service12345678-1234-5678-1234-56789abcdef0Kamoji control
CMD characteristic12345678-1234-5678-1234-56789abcdef1Write your commands here
TX characteristic12345678-1234-5678-1234-56789abcdef3Notify: 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-5Bluetooth MAC address
6Hardware revision: 7 = R7, 8 = R8, 0 = unknown
7 onwardsFirmware 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 same TOPIC:field:field style.
  • 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_FAST before a burst of commands; it keeps the link fast for 8 seconds, so repeat it every 5 seconds while you are active.
  • Send LINK_ALIVE every 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​

CommandAnswerWhat it does
AUTH_HELLO:<id>, AUTH_PROOF:<hmac>, AUTH_ENROLL:<key>See AuthorizationProve this device is trusted (first)
GET_INFOFW:<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_SETTINGSSETTING:<key>=<value> for each setting, then SETTINGS:ENDEvery 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_FILEPlay it repeatedly until STOP_PLAY
STOP_PLAYNoneStop what PLAY_FILE / PLAY_LOOP started
GET_DRIVE_STATSDRIVE_TOTAL:…, DRIVE_TRIP:… per trip, then DRIVE_STATS:END:<n>Drive statistics (R8)
IMU_STREAM:<hz>, IMU_STOPIMU:ON:<hz>, then IMU:D:…; IMU:OFFLive motion-sensor readings: see Diagnostics
BBOX_LIST, BBOX_GET:<trip>:<seq>, BBOX_TAIL:<gen>:<offset>See Black BoxList 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_DISCONNECTWIFI_CHECK:… or WIFI_AP:…Find Kamoji on Wi-Fi: see Reaching Kamoji over Wi-Fi
GET_MIC_TOKENMIC_TOKEN:<token>Get this phone's key for audio recording
LED_SET:…, LED_GET, LED_AUTOLED: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_FASTNoneKeep the BLE link fast for 8 s
LINK_ALIVENoneHeartbeat: after the first one, 60 s without any command drops the link
CHECK_UPDATENoneCheck the update server for new firmware now
REBOOTNoneRestart 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.

warning

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​

PhotoAnimation
FormatBaseline JPEG (not progressive)GIF (animated or not)
Size on screen240 × 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 sizeUp 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 up1 to 600 seconds1 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
  1. IMG_BEGIN:<bytes>:<seconds> announces the file's size and how long it stays up. Kamoji answers IMG:READY:<N> (it will acknowledge every N bytes, currently 4096), or IMG:ERR:<code>.

  2. IMG_DATA writes carry the file, in order. They are binary, not text: the 9 ASCII bytes IMG_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 data

    Send them as writes without response, so many fit in each radio exchange. A write with response works too, but is several times slower.

  3. Kamoji answers IMG:ACK:<received> each time another N bytes are in, and once the whole file is in. Stay at most 2 × N bytes ahead of the last ack.

  4. 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.

  5. Once the last byte is in, Kamoji decodes the file: IMG:OK = on screen, or IMG:ERR:<code>. When it leaves the screen, Kamoji sends IMG:END:<why>: finished (its time is up), stopped, or replaced (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: codeMeaning
sizeSize is 0, over the limit, or the data overran it. Sent as IMG:ERR:size:<max bytes>
durationSeconds outside 1-600
busyA firmware or asset upload is running: try again afterwards
memNot enough memory right now: try again
stateIMG_DATA without a transfer in progress
timeoutThe transfer stalled
decodeThe file could not be decoded (a progressive JPEG, a broken GIF…)
unavailableThe 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>
  1. ASSET_LIST lists 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.
  2. ASSET_PUT:<size>:<md5>:<name> announces one file (at most 1 MB; names use only A-Z a-z 0-9 _ - .). After ASSET:READY:<N>, send the file exactly like a photo: 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> when a write was lost.
  3. 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.
  4. ASSET_SET:<md5> stores the MD5 of the whole set, the value GET_INFO reports as AMD5, so the app sees the Kamoji as up to date. It answers ASSET: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: codeMeaning
busyKamoji is updating itself (files or firmware): try again in a moment
args / size / nameA malformed command, a size of 0 or over 1 MB, or a name it does not accept
md5The file that arrived does not match its MD5
spaceNot enough room in its storage
memNot enough memory right now: try again
stateASSET_DATA without a transfer in progress
timeoutThe transfer stalled
write / rename / fsThe 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, and IMU:ON:<hz> echoes the rate you get (IMU_STREAM:200 → IMU:ON:100). Anything else (empty, 0, 10.5) is answered IMU:ERR:rate.
  • Send IMU_STREAM again 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. Sending IMU_STREAM while it runs changes the rate.
  • IMU_STOP stops it now and answers IMU: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:

FieldsWhatUnit
ax, ay, azAcceleration, gravity included, in the sensor's axesmm/s²
gx, gy, gzRotation rate0.1 °/s
pitch, rollTilt angle, fused from both sensors0.01 °
gfx, gfy, gfzG-force: acceleration with gravity removed, in the sensor's axes1/1000 g
lightLight sensormV (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 3 below: 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 (0 if 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: codeMeaning
argsA malformed command
busyAnother BBOX_GET or BBOX_TAIL is running: wait for it
missingNo such chunk (it may just have been deleted to make room), or it could not be read
ioNot 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:

BytesContent
0-3KBX1
4Format version (1)
5, 6Continuous and burst rate in Hz (25, 100)
7Values per sample (9)
8-11, 12-15Trip, sequence number
16-19, 20-23Kamoji's clock (ms) at the first and last record
24-27Milliseconds of recording
28-31, 32-35The Unix time (s, 0 = unknown) and Kamoji's clock (ms) at the moment the chunk was written
36-390

Then records, back to back, each starting with a letter and Kamoji's clock in ms (u32):

RecordFields after the letterMeaning
Cu32 ms, u8 interval ms, u16 count, samplesContinuous samples (one second)
Bu32 ms, u8 interval ms, u16 count, u8 reason, samplesA burst; reason 1 acceleration, 2 rotation, 3 routine sample
Eu32 ms, u8 eventDriving event: 1 hard braking, 2 hard acceleration, 3 / 4 hard lean one way / the other, 5 bump
Iu32 msKamoji went idle
Tu32 ms, u16 idIts face animation changed (the id is internal to the firmware and can change between versions)
Su32 ms, u32 Unix time (s), u16 ms, u8 sourceKamoji 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>

FieldValues
effect0 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)
brightness0-100 (%)
speed1-100: one cycle of the effect lasts from about 3 s (1) down to 0.3 s (100)
c0-c3Colours 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_GET answers LED:STATE:<effect>:<brightness>:<speed>:<c0>,<c1>,<c2>,<c3>:<lights>, colours in lowercase hex, <lights> = 4 on R7, 2 on R8. Since LED_SET is silent, use LED_GET to check whether the firmware has the lights commands at all (older firmware answers UNKNOWN_CMD:LED_GET).
  • LED_AUTO gives the lights back to Kamoji and answers LED:STATE: with effect 0. 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:

  1. Authorize over Bluetooth.
  2. Send WIFI_INFO, and join Kamoji's hotspot if it tells you to.
  3. Get the key with GET_MIC_TOKEN.
  4. 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).

AnswerMeaning
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_IPSame network, but Kamoji has no IP address on it yet: try again
WIFI_AP:ERR:START_FAILEDThe hotspot could not start
WIFI_CHECK:ERR:BAD_JSONThe JSON did not parse
CHECK_BUSY:ERR:FW_BUSY, …:AS_BUSY, …:BUSYKamoji 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:

  1. The app window that WIFI_INFO opens.
  2. This phone's key, sent as the header X-Kamoji-Mic-Token: <key>. Get it over Bluetooth, after authorizing: GET_MIC_TOKEN answers MIC_TOKEN:<key> (32 lowercase hex digits). Only a trusted phone can ask, and each GET_MIC_TOKEN replaces 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.

RequestAnswer
GET /mic/statusThe 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.wavThe 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 statuserrorMeaning
403no_app_sessionNo app window open, or a missing or wrong key
400bad_secondsseconds missing, or outside 1-30
409busyA recording is already running
409not_readyNo finished clip to download yet
507no_memoryNot 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.

macOS

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)
Linux

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)]