Skip to content
This repository was archived by the owner on Jun 16, 2026. It is now read-only.

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 subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

4 participants