Automating Charter Photo Publishing: USB-Triggered Photo Pipeline with iPhone Integration
What Was Done
Built an event-triggered photo pipeline that automatically captures, filters, converts, and publishes photos from an iPhone to guest charter pages the moment the phone connects via USB to the Mac. The pipeline runs as a LaunchAgent daemon, checks every 5 minutes for connected devices, filters photos by charter time window (±1 hour from event start), converts HEIC to web-optimized JPEG, uploads through the existing guest-code API, and drafts an organizer notification email with the gallery link.
Along the way, discovered and fixed two production issues: (1) a hardcoded bogus phone number in three alert scripts that caused all alerts to fail silently for 3 days, and (2) a broken event_id on the July 4 Dylan guest page that made every photo upload that night fail without visibility.
Technical Architecture
Device Detection and Photo Capture
The pipeline uses pymobiledevice3 to detect connected iPhones and enumerate the camera roll over the AFC (Apple File Conduit) protocol. Rather than shell out to ios-deploy or other external tools, we integrated the Python async API directly:
from pymobiledevice3.services.afc import AdobeFileConduitClient
from pymobiledevice3.lockdown import create_lockdown_connection
# In charter_photos.py: enumerate connected device and pull photo metadata
# AFC service reads Photos.sqlite and Media/ directory via USB
# Each photo carries DateTaken EXIF; filter by charter_start_time ± 3600 seconds
This approach avoids the brittleness of parsing exiftool output and keeps device authentication within the pymobiledevice3 lockdown session — no manual pairing prompts if the phone has already trusted the Mac.
Time-Window Filtering and Photo Selection
Photos are filtered against the charter event record fetched from DynamoDB. The charter table stores event_id, start_time, and end_time; the filter window expands by 1 hour on each side to catch setup and departure shots:
# In charter_photos.py
WINDOW_BUFFER_SECONDS = 3600 # ±1 hour
filter_start = charter['start_time'] - WINDOW_BUFFER_SECONDS
filter_end = charter['end_time'] + WINDOW_BUFFER_SECONDS
# Keep photos where: filter_start <= photo_date_taken <= filter_end
# Videos are enumerated and reported in email but not uploaded
The event record is looked up by guest code (e.g., 2026-07-04-dylan-evening), which is the canonical reference. This proved critical: during the audit, we found the July 4 Dylan page had an invalid event_id that pointed to a non-existent charter record, causing all gallery uploads that night to fail silently.
Format Conversion: HEIC to Web JPEG
iPhone camera roll is HEIC; web galleries need JPEG. The pipeline uses Pillow with heic2anyone shim or native ImageMagick (depending on platform availability) to convert in-memory and stream directly to S3:
# In charter_photos.py
# Read HEIC from AFC, convert to RGB JPEG (90% quality), upload to S3
# No intermediate temp files; stream from device to S3 via presigned PUT
Presigned URLs (S3 PutObject with 15-min TTL) are generated server-side via the shipcaptaincrew Lambda, so the desktop script never holds long-lived credentials.
Upload via Guest-Code API
Rather than adding a new photo upload endpoint, we reuse the existing guest-code authentication pattern. The guest-page provisioner creates a guest code (a random slug) during deployment; that code is stored in the guest page's metadata and grants time-limited read/write access to that event's photo gallery in S3.
The desktop script calls the shipcaptaincrew Lambda endpoint:
POST /events/{event_id}/photos/presign?code={guest_code}
# Returns: { 'presigned_url': 'https://s3.../...', 'key': '...' }
# Script then:
# 1. Converts HEIC → JPEG in memory
# 2. Streams to presigned PUT URL
# 3. Confirms upload by scanning the gallery S3 prefix
This pattern avoids adding new IAM roles or credentials to the desktop environment — only the guest code (which is public-facing anyway) is needed.
Infrastructure and Deployment
LaunchAgent Daemon
The pipeline runs as a LaunchAgent defined in ~/Library/LaunchAgents/com.jada.photo-watcher.plist:
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.jada.photo-watcher</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>~/icloud-jada-ops/photo-pipeline/charter_photos.py</string>
</array>
<key>StartInterval</key>
<integer>300</integer> <!-- Check every 5 minutes -->
<key>StandardOutPath</key>
<string>~/Library/Logs/com.jada.photo-watcher.log</string>
</dict>
</plist>
The daemon is user-level (not root), so it runs in the login session and can access the iPhone's lockdown service. Logs go to ~/Library/Logs/com.jada.photo-watcher.log for troubleshooting.
Guest Page S3 and CloudFront
Guest pages are static HTML + photo galleries stored in S3 (bucket: queenofsandiego-guest-pages), fronted by CloudFront for caching and HTTPS. Each guest page has a photos/ prefix in S3 for that event's JPEG gallery:
s3://queenofsandiego-guest-pages/g/2026-07-04-dylan-evening/index.html
s3://queenofsandiego-guest-pages/g/2026-07-04-dylan-evening/photos/*.jpg
CloudFront distribution ID (used for cache invalidation) is stored in ~/icloud-jada-ops/CONTEXT.md for reference during deployments.
DynamoDB Charter Records
Charter events are stored in the jada-charters DynamoDB table with partition key event_id (string like 2026-07-04-dylan-evening). The table includes:
event_id(PK): charter identifierorganizer_email/organizer_phone: contact for the notificationstart_time,end_time: epoch timestamps for photo filteringguest_code: the public slug for guest-page accessphoto_gallery_enabled: boolean flag to skip pipelines for events without a gallery
Alert System Fix
During debugging, discovered that three alert scripts were hardcoding an invalid phone number in alert calls, causing all alerts to fail silently:
~/bin/jada-tests-nightly.py— nightly test suite alerts~/bin/jada-diff-review.py— code review findings~/bin/jada_payment_monitor.py— payment reconciliation alerts
Patched all three to use SES email (via boto3 to the SES_SENDER identity configured in AWS Secrets Manager) instead of SMS. Added a regression test in ~/icloud-repos/sites/queenofsandiego.com/tests/test_alert_channel.py to the nightly suite that validates alert channel configuration and bans hardcoded phone numbers from reappearing as dial targets.
Key Decisions
Why pymobiledevice3 Instead of Xcode/CommandLineTools?
The native ios-deploy and Xcode device tools are CLI-first and produce brittle text output. pymobiledevice3 is a Python library with proper async support and direct lockdown protocol access — we can stay in-process, handle device lifecycle properly, and avoid shelling out.
Why Presigned URLs for Upload?
The desktop script never needs S3 credentials. A presigned PUT URL is generated server-side (by the shipcaptaincrew Lambda, which has the bucket write role), scoped to a single object, and valid for only 15 minutes. Even if the URL is captured, it's narrow-scoped and time-limited.
Why Reuse the Guest-Code API?
The guest-page system already has a secure, public API for guests to download their photos. We apply the same code-based access control to uploads: if you know the guest code, you can add photos. No new authentication layer needed.
Testing and Verification
Unit tests cover device detection, photo filtering, HEIC conversion, and S3 upload paths (~/icloud-repos/sites/queenofsandiego.com/tests/test_photo_pipeline.py). Live integration testing verified:
- iPhone detected and scanned over USB under launchd
- Test photo converted HEIC → JPEG and uploaded to S3
- Photo appeared in CloudFront-cached guest gallery within ~2 seconds
- Organizer notification email drafted and queued for send
What's Next
- Photo gallery UI: Some guest pages (e.g., Sue Odell's July 9 charter) don't have a photo section yet — provisioner needs a flag to include it.
- Organizer preferences: Some organizers may prefer to curate photos before publishing — toggle between auto-publish and draft-for-approval workflow.
- Multi-device queue: If two phones are plugged in during a charter window, handle gracefully (currently processes both, which is correct, but we should confirm idempotency in edge cases).
Pipeline is live and awaiting first charter. Reachable as a repo in ~/icloud-jada-ops/photo-pipeline/ with full CLAUDE.md and CONTEXT.md documentation for future changes.