Serial protocol
J++Device Serial Management Protocol v1 — a binary protocol over the device's native USB port for host-side tools (PC clients, the JPPD desktop app) to manage files on the SD card, query device information, and retrieve LRV verification data.
This page is for host-tooling authors.
You do not need any of it to write apps for the device — that's the App Developer Guide. This is the wire protocol a PC-side tool speaks to the device over USB.
A ready-made upload script ships with the firmware: scripts/jppd_upload.py uploads a built app artifact directory to the device SD card using the commands described here. Run python3 scripts/jppd_upload.py --help for usage.
Physical layer¶
| Parameter | Value |
|---|---|
| Interface | Native USB-Serial-JTAG (the board's single USB-C port has no separate UART bridge chip, so this — not UART0 — is the channel a host actually reaches) |
| Framing | USB CDC-ACM byte stream via the usb_serial_jtag driver; no baud rate to configure |
| Coexistence | ESP_LOG text output shares the same channel; the device uses a TX mutex so log bytes never interleave with binary frame bytes |
Frame format¶
Every message — in both directions — is wrapped in the same envelope:
[SOF 4 B] 0x01 0x4A 0x50 0x50 ("\x01JPP")
[LEN 2 B] payload byte count, little-endian
[PAYLOAD …] LEN bytes
[CRC 2 B] CRC-16/CCITT-FALSE over LEN(2) + PAYLOAD(LEN), little-endian
CRC-16/CCITT-FALSE parameters: polynomial 0x1021, initial value 0xFFFF, no input/output reflection, no final XOR. The CRC covers the two LEN bytes followed by the payload bytes.
Payload format¶
Command (host → device):
[SEQ 1 B] sequence number — echoed in the response
[CMD 1 B] command byte
[FLAGS 1 B] reserved, must be 0x00
[BODY … ] command-specific body (may be empty)
Response (device → host):
[SEQ 1 B] echoed from the command
[STATUS 1 B] result code (see Status codes)
[BODY … ] command-specific body (present only on OK responses unless noted)
Device-initiated events¶
The device can also send a frame the host never asked for — currently just to
announce that a session ended without a SESSION_END command. It reuses the
response shape with one reserved value standing in for SEQ:
[SEQ 1 B] always 0xFF — a value the host must never assign to a command
[EVENT 1 B] event code (see below)
[BODY … ] event-specific body
0xFF is not a valid response SEQ for anything a host sent, so a frame
carrying it is unambiguously an event, not a reply — check SEQ first, before
looking at the second byte. A host implementation must reserve 0xFF on its
own side too (skip it when assigning sequence numbers to outgoing commands) so
a real response can never coincidentally collide with it; scripts/jppd_upload.py's
_next_seq() does this.
| Event | Code | Body | Meaning |
|---|---|---|---|
SESSION_ENDED |
0x01 |
— | The device closed the session without a SESSION_END command — the user held OK on the device. Any command already in flight will fail with ERR_NO_SESSION; a new session needs a fresh SESSION_START. |
An event can arrive at any point while a session is open, including between a
command and its response. A host implementation should treat any frame whose
SEQ is 0xFF as an event first, regardless of what it was waiting for.
Every session needs physical consent.
SESSION_START puts a Deny/Allow dialog on the device's OLED and blocks
until someone presses a button. There is no way for a host tool to open a
session unattended, and no app may be running while one is open.
Session lifecycle¶
A host must open a session before issuing any command other than SESSION_START.
- Host sends
SESSION_START. - The device displays an OLED consent dialog (Allow / Deny) and plays a notification chime. The host blocks until the user responds.
- If the user allows,
SESSION_STARTreturnsOKand commands may flow. - If the user denies,
SESSION_STARTreturnsERR_DENIED. No session is opened. - The host sends
SESSION_ENDwhen done, the session times out after 30 seconds of inactivity, or the user ends it from the device by holding OK — which also sends aSESSION_ENDEDevent so the host doesn't have to find out from a failed command or a timeout. Any valid command resets the inactivity timer, including the no-opKEEPALIVE, which exists for a host that has nothing else to send during a long idle stretch.
Mutual exclusion:
- A session cannot be opened while an SD app is running (ERR_APP_RUNNING).
- SD app launch is blocked while a session is open.
- Deep sleep is suppressed for the duration of the consent dialog and any active session.
Provisioning builds: on firmware built with CONFIG_JPP_LRV_PROVISIONING, the very first SESSION_START after boot auto-accepts with no OLED dialog; every session after that still requires manual consent. This lets scripts/prepare_device.py run unattended once a freshly-flashed unit is powered on. Production firmware always shows the consent dialog.
All commands except SESSION_START return ERR_NO_SESSION if no session is open.
Commands¶
0x00 — SESSION_START¶
Opens a management session. Triggers the on-device consent dialog.
| Direction | Body |
|---|---|
| Host → device | [proto_ver: 1 B] — must be 0x01 |
| Device → host | [proto_ver: 1 B] — echoes 0x01 |
Returns ERR_DENIED if the user denies, ERR_BUSY if a session is already open, ERR_APP_RUNNING if an app is currently running.
0x01 — SESSION_END¶
Closes the current session.
| Direction | Body |
|---|---|
| Host → device | — |
| Device → host | — |
0x02 — GET_INFO¶
Returns device identification and SD card information.
| Direction | Body |
|---|---|
| Host → device | — |
| Device → host | [fw_version: NUL-terminated string][username: NUL-terminated string][hwid: NUL-terminated string][sd_total: 8 B LE uint64][sd_used: 8 B LE uint64][sd_free: 8 B LE uint64][sd_label: NUL-terminated string] |
usernameis empty (single\0) when no name has been set.hwidis the eFuse base MAC address formatted as"AA:BB:CC:DD:EE:FF".sd_total,sd_used,sd_freeare byte counts (little-endian uint64). All three are zero when the SD card is unavailable.sd_labelis the FAT volume label; empty (single\0) if the SD is unavailable or has no label.
0x03 — GET_LRV_DATA¶
Returns Limited Run Verification data for the certificate verification flow. Requires the device to carry an LRV identity (provisioned at manufacturing); returns ERR_NOT_FOUND on a device without one. No password or unlock step is involved — a provisioned device serves this data from boot.
| Direction | Body |
|---|---|
| Host → device | — |
| Device → host | [cert: NUL-terminated] [cert_sig: 64 B] [device_pubkey: 32 B] [challenge: NUL-terminated] [resp_sig: 64 B] |
Challenge format: {username}|{YYYY-MM-DDTHH:MM:SSZ} where username is taken from the device's stored user name and the timestamp is the device RTC reading at the moment of the request, converted to UTC (the RTC keeps local time; the configured timezone offset is subtracted) so the trailing Z is accurate.
resp_sig is a 64-byte raw Ed25519 signature of the challenge bytes using the device's private key.
0x04 — SET_TIME¶
Sets the device's RTC (in-RAM state, and the DS1307 hardware if one is attached) from the host's clock.
| Direction | Body |
|---|---|
| Host → device | [year: 2 B LE u16][month: 1 B][day: 1 B][weekday: 1 B][hour: 1 B][minute: 1 B][second: 1 B] (8 bytes total) |
| Device → host | — |
weekday is 0–6; the firmware does not interpret its meaning beyond storing it. Returns ERR_INVALID if the body is not exactly 8 bytes or the fields fail range validation (year ≥ 2000, month 1–12, day 1–31, hour 0–23, minute/second 0–59).
0x05 — KEEPALIVE¶
A no-op the host can send to reset the session's inactivity timeout without
doing anything else. Use it during a stretch where the host has no other
command to send — waiting on user input, redrawing a UI — and would
otherwise let the session lapse after 30 seconds. Any valid command already
resets the timeout; KEEPALIVE just exists for when there's nothing else to
say.
| Direction | Body |
|---|---|
| Host → device | — |
| Device → host | — |
0x10 — FS_LIST_DIR¶
Lists the contents of a directory on the SD card. All paths must start with /sd.
| Direction | Body |
|---|---|
| Host → device | [path: NUL-terminated] |
| Device → host | [count: 2 B LE] then N × [flags: 1 B][size_or_count: 4 B LE][name: NUL-terminated] |
Flags byte: bit 0 = 1 for directory, 0 for file. Bits 1–7 are reserved.
size_or_count: for files, the file size in bytes; for directories, the number of non-hidden first-level children. Both are little-endian uint32.
0x11 — FS_MKDIR¶
Creates a directory (and any missing parent directories) on the SD card.
| Direction | Body |
|---|---|
| Host → device | [path: NUL-terminated] |
| Device → host | — |
0x12 — FS_REMOVE¶
Removes a file or empty directory.
| Direction | Body |
|---|---|
| Host → device | [path: NUL-terminated] |
| Device → host | — |
0x13 — FS_RENAME¶
Renames or moves a file or directory within /sd.
| Direction | Body |
|---|---|
| Host → device | [src: NUL-terminated][dst: NUL-terminated] |
| Device → host | — |
File upload (0x14 – 0x16)¶
Upload a file to the SD card in three steps:
0x14 — FS_UPLOAD_BEGIN
| Direction | Body |
|---|---|
| Host → device | [file_size: 4 B LE][path: NUL-terminated] |
| Device → host | [xfer_id: 1 B] — always 0x00 in v1 |
0x15 — FS_UPLOAD_CHUNK
Send up to 1024 bytes at a time. Chunks are zero-indexed.
| Direction | Body |
|---|---|
| Host → device | [xfer_id: 1 B][chunk_idx: 2 B LE][data…] |
| Device → host | [chunk_idx: 2 B LE] — echoed on success |
0x16 — FS_UPLOAD_END
Finalises the transfer and verifies integrity.
| Direction | Body |
|---|---|
| Host → device | [xfer_id: 1 B][crc32: 4 B LE] |
| Device → host | — |
The crc32 field uses CRC-32/ISO-HDLC (poly 0xEDB88320 reflected, init/xorout 0xFFFFFFFF), matching Python's zlib.crc32 output. The device verifies the complete file against this value before committing.
File download (0x17 – 0x19)¶
Download a file from the SD card in three steps:
0x17 — FS_DOWNLOAD_BEGIN
| Direction | Body |
|---|---|
| Host → device | [path: NUL-terminated] |
| Device → host | [xfer_id: 1 B][file_size: 4 B LE][chunk_count: 2 B LE][crc32: 4 B LE] |
The crc32 is computed over the entire file (same algorithm as upload). The host should verify it after receiving all chunks.
0x18 — FS_DOWNLOAD_CHUNK
Request chunks by index (zero-based, up to 1024 bytes each).
| Direction | Body |
|---|---|
| Host → device | [xfer_id: 1 B][chunk_idx: 2 B LE] |
| Device → host | [xfer_id: 1 B][chunk_idx: 2 B LE][data…] |
0x19 — FS_DOWNLOAD_END
Signals the host is done with the transfer.
| Direction | Body |
|---|---|
| Host → device | [xfer_id: 1 B] |
| Device → host | — |
0x1A — APPLY_BACKUP¶
Apply a JPPDOS settings backup file that already resides on the SD card. The host uploads the file first (using the standard FS_UPLOAD commands), then sends this command with the path to it.
| Direction | Body |
|---|---|
| Host → device | [path: NUL-terminated] |
| Device → host | — |
Flow:
1. The device reads and validates the file (jppdos_backup: 1 marker must be present).
2. An on-screen confirmation dialog is shown (Apply backup? / <filename> / This will restart) with a notification chime. The user selects Allow or Deny (default cursor on Deny).
3. On Deny: responds ERR_DENIED; the session remains open and the file is untouched.
4. On Allow: applies all NVS namespaces and settings.json, responds OK, then restarts the device.
Returns ERR_NOT_FOUND if the path does not exist, ERR_OVERFLOW if the file exceeds 8 KB, ERR_INVALID if the file is not a valid backup, ERR_IO if applying fails.
Backups do not contain LRV identity data.
The LRV identity lives on the external AT24C32 EEPROM and is provisioned
once at manufacturing (see PROVISION_LRV). It survives a factory reset and
a full reflash, and it is neither backed up nor restored.
0x30 — PROVISION_LRV (provisioning firmware only)¶
Write the raw LRV identity record to the device's AT24C32 EEPROM (write-once). This command exists only in firmware built with CONFIG_JPP_LRV_PROVISIONING=y; a production device answers ERR_INVALID (unknown command). Sent by the one-command scripts/prepare_device.py orchestrator (or the lower-level scripts/lrv_manufacturing.py provision-device for manual runs). The orchestrator reads the device's own eFuse MAC via GET_INFO for the certificate hwid, so no esptool.py chip_id step is needed.
| Direction | Body |
|---|---|
| Host → device | [record: raw LRV identity record] |
| Device → host | — |
The record is the packed, unencrypted layout documented in scripts/lrv_manufacturing.py and mirrored by main/jpp_lrv.c: serial(2 LE) + device_pubkey(32) + device_seckey(64) + cert_sig(64) + hwid(24) + cert_len(2 LE) + cert(cert_len).
The device write-once-writes the record to the EEPROM IDENTITY region. If an identity is already provisioned it responds ERR_EXISTS and does not overwrite it. Returns ERR_INVALID on a malformed/too-short record, ERR_NOT_FOUND if no EEPROM is fitted.
Status codes¶
| Code | Name | Meaning |
|---|---|---|
| 0x00 | OK | Success |
| 0x01 | ERR_DENIED | User denied consent |
| 0x02 | ERR_NOT_FOUND | File or directory does not exist (or no LRV identity / no EEPROM) |
| 0x03 | ERR_IO | SD card or filesystem error |
| 0x04 | ERR_EXISTS | Target already exists (rename/mkdir collision, or LRV already provisioned) |
| 0x05 | ERR_INVALID | Malformed request (bad path, unknown command, wrong proto version) |
| 0x06 | ERR_BUSY | Session already open |
| 0x07 | ERR_NO_SESSION | Command sent without an open session |
| 0x08 | ERR_TRANSFER | Chunk out of order, transfer ID mismatch, or CRC mismatch on end |
| 0x09 | ERR_OVERFLOW | Response too large for internal buffer |
| 0x0A | ERR_APP_RUNNING | Session cannot open while an app is running |
Transfer rules¶
- Only one active transfer at a time. Starting a new
FS_UPLOAD_BEGINorFS_DOWNLOAD_BEGINwhile a transfer is in progress returnsERR_BUSY. xfer_idis always0x00in protocol version 1.- Chunk size is 1024 bytes. The final chunk may be smaller.
- File paths are restricted to the
/sdtree. Paths outside/sdreturnERR_INVALID.
Example: uploading a file¶
HOST → SESSION_START (proto_ver=0x01)
DEV → OK (proto_ver=0x01, user pressed Allow)
HOST → FS_UPLOAD_BEGIN (file_size=2048, path="/sd/apps/myapp/main.mpy")
DEV → OK (xfer_id=0x00)
HOST → FS_UPLOAD_CHUNK (xfer_id=0x00, chunk_idx=0, data[0..1023])
DEV → OK (chunk_idx=0)
HOST → FS_UPLOAD_CHUNK (xfer_id=0x00, chunk_idx=1, data[1024..2047])
DEV → OK (chunk_idx=1)
HOST → FS_UPLOAD_END (xfer_id=0x00, crc32=<zlib.crc32 of full file>)
DEV → OK
HOST → SESSION_END
DEV → OK