espurr Apple apps¶
Native SwiftUI controllers for iPhone/iPad and Mac Catalyst (iOS/iPadOS 26+, macOS 26+ for Catalyst), with a watchOS 26+ companion. The phone provides detailed configuration, diagnostics and firmware updates; the Watch is a compact remote. The ears work independently.
Both apps require application protocol 2 and the authenticated firmware catalogue. Current firmware uses bonded LE Secure Connections. Older Security 2 firmware is unsupported; update it by USB before connecting.
Optional analytics and crash reports¶
The phone/iPad and Mac Catalyst target links Firebase Apple SDK 13.1.1 through
SwiftPM: FirebaseAnalyticsCore and FirebaseCrashlytics. The old
FirebaseAnalyticsWithoutAdId product was removed upstream; Core is the current
product without the separate IdentitySupport/IDFA library. Watch and its widget do
not link Firebase. IDFV collection, advertising consent/personalisation, automatic
screen reporting, on-device conversion events and Firebase Sessions are disabled.
The first-open Allow analytics and crash reports? sheet defaults to no collection.
No FirebaseApp.configure() happens without a saved explicit opt-in. Share
analytics and crash reports in Settings or Support changes the choice. There are
only two allowlisted usage events:
connection and firmware outcomes with duration buckets. There are no user IDs,
accessory identities, recordings or logs in these payloads. Generated SDK identifiers and crash/system metadata remain pseudonymous.
This app-specific consent is independent of Apple's system analytics sharing choices.
Crashlytics automatic uploading stays disabled permanently. A continuously opted-in cold launch authorises sending prior reports; first opt-in or a new consent period discards old pending data. Turning off disables/resets Analytics and prevents future crash-send calls, and the next cold launch clears SDK-owned pending stores before any later configuration. The SDK can retain a local current-session crash file until then. Already authorised/in-flight network requests cannot be recalled. iOS never force-quits the app when consent changes.
The checked-in ios/GoogleService-Info.plist configures local, Xcode Cloud and GitHub
Actions builds. Alternatively, set
FIREBASE_CONFIG_FILE to a private build-time path. It must match the phone's bundle
identity. A missing file (or FIREBASE_ENABLED=NO) leaves source/CI builds usable
without Firebase configuration. The Downloads original is preserved; ~/Downloads is not a build dependency. The resource phase copies the configuration
and separately generated, pin/hash-checked offline notices into phone/Mac products.
The phone target's script sandbox is disabled for this optional-resource staging and
Crashlytics tooling; the Catalyst runtime app sandbox remains enabled.
Ordinary builds do not upload symbols. For a configured Release archive, set
FIREBASE_UPLOAD_SYMBOLS=YES; the final build phase invokes Firebase's own
Crashlytics/run with the bundled configuration. Keep exact matching dSYMs for each
distributed build. Console receipt/symbolication from real crashes is a separate
release check. UI tests force a fake backend and never upload to the local project.
The Debug-only live probes and the completed synthetic event/crash checks are documented in live telemetry acceptance. They require normal saved opt-in; Release builds exclude them.
Connect¶
- Put the ears in BLE mode and choose Find ears. iPhone/iPad use the system AccessorySetupKit picker; Catalyst uses native BLE discovery. Normal firmware starts in BLE mode; see button gestures to switch radios.
- Select the ears, confirm the OS numeric-comparison prompt and make a fresh headset click within 30 seconds. Compare the serial number with the OS prompt if you need human-verified numeric comparison. There is no fixed PIN.
- Wait for fresh settings before editing. The OS remembers the bond for reconnects.
A remembered row connects directly; its information button opens connection settings. Saved associations contain only device identifiers/names/preferences; OS-managed bonds supply authentication. No app password or shared phone/Watch Keychain group is needed. Existing iPhone/iPad users choose Continue setup once to migrate their saved UUID into ASK before any CoreBluetooth central starts. Cancellation leaves migration pending. Authorisation is reconciled before reconnect; revocation/removal disconnects the client and clears the matching Watch association. Forget device removes ASK authorisation on iPhone/iPad, or the saved app association on Catalyst. It does not erase all bonds on the ears or the Watch. Manage independent OS bonds separately.
If accessory setup becomes unavailable, the app clears its session authorization and pending selection; Find ears can retry. A framework invalidation event discards that session without cancelling it a second time. See the startup regression evidence.
ASK matches the local-control service plus the espurr- name and requests LE pairing.
Its bundled cat artwork and friendly display names do not authenticate the accessory.
The raw hardware name is kept separately for Watch discovery. No unrestricted scan
fallback is used on iPhone/iPad. Bluetooth restoration/background relaunch is deferred.
New iOS 26.1+ picker results use the advertised name. The app also substitutes the
known hardware name for older generic saved labels, while preserving custom names.
Existing names saved by the system picker itself remain OS-managed.
The phone makes one automatic reconnect attempt per opening when remembered setup is available. An explicit device choice is saved before connection so a failed attempt doesn't lose the target. If ASK has exactly one authorised accessory but the app has no saved target, it restores that selection; multiple accessories still need a choice. Migration and authorisation checks precede CoreBluetooth. It retains an authenticated ordinary BLE connection through screen lock/background while iOS preserves the process and link. Discovery, polling and edits pause; unsent edits are discarded, and fresh state is read before editing resumes. Connecting or uploading in the background disconnects. Explicit Disconnect and Watch release also end ownership; OTA refuses handoff. OS termination/link loss needs reconnect.
Cancel connection ends pending Bluetooth, Watch-release and accessory-setup work, immediately restoring the welcome controls. It suppresses automatic reconnect for the current visit. Reopening after backgrounding, or explicitly choosing Find ears or a device, permits another attempt. Temporary system-prompt interruptions don't retry a failed or cancelled connection. Saved setup and OS bonds are retained. See the cancellation checks.
Apple interface¶
The Apple app uses one window on iPhone, iPad and Mac Catalyst. Its Bluetooth session, accessory setup and Watch ownership are shared app resources; a second window must not independently pause or replace them. The window can still resize and participate in system multitasking. Native toolbar actions provide titles and symbols so the system can adapt them to horizontal or vertical bars. Connection status retains distinct demo, refreshing, applying, saving and save-failed accessible values. Demo also remains visible as a native navigation subtitle when the toolbar uses only a symbol.
- Control: power, brightness, temporary flashlight when supported, catalogue effects and their parameters, palettes and motion switches. Firmware confirms application; a successful Bluetooth write alone is insufficient.
- Colours: palette editing, Smooth/Bands, hue cycling, background glow and device-saved palettes. Replacement/deletion/restoration require confirmation; naming refusals retain the draft. Artistic RGB stays unscaled.
- Settings: calibration, Watch setup, LED output, ear wiring, Wi-Fi, device information, connection, diagnostics and updates. Firmware capabilities govern availability. Missing sensors don't disable unrelated controls.
Pending, applied, saving and save-failed states stay distinct. Polls don't reset the effect picker. Calibration needs two seconds of continuous stillness within a five-second capture window; the app waits for the confirmed result. See calibration and motion.
Control's optional local ear simulation uses the shared C renderer and phone/manual motion. Audition stays local; disconnect freezes the last frame. It isn't headset telemetry. The Watch has no simulation.
Settings → Audio diagnostics can request temporary capture and, on supported hardware, a separate five-second recording. Native Save/Share exports WAV plus a JSON sidecar. The app doesn't use the phone microphone. See the audio contract and recording wire format.
Support works from welcome, Settings and recovery, including disconnected/demo use. It exposes offline privacy/notices and explicit report/export choices. Local reliability counts default off; see support reporting.
Firmware updates and recovery¶
Use Check for firmware, optionally include release candidates, and download a
matching release for review. Choose firmware imports a local application .bin.
Install firmware is a separate action. Local files and BLE work without Pages.
The picker sends no accessory identifiers or credentials to the download host.
Preflight checks the image family/chip and the actual inactive-slot capacity: 1700 KiB on ESP32, 3072 KiB on legacy S3/v2 8 MB, and 4096 KiB on v2 16 MB. Firmware validates SHA-256 and the native image before selecting it. App OTA can't change partition layouts; use the update guide.
Keep the app foreground and ears powered/in range. Normal polling/edits pause and auto-lock is disabled during upload. Cancel waits for the active exchange and aborts; there is no partial resume. After a lost final reply, reconnect to inspect the running firmware before retrying. Selection is not proof of a healthy boot.
BLE recovery¶
Dedicated recovery opens the updater directly, using the firmware-reported normal upload family. Wearable settings/calibration are unavailable. Watch directs users to phone and can release its connection. Bonded phone clients can set the AP password from Set up recovery Wi-Fi, or normal Settings → Wi-Fi. Password fields are transient and never read back. See recovery and storage repair.
Apple Watch companion¶
The dependent companion reuses EspurrControl and communicates directly with the
ears. Swipe between Control, Appearance, Motion and Connection. It supports
power/brightness with Crown and touch, catalogue effects, builtin/saved palettes,
overlay switches/shared offset and confirmed neutral recenter. Detailed editors,
full mounting calibration, recordings, reporting and OTA stay on phone.
Setup and ownership¶
- Install the phone app and its embedded Watch companion.
- Connect the phone to the ears. A versioned, non-secret WatchConnectivity application context automatically carries the selected ears' setup ID, hardware discovery name and display name. Delivery is opportunistic; both apps needn't be reachable at once. Settings → Apple Watch → Retry sync retries queueing the current selection.
- Open the Watch app. It requests phone release, then connects to its remembered peripheral or makes one bounded discovery attempt for the hardware name. It discovers its own UUID; the phone UUID identifies setup, never the Watch's Bluetooth peripheral. Confirm the Watch's own OS pairing and click the headset button the first time.
- Reopen phone to request reciprocal release and reconnect. If the other controller is unreachable and still holds BLE, explicitly leave/disconnect it and retry.
The Watch follows the phone's selected ears. Duplicate/stale contexts are ignored; renaming preserves its own peripheral ID. Replacement/forget clears the old target and connection. If setup arrives after opening the Watch app, it requests release and connects while active; background receipt only saves setup for the next opening. Setup received while dimmed waits until active. Phone and Watch maintain independent bonds. Android can control the same bonded firmware, with system companion-device setup/direct scan fallback and explicit ownership release.
Release requests match setup account/request ID and a three-second expiry, never credentials. OTA refuses release. Unavailable messaging permits one direct attempt; explicit refusal stops it. No queued retry loop or stale callback may acquire BLE.
Watch wrist-down/frontmost inactivity retains BLE and pauses polls/edits. Raising it resumes the same session. Actual backgrounding releases BLE and unsent work. After release, dimming/reactivation alone doesn't reconnect. Cached controls stay disabled until a new authoritative read. Active Watch polling is every three seconds.
espurrWatchLauncher is a static circular WidgetKit complication that opens the
app. It has no device state, credentials, Bluetooth or scheduled refresh.
Build and install¶
Use Xcode 27.0 or later. The app uses SDK APIs introduced in Xcode 27 while retaining its iOS/macOS/watchOS 26 deployment targets. Each app/extension target runs the toolchain check before its sources; GitHub CI and the Cloud pre-build hook run the same check. For command-line preflight:
python3 tools/check_xcode.py
GitHub's Apple job uses the xcode-27 runner and explicitly selects Xcode 27.2,
matching Cloud's validated toolchain. A runtime #available check cannot make
an older build SDK recognise a newer API.
Open ios/espurr.xcodeproj and select espurr or espurrWatch. Signing overrides
belong in ignored ios/Local.xcconfig:
DEVELOPMENT_TEAM = YOURTEAMID
Enable Developer Mode and pair intended physical devices in Xcode. ASK is included
only in the iPhone/iPad plist; Catalyst retains native discovery. The app doesn't
request Keychain sharing. Signing, physical ASK discovery and pairing need device checks.
All Apple targets share ReleaseVersion.xcconfig; keep their release versions aligned.
The ios/vX.Y.Z tag covers iPhone/iPad, Watch and macOS Catalyst together; there is
no separate macos/ track. Its base must match MARKETING_VERSION. Xcode Cloud
assigns distribution build numbers; seed its counter above previous uploads and at
least as high as committed CURRENT_PROJECT_VERSION. Local/manual store uploads must
also use a fresh build number. App versions are independent of firmware.
The Apple release setup uses
Xcode Cloud for signing and TestFlight, including the embedded Watch and complication.
Mac Catalyst uses its own App Sandbox entitlements for Bluetooth, outgoing network
connections and files selected through the system picker, as required for Mac App
Store distribution.
GitHub retains unsigned CI archives and a draft as source/build evidence; those build
numbers do not identify the separately built Cloud distribution archives. Public
store release and physical acceptance remain separate steps.
From the repository root:
swift test --package-path ios/EspurrControl
xcodebuild -project ios/espurr.xcodeproj -scheme espurr \
-destination 'generic/platform=iOS' -derivedDataPath ios/DerivedData \
CODE_SIGNING_ALLOWED=NO build
xcodebuild -project ios/espurr.xcodeproj -scheme espurrWatch \
-destination 'generic/platform=watchOS' -derivedDataPath ios/DerivedData \
CODE_SIGNING_ALLOWED=NO build
For a signed Catalyst build, use destination platform=macOS,variant=Mac Catalyst
and omit CODE_SIGNING_ALLOWED=NO. Build output stays in ignored
ios/DerivedData/; package output stays in ios/EspurrControl/.build/.
To build signed Release phone/Watch apps without installing:
./ios/deploy-release.sh --build-only
Without --build-only, the helper installs both apps without launching/debugging.
It prompts for targets from xcrun devicectl list devices, saves shell-escaped Bash
assignments in ignored ios/deploy-release.conf, and sources that local file.
Optional phone/Watch positional arguments override saved targets. Noninteractive
installation requires existing configuration. Check destinations before installing.
Full build output goes to ios/DerivedData/deploy-release.log.
Validation¶
Reuse the named project destinations in the simulator policy.
ESPurr Duo is the persistent iPhone Duo profile for vertical bars and folding
transitions. Resolve its UUID together with the device type and compatible
Duo-specific runtime; the ordinary iOS runtime with the same version is not
interchangeable. Use Device Hub's Closed, Partially Open, Open and rotation controls
for manual transition checks, preserving the current navigation, sheets and drafts.
Choose available simulators with xcrun simctl list devices available and set
ESPURR_PHONE_SIMULATOR / ESPURR_WATCH_SIMULATOR to the intended UUIDs:
xcodebuild -project ios/espurr.xcodeproj -scheme espurr \
-destination "platform=iOS Simulator,id=$ESPURR_PHONE_SIMULATOR" \
-derivedDataPath ios/DerivedData CODE_SIGNING_ALLOWED=NO ENABLE_DEBUG_DYLIB=NO \
-collect-test-diagnostics never test
xcodebuild -project ios/espurr.xcodeproj -scheme espurrWatch \
-destination "platform=watchOS Simulator,id=$ESPURR_WATCH_SIMULATOR" \
-derivedDataPath ios/DerivedData CODE_SIGNING_ALLOWED=NO test
ENABLE_DEBUG_DYLIB=NO avoids a preview-launcher link failure on the tested Xcode 27
installation; Release doesn't need it. Disabling test diagnostics avoids slow
post-failure simulator collection. Tests exercise real views with explicit mocks,
not real BLE, system ASK UI, WatchConnectivity delivery or mounted sensors.
After a Debug Catalyst build, run the relevant host regression helpers:
tools/test_phone_lifecycle.sh
tools/test_watch_release.sh
tools/test_watch_foreground.sh
tools/test_watch_setup_callbacks.sh
tools/test_effect_picker.sh <booted-phone-simulator-uuid> checks wheel stability
through synthetic polls. Other Debug fixtures are selected by launch arguments:
| Arguments | Purpose |
|---|---|
--ui-preview, optionally --ui-v2, --ui-colours, --ui-settings |
Labelled in-memory phone UI; no Bluetooth or credentials |
--ui-polls, --ui-refuse-save, --ui-no-motion |
Polling, palette refusal and missing-motion cases |
--motion-preview, --motion-sflp-preview, --motion-off-preview |
Host/SFLP/unavailable motion filtering |
--ear-preview, --audio-preview, --hardware-preview |
Diagnostic/capability routes without invented readings |
--firmware-preview, --firmware-catalogue-preview |
Local picker / synthetic release downloads |
--firmware-catalogue-failure, --firmware-catalogue-slow |
Offline and cancellation fixtures |
--watch-setup-preview |
Automatic Watch setup copy; sync/release disabled |
--recovery-preview |
Phone-only recovery route with installation disabled |
--watch-preview, optionally --watch-recovery, --watch-no-motion |
Labelled Watch preview variants |
--watch-disconnected-preview, --watch-unsupported, --watch-calibrated-preview |
Cached, unsupported and synthetic recenter cases |
For an explicitly authorised hardware smoke test, Debug --ble-acceptance connects
to the remembered ears, checks reconnect/polling, changes brightness by one step,
restores it and waits for save completion. --connection-timing instead performs
one read-only connection. iPhone/iPad must already have ASK authorisation from normal
setup. Neither proves optical output, reboot persistence or rollback. These modes
use remembered associations and OS bonds without exporting them and print
ESPURR_ACCEPTANCE results.
Historical measurements and installations are in Apple acceptance, early firmware evidence and mobile release checks. Real pairing/bonds, phone lock retention, Watch handoff/Crown, provider exports, mounted calibration, interrupted OTA and rollback still need physical acceptance.
Protocol dependencies and notices¶
SwiftProtobuf is pinned to 1.38.1. Canonical ESP-IDF v6.1 protobuf inputs live in
EspurrControl/Protocol/; regenerate with tools/generate_control_protos.sh.
Legacy Espressif Security 2/SRP/BigInt source, adapter, target and schemas were
removed. Its historical provenance
remains for old releases. Current firmware uses OS bonding.
Run python3 tools/generate_mobile_notices.py after reviewed dependency changes;
--check verifies generated notices. Shared/PrivacyPolicy.txt is an imported
artifact from the web-owned policy; do not edit it directly. Its sibling
PrivacyPolicy.source.json pins the reviewed revision and hashes. Normal CI runs
python3 tools/import_privacy_policy.py --check offline; use the
policy import workflow for updates.
The imported draft still needs its publication/provider checks before submission.
Store operations remain in
the submission checklist.
Icon provenance is in app-icon.md.