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)
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, or the session times out after 30 seconds of inactivity.
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 at the moment of the request.
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).
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