Skip to content

Repository files navigation

esp-photobooth

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.

Status: working end to end on real hardware

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.JPG

Shutter 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.


Documentation

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

What you need

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.


Camera setup — do this first

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.

  1. MENU → Setup → USB → USB Connection Mode → PC Remote Not Mass Storage, not MTP, not Auto. In Auto the camera decides from the host's enumeration behaviour and generally lands on MTP, where SDIO_ControlDevice is rejected.

  2. MENU → Setup → USB → PC Remote Function → Still Img. Save Dest. → PC Only This is what makes the camera put the captured frame in its RAM buffer (object handle 0xFFFFC001) instead of writing it to its own card.

  3. MENU → Shooting → Image Quality/Rec → File Format → include JPEG The 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.

  4. MENU → Setup → USB → USB Power Supply → Off The 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.

The firmware changes two settings on your camera

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.


SD card setup

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/disk4

Disk Utility's GUI refuses FAT32 above 32 GB; the command above does not.

⚠️ This build reformats cards it cannot mount, without asking. sdkconfig.defaults sets CONFIG_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 to n (and delete the stale line from sdkconfig) 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

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.


Build and flash

. $HOME/esp/esp-idf/export.sh
idf.py set-target esp32p4
idf.py build
idf.py -p /dev/cu.usbmodem* flash monitor       # USB-C port

The 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 monitor

Current size: ~890 KB in a 4 MB app partition.

Host-side unit tests

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 && make
passed: 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.


How it works

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.


Performance

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:

  1. 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.
  2. The MPF second image. The JPEG's APP2 MPF 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.
  3. 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.


What is verified, and what is not

Verified on hardware, with a camera attached

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 → GetObjectInfoGetObject 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.

Verified on hardware, without a camera

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_refreshjpeg_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.

Compiles clean, never executed

Two paths are logically complete, warning-free, and have never run. Neither is on the capture path:

  • ptp_usb_host_device_reset() and ptp_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 names SDIO_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. Plain GetObject is 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.

Two deviations from the original brief

  1. 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_tinyusb is device-side only, and the Tab5 BSP itself brings up the native host library in bsp_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).
  2. The shutter uses SDIO_ControlDevice (0x9207), not SDIO_SetControlDeviceB (0x96F8). In libgphoto2's library.c, 0x96F8 is the Sony QX path; Alpha bodies including the a7 IV go through 0x9207. Both are defined in ptp_codes.h; only 0x9207 is wired up.

Triage, in order, when a capture does not happen

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() returned ESP_OK above it.
  • Class 08/06/50 → the camera is in Mass Storage mode. Camera setup step 1.
  • Full Speed, EP0 MPS 8, class 03 (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).

  • TIMEOUT on the very first transaction → almost certainly the class driver, not the protocol. Check the endpoint addresses and wMaxPacketSize.
  • Fails partway through SDIO_Connect → enumerating as PTP but refusing the vendor extension. That is the PC Remote setting 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 said events or polling 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 failed immediately → GetObject on 0xFFFFC001 was 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_ROTATION in app_main.c.

Blank or flickering panel? That is a different problem with its own writeup: docs/display-bringup.md.


Known limitations

  • 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 one GetObjectInfo (~5 ms) and returns PTP_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_host is 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_ROTATION to LV_DISPLAY_ROTATION_0 for 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.

Reference material

  • libgphoto2 camlibs/ptp2/ptp.h — every constant in ptp_codes.h is transcribed from here rather than guessed.
  • libgphoto2 camlibs/ptp2/library.c — the Sony handshake, the ObjectInMemory threshold, and the 0x9207-vs-0x96F8 split.
  • PIMA 15740 / PTP — container format.
  • USB Still Image Capture Device class definition — the 06/01/01 triple.
  • ESP-IDF USB Host Library documentation — the daemon/client task model.

Licence and credits

Apache-2.0 — see LICENSE.

  • components/m5stack_tab5/ is a fork of Espressif's espressif/m5stack_tab5 BSP (Apache-2.0), esp-bsp commit 91c2e12. Upstream's licence file is kept intact. docs/bsp-fork.md describes the delta.
  • components/esp_lcd_st7121/ is vendored from M5Stack's M5Tab5-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 via esp_lvgl_port.

About

PTP-over-USB shutter trigger and live capture for M5Stack Tab5 (ESP32-P4) driving a Sony a7 IV. Photo on screen 53 ms after the shutter.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages