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
Closed
feat: v1.0 + v2.0 — self-hosted server, WHOOP Gen4, Android JNI, standard HR GATT, upstream PR integration#16tigercraft4 wants to merge 429 commits into
tigercraft4 wants to merge 429 commits into
Conversation
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
… into upload pipeline
- 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)
…-02 device_id filter (Task 2)
…st be < chrono_now())
…(gap closure WEAR-01/WEAR-03 + CR-02)
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
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
This PR contributes two milestones of work back to the upstream:
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 upon any Linux server.server/ingest/— FastAPI app:POST /v1/ingest-decodedreceives already-decoded biometric data from the iOS appserver/ingest/whoop_protocol/— Python decoder for WHOOP proprietary BLE framesiOS upload client
Automatic upload from iPhone to the self-hosted server after each capture session:
GooseSwift/GooseUploadService.swift— HTTP upload withdevice_type,device_generation,device_idfields, retry logic (1s/2s/4s)GooseSwift/GooseAppModel+Upload.swift— hook into BLE pipeline; health check on startup;triggerManualUploadGooseSwift/RemoteServerPersistence.swift— URL (UserDefaults) + Bearer token (Keychain) persistenceGooseSwift/MoreRemoteServerViews.swift— configuration UI + upload status in More tabUpstream PR integrations
Closes/resolves the following upstream open PRs and issues:
Additional security:
ci/security/codeql.yml)NSAllowsArbitraryLoadsremoved from Info.plist — enforced HTTPS for public hostsv2.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.swift—WearableDescriptortype with static.whoopGen4and.whoopGen5instances;GooseDiscoveredDevice.generationfield;GooseNotificationEvent.rustDeviceTypereturns"GEN4"for 0x6108-prefixed characteristicsGooseSwift/GooseBLEClient+Commands.swift—supportsV5*guards updated to accept Gen4 command characteristic UUID prefix (61080002-...), enabling historical sync and overnight mode for Gen4GooseSwift/GooseBLEClient+CentralDelegate.swift—generation(from:)derives "4.0" or "5.0" from advertised BLE service UUID at connect timeGooseSwift/GooseAppModel.swift—connectedDeviceGeneration: String@published property propagated to UIdevice_generation: "4.0"for Gen4 captures (verified by Swift unit tests)GooseSwiftTests/GooseBLETypesTests.swift— 15 Swift unit tests covering WearableDescriptor prefix logic, generation derivation, rustDeviceTypeAndroid port foundations — closes #2, #9
Cross-compilation of the Rust core to
aarch64-linux-androidviacargo-ndk, with a thin JNI wrapper and CI validation:Rust/core/Cargo.toml—tungstenitecfg-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.rs—debug_ws_servermodule cfg-gated off AndroidRust/core/src/bridge.rs—#[cfg(target_os = "android")] pub mod androidwithJava_com_goose_core_GooseBridge_handleJNI entry point;extern "system"ABI;#[unsafe(no_mangle)](Rust 2024 Edition).github/workflows/rust-core-ci.yml—android-build:job usingnttld/setup-ndk@v1(NDK r29) +cargo-ndk 4.1.2;cargo ndk -t arm64-v8a build --release --libdocs/ADR-android-jni.md— Architecture Decision Record covering: whycdylib+ JNI shim over a separate crate,panicstrategy, MUTF-8 string handling policy,rusqlite bundledaarch64 limitation, path to future Android appServer CI — closes #6 (server part)
.github/workflows/server-ci.yml— pytest suite runs on push/PR toserver/**; real TimescaleDB container viaconftest.pyDocker lifecycle pattern;pip install -r requirements-dev.txt+pytest tests/ -v --tb=shortStandard 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.rs—parse_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 variantsRust/core/src/protocol.rs—DeviceType::HrMonitorvariant added; exhaustive match armsRust/core/src/bridge.rs—parse_device_type("HR_MONITOR")→DeviceType::HrMonitor;capture_import.rsHrMonitor branch stores raw GATT bytes asdecoded_framesrows (bypassing the 0xAA WHOOP frame start);upload_get_recent_decoded_streams_bridgedecodes hr_bpm + rr_intervals_ms from stored GATT bytes and returns{"ts": f64, "bpm": u16, "rr_intervals": [f64]}in thehrstream;unix_from_iso8601helper (no chrono dependency); 3 integration testsiOS BLE layer:
GooseSwift/GooseBLEClient+HRMonitor.swift—GooseBLEHRMonitorManager: dedicatedCBCentralManagerscanning exclusively forCBUUID("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.swift—WearableDescriptor.genericHRMonitorwithserviceUUIDPrefix: "180d",commandCharacteristicPrefix: ""; empty-prefix guard inisCommandCharacteristic/isCommandUUID(prevents false writes to read-only sensors);rustDeviceTypereturns"HR_MONITOR"for short form ("2A37"), lowercase, and full 128-bit form ("00002A37-0000-1000-8000-00805F9B34FB")GooseSwift/GooseAppModel+NotificationPipeline.swift—notificationIngestResultbypass: 0x2A37 raw bytes skip WHOOP 0xAA frame reassembly, stored as singleNotificationFrameGooseSwift/GooseUploadService.swift—buildUploadPayloadpure internal function; explicitswitch deviceType: GEN4 →device_generation: "4.0", GOOSE →device_generation: "5.0", default →device_class: "HR_MONITOR"+device_type: sanitizedDeviceNameGooseSwiftTests/GooseBLETypesTests.swift— 9 new tests:genericHRMonitordescriptor, empty-prefix guard, short/full/case-insensitive 0x2A37 UUID matchingGooseSwiftTests/GooseUploadServiceTests.swift— 6 tests: payload taxonomy for Gen4/Gen5/HR monitorFiles changed summary
Key new files:
server/— complete FastAPI+TimescaleDB serverGooseSwift/GooseUploadService.swift— iOS upload clientGooseSwift/GooseBLEClient+HRMonitor.swift— HR monitor BLERust/core/src/heart_rate_gatt_protocol.rs— 0x2A37 parserRust/core/tests/heart_rate_gatt_protocol_tests.rs— 10 testsdocs/ADR-android-jni.md— Android architecture decision.github/workflows/server-ci.yml— server pytest CITesting
cargo test— 75 test suites, 0 failuresGooseSwiftTests— 24+ tests (generation derivation, rustDeviceType, upload payload taxonomy, HR monitor descriptors)rust-core-ci.yml(build + test + android-build),server-ci.yml(pytest)Known limitations / deferred
startHRMonitorScan()anddiscoveredHRDevicesare implemented but no SwiftUI view calls them yet — the user cannot initiate a scan from the app UI. Planned for v3.0.