Skip to content

feat: v1.0 + v2.0 — self-hosted server, WHOOP Gen4, Android JNI, standard HR GATT, upstream PR integration#16

Closed
tigercraft4 wants to merge 429 commits into
b-nnett:mainfrom
tigercraft4:main
Closed

feat: v1.0 + v2.0 — self-hosted server, WHOOP Gen4, Android JNI, standard HR GATT, upstream PR integration#16
tigercraft4 wants to merge 429 commits into
b-nnett:mainfrom
tigercraft4:main

Conversation

@tigercraft4

Copy link
Copy Markdown

Overview

This PR contributes two milestones of work back to the upstream:

  • v1.0 — Remote Server + Upstream PRs: self-hosted FastAPI+TimescaleDB server, automatic iOS→server upload, and integration of 9 open upstream PRs/issues
  • v2.0 — Multi-Device & Platform Foundations: full WHOOP 4.0 (Gen4) iOS support, Android JNI foundations, standard Bluetooth Heart Rate GATT device support (0x180D/0x2A37), server CI

v1.0 — Remote Server + Upstream PRs

Self-hosted server (server/)

A FastAPI+TimescaleDB server for persisting biometric data captured from WHOOP devices. The user can run it with docker compose up on any Linux server.

  • server/ingest/ — FastAPI app: POST /v1/ingest-decoded receives already-decoded biometric data from the iOS app
  • server/ingest/whoop_protocol/ — Python decoder for WHOOP proprietary BLE frames
  • Multi-stage Docker image with named volumes, GOOSE_* env var prefix
  • pytest suite covering the ingest endpoint

iOS upload client

Automatic upload from iPhone to the self-hosted server after each capture session:

  • GooseSwift/GooseUploadService.swift — HTTP upload with device_type, device_generation, device_id fields, retry logic (1s/2s/4s)
  • GooseSwift/GooseAppModel+Upload.swift — hook into BLE pipeline; health check on startup; triggerManualUpload
  • GooseSwift/RemoteServerPersistence.swift — URL (UserDefaults) + Bearer token (Keychain) persistence
  • GooseSwift/MoreRemoteServerViews.swift — configuration UI + upload status in More tab

Upstream PR integrations

Closes/resolves the following upstream open PRs and issues:

PR/Issue Title Status
closes #1 Fix stale timeout message and deduplicate duration parsing ✅ Merged
closes #3 Document FFI safety contracts for bridge entry points ✅ Merged
closes #4 Reduce scroll frame drops on Home and Health views ✅ Merged
closes #5 Apple Health fallback for sleep, recovery, strain, and vitals ✅ Merged
closes #6 Add Rust core CI workflow (fmt + build + test) ✅ Merged
closes #7 feat(bridge): add core.list_methods RPC for method discovery ✅ Merged
closes #10 Add Rust CI workflow and fix bugs it surfaces ✅ Merged
closes #12 Optimize FFI bridge serialization and move blocking FFI calls to background ✅ Merged
closes #13 Fix Rust core integration tests and Windows compatibility ✅ Merged
closes #15 Block state-changing debug deep links ✅ Merged
closes #11 License + Gen4 contribution interest (MIT → GPL-3.0-or-later) ✅ MIT added, then changed to GPL-3.0 per issue

Additional security:

  • CodeQL workflow for Swift and Python (ci/security/codeql.yml)
  • NSAllowsArbitraryLoads removed from Info.plist — enforced HTTPS for public hosts
  • CodeQL findings fixed: py/log-injection, py/stack-trace-exposure

v2.0 — Multi-Device & Platform Foundations

WHOOP Gen4 (4.0) iOS support — closes #8, #11 (Gen4 part)

Full iOS app layer for WHOOP 4.0 devices. The Rust core already fully supported Gen4 (DeviceType::Gen4, 4-byte header, CRC8, UUID 61080001-...). What was missing was the iOS layer:

  • GooseSwift/GooseBLETypes.swiftWearableDescriptor type with static .whoopGen4 and .whoopGen5 instances; GooseDiscoveredDevice.generation field; GooseNotificationEvent.rustDeviceType returns "GEN4" for 0x6108-prefixed characteristics
  • GooseSwift/GooseBLEClient+Commands.swiftsupportsV5* guards updated to accept Gen4 command characteristic UUID prefix (61080002-...), enabling historical sync and overnight mode for Gen4
  • GooseSwift/GooseBLEClient+CentralDelegate.swiftgeneration(from:) derives "4.0" or "5.0" from advertised BLE service UUID at connect time
  • GooseSwift/GooseAppModel.swiftconnectedDeviceGeneration: String @published property propagated to UI
  • Upload payload: device_generation: "4.0" for Gen4 captures (verified by Swift unit tests)
  • Onboarding copy updated: "Connect your WHOOP (4.0 or 5.0)"
  • Device view: generation label ("Gen 4" / "Gen 5") while connected
  • GooseSwiftTests/GooseBLETypesTests.swift — 15 Swift unit tests covering WearableDescriptor prefix logic, generation derivation, rustDeviceType
  • Rust: 3 bridge tests verifying GEN4 device_type alias fix

Android port foundations — closes #2, #9

Cross-compilation of the Rust core to aarch64-linux-android via cargo-ndk, with a thin JNI wrapper and CI validation:

  • Rust/core/Cargo.tomltungstenite cfg-gated out on Android: [target.'cfg(not(target_os = "android"))'.dependencies]; jni = { version = "0.21", default-features = false } on Android; panic = "abort" in [profile.release]
  • Rust/core/src/lib.rsdebug_ws_server module cfg-gated off Android
  • Rust/core/src/bridge.rs#[cfg(target_os = "android")] pub mod android with Java_com_goose_core_GooseBridge_handle JNI entry point; extern "system" ABI; #[unsafe(no_mangle)] (Rust 2024 Edition)
  • .github/workflows/rust-core-ci.ymlandroid-build: job using nttld/setup-ndk@v1 (NDK r29) + cargo-ndk 4.1.2; cargo ndk -t arm64-v8a build --release --lib
  • docs/ADR-android-jni.md — Architecture Decision Record covering: why cdylib + JNI shim over a separate crate, panic strategy, MUTF-8 string handling policy, rusqlite bundled aarch64 limitation, path to future Android app
  • MSRV bumped: 1.94 → 1.96 (CI toolchain)

Server CI — closes #6 (server part)

  • .github/workflows/server-ci.yml — pytest suite runs on push/PR to server/**; real TimescaleDB container via conftest.py Docker lifecycle pattern; pip install -r requirements-dev.txt + pytest tests/ -v --tb=short

Standard Bluetooth HR GATT device support (0x180D/0x2A37)

Extensibility validation: a second wearable type (standard Bluetooth Heart Rate monitors — Polar H10, Wahoo TICKR, Garmin HRM, etc.) supported end-to-end from BLE → SQLite → upload:

Rust core:

  • Rust/core/src/heart_rate_gatt_protocol.rsparse_hr_measurement(data: &[u8]) -> Result<HrMeasurement, String>: parses standard 0x2A37 HR Measurement characteristic (8-bit/16-bit HR, RR intervals in ms, energy expended, sensor contact); #[derive(Debug, Clone, PartialEq)]
  • Rust/core/tests/heart_rate_gatt_protocol_tests.rs — 10 integration tests covering all encoding variants
  • Rust/core/src/protocol.rsDeviceType::HrMonitor variant added; exhaustive match arms
  • Rust/core/src/bridge.rsparse_device_type("HR_MONITOR")DeviceType::HrMonitor; capture_import.rs HrMonitor branch stores raw GATT bytes as decoded_frames rows (bypassing the 0xAA WHOOP frame start); upload_get_recent_decoded_streams_bridge decodes hr_bpm + rr_intervals_ms from stored GATT bytes and returns {"ts": f64, "bpm": u16, "rr_intervals": [f64]} in the hr stream; unix_from_iso8601 helper (no chrono dependency); 3 integration tests

iOS BLE layer:

  • GooseSwift/GooseBLEClient+HRMonitor.swiftGooseBLEHRMonitorManager: dedicated CBCentralManager scanning exclusively for CBUUID("180D"); manual connect; 0x2A37 characteristic subscription; onNotification? delivered on CoreBluetooth background queue (never on @mainactor); device name sanitization (trim, cap 64 chars, fallback "unknown_hr_monitor")
  • GooseSwift/GooseBLETypes.swiftWearableDescriptor.genericHRMonitor with serviceUUIDPrefix: "180d", commandCharacteristicPrefix: ""; empty-prefix guard in isCommandCharacteristic/isCommandUUID (prevents false writes to read-only sensors); rustDeviceType returns "HR_MONITOR" for short form ("2A37"), lowercase, and full 128-bit form ("00002A37-0000-1000-8000-00805F9B34FB")
  • GooseSwift/GooseAppModel+NotificationPipeline.swiftnotificationIngestResult bypass: 0x2A37 raw bytes skip WHOOP 0xAA frame reassembly, stored as single NotificationFrame
  • GooseSwift/GooseUploadService.swiftbuildUploadPayload pure internal function; explicit switch deviceType: GEN4 → device_generation: "4.0", GOOSE → device_generation: "5.0", default → device_class: "HR_MONITOR" + device_type: sanitizedDeviceName
  • GooseSwiftTests/GooseBLETypesTests.swift — 9 new tests: genericHRMonitor descriptor, empty-prefix guard, short/full/case-insensitive 0x2A37 UUID matching
  • GooseSwiftTests/GooseUploadServiceTests.swift — 6 tests: payload taxonomy for Gen4/Gen5/HR monitor

Files changed summary

181 files changed, 25826 insertions(+), 692 deletions(-)

Key new files:

  • server/ — complete FastAPI+TimescaleDB server
  • GooseSwift/GooseUploadService.swift — iOS upload client
  • GooseSwift/GooseBLEClient+HRMonitor.swift — HR monitor BLE
  • Rust/core/src/heart_rate_gatt_protocol.rs — 0x2A37 parser
  • Rust/core/tests/heart_rate_gatt_protocol_tests.rs — 10 tests
  • docs/ADR-android-jni.md — Android architecture decision
  • .github/workflows/server-ci.yml — server pytest CI

Testing

  • Rust: cargo test — 75 test suites, 0 failures
  • Swift: GooseSwiftTests — 24+ tests (generation derivation, rustDeviceType, upload payload taxonomy, HR monitor descriptors)
  • CI: GitHub Actions on push — rust-core-ci.yml (build + test + android-build), server-ci.yml (pytest)

Known limitations / deferred

  • HR monitor scan UI: startHRMonitorScan() and discoveredHRDevices are implemented but no SwiftUI view calls them yet — the user cannot initiate a scan from the app UI. Planned for v3.0.
  • HR monitor independent capture: frames are currently only stored to SQLite during an active WHOOP capture session (capture gate). Planned for v3.0.
  • Per-row device_id filtering (CR-02): the upload bridge time-window filter is sufficient for the single-device case; per-row filter deferred to v3.0 (namespace mismatch between CoreBluetooth UUID and BLE device name).

Note: .planning/ directory contains GSD (Get Shit Done) workflow planning artifacts — context docs, plans, summaries. These are internal development tools and can be .gitignored by the upstream if preferred.

tigercraft4 added 30 commits June 3, 2026 23:42
…alUpload

- Remove hardcoded "GOOSE" string literal
- Derive WHOOP type from activeDescriptor commandCharacteristicPrefix (610800 prefix = GEN4)
- Add HR monitor upload when hrConnectionState != disconnected, using connectedDeviceName
…rse_device_type

- Add HrMonitor variant to DeviceType enum in protocol.rs
- Update header_len, expected_frame_len, declared_len, header_crc_valid match arms
- Add DeviceType::HrMonitor => "HR_MONITOR" to device_type_name in store.rs
- Add "HR_MONITOR" | "hr_monitor" arm to parse_device_type in bridge.rs (not Goose)
- Handle HrMonitor in openwhoop_reference.rs whoop_generation_from_device_type (=> None)
- cargo check passes with no errors
… full suite passes

- Add parse_device_type_hr_monitor_uppercase: HR_MONITOR => DeviceType::HrMonitor
- Add parse_device_type_hr_monitor_lowercase: hr_monitor => DeviceType::HrMonitor
- Add parse_device_type_goose_no_regression: GOOSE => DeviceType::Goose
- cargo test full suite: zero failures across all test files
- Silent Gen5 fallback removed; device_class: HR_MONITOR in upload payload
- DeviceType::HrMonitor variant wired through protocol, store, bridge, openwhoop_reference
- triggerManualUpload derives WHOOP type from activeDescriptor; adds HR monitor upload path
- Rust test suite: zero failures; 3 new parse_device_type assertions
- WEAR-03 satisfied
- Add internal buildUploadPayload(deviceID:deviceType:streams:) with no async/URLSession/bridge
- performUpload delegates payload construction to the extracted function
- GEN4 -> device_generation 4.0 (no device_class); GOOSE -> 5.0; default -> device_type + device_class HR_MONITOR
- Internal visibility allows @testable import GooseSwift test access (HIGH-3)
…onomy (HIGH-3)

- Create GooseSwiftTests/GooseUploadServiceTests.swift with 6 test methods
- Gen4 -> device_generation 4.0 + no device_class; Gen5 -> 5.0 + no device_class
- HR monitor (Polar H10) -> device_type + device_class HR_MONITOR + no device_generation
- Unknown device defaults to HR_MONITOR device_class
- Streams round-trip preserved exactly in payload
- triggerManualUpload source-assertion: no unconditional deviceType: "GOOSE" literal
- Add GooseUploadServiceTests to pbxproj (file ref + build file + PBXGroup + Sources phase)
- Add HEADER_SEARCH_PATHS ($(SRCROOT)/Rust/core/include) to GooseSwiftTests Debug+Release configs
- [Rule 3] Fix stored property in extension: move sevenDayStrainCache from HealthDataStore+Snapshots.swift to HealthDataStore.swift (pre-existing Swift compile error blocking @testable import GooseSwift)
- buildUploadPayload extracted; 6 GooseUploadServiceTests passing
- Rule 3 fixes: sevenDayStrainCache moved to class body, HEADER_SEARCH_PATHS added to test target
- HIGH-3 resolved; WEAR-03 upload taxonomy locked behind regression tests
- Add DeviceType::HrMonitor branch in import_captured_frame_timed
- Bypass parse_frame (which requires 0xAA FRAME_START) for HR monitor frames
- Construct ParsedFrame with payload_hex = hex::encode(raw GATT bytes)
- Set header_crc_valid and payload_crc_valid to true so upload bridge CRC-skip does not drop the row
- Insert via store.insert_decoded_frame using existing DecodedFrameInput pattern
- Non-HrMonitor flow is unchanged
…1/WEAR-03, CR-02)

- bridge_hr_monitor_upload_stream_contains_bpm_and_rr: asserts hr stream populated with bpm and rr_intervals from 0x2A37 GATT frames
- bridge_hr_monitor_upload_stream_no_rr_when_not_present: asserts rr_intervals is [] when GATT flags bit 4 is clear
- bridge_hr_monitor_upload_stream_device_id_filter: asserts device_id filter returns only matching device frames (CR-02)
tigercraft4 added 29 commits June 6, 2026 12:11
…iders

- Iterate CoachProviderRegistry().allProviders — assert id/displayName non-empty for all four providers
- Assert availablePresets non-empty for ChatGPT/Claude/Gemini; accessible (may be empty) for Custom
- Replace XCTSkip testSendReturnsAsyncStreamShape with compile-time type assertion assigning provider.send to typed let
- Add @mainactor annotation to match CoachProviderRegistry isolation
- Full GooseSwiftTests suite: TEST SUCCEEDED (zero failures)
… human verify

- CoachProviderTests finalised: all four providers iterated, XCTSkip removed, AsyncStream<String> compile-time proof
- Full GooseSwiftTests suite: TEST SUCCEEDED
- Task 2 human-verify checkpoint documented (COACH-06 migration + per-provider streaming)
…mini providers — eliminates per-frame Keychain reads causing UI stutter
- GooseAppModel: nonisolated(unsafe) → @ObservationIgnored nonisolated(unsafe)
  for captureFrameRowBuildQueueDepth, captureFrameRowBuildQueueHighWatermark,
  frameReassemblyBuffers — @observable macro generated backing storage that
  made nonisolated(unsafe) have no effect; @ObservationIgnored opts out of
  tracking so the annotation applies to the plain stored property
- HealthDataStore: same fix for heartRateSeriesUpdateObserver
- GeminiCoachProvider: remove @mainactor — conformance to CoachProvider crossed
  actor boundaries; aligns with ClaudeCoachProvider pattern (@observable only)
- CoachChatModel: remove unnecessary await from sync seedAssistantPromptIfNeeded()
  calls (no async operations in the await expression)
WR-01: GeminiOAuthWebView.updateUIView — guard against reload on SwiftUI
state updates (guard webView.url == nil); previously restarted OAuth flow
on any parent state change mid-flight

WR-02: GeminiCoachProvider.send() — replace force-unwrap URL(string:)! with
guard let to handle invalid model ID strings safely
Eliminates the dominant startup bottleneck: contentsOfDirectory + JSONL
line-count scans (potentially MBs of data) were blocking the main thread
during GooseAppModel.init().

- recoverUncleanOvernightGuardSessionIfNeeded: dispatch
  latestRecoverableOvernightGuardSession() to rustStartupQueue, then
  callback to main for state mutations (applyUncleanSessionRecovery)
- latestRecoverableOvernightGuardSession + 8 file-I/O helpers marked
  nonisolated (overnightGuardRootDirectoryURL, readJSONObject, fileSize,
  readStatusValues, countSuccessfulHistoricalRangePolls, countJSONLRecords,
  overnightGuardIntValue, overnightGuardTargetCounts) — none access
  actor-isolated state, only FileManager and pure computation
- captureTimestampFormatter kept @mainactor; local ISO8601DateFormatter
  used inside the nonisolated function to avoid Sendable warning
- defaultDatabasePath() cached via private static let _sharedDatabasePath
  (computed once at first call); eliminates repeated FileManager.createDirectory
  syscalls across CaptureFrameWriteQueue, GooseUploadService,
  OvernightSQLiteMirrorQueue, GooseAppModel+*, HealthDataStore
…Gemini, Custom)

Merges plan/phase-18-coach-multi-provider into main.

Phase 18 delivers the full Coach Multi-Provider feature:
- CoachProvider protocol + CoachProviderRegistry (4 providers)
- ChatGPTCoachProvider (OAuth, existing flow preserved)
- ClaudeCoachProvider (Anthropic API key + Keychain)
- GeminiCoachProvider (Google OAuth PKCE via WKWebView)
- CustomEndpointCoachProvider (OpenAI-compatible base URL + API key)
- CoachSettingsSheet with provider picker + per-provider config forms
- Gear icon in CoachView + active-provider indicator
- Swift 6 concurrency warnings resolved
- Per-frame Keychain reads eliminated (isAuthenticated cached)
- Overnight session discovery moved to background queue
Keychain survives app deletion, so restoreIntoDefaultsIfAvailable with
restoreCompletion:true was skipping onboarding entirely on reinstall.
Now restores profile data only (name, height, weight — pre-fills fields)
but leaves onboardingComplete=false so onboarding shows on fresh install.
Audit result: 129 real-text strings missing pt-PT (mostly Phase 18 Coach UI).
Phase 19 covers full translation + startup fixes already shipped.
…ROUP A)

- 32 strings translated: API Key, Anthropic API key, Base URL, Bearer token,
  Calibrate, Change, Coach Settings, Configuration, Enter an API key first,
  Filters, Generation stopped, Key saved, Model ID, Must start with https://,
  No key saved, Not signed in, Provider, Remove API Key, Remove Key,
  Save API Key, Save Endpoint, Sign Out?, Sign in with ChatGPT/Google,
  Sign-in failed, Signed in, Signing in..., Keychain deletion message,
  provider re-auth message, https://hostname:8770 (technical placeholder)
- Brand names intentionally excluded per D-02 (Claude, GPT, Gemini models)
- JSON validated
…GROUPs B-E)

- GROUP B (41 strings): Add Sleep, Cardio Load descriptions, Energy Bank,
  Heart rate unavailable, Sleep Insights, Workout Details, Stages, Wake,
  Period, Primary Sleep, Refresh Health/Score, Target sleep, avg, vs avg, etc.
- GROUP C (27 strings): Alarm config (Choose vibration, Set Alarm, Disable
  WHOOP Alarms), device controls (Controls Locked, Sync from band, Save to
  band), general UI (Command Evidence, Data source, Route, Remove, etc.)
- GROUP D (11 strings): Format specifiers with text - beats per minute,
  records acked, active, ZONE %lld -> ZONA %lld, Avg -> Med, load, etc.
- GROUP E (5 strings): Time display - NOW -> AGORA, 30 MIN AGO -> HA 30 MIN,
  plus unit strings kept identical (0 min, 0h, 7h 39m)
- Python scan confirms 0 non-trivial strings missing pt-PT
- JSON validated
- 119 non-trivial strings translated across 5 groups (A-E)
- Coach/provider config, Health/Sleep/Cardio, UI/alarm, format specifiers
- Python scan: 0 non-trivial strings missing pt-PT
- xcodebuild BUILD SUCCEEDED
…ROUP A)

- 32 strings translated: API Key, Anthropic API key, Base URL, Bearer token,
  Calibrate, Change, Coach Settings, Configuration, Enter an API key first,
  Filters, Generation stopped, Key saved, Model ID, Must start with https://,
  No key saved, Not signed in, Provider, Remove API Key, Remove Key,
  Save API Key, Save Endpoint, Sign Out?, Sign in with ChatGPT/Google,
  Sign-in failed, Signed in, Signing in..., Keychain deletion message,
  provider re-auth message, https://hostname:8770 (technical placeholder)
- Brand names intentionally excluded per D-02 (Claude, GPT, Gemini models)
- JSON validated
…GROUPs B-E)

- GROUP B (41 strings): Add Sleep, Cardio Load descriptions, Energy Bank,
  Heart rate unavailable, Sleep Insights, Workout Details, Stages, Wake,
  Period, Primary Sleep, Refresh Health/Score, Target sleep, avg, vs avg, etc.
- GROUP C (27 strings): Alarm config (Choose vibration, Set Alarm, Disable
  WHOOP Alarms), device controls (Controls Locked, Sync from band, Save to
  band), general UI (Command Evidence, Data source, Route, Remove, etc.)
- GROUP D (11 strings): Format specifiers with text - beats per minute,
  records acked, active, ZONE %lld -> ZONA %lld, Avg -> Med, load, etc.
- GROUP E (5 strings): Time display - NOW -> AGORA, 30 MIN AGO -> HA 30 MIN,
  plus unit strings kept identical (0 min, 0h, 7h 39m)
- Python scan confirms 0 non-trivial strings missing pt-PT
- JSON validated
- ae7d3c1: GROUP A Coach/provider strings (cherry-picked into worktree)
- 9146b53: GROUPs B-E Health/UI strings (cherry-picked into worktree)
…fix UX-01 skip button key

- Claude Haiku/Sonnet/Opus 4.x model preset names (kept as proper nouns in pt-PT)
- Gemini 2.5 Flash/Pro model preset names (kept as proper nouns)
- Google Client ID → "ID de cliente Google"
- GPT-5.5 High/Medium/Low → "GPT-5.5 Alto/Médio/Baixo"
- OnboardingView.swift: change skip button key from hardcoded "Saltar configuração"
  to "Skip setup" with pt-PT translation in xcstrings — now translatable for all locales
- milestones/v4.0-ROADMAP.md — full phase archive (16-19)
- milestones/v4.0-REQUIREMENTS.md — requirements with final status
- milestones/v4.0-MILESTONE-AUDIT.md — audit report (tech_debt, 13/13 integration)
- MILESTONES.md — v3.0 and v4.0 entries added
- PROJECT.md — full evolution review; v4.0 requirements moved to Validated
- STATE.md — deferred items documented
- ROADMAP.md — v4.0 collapsed to details block; phase details removed; progress table updated
@tigercraft4 tigercraft4 closed this Jun 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants