Tap a button on a tablet. A Sony a7 IV fires. The photo appears on the tablet 53 milliseconds later.
PTP-over-USB shutter trigger and live image capture for the M5Stack Tab5 (ESP32-P4), driving a Sony Alpha a7 IV over the Tab5's USB-A host port.
The firmware fires the camera, waits for the image, streams the JPEG out of the camera's RAM buffer, writes it to the microSD card, decodes it, and shows it full-screen. A built-in gallery browses everything already on the card. No network, no phone, no app — the ESP32-P4 has no radio at all. Just a cable.
Flashed to a Tab5 (ESP32-P4 rev v1.3) with an a7 IV on the USB-A port:
capture complete: 7360672 bytes, 7931 ms (preview 53 ms), saved as IMG_0014.JPGShutter fires, the JPEG comes off the camera, lands on the card, decodes, and reaches the glass. The protocol layers also pass a 206-assertion host test suite against a mock PTP responder, under ASan and UBSan.
Two code paths compile clean and have never been executed — both off the capture path. They are named in What is verified, and what is not.
The README gets you running. These go deep on one thing each.
| Document | What it covers |
|---|---|
docs/board.md |
The Tab5 hardware: variants, memory, pinout, the two USB ports, microSD |
docs/architecture.md |
Layering, tasks, the streaming decode, the two-tier preview, testing strategy |
docs/sony-ptp.md |
Talking PTP to an Alpha: the SDIO handshake, the shutter sequence, every protocol quirk found |
docs/workarounds.md |
Every non-obvious thing in this repo and why it is there. Start here when the code looks strange |
docs/display-bringup.md |
Why the panel showed nothing, why it then flickered, and how to triage a dark Tab5 |
docs/usb-highspeed.md |
The one Kconfig line that stopped the camera attaching at all |
docs/bsp-fork.md |
What was changed in the vendored Espressif BSP, and how to re-create the diff |
| Board | M5Stack Tab5 (ESP32-P4, 32 MB PSRAM, 5" 720x1280 MIPI-DSI touch panel) |
| Camera | Sony Alpha a7 IV. Other Alpha bodies are likely to work; nothing else is tested |
| Cable | USB-A (Tab5) → USB-C (camera). The a7 IV's micro-USB port is for accessories, not PTP |
| Card | microSD, FAT32 — optional, see below |
| Toolchain | ESP-IDF v5.4.x (5.5+ works too — see docs/workarounds.md) |
The Tab5 has two USB ports and they do completely different things. USB-C is USB-Serial-JTAG — flash and monitor through it. USB-A is the USB 2.0 host port — the camera plugs in there. You cannot flash through USB-A.
The a7 IV will not answer PTP control commands until it is in the right USB mode. Nothing else in this README matters until these four settings are right.
-
MENU → Setup → USB → USB Connection Mode →
PC RemoteNotMass Storage, notMTP, notAuto. InAutothe camera decides from the host's enumeration behaviour and generally lands on MTP, whereSDIO_ControlDeviceis rejected. -
MENU → Setup → USB → PC Remote Function → Still Img. Save Dest. →
PC OnlyThis is what makes the camera put the captured frame in its RAM buffer (object handle0xFFFFC001) instead of writing it to its own card. -
MENU → Shooting → Image Quality/Rec → File Format → include
JPEGThe firmware will fix this for you if it can, but setting it yourself is one less thing to go wrong. A RAW-only body sends a ~68 MB ARW — more PSRAM than the board has, and nothing the decoder can use, so the download is refused deliberately rather than attempted and crashed. -
MENU → Setup → USB → USB Power Supply →
OffThe Tab5's USB-A port supplies 5 V. Some bodies renegotiate the link mid-session if they try to charge while tethered. Run the camera on its own battery.
Leave the lens in AF if you want autofocus before the exposure: the capture
sequence half-presses first and waits for FocusFound (0xD213). In MF that
property never asserts and the firmware falls through to the full press after a
timeout.
Worth knowing before you plug in a body you care about. During connect,
prefer_jpeg() writes two Sony device properties, and the camera keeps them
across a power cycle — confirmed on an a7 IV:
| Property | Menu name | What the firmware writes |
|---|---|---|
PcSaveImageFormat (0xD269) |
RAW+J PC Save Image | JPEG Only, unless it already is |
CompressionSetting (0xD253) |
File Format | RAW+JPEG, only if the body was shooting RAW alone |
The second is the intrusive one: a RAW-only body has no JPEG for anyone to ask
for, so a JPEG has to start being produced before tethering can work at all. A
body already shooting JPEG is left alone, and PcSaveImageSize is deliberately
never touched, so what lands on the camera's own card stays full-resolution.
Neither write is fatal if it fails. To opt out entirely, set
cam.force_jpeg = false before sony_connect() in main/app_main.c — the
firmware then leaves your camera exactly as it found it and refuses any
non-JPEG object rather than fetching it.
Full protocol detail, including the value tables and the dead ends:
docs/sony-ptp.md.
Captures are written to the Tab5's microSD card, and the gallery browses that card. Format FAT32 (MS-DOS), not exFAT — ESP-IDF's FatFs has no exFAT support compiled in, so an SDXC card straight out of the packet will not mount. On macOS:
diskutil list # find the card, e.g. /dev/disk4
diskutil eraseDisk FAT32 TAB5 MBRFormat /dev/disk4Disk Utility's GUI refuses FAT32 above 32 GB; the command above does not.
⚠️ This build reformats cards it cannot mount, without asking.sdkconfig.defaultssetsCONFIG_BSP_SD_FORMAT_ON_MOUNT_FAIL=y, so an exFAT card — or a card with anything else on it — is silently erased at boot. Convenient for a card dedicated to this project, and destructive the moment you put your camera's card in the Tab5 by mistake: the photos are gone before the UI has drawn. Set it ton(and delete the stale line fromsdkconfig) if you would rather format deliberately.
Files land in /sdcard/PHOTOS/ as IMG_0001.JPG, IMG_0002.JPG, … Numbering
resumes past the highest file present, so a card carried between sessions never
overwrites itself, and deleting a photo does not recycle its number. Names are
8.3 on purpose.
No card is not an error. Shutter, decode and display all work with an empty slot; the photo simply is not persisted and the status line says so:
| Status line | Meaning |
|---|---|
SD card ready (N photos) |
mounted, N photos indexed |
SD card is not FAT32 - reformat it (exFAT unsupported) |
a card is present and readable, but its filesystem is not FAT |
No SD card - photos will not be saved |
nothing in the slot |
The Gallery button in the footer swaps the shutter row for prev / next / delete / close, and is enabled whenever the card holds a photo. It reads the SD card and never touches USB, so it works with no camera attached — which makes it the easiest part of the firmware to try first. Decoding reuses the same descale-during-decode core as the live path, so reopening a 30 MB capture costs the same bounded memory as capturing it did.
. $HOME/esp/esp-idf/export.sh
idf.py set-target esp32p4
idf.py build
idf.py -p /dev/cu.usbmodem* flash monitor # USB-C portThe repo also ships tools/idf.sh, a wrapper that activates ESP-IDF with
IDF_PYTHON_ENV_PATH pinned — needed on machines whose default python3 is
newer than the installed ESP-IDF virtualenv:
./tools/idf.sh build
./tools/idf.sh -p /dev/cu.usbmodem1101 flash monitorCurrent size: ~890 KB in a 4 MB app partition.
ptp_core, ptp_sony and exif_thumb are ESP-IDF-free and are tested on the
build machine — the first two against a mock PTP responder, the third against
synthetic JPEGs — under ASan and UBSan with -Wall -Wextra -Werror:
cd test/host && makepassed: 189 failed: 0 <- test_ptp
passed: 17 failed: 0 <- test_exif
The EXIF suite is mostly malformed input on purpose: the parser indexes into a buffer using offsets the camera wrote, so ASan catching an out-of-bounds read is the assertion in several cases. One test sweeps every prefix of a valid file and checks no returned extent ever runs past the bytes held — which is exactly what parsing a download while it is still arriving does.
main/ orchestration, LVGL UI, streaming decode, SD store
components/ptp_core/ PTP/PIMA-15740 transport. Vendor-neutral, no ESP-IDF.
components/ptp_sony/ Sony SDIO vendor extension. No ESP-IDF except a clock.
components/ptp_usb_host/ USB class driver: enumeration, endpoints, transfers.
components/exif_thumb/ finds the JPEG's own embedded thumbnail. No ESP-IDF.
components/m5stack_tab5/ forked BSP - see docs/bsp-fork.md
test/host/ mock PTP responder + unit tests (run on your Mac/PC)
The split is load-bearing. ptp_core knows nothing about Sony, ptp_sony knows
nothing about USB, and neither knows about ESP-IDF — which is what lets 206
assertions run on a laptop, and what you would reuse to talk to a Canon body
(add a ptp_canon beside ptp_sony; leave the transport alone).
The whole design follows from one number. An a7 IV frame is 7008x4672 — 33 megapixels, 65 MB decoded to RGB565. The board has 32 MB of PSRAM. So the firmware never holds a whole image and a whole decode at the same time: TJpgDec descales 1/8 while it decodes, pulling compressed bytes out of a 256 KB StreamBuffer that the PTP data phase pushes into. Peak extra memory is ~1.3 MB regardless of file size, and the same chunk stream tees to the SD card as it goes.
Full picture — tasks, queues, the drain invariant, why the P4's hardware JPEG
decoder is deliberately unused: docs/architecture.md.
| Stage | Time |
|---|---|
| Shutter → object ready | ~0.3 s |
| EXIF thumbnail found, decoded, on screen | 53 ms |
| 7.4 MB download off the camera | ~2.1 s, overlapped |
| TJpgDec decode, 7008x4672 at 1/8 → 876x584 | ~7.8 s |
| Total to a sharp image | 7.9 s |
The wire was never the bottleneck. The download overlaps the decode and finishes well inside it; the software decode of 511,000 MCUs is essentially the whole number.
Getting to 53 ms meant separating when something appears from when it is
sharp. The plan had been GetThumb (0x100A) — that path is built and tested,
and the a7 IV refuses it (measured, three ways; see
docs/sony-ptp.md). So
the preview comes from somewhere strictly better: the 160x120 thumbnail in IFD1
of the JPEG's own EXIF header, ~47 KB into a file already being downloaded.
Zero extra round trips.
Three ways to shorten the 7.9 s remain, none pulled:
PcSaveImageSize(0xD268) →2M— one line, decodes in well under a second, and every photo on the card becomes a 2 MP thumbnail. Declined: it defeats the point of keeping the pictures.- The MPF second image. The JPEG's
APP2MPF index lists a 135 KB second image — but at file offset ~7.22 MB, the end of the file. It cannot beat the EXIF thumbnail to the screen; it would only reach the sharp tier at ~2.3 s instead of 7.9 s, for an MPF parser and a tail buffer. - The P4's hardware JPEG decoder. No downscaling, so at full resolution it would need 65 MB of output. It is an accelerator for options 1 and 2, not an option of its own.
The status line prints both numbers after every capture, so any change here is immediately measurable.
An a7 IV in PC Remote mode on the Tab5's USB-A port, observed over the serial
console. The whole chain runs: shutter → object-ready → GetObjectInfo →
GetObject stream → SD write → decode → full-screen display.
I (9496) app: camera: ObjectInMemory = 0x8001
I (9500) photos: writing /sdcard/PHOTOS/IMG_0004.JPG
I (9512) app: camera: object format = 0x3801
I (8955) app: preview: EXIF thumbnail 160x120 from 6106 bytes, shown 53 ms in (47 KB downloaded)
I (8976) jpegstream: JPEG 7008x4672
I (16812) jpegstream: decoded to 876x584 (1/8), 999 KB
I (17013) app: capture complete: 7360672 bytes, 7931 ms (preview 53 ms), saved as IMG_0014.JPG
Piece by piece: High-Speed enumeration and interface claim (054c:0da7, class
06/01/01); OpenSession; GetDeviceInfo; the Sony SDIO handshake with
protocol 3.00 negotiated and 389 properties advertised; property writes via
0x9205 with read-back confirming they stuck across a power cycle; shutter via
SDIO_ControlDevice (0x9207); GetObjectInfo + GetObject streaming 7.4 MB
out of camera RAM while teeing to the SD card; TJpgDec at 1/8 scale; and the
two-tier preview at 53 ms.
Boots clean and stays up — no resets, no panics. PSRAM at 32 MB (~30.1 MB free after init, ~336 KB internal). Display and touch initialise (720x1280, ST7123 touch firmware 1.80.1.16) and the panel renders — no flicker, confirmed visually as well as by instrumentation counting zero DSI underflows on an idle screen. The USB co-existence path attaches to the BSP's already-installed host library rather than installing a second one. SD mounts at 4-bit/40 MHz and measures 3.6 MB/s.
The whole storage → gallery chain is proven without a camera by generating a
real JPEG with the P4's hardware encoder and pushing it through the same
photo_writer_* → photo_store_refresh → jpeg_decode_file path a capture
uses (SEED_TEST_PHOTO in app_main.c; encode 40 KB in 28 ms, write in 17 ms,
decode back to 800x448). selftest_dump_screen() base64s the composited LVGL
screen to the console and tools/screenshot.py recovers it — the only way to
see what the panel shows without being in the room with it.
Two paths are logically complete, warning-free, and have never run. Neither is on the capture path:
ptp_usb_host_device_reset()andptp_usb_host_get_device_status()— spec-correct per the USB Still Image class definition, but many cameras STALL Get Device Status and the a7 IV's behaviour is unknown. They exist for a wedged-transport recovery that has not been needed.sony_download_object_partial()— libgphoto2 namesSDIO_GetPartialLargeObject (0x9211)but does not use it for Alpha bodies, so the parameter layout(handle, offset_lo, offset_hi, max_bytes)is inferred, not verified. PlainGetObjectis what libgphoto2 uses, what the app calls, and what is confirmed working. If you need the partial variant, expect to fix the parameter order first.
Both are marked UNTESTED ON HARDWARE in their headers.
- This uses ESP-IDF's native USB Host Library, not TinyUSB. TinyUSB's
host stack is not what ESP-IDF exposes on the P4 —
esp_tinyusbis device-side only, and the Tab5 BSP itself brings up the native host library inbsp_usb_host_start(). Building on TinyUSB host would have meant fighting the BSP for ownership of the controller. The class driver is still modelled on the MSC/CDC host class structure as intended (daemon task + client task + per-endpoint transfers). - The shutter uses
SDIO_ControlDevice (0x9207), notSDIO_SetControlDeviceB (0x96F8). In libgphoto2'slibrary.c,0x96F8is the Sony QX path; Alpha bodies including the a7 IV go through0x9207. Both are defined inptp_codes.h; only0x9207is wired up.
Every step below has been walked on an a7 IV, so each is a thing that does work — which makes this a bisect. Go in order.
1. Does it enumerate? Watch the ptp_usb log on attach; open_device()
prints every interface with its class/subclass/protocol. You want 06/01/01.
- Nothing at all → the port is not powered or the cable is charge-only. Check
bsp_usb_host_start()returnedESP_OKabove it. - Class
08/06/50→ the camera is in Mass Storage mode. Camera setup step 1. Full Speed,EP0 MPS 8, class03(HID), one endpoint → the camera is switched off. An Alpha still enumerates when off so it can charge; the UI says "switch the camera on" for this case specifically.EP MPS (512) exceeds supported limit (256)→ the USB host FIFO bias. This one cost a lot of time:docs/usb-highspeed.md.
2. Does the handshake succeed? The subtitle should read
Sony ILCE-7M4 | 054c:0xxx | events (or polling only).
TIMEOUTon the very first transaction → almost certainly the class driver, not the protocol. Check the endpoint addresses andwMaxPacketSize.- Fails partway through
SDIO_Connect→ enumerating as PTP but refusing the vendor extension. That is thePC Remotesetting again.
3. Does the shutter fire? If the camera exposes but nothing else happens, the control path works and the problem is downstream.
4. Does the image arrive? Status goes Waiting for image... →
Downloading... with a progress bar.
- Stuck on
Waiting for image...→ image-ready detection. Note whether the subtitle saideventsorpolling only; the firmware runs both, so forcing one isolates which is lying. Camera sent RAW (ARW), not JPEG→ the format guard fired before spending a 68 MB download on something undecodable.Download failedimmediately →GetObjecton0xFFFFC001was rejected.
5. Does it decode and display?
Out of memory decoding image→ the size check did its job; look at the logged dimensions.Could not decode image→ TJpgDec rejected the stream. Most likely the stream buffer stalled (16 s), or the JPEG is progressive, which TJpgDec does not support. The a7 IV shoots baseline.- Rotated or mirrored →
APP_DISPLAY_ROTATIONinapp_main.c.
Blank or flickering panel? That is a different problem with its own writeup:
docs/display-bringup.md.
- JPEG only. RAW is refused by arithmetic, not policy — an a7 IV ARW is ~68 MB and cannot be handled in 32 MB of PSRAM.
- No progressive JPEG. TJpgDec is baseline-only. Sony shoots baseline.
- A picture appears in 53 ms, but it is soft for the first 7.9 s. The instant preview is a 160x120 EXIF thumbnail upscaled onto a 720-line panel, and it looks like it. The file written to the card is unaffected — it is the decode that is slow, not the wire.
GetThumb (0x100A)does not work on the a7 IV. The path is present and tested for bodies that do support it; here it costs oneGetObjectInfo(~5 ms) and returnsPTP_ERR_UNSUPPORTED.- What is displayed is downscaled 1/8 — a viewfinder, not a proof tool. The file on the card is the camera's full-resolution JPEG, untouched.
- One camera at a time.
ptp_usb_hostis a singleton; no hub support. - A second shutter tap during a capture is dropped, not queued — which is how a physical shutter button behaves.
- Software display rotation costs a full-frame transform per redraw. Set
APP_DISPLAY_ROTATIONtoLV_DISPLAY_ROTATION_0for native portrait. - The Tab5 BSP is a local fork, because this board's panel is an ST7121 that
upstream does not support:
docs/bsp-fork.md. esp_lcd_dpi_panel_set_pattern()is not safe to call at runtime — it can leave the display flickering until the next power cycle.- Tested against exactly one camera and one board. Everything here is an a7 IV on a board-version-3 Tab5.
- libgphoto2
camlibs/ptp2/ptp.h— every constant inptp_codes.his transcribed from here rather than guessed. - libgphoto2
camlibs/ptp2/library.c— the Sony handshake, theObjectInMemorythreshold, and the0x9207-vs-0x96F8split. - PIMA 15740 / PTP — container format.
- USB Still Image Capture Device class definition — the
06/01/01triple. - ESP-IDF USB Host Library documentation — the daemon/client task model.
Apache-2.0 — see LICENSE.
components/m5stack_tab5/is a fork of Espressif'sespressif/m5stack_tab5BSP (Apache-2.0), esp-bsp commit91c2e12. Upstream's licence file is kept intact.docs/bsp-fork.mddescribes the delta.components/esp_lcd_st7121/is vendored from M5Stack'sM5Tab5-UserDemo, which is not published on the component registry.- The PTP and Sony SDIO constants are transcribed from libgphoto2 (LGPL-2.1) — codes and protocol knowledge, not code.
- TJpgDec (ChaN) via ESP-IDF's
esp_jpeg; LVGL 9 viaesp_lvgl_port.