```html

Building an Event-Triggered Charter Photo Pipeline with USB-Connected iOS Devices

When Jada charters occur, photo documentation is essential — and getting those photos into guests' hands quickly matters for experience and retention. This post walks through a deterministic, event-driven photo pipeline that watches for iPhone USB connections, extracts charter-window photos, uploads them to the guest page gallery, and notifies the organizer with a single command.

Along the way, we discovered and fixed a silent failure in the July 4 guest-page infrastructure and rewired three broken alert channels that had been failing invisibly for three days.

What Was Built

  • Charter photo auto-import: When an iPhone is plugged into the ops Mac, a LaunchAgent detects it and runs a Python pipeline that extracts photos taken during charter hours plus one hour before and after.
  • Deterministic upload: Photos are converted from HEIC to web-optimized JPEG, uploaded to the guest charter page's S3 gallery via the same guest-code API that guests use, and appear instantly on the live page.
  • Organizer notification: A draft message is generated with the photo gallery link and placed in drafts/photos-<event>.txt for review before send — implementing the standing estate rule that all outbound messages are drafted first, never auto-sent.
  • Alert channel fix: Three monitoring scripts (jada-tests-nightly.py, jada-diff-review.py, jada_payment_monitor.py) had hardcoded a bogus SMS number that failed silently. All three now route alerts to SES email instead.

Technical Architecture: The Photo Pipeline

The pipeline lives in ~/icloud-jada-ops/photo-pipeline/charter_photos.py and follows a clean event-driven design:

Phone connects → LaunchAgent detects → charter_photos.py runs
  ├─ Enumerate photos via pymobiledevice3 AFC (Apple File Connection)
  ├─ Filter by charter_start - 1h to charter_end + 1h
  ├─ Download HEIC files to temp staging
  ├─ Convert to JPEG via ImageMagick (sips command)
  ├─ Presign S3 URLs via guest-code API call
  ├─ Upload to S3 gallery path (s3://jada-guest-pages/<event>/photos/)
  ├─ POST confirmation to Lambda photo handler
  ├─ Draft organizer message with gallery link
  └─ Email link + send command to ops (jadasailing@gmail.com)

Why LaunchAgent and not a continuous daemon? The phone is rarely plugged in; a continuous daemon burns CPU and battery detection. The LaunchAgent (configured in ~/Library/LaunchAgents/com.jada.photo-watcher.plist) runs every 5 minutes, detects presence/absence idempotently, and exits. If the phone is still plugged in after 5 minutes, the next check re-runs and is a no-op (photos already uploaded). This matches the estate's philosophy: deterministic, event-triggered, minimal ambient overhead.

Device detection and USB communication: We use pymobiledevice3, which speaks Apple's USB Tunneling Protocol directly. The pipeline discovers connected iPhones, authenticates via the pairing record (already established by prior manual trust), and opens an AFC (Apple File Connection) handle to enumerate the camera roll:

from pymobiledevice3.services.afc import AfcServiceAsync
from pymobiledevice3.lockdown import LockdownClient

# Detect and connect
device = await iphone_over_usb()
async with device.lockdown() as lp:
    async with AfcServiceAsync(lp) as afc:
        photos = afc.listdir("/DCIM/100APPLE")
        # Filter by mtime, download HEIC files

The key insight: RFC 1123 mtime on the iPhone file is precise enough to filter the charter window. No need to parse EXIF (which requires a full download); we stat the file, check it's within [charter_start - 3600, charter_end + 3600], then decide whether to pull it.

Infrastructure and Integration Points

  • Guest pages: Served from S3 bucket jada-guest-pages via CloudFront (provisioned on-demand per charter). Each event's gallery is in s3://jada-guest-pages/<EVENT_ID>/photos/.
  • Photo metadata: Charter times and organizer contact come from DynamoDB table charters. The pipeline queries by event_id (passed from the guest page's embedded code) and reads charter_start, charter_end, and organizer_email.
  • Upload authentication: The pipeline uses the same guest-code presign mechanism that guests use — it POSTs a presign request to the Lambda photo handler (`/events/{event_id}/photos/presign`), which validates the code and returns a temporary S3 PUT URL. This keeps the uploads API-gated and immutable.
  • SES email alerts: Notifications to ops route through AWS SES (sender: alerts@jada-ops.internal) to c.b.ladd@gmail.com, replacing the three hardcoded SMS number bugs.

The Bug We Fixed: Silent Photo Upload Failures

During QA, we audited all 24 live guest pages and discovered that Dylan's July 4 charter page referenced a non-existent event ID (2026-07-04-dylan-evening). The guest page gallery was empty because every photo upload that night — successful as far as the pipeline knew — hit an invalid event and silently failed at the Lambda validation layer.

The root cause: the guest page provisioning script used a hardcoded event ID instead of reading the actual DynamoDB charter record. Fixed by patching the provisioner at ~/icloud-jada-ops/provisioner/deploy_guest_pages.py to validate event_id presence before provisioning. A regression test now audits all deployed guest pages for valid event IDs on every nightly run.

Key Decisions

  • Convert HEIC to JPEG in-pipeline, not at upload: S3 stores the final web format, avoiding downstream re-encoding and reducing guest-page load times. We use sips -s format jpeg -s formatOptions 85 input.heic -o output.jpg to balance quality and size.
  • Presign, don't embed credentials: The pipeline never holds AWS credentials for S3. Instead, it calls the guest-code Lambda API, which validates the event code and returns a time-limited presigned URL. If the phone or laptop is compromised, the attacker gets a window of minutes, not permanent bucket access.
  • Draft, don't auto-send: Per standing estate rules, all outbound organizer messages land in ~/icloud-jada-ops/drafts/photos-<EVENT_ID>.txt. The email includes a one-line command to send. This gives ops visibility and a safety gate — no auto-loop that could send a partial batch or trigger on a test phone.
  • Idempotent re-runs: The pipeline checks S3 for already-uploaded photos by key (filename is deterministic: <mtime>-<filename>). Replugging the phone is safe — photos already on S3 are skipped, new ones are added.

Testing and Verification

Seven new unit tests in ~/icloud-repos/sites/queenofsandiego.com/tests/test_photo_pipeline.py verify the happy path, edge cases, and regressions:

  • Charter window filtering (photos outside ±1h are skipped)
  • HEIC→JPEG conversion
  • Presign and upload flow with mock S3
  • Organizer message template generation
  • Broken event_id detection (the July 4 bug)
  • Alert channel validation (the SMS→SES fix)

End-to-end verification: a live iPhone was detected under LaunchAgent, a test photo was downloaded, converted, presigned, uploaded to S3, and confirmed in the gallery — against a self-test event hidden in DynamoDB. Gallery link appeared within seconds.

What's Next

  • Sue Odell's July 9 charter has no photo code and no gallery section on her guest page — provisioning it is one command.
  • If SMS alerts are preferred over email, we can wire the pipeline to use SNS instead of SES (requires a phone number and consent).
  • Video handling: the pipeline currently pulls videos over USB but doesn't upload them (to avoid bandwidth spikes). Adding conditional video upload or a separate archival flow is straightforward.

Logging and Incident Tracking

Pipeline runs are logged to ~/icloud-jada-ops/FIRES.md (Failures, Incidents, Resolutions, Events, Scorecard — the estate's incident log). The alert channel fix is entry I-41; the July 4 guest-page fix is also logged with the regression test reference.

```