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.

  1. Host sends SESSION_START.
  2. The device displays an OLED consent dialog (Allow / Deny) and plays a notification chime. The host blocks until the user responds.
  3. If the user allows, SESSION_START returns OK and commands may flow.
  4. If the user denies, SESSION_START returns ERR_DENIED. No session is opened.
  5. The host sends SESSION_END when done, the session times out after 30 seconds of inactivity, or the user ends it from the device by holding OK — which also sends a SESSION_ENDED event 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-op KEEPALIVE, 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]
  • username is empty (single \0) when no name has been set.
  • hwid is the eFuse base MAC address formatted as "AA:BB:CC:DD:EE:FF".
  • sd_total, sd_used, sd_free are byte counts (little-endian uint64). All three are zero when the SD card is unavailable.
  • sd_label is 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 06; 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_BEGIN or FS_DOWNLOAD_BEGIN while a transfer is in progress returns ERR_BUSY.
  • xfer_id is always 0x00 in protocol version 1.
  • Chunk size is 1024 bytes. The final chunk may be smaller.
  • File paths are restricted to the /sd tree. Paths outside /sd return ERR_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