SDK

Mirror codec and protocol

The three files behind the live screen mirror of a Kode Dot: the P4's JPEG encoder, its pixel accelerator and the frame header with its credit window.

What it is

The screen mirror streams the glass of a Kode Dot to a computer as a series of JPEG frames. The module kdrv/mirror holds the three files it is made of:

FileJob
kdrv_jpeg.cThe ESP32-P4's hardware JPEG encoder: RGB565 in, JPEG out
kdrv_ppa.cThe P4's Pixel Processing Accelerator: halves a band of the screen in both directions in one hardware pass
kmirror_proto.cThe 14-byte frame header, the flow-control window and the arithmetic that turns changed rows into a rectangle. No hardware in it

An app does not call them. AO_Display owns the codec and the scaler (sdk/kaos/display), and the viewer side of the stream is on internals/mirror.

The encoder is never the slow part. A full 410 by 502 frame is 402 KiB of RGB565. The encoder turns it into 19.7 KB at quality 70 in 3 ms, measured on a Kode Dot, reading the pixels where they already are. The transport to the computer is always the bottleneck.

The three files are compiled only with CONFIG_KODEOS_MIRROR, which depends on KODEOS_SCREENSHOT and is on by default. Between them the codec and the scaler hold about 530 KB of PSRAM from boot, 128 KB of encoder output and one full panel of RGB565 for the scaler. A build that turns the mirror off does not pay it. The JPEG encoder also serves the screenshot dst=http upload, which encodes the whole glass once per picture.

How it is connected

flowchart LR
subgraph board["On the Kode Dot"]
  ao["AO_Display"] --> fb["front<br/>framebuffer"]
  fb -- "kdrv_display_band" --> ppa["kdrv_ppa"]
  fb -- "scale 1, in place" --> jpeg["kdrv_jpeg"]
  ppa --> jpeg
  jpeg --> hdr["kmirror_proto<br/>header"]
  cred["credit window"]
end
hdr --> wire["console wire<br/>or BLE"]
wire --> host["viewer on<br/>the computer"]
host -- "mirror_ack seq" --> cred
class ao ao
class fb store
class host ext
One mirror frame

The owner and one pass

AO_Display owns the two peripherals. It claims KBSP_RES_JPEG and KBSP_RES_PPA, and calls kdrv_jpeg_init() and kdrv_ppa_init() while it brings the panel up. Neither init is fatal. A board whose encoder or scaler will not allocate still has a panel and still boots, and the mirror reports that it is blocked.

Each pass of the mirror is one AO step in mirror_try_start():

    1. Take the rows that changed since the last frame from the display driver, or the whole panel for the first frame after a host attaches.
    2. kmirror_band() turns that range into a scaled rectangle that stays inside the panel. An empty range is not an error: an idle screen produces no bytes.
    3. Read the band from the front framebuffer. kdrv_display_band() returns it top-down, at full size.
    4. At scale 2, kdrv_ppa_prepare() halves it into the scaler's own buffer. At scale 1 the band is encoded where it lies, since the encoder accepts any input alignment.
    5. kdrv_jpeg_encode() compresses it. AO_Display clamps the requested quality to 70 (KMIRROR_QUALITY_MAX).
    6. A frame larger than the 14-byte header plus 32 KB is dropped and logged.
    7. The header and the JPEG are copied into the AO's own frame buffer in the same step, because both drivers hand back a pointer that is valid only until their next call. The frame then leaves in slices.

A refusal at step 3, 4 or 5 keeps the rows for the next pass. Five seconds without progress, or without credit from the host, log one warning and pause the stream (KMIRROR_STALL_MS).

The try-lock

One engine and one buffer, so a try-lock. Each driver has one hardware engine and one output buffer. A second caller inside would read the buffer while the first still writes it. The guard is a mutex used only as a try-lock: xSemaphoreTake() with a zero timeout. The loser is refused with KODE_DENIED and not blocked, because khal_mutex_t offers only a blocking take, and blocking would put AO_Display (priority 15) behind someone else's job inside what must stay a short AO step.

The lock exists because test component=jpeg also calls the encoder, inline on AO_Autotest's task, which is not AO_Display's.

The two drivers differ in how long they hold it.

Alignment

The scaler's buffer has two alignment rules. kdrv_ppa allocates its output with MALLOC_CAP_SPIRAM | MALLOC_CAP_CACHE_ALIGNED, which makes the address line up with the caches. The PPA driver also checks the size: it must be a whole number of cache lines.

A full panel is 410 x 502 x 2 = 411,640 bytes, which is not a multiple of the chip's 64-byte line.

So PPA_OUT_CAP is written as arithmetic that rounds the panel size up to a whole number of the larger of the L1 and L2 line sizes. That gives 411,648 bytes here, and it follows a change of the panel or of the cache setting.

The encoder's output must also start on a cache line, so kdrv_jpeg allocates it with jpeg_alloc_encoder_mem() and not heap_caps_malloc(). ESP-IDF refuses any other output buffer. The input has no alignment rule.

The protocol

The protocol has no hardware. kmirror_proto.c includes no ESP-IDF header and keeps no state of its own, so the host tests check all of it on a computer. It holds three things that are hard to get right and impossible to notice on a bench: a header's byte order, a sequence number across its wrap, and a band's edges after scaling. Each shows up as a fault only hours into a stream.

Specifications

The frame header

Every frame is a header followed by len bytes of JPEG. All numbers are little-endian. x, y, w and h count pixels of the scaled picture. At present the mirror always sends full-width bands, so x is 0.

OffsetSizeFieldMeaning
02seqFrame number; wraps at 65535; mirror_ack seq= names it
22xLeft edge of the rectangle
42yTop edge
62wWidth
82hHeight
104lenJPEG bytes that follow the header

KMIRROR_HDR_LEN is 14.

The credit window

kmirror_credit_t is the sender's half of the flow control: next (the sequence of the next frame), acked (the last one the host confirmed) and window (frames that may be outstanding, a uint8_t). AO_Display starts it with a window of 4 (KMIRROR_WINDOW).

CallWhat it does
kmirror_credit_init(c, window)next 0, acked 0xFFFF, which reads as "nothing sent". A window of 0 becomes 1, since 0 would stall the stream for ever
kmirror_may_send(c)True when fewer frames are in flight than the window allows
kmirror_in_flight(c)Frames sent and not acknowledged. Public for mirror_info
kmirror_sent(c)Takes the next sequence number and counts the frame as out. Returns 0 for a NULL c, since no value could signal the error
kmirror_ack(c, seq)Records an ack. False when seq is not in flight: the distance from the last ack must be at least 1 and at most the frames in flight

The band

kmirror_band(y1, y2, scale, v_res, &out_y, &out_h) clamps [y1, y2) into the panel (v_res is 502 rows) and scales it. Scale 1 is full size, 2 is half. A scale of 0 becomes 1. y1 >= y2 after clamping gives height 0 and is not an error. The result never runs past v_res / scale.

The encoder and the scaler

ThingValue
Encoder inputRGB565, any alignment, any width and height (need not be a multiple of 16)
Sub-samplingYUV420. The 19.7 KB figure is at this setting
Quality1 to 100 at the driver. AO_Display clamps the mirror to 70 (KMIRROR_QUALITY_MAX)
Encoder output buffer128 KB (JPEG_OUT_CAP), four times the 29 KB a full frame took at quality 90. Allocated by jpeg_alloc_encoder_mem()
Encoder deadline2000 ms (JPEG_TIMEOUT_MS). A full frame takes 3 ms, so past this the peripheral is stuck and not slow
Scaler operationScale, rotate and mirror in blocking mode; one client, one pending transaction
Scaler scale1 or 2 only. Scale 2 needs a source of at least 2 by 2 pixels. The result must fit the output buffer
Scaler output buffer411,648 bytes (PPA_OUT_CAP), in PSRAM, cache-line aligned in address and size
Scaler transformNone besides the scale: no rotation, no flip. kdrv_display_band() already returns the band top-down
Mirror frame ceiling14 bytes of header plus 32 KB (KMIRROR_JPEG_CAP)
Mirror pacing10 ms tick (KMIRROR_TICK_MS); 5 s without progress pauses (KMIRROR_STALL_MS)

The calls

CallWhat it does
kdrv_jpeg_init()Creates the guard, the engine and the output buffer. Idempotent. KODE_NO_RESOURCES if any will not allocate
kdrv_jpeg_encode(rgb565, w, h, quality, &out, &len)One encode. out is borrowed until the next call. KODE_DENIED: not initialised, a bad argument (NULL, w or h 0, quality outside 1 to 100) or another call is running. KODE_ERROR: the engine refused. KODE_NO_RESOURCES: the result did not fit
kdrv_jpeg_selftest()Encodes a 16 by 16 mid-grey square, one whole MCU at YUV420, at quality 80, and checks it starts with FFD8 and ends with FFD9. The body of test component=jpeg. KODE_DENIED means the encoder was busy and not broken. KODE_NO_RESOURCES means it never initialised
kdrv_ppa_init()Registers the client, allocates the output buffer and the guard. Idempotent
kdrv_ppa_prepare(src, w, h, scale, &dst, &dw, &dh)One blocking pass. dst is borrowed until the next call. KODE_DENIED: not initialised, a bad argument, a scale other than 1 or 2, a result too large, or another pass is running. KODE_ERROR: the hardware refused
kmirror_hdr_put(dst, h), kmirror_hdr_get(src, h)Serialise and parse the header. put ignores a NULL; get returns false for a NULL
kmirror_credit_init(), _may_send(), _in_flight(), kmirror_sent(), kmirror_ack()The window, above
kmirror_band()The rectangle, above

The files

FileWhat it does
include/kdrv_jpeg.h, src/kdrv_jpeg.cThe JPEG encoder: engine, output buffer, try-lock, self-test
include/kdrv_ppa.h, src/kdrv_ppa.cThe scaler: client, output buffer sized in whole cache lines, try-lock
include/kmirror_proto.h, src/kmirror_proto.cHeader serialisation, the credit window, the band arithmetic. Pure C

Gotchas

Example code

The protocol half runs without the screen. This walks the credit window and the band arithmetic by hand and logs the results.

/*
 * mirror_credit.c - 2026-10-28 - from Kode with love
 *
 * Fills the mirror's credit window of 4, retires all four frames with one
 * cumulative ack, and maps rows 1 to 3 to a half-scale rectangle. The log
 * shows: in flight 4, may send 0; in flight 0, may send 1; band y 0 h 2.
 */
#include "kode.h"
#include "kmirror_proto.h"
 
#define TAG  "credit"
 
/* Exercise the window and the band, then idle so the console stays up. */
void app_main(void)
{
    kmirror_credit_t credit;
    uint16_t         seq = 0U;
    uint32_t         y = 0U;
    uint32_t         h = 0U;
 
    kode_init();
    kmirror_credit_init(&credit, 4U);
    for (uint8_t i = 0U; i < 4U; ++i) {
        seq = kmirror_sent(&credit);
    }
    KLOG_N(TAG, "in flight %u, may send %u",
           (unsigned)kmirror_in_flight(&credit), (unsigned)kmirror_may_send(&credit));
 
    (void)kmirror_ack(&credit, seq);   /* the newest frame retires the older ones */
    KLOG_N(TAG, "in flight %u, may send %u",
           (unsigned)kmirror_in_flight(&credit), (unsigned)kmirror_may_send(&credit));
 
    kmirror_band(1, 3, 2U, 502U, &y, &h);
    KLOG_N(TAG, "band y %lu h %lu", (unsigned long)y, (unsigned long)h);
 
    while (1) {
        kode_delay_ms(1000);
    }
}

Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. TBD: a run on a Kode Dot.

In the SDK