From e82fd385dd332f0b964431e030e84eb4fd2fca28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20Br=C3=B6nner?= Date: Sun, 16 Aug 2026 15:53:40 +0200 Subject: [PATCH 1/4] Add on-device About diagnostics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2c3d711c-cb97-4bfa-a302-2d01fe5d2f1f --- DEVELOPMENT.md | 25 +- README.md | 8 +- Sources/VirtualGearsCore/Diagnostics.swift | 227 ++++++++++++++++++ .../DiagnosticsTests.swift | 83 +++++++ VirtualGearsProduct/SetupView.swift | 180 +++++++++++++- VirtualGearsProduct/VirtualGearsApp.swift | 1 + .../VirtualGearsHomeView.swift | 2 + VirtualGearsUITests/VirtualGearsUITests.swift | 24 +- docs/APP_STORE.md | 17 +- docs/DEMO_VIDEO.md | 5 +- docs/PRIVACY.md | 8 +- docs/index.md | 6 + docs/requirements.md | 8 +- docs/support.md | 5 + 14 files changed, 576 insertions(+), 23 deletions(-) create mode 100644 Sources/VirtualGearsCore/Diagnostics.swift create mode 100644 Tests/VirtualGearsCoreTests/DiagnosticsTests.swift diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5aa8350..d046838 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -476,11 +476,8 @@ resistance commands — it sent exactly one of those, at startup. The CPS-enabled iPhone build was then tested directly on 2026-08-16. FulGaz on macOS connected to Virtual Gears and displayed live power and cadence. FulGaz on -Windows saw the same phone but initially failed before subscribing to any -app-owned characteristic. The same Windows installation connected to -CPS-enabled AppTap, and RideSim on a Mac connected to the phone, discovered FTMS -and CPS, exchanged control commands, received ride data, disconnected and -reconnected with all 15 checks passing. +Windows saw the same phone but failed before subscribing to any app-owned +characteristic. The same Windows installation connected to CPS-enabled AppTap. [QZ (qdomyos-zwift)](https://github.com/cagnulein/qdomyos-zwift), an independent GPL-3.0 project, supplied the missing comparison through its publicly visible @@ -497,10 +494,20 @@ independently and then proved with the four phone builds below: | FTMS only | Read + notify | Connection failed | | FTMS + CPS | Read + notify | Connected | -The last build delivered live power and cadence. FulGaz on Windows therefore -requires the advertised service list and readable measurement surface to agree; -neither half fixes the connection by itself. The shipping peripheral now uses -that proven combination, and RideSim checks both properties. +That experiment showed one Windows connection using the full contract, but it +did not establish reliable compatibility. With TestFlight build 1.0 (11) +installed and verified, a fresh macOS RideSim central passes all 17 checks: +advertised FTMS and CPS, readable and notifiable Indoor Bike Data and Cycling +Power Measurement, control, telemetry, disconnect and reconnect. FulGaz on +macOS works against the same build. RealVelo and MyWhoosh work on Windows. +FulGaz on Windows remains intermittent: it can see Virtual Gears and still fail +to connect. The shipping peripheral exposes the correct GATT contract, but that +does not prove or fix FulGaz compatibility. + +The in-app About & Diagnostics screen reports this live contract and the +existing trainer, proxy, subscriber, control and latest-event state. It reads +the observable service state only and does not change Bluetooth behavior. Its +copyable report omits user names, UUIDs, trainer identifiers and product logs. iOS also changes peripheral advertising when the phone locks. The app therefore keeps the screen awake from the moment the trainer proxy is made available, not diff --git a/README.md b/README.md index a78d582..a6291c8 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,10 @@ direct-drive KICKR models are expected to work but have not yet been physically tested. The KICKR SNAP, KICKR BIKE and trainers from other brands are not supported. -On the riding-app side, FulGaz on macOS and Windows, plus RealVelo and MyWhoosh -on Windows, have been ridden end to end. Other FTMS riding apps are expected to +On the riding-app side, FulGaz on macOS and RealVelo and MyWhoosh on Windows +have been ridden end to end. FulGaz on Windows intermittently sees Virtual +Gears but still fails to connect, despite the same build passing the complete +FTMS and Cycling Power contract check. Other FTMS riding apps are expected to work but have not been tested. [Detailed compatibility information](https://sbroenne.github.io/VirtualGears/requirements/#which-trainers-work) @@ -96,6 +98,8 @@ with physical hardware. - **Mid-ride changes** to the trainer, gears, Click or Headwind. - **Ride continuity** when you answer a call or briefly switch apps. - **A Bluetooth-free Demo Mode** for exploring the app without equipment. +- **About & Diagnostics** in Settings, with the app version, iPhone software, + trainer/proxy state and a user-initiated copyable report that stays on-device. ## Screenshots diff --git a/Sources/VirtualGearsCore/Diagnostics.swift b/Sources/VirtualGearsCore/Diagnostics.swift new file mode 100644 index 0000000..19fe3d4 --- /dev/null +++ b/Sources/VirtualGearsCore/Diagnostics.swift @@ -0,0 +1,227 @@ +import Foundation + +public struct AppIdentity: Equatable, Sendable { + public let displayName: String + public let marketingVersion: String + public let buildNumber: String + + public init( + displayName: String, + marketingVersion: String, + buildNumber: String + ) { + self.displayName = displayName + self.marketingVersion = marketingVersion + self.buildNumber = buildNumber + } + + public init(infoDictionary: [String: Any]) { + displayName = Self.value( + for: "CFBundleDisplayName", + fallbackKey: "CFBundleName", + in: infoDictionary, + fallback: "Virtual Gears" + ) + marketingVersion = Self.value( + for: "CFBundleShortVersionString", + in: infoDictionary, + fallback: "Unknown" + ) + buildNumber = Self.value( + for: "CFBundleVersion", + in: infoDictionary, + fallback: "Unknown" + ) + } + + public var versionAndBuild: String { + "\(marketingVersion) (\(buildNumber))" + } + + private static func value( + for key: String, + fallbackKey: String? = nil, + in dictionary: [String: Any], + fallback: String + ) -> String { + let candidates = [key, fallbackKey].compactMap { $0 } + for candidate in candidates { + if let value = dictionary[candidate] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + } + return fallback + } +} + +public struct DiagnosticsState: Equatable { + public let trainerConnection: ProductConnectionState + public let isProxyAdvertising: Bool + public let subscriberCount: Int + public let isControlledByRidingApp: Bool + public let latestPeripheralEvent: FTMSPeripheralEvent? + + public init( + trainerConnection: ProductConnectionState, + isProxyAdvertising: Bool, + subscriberCount: Int, + isControlledByRidingApp: Bool, + latestPeripheralEvent: FTMSPeripheralEvent? + ) { + self.trainerConnection = trainerConnection + self.isProxyAdvertising = isProxyAdvertising + self.subscriberCount = max(0, subscriberCount) + self.isControlledByRidingApp = isControlledByRidingApp + self.latestPeripheralEvent = latestPeripheralEvent + } + + public var trainerSummary: String { + switch trainerConnection { + case .ready: + "Connected and ready" + case .disconnected: + "Not connected" + case .scanning: + "Looking for trainer" + case .reconnecting: + "Reconnecting" + case .connecting: + "Connecting" + case .discovering, .preparing: + "Connected, getting ready" + case .disconnecting: + "Disconnecting" + case let .unavailable(reason), let .failed(reason): + reason + } + } + + public var advertisingSummary: String { + isProxyAdvertising ? "Advertising" : "Not advertising" + } + + public var subscribersSummary: String { + switch subscriberCount { + case 0: "No riding apps subscribed" + case 1: "1 riding app subscribed" + default: "\(subscriberCount) riding apps subscribed" + } + } + + public var controlSummary: String { + isControlledByRidingApp + ? "A riding app has control" + : "No riding app has control" + } + + public var latestEventSummary: String { + latestPeripheralEvent?.diagnosticsDescription ?? "No peripheral event yet" + } +} + +public enum DiagnosticsReport { + public static let serviceContract = + "FTMS 0x1826 + CPS 0x1818; Indoor Bike Data and Cycling Power " + + "Measurement are readable and notifiable" + + public static func make( + timestamp: Date, + app: AppIdentity, + operatingSystem: String, + device: String, + state: DiagnosticsState + ) -> String { + [ + "\(app.displayName) diagnostics", + "Timestamp: \(timestampString(timestamp))", + "App: \(app.versionAndBuild)", + "OS: \(operatingSystem)", + "Device: \(device)", + "KICKR: \(state.trainerSummary)", + "Trainer proxy: \(state.advertisingSummary)", + "Subscribers: \(state.subscribersSummary)", + "Control: \(state.controlSummary)", + "Latest FTMS event: \(state.latestEventSummary)", + "Bluetooth contract: \(serviceContract)", + "Privacy: Generated on-device and copied only when requested.", + ].joined(separator: "\n") + } + + private static func timestampString(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } +} + +public extension FTMSPeripheralEvent { + var diagnosticsDescription: String { + switch self { + case .advertisingStarted: + "Trainer proxy started advertising" + case .advertisingStopped: + "Trainer proxy stopped advertising" + case let .centralSubscribed(_, characteristic): + "A riding app subscribed to \(Self.characteristicName(characteristic))" + case let .centralUnsubscribed(_, characteristic): + "A riding app unsubscribed from \(Self.characteristicName(characteristic))" + case let .controlRequest(_, request): + "A riding app requested \(request.diagnosticsDescription)" + case let .controlResponse(_, response): + "Control request 0x\(Self.hex(response.requestOpcode)) returned " + + response.result.diagnosticsDescription + case let .failed(message): + "Trainer proxy error: \(message)" + } + } + + private static func characteristicName(_ value: String) -> String { + switch value.uppercased() { + case FTMSUUID.indoorBikeData: + "FTMS Indoor Bike Data" + case FTMSUUID.fitnessMachineControlPoint: + "the FTMS Control Point" + case CyclingPowerUUID.measurement: + "Cycling Power Measurement" + default: + "Bluetooth characteristic 0x\(value.uppercased())" + } + } + + private static func hex(_ value: UInt8) -> String { + String(format: "%02X", value) + } +} + +private extension FitnessMachineControlPointRequest { + var diagnosticsDescription: String { + switch self { + case .requestControl: "control" + case .reset: "a reset" + case let .setTargetResistanceLevel(tenths): + "resistance \(Double(tenths) / 10)%" + case let .setTargetPower(watts): + "target power \(watts) W" + case .startOrResume: "start or resume" + case let .stopOrPause(action): + action == .stop ? "stop" : "pause" + case let .setIndoorBikeSimulationParameters(parameters): + "simulation grade \(Double(parameters.gradeHundredthsPercent) / 100)%" + case let .setWheelCircumference(tenths): + "wheel circumference \(Double(tenths) / 10) mm" + } + } +} + +private extension FTMSControlPointResult { + var diagnosticsDescription: String { + switch self { + case .success: "success" + case .opcodeNotSupported: "opcode not supported" + case .invalidParameter: "invalid parameter" + case .operationFailed: "operation failed" + case .controlNotPermitted: "control not permitted" + } + } +} diff --git a/Tests/VirtualGearsCoreTests/DiagnosticsTests.swift b/Tests/VirtualGearsCoreTests/DiagnosticsTests.swift new file mode 100644 index 0000000..4caf5ce --- /dev/null +++ b/Tests/VirtualGearsCoreTests/DiagnosticsTests.swift @@ -0,0 +1,83 @@ +import Foundation +import XCTest +@testable import VirtualGearsCore + +final class DiagnosticsTests: XCTestCase { + func testAppIdentityReadsBundleValuesAndFormatsVersion() { + let identity = AppIdentity(infoDictionary: [ + "CFBundleDisplayName": "Virtual Gears", + "CFBundleShortVersionString": "1.0", + "CFBundleVersion": "11", + ]) + + XCTAssertEqual(identity.displayName, "Virtual Gears") + XCTAssertEqual(identity.versionAndBuild, "1.0 (11)") + } + + func testAppIdentityUsesSafeFallbacksForMissingMetadata() { + let identity = AppIdentity(infoDictionary: [ + "CFBundleName": "VirtualGears", + ]) + + XCTAssertEqual(identity.displayName, "VirtualGears") + XCTAssertEqual(identity.versionAndBuild, "Unknown (Unknown)") + } + + func testLiveStateMapsToPlainEnglishWithoutIdentifiers() { + let appID = UUID(uuidString: "10000000-0000-0000-0000-000000000004")! + let state = DiagnosticsState( + trainerConnection: .ready, + isProxyAdvertising: true, + subscriberCount: 1, + isControlledByRidingApp: true, + latestPeripheralEvent: .centralSubscribed( + appID, + characteristic: FTMSUUID.indoorBikeData + ) + ) + + XCTAssertEqual(state.trainerSummary, "Connected and ready") + XCTAssertEqual(state.advertisingSummary, "Advertising") + XCTAssertEqual(state.subscribersSummary, "1 riding app subscribed") + XCTAssertEqual(state.controlSummary, "A riding app has control") + XCTAssertEqual( + state.latestEventSummary, + "A riding app subscribed to FTMS Indoor Bike Data" + ) + XCTAssertFalse(state.latestEventSummary.contains(appID.uuidString)) + } + + func testReportContainsRequiredStateAndNoBluetoothIdentifiers() { + let appID = UUID(uuidString: "10000000-0000-0000-0000-000000000004")! + let report = DiagnosticsReport.make( + timestamp: Date(timeIntervalSince1970: 0), + app: AppIdentity( + displayName: "Virtual Gears", + marketingVersion: "1.0", + buildNumber: "11" + ), + operatingSystem: "iOS 26.6", + device: "iPhone", + state: DiagnosticsState( + trainerConnection: .reconnecting(attempt: 2), + isProxyAdvertising: false, + subscriberCount: 2, + isControlledByRidingApp: false, + latestPeripheralEvent: .controlRequest( + appID, + .setWheelCircumference(tenthsOfMillimeter: 22_000) + ) + ) + ) + + XCTAssertTrue(report.contains("App: 1.0 (11)")) + XCTAssertTrue(report.contains("OS: iOS 26.6")) + XCTAssertTrue(report.contains("Device: iPhone")) + XCTAssertTrue(report.contains("KICKR: Reconnecting")) + XCTAssertTrue(report.contains("Subscribers: 2 riding apps subscribed")) + XCTAssertTrue(report.contains("wheel circumference 2200.0 mm")) + XCTAssertTrue(report.contains("FTMS 0x1826 + CPS 0x1818")) + XCTAssertTrue(report.contains("Generated on-device")) + XCTAssertFalse(report.contains(appID.uuidString)) + } +} diff --git a/VirtualGearsProduct/SetupView.swift b/VirtualGearsProduct/SetupView.swift index 15be709..e38c7bb 100644 --- a/VirtualGearsProduct/SetupView.swift +++ b/VirtualGearsProduct/SetupView.swift @@ -1,4 +1,6 @@ import SwiftUI +import UIKit +import UniformTypeIdentifiers import VirtualGearsCore /// Setup follows the ordinary iOS Settings pattern: a short list of rows that @@ -10,6 +12,7 @@ struct SetupView: View { @Bindable var kickr: KickrCentralService @Bindable var click: ClickCentralService @Bindable var headwind: HeadwindCentralService + @Bindable var coordinator: ProxyCoordinator var onFinish: (() -> Void)? var autoConnectsOnAppear = true @@ -19,6 +22,7 @@ struct SetupView: View { wheelSizeSection gearsSection chainLineSection + aboutSection } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) @@ -187,6 +191,166 @@ struct SetupView: View { } } + private var aboutSection: some View { + Section { + NavigationLink { + AboutDiagnosticsView(kickr: kickr, coordinator: coordinator) + } label: { + Label("About & Diagnostics", systemImage: "info.circle") + } + } footer: { + Text("Version information and live, on-device connection diagnostics.") + } + } + + private struct AboutDiagnosticsView: View { + @Bindable var kickr: KickrCentralService + @Bindable var coordinator: ProxyCoordinator + @State private var showsCopiedConfirmation = false + + private var app: AppIdentity { + AppIdentity(infoDictionary: Bundle.main.infoDictionary ?? [:]) + } + + private var state: DiagnosticsState { + DiagnosticsState( + trainerConnection: kickr.state, + isProxyAdvertising: coordinator.peripheral.isAdvertising, + subscriberCount: coordinator.peripheral.subscribedAppCount, + isControlledByRidingApp: coordinator.peripheral.controllingAppID != nil, + latestPeripheralEvent: coordinator.peripheral.latestEvent + ) + } + + var body: some View { + Form { + Section { + VStack(spacing: 8) { + Image(systemName: "bicycle") + .font(.system(size: 40, weight: .semibold)) + .foregroundStyle(.tint) + .accessibilityHidden(true) + Text(app.displayName) + .font(.title2.bold()) + Text("Version \(app.versionAndBuild)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .accessibilityElement(children: .combine) + } + + Section("This iPhone") { + LabeledContent("Device", value: UIDevice.current.model) + LabeledContent( + "Software", + value: "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)" + ) + } + + Section("Live connections") { + diagnosticRow( + "KICKR", + value: state.trainerSummary, + ready: kickr.isReady + ) + diagnosticRow( + "Trainer proxy", + value: state.advertisingSummary, + ready: state.isProxyAdvertising + ) + LabeledContent("Riding apps", value: state.subscribersSummary) + LabeledContent("Control", value: state.controlSummary) + } + + Section("Latest FTMS peripheral event") { + Text(state.latestEventSummary) + .textSelection(.enabled) + } + + Section { + Label( + "Advertises FTMS 0x1826 and CPS 0x1818", + systemImage: "antenna.radiowaves.left.and.right" + ) + Label( + "FTMS Indoor Bike Data is readable and notifiable", + systemImage: "checkmark.circle" + ) + Label( + "Cycling Power Measurement is readable and notifiable", + systemImage: "checkmark.circle" + ) + } header: { + Text("Bluetooth service contract") + } footer: { + Text( + "These facts identify the trainer interface exposed by this " + + "build. They do not test or change a riding app." + ) + } + + Section { + Button { + UIPasteboard.general.setItems( + [[UTType.plainText.identifier: report]], + options: [ + .localOnly: true, + .expirationDate: Date().addingTimeInterval(600), + ] + ) + showsCopiedConfirmation = true + } label: { + Label("Copy Diagnostics", systemImage: "doc.on.doc") + } + .accessibilityHint("Copies this on-device report to the clipboard") + + if showsCopiedConfirmation { + Label("Diagnostics copied", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } footer: { + Text( + "Diagnostics stay on this iPhone. Nothing is uploaded or sent. " + + "Copying happens only when you tap Copy Diagnostics." + ) + } + } + .navigationTitle("About & Diagnostics") + .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("screen.about-diagnostics") + } + + private var report: String { + DiagnosticsReport.make( + timestamp: Date(), + app: app, + operatingSystem: + "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)", + device: UIDevice.current.model, + state: state + ) + } + + private func diagnosticRow( + _ title: LocalizedStringKey, + value: String, + ready: Bool + ) -> some View { + LabeledContent { + Text(value) + } label: { + Label( + title, + systemImage: ready ? "checkmark.circle.fill" : "circle.dashed" + ) + .foregroundStyle(ready ? .green : .secondary) + } + .accessibilityElement(children: .combine) + } + } + private func autoConnectSavedEquipment() { kickr.autoConnectSavedDevice() if store.configuration.usesClick { @@ -1591,12 +1755,22 @@ private struct EquipmentSummary: View { } #Preview("Setup") { + let kickr = KickrCentralService() + let click = ClickCentralService() + let headwind = HeadwindCentralService() + let coordinator = ProxyCoordinator( + kickr: kickr, + click: click, + peripheral: FTMSPeripheral(), + screen: DeviceScreenWake() + ) NavigationStack { SetupView( store: ConfigurationStore(defaults: UserDefaults(suiteName: "preview.setup")!), - kickr: KickrCentralService(), - click: ClickCentralService(), - headwind: HeadwindCentralService() + kickr: kickr, + click: click, + headwind: headwind, + coordinator: coordinator ) } } diff --git a/VirtualGearsProduct/VirtualGearsApp.swift b/VirtualGearsProduct/VirtualGearsApp.swift index 14b241a..aafe487 100644 --- a/VirtualGearsProduct/VirtualGearsApp.swift +++ b/VirtualGearsProduct/VirtualGearsApp.swift @@ -164,6 +164,7 @@ private struct ScreenshotFixtureView: View { kickr: kickr, click: click, headwind: headwind, + coordinator: coordinator, autoConnectsOnAppear: false ) } diff --git a/VirtualGearsProduct/VirtualGearsHomeView.swift b/VirtualGearsProduct/VirtualGearsHomeView.swift index e64645b..16dfdbf 100644 --- a/VirtualGearsProduct/VirtualGearsHomeView.swift +++ b/VirtualGearsProduct/VirtualGearsHomeView.swift @@ -197,6 +197,7 @@ struct StartupView: View { kickr: kickr, click: click, headwind: headwind, + coordinator: coordinator, onFinish: { showsSettings = false } ) } @@ -1195,6 +1196,7 @@ struct ShiftingView: View { kickr: kickr, click: click, headwind: headwind, + coordinator: coordinator, onFinish: { showsSettings = false } ) } diff --git a/VirtualGearsUITests/VirtualGearsUITests.swift b/VirtualGearsUITests/VirtualGearsUITests.swift index acf30cb..caa87dd 100644 --- a/VirtualGearsUITests/VirtualGearsUITests.swift +++ b/VirtualGearsUITests/VirtualGearsUITests.swift @@ -242,7 +242,16 @@ final class VirtualGearsUITests: XCTestCase { launch("-shotSettings") assertVisible("screen.settings") - for destination in ["Trainer", "Zwift Click", "Wahoo Headwind", "Gears"] { + for destination in [ + "Trainer", + "Zwift Click", + "Wahoo Headwind", + "Gears", + "About & Diagnostics", + ] { + if destination == "About & Diagnostics" { + app.swipeUp() + } let row = app.staticTexts[destination].firstMatch assertVisibleElement(row) row.tap() @@ -250,6 +259,19 @@ final class VirtualGearsUITests: XCTestCase { app.navigationBars[destination].waitForExistence(timeout: 2), "\(destination) screen did not open" ) + if destination == "About & Diagnostics" { + assertVisibleElement( + app.staticTexts.matching( + NSPredicate(format: "label BEGINSWITH %@", "Version ") + ).firstMatch + ) + app.swipeUp() + app.swipeUp() + let copy = app.buttons["Copy Diagnostics"] + assertVisibleElement(copy) + copy.tap() + assertVisibleElement(app.staticTexts["Diagnostics copied"]) + } app.navigationBars.buttons.firstMatch.tap() } } diff --git a/docs/APP_STORE.md b/docs/APP_STORE.md index 1bc461d..988a441 100644 --- a/docs/APP_STORE.md +++ b/docs/APP_STORE.md @@ -147,6 +147,12 @@ the iPhone and when advertising itself as a trainer. The app has no networking code in it at all. Nothing about your ride leaves your iPhone. + ON-DEVICE DIAGNOSTICS + Settings includes the installed app version and live trainer, proxy and riding-app + connection state. A concise support report can be copied only when you tap Copy + Diagnostics. It contains no user names, Bluetooth identifiers, trainer identifiers + or ride logs, and nothing is uploaded. + Requires a compatible Wahoo KICKR. Built and physically tested with KICKR V5. Virtual Gears is not made by, endorsed by or affiliated with Wahoo Fitness. @@ -232,7 +238,7 @@ Guideline 2.1: DEVICES AND SYSTEMS TESTED ON (point 2) iPhone 17 Pro, iOS 26.6. - Trainer: Wahoo KICKR V5. Accessories: original Zwift Click, Wahoo KICKR HEADWIND. Riding apps driven end to end: FulGaz on macOS and Windows, plus RealVelo and MyWhoosh on Windows. + Trainer: Wahoo KICKR V5. Accessories: original Zwift Click, Wahoo KICKR HEADWIND. Riding apps driven end to end: FulGaz on macOS, plus RealVelo and MyWhoosh on Windows. FulGaz on Windows intermittently sees Virtual Gears but currently fails to connect; it is not claimed as compatible. EXTERNAL SERVICES, TOOLS AND PLATFORMS (point 5) None. The app contains no networking code at all. There are no servers, accounts, analytics, adverts, tracking, payment processors, data providers or AI services. Everything happens on the device and over local Bluetooth. Nothing leaves the phone. @@ -318,9 +324,12 @@ Virtual Gears appears as a trainer as soon as the KICKR is ready; **Start Shifting** applies the gears and **Stop Shifting** removes them without ending the riding app's ride. A saved normal wheel circumference supplies the fallback when the riding app sends none. Cycling Power Service adds the power and cadence -path MyWhoosh actually reads. FulGaz on Windows is supported by the -hardware-proven combination of advertising FTMS and Cycling Power together and -making both live measurements readable and notifiable. The iPhone stays awake +path MyWhoosh actually reads. The build advertises FTMS and Cycling Power +together and makes both live measurements readable and notifiable. A fresh +RideSim central passes all 17 checks against the installed TestFlight build, +including control, telemetry, disconnect and reconnect. FulGaz on Windows still +intermittently sees Virtual Gears and fails to connect, so it is not claimed as +compatible. The iPhone stays awake while the trainer proxy is available so computer riding apps can discover it before connecting. diff --git a/docs/DEMO_VIDEO.md b/docs/DEMO_VIDEO.md index 0e75982..7131631 100644 --- a/docs/DEMO_VIDEO.md +++ b/docs/DEMO_VIDEO.md @@ -163,8 +163,9 @@ video: > Full answers to your points 2 to 7 are in the App Review Information Notes > field, which has been updated. In short: tested on iPhone 17 Pro with iOS 26.6, > against a Wahoo KICKR V5 trainer, an original Zwift Click and a Wahoo KICKR -> HEADWIND, driven end to end by FulGaz on macOS and Windows, plus RealVelo and -> MyWhoosh on Windows. The app +> HEADWIND, driven end to end by FulGaz on macOS, plus RealVelo and MyWhoosh on +> Windows. FulGaz on Windows intermittently sees the trainer but currently fails +> to connect, so it is not claimed as compatible. The app > uses no external services and contains no networking code. It behaves > identically in every region. It is not a regulated industry and includes no > protected third-party material; it speaks the public Bluetooth SIG Fitness diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 72ee370..f68e52d 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy for Virtual Gears -Last updated: 8 August 2026 +Last updated: 16 August 2026 ## The short version @@ -22,6 +22,12 @@ Demo Mode uses a separate in-memory setup. Its simulated equipment and gear choices are discarded when you leave the demo and are not written over the equipment or gears saved for real rides. +The About & Diagnostics screen reads live connection state already held by the +app. Its report is generated on the iPhone and contains no user names, Bluetooth +identifiers, trainer identifiers or ride logs. Nothing happens until you tap +**Copy Diagnostics**, which places the text on the iOS clipboard for you to +paste where you choose. Virtual Gears does not upload or send it. + ## What the app does not do - **No accounts.** There is nothing to sign up for. diff --git a/docs/index.md b/docs/index.md index 3d78c8d..0e4b210 100644 --- a/docs/index.md +++ b/docs/index.md @@ -123,6 +123,12 @@ During shifting, the trainer, Click, fan and riding app each keep their own status. A riding app that is still waiting to connect does not hide the equipment Virtual Gears already connected. +Settings also contains **About & Diagnostics**. It shows the installed version +and build, iPhone software, KICKR readiness, trainer-proxy advertising, +subscriber and control state, the latest FTMS peripheral event, and the +Bluetooth service contract exposed by the build. A concise report can be copied +deliberately; it is generated on-device and is never uploaded. + ### Everything changeable mid-ride Trainer, gears, Click and Headwind can all be changed from the ride screen. diff --git a/docs/requirements.md b/docs/requirements.md index c3c2940..50f7d9b 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -95,7 +95,7 @@ computer: | Riding app | Status | |---|---| | FulGaz on macOS | Works, including power and cadence | -| FulGaz on Windows | Works, including power and cadence | +| FulGaz on Windows | Intermittently sees Virtual Gears but currently fails to connect | | RealVelo on Windows | Works | | MyWhoosh on Windows | Works, including power and cadence | | Others | Expected to work, not tested | @@ -104,6 +104,12 @@ MyWhoosh ignored FTMS ride data in direct testing and read power and cadence from Cycling Power Service instead. Virtual Gears now publishes that service, and the complete iPhone-to-Windows path has been ridden end to end. +TestFlight build 1.0 (11) passes all 17 RideSim checks, including advertising +FTMS and Cycling Power together, readable and notifiable live measurements, +control, telemetry, disconnect and reconnect. FulGaz on Windows remains an +application-specific connection problem; the passing Bluetooth contract check +does not make it compatible. + ## Your first ride 1. Put your bike on the trainer and wake the trainer by turning the pedals. diff --git a/docs/support.md b/docs/support.md index e6a585a..dead109 100644 --- a/docs/support.md +++ b/docs/support.md @@ -41,6 +41,11 @@ The more of this you can give, the better the odds of a fix: - Whether the app showed an error, and its exact wording - Whether a Zwift Click or Headwind was connected +In Virtual Gears, open **Settings → About & Diagnostics** and tap **Copy +Diagnostics**. Paste that report into the issue. It includes the app version and +live connection state, but no trainer identifier, riding-app identifier, user +name or ride log. + ## What to expect This is a spare-time project, so there is no guaranteed response time. Bugs that From 2c5685e8ccdf7f74f2010ebf298b451bf8a4b785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20Br=C3=B6nner?= Date: Sun, 16 Aug 2026 15:56:10 +0200 Subject: [PATCH 2/4] Tighten diagnostics privacy coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2c3d711c-cb97-4bfa-a302-2d01fe5d2f1f --- Sources/VirtualGearsCore/Diagnostics.swift | 27 ++++++-- .../DiagnosticsTests.swift | 69 +++++++++++++++++++ VirtualGearsProduct/SetupView.swift | 3 +- 3 files changed, 94 insertions(+), 5 deletions(-) diff --git a/Sources/VirtualGearsCore/Diagnostics.swift b/Sources/VirtualGearsCore/Diagnostics.swift index 19fe3d4..849b188 100644 --- a/Sources/VirtualGearsCore/Diagnostics.swift +++ b/Sources/VirtualGearsCore/Diagnostics.swift @@ -77,7 +77,7 @@ public struct DiagnosticsState: Equatable { } public var trainerSummary: String { - switch trainerConnection { + let summary = switch trainerConnection { case .ready: "Connected and ready" case .disconnected: @@ -95,6 +95,7 @@ public struct DiagnosticsState: Equatable { case let .unavailable(reason), let .failed(reason): reason } + return DiagnosticsReport.redactingIdentifiers(in: summary) } public var advertisingSummary: String { @@ -116,11 +117,15 @@ public struct DiagnosticsState: Equatable { } public var latestEventSummary: String { - latestPeripheralEvent?.diagnosticsDescription ?? "No peripheral event yet" + DiagnosticsReport.redactingIdentifiers( + in: latestPeripheralEvent?.diagnosticsDescription + ?? "No peripheral event yet" + ) } } public enum DiagnosticsReport { + public static let clipboardLifetime: TimeInterval = 10 * 60 public static let serviceContract = "FTMS 0x1826 + CPS 0x1818; Indoor Bike Data and Cycling Power " + "Measurement are readable and notifiable" @@ -132,7 +137,7 @@ public enum DiagnosticsReport { device: String, state: DiagnosticsState ) -> String { - [ + return redactingIdentifiers(in: [ "\(app.displayName) diagnostics", "Timestamp: \(timestampString(timestamp))", "App: \(app.versionAndBuild)", @@ -145,7 +150,21 @@ public enum DiagnosticsReport { "Latest FTMS event: \(state.latestEventSummary)", "Bluetooth contract: \(serviceContract)", "Privacy: Generated on-device and copied only when requested.", - ].joined(separator: "\n") + ].joined(separator: "\n")) + } + + public static func clipboardExpiration(after date: Date) -> Date { + date.addingTimeInterval(clipboardLifetime) + } + + static func redactingIdentifiers(in value: String) -> String { + let pattern = + #"\b[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\b"# + return value.replacingOccurrences( + of: pattern, + with: "[identifier removed]", + options: .regularExpression + ) } private static func timestampString(_ date: Date) -> String { diff --git a/Tests/VirtualGearsCoreTests/DiagnosticsTests.swift b/Tests/VirtualGearsCoreTests/DiagnosticsTests.swift index 4caf5ce..da71299 100644 --- a/Tests/VirtualGearsCoreTests/DiagnosticsTests.swift +++ b/Tests/VirtualGearsCoreTests/DiagnosticsTests.swift @@ -47,6 +47,75 @@ final class DiagnosticsTests: XCTestCase { XCTAssertFalse(state.latestEventSummary.contains(appID.uuidString)) } + func testEveryTrainerStateAndSubscriberCountMapsToPlainEnglish() { + let states: [(ProductConnectionState, String)] = [ + (.unavailable("Bluetooth is off"), "Bluetooth is off"), + (.disconnected, "Not connected"), + (.scanning, "Looking for trainer"), + (.reconnecting(attempt: 3), "Reconnecting"), + (.connecting(name: "KICKR 2A93"), "Connecting"), + (.discovering, "Connected, getting ready"), + (.preparing, "Connected, getting ready"), + (.ready, "Connected and ready"), + (.disconnecting, "Disconnecting"), + (.failed("Control denied"), "Control denied"), + ] + + for (connection, expected) in states { + let state = DiagnosticsState( + trainerConnection: connection, + isProxyAdvertising: false, + subscriberCount: 0, + isControlledByRidingApp: false, + latestPeripheralEvent: nil + ) + XCTAssertEqual(state.trainerSummary, expected) + XCTAssertEqual(state.advertisingSummary, "Not advertising") + XCTAssertEqual(state.subscribersSummary, "No riding apps subscribed") + XCTAssertEqual(state.controlSummary, "No riding app has control") + XCTAssertEqual(state.latestEventSummary, "No peripheral event yet") + } + + let multipleSubscribers = DiagnosticsState( + trainerConnection: .ready, + isProxyAdvertising: true, + subscriberCount: 3, + isControlledByRidingApp: true, + latestPeripheralEvent: nil + ) + XCTAssertEqual( + multipleSubscribers.subscribersSummary, + "3 riding apps subscribed" + ) + } + + func testPeripheralFailureRedactsBluetoothIdentifier() { + let identifier = "10000000-0000-0000-0000-000000000004" + let state = DiagnosticsState( + trainerConnection: .failed("Peripheral \(identifier) failed"), + isProxyAdvertising: false, + subscriberCount: 0, + isControlledByRidingApp: false, + latestPeripheralEvent: .failed("Peripheral \(identifier) failed") + ) + + XCTAssertEqual( + state.latestEventSummary, + "Trainer proxy error: Peripheral [identifier removed] failed" + ) + XCTAssertFalse(state.latestEventSummary.contains(identifier)) + } + + func testClipboardPolicyExpiresAfterTenMinutes() { + let copiedAt = Date(timeIntervalSince1970: 1_000) + + XCTAssertEqual(DiagnosticsReport.clipboardLifetime, 600) + XCTAssertEqual( + DiagnosticsReport.clipboardExpiration(after: copiedAt), + Date(timeIntervalSince1970: 1_600) + ) + } + func testReportContainsRequiredStateAndNoBluetoothIdentifiers() { let appID = UUID(uuidString: "10000000-0000-0000-0000-000000000004")! let report = DiagnosticsReport.make( diff --git a/VirtualGearsProduct/SetupView.swift b/VirtualGearsProduct/SetupView.swift index e38c7bb..4526eb8 100644 --- a/VirtualGearsProduct/SetupView.swift +++ b/VirtualGearsProduct/SetupView.swift @@ -297,7 +297,8 @@ struct SetupView: View { [[UTType.plainText.identifier: report]], options: [ .localOnly: true, - .expirationDate: Date().addingTimeInterval(600), + .expirationDate: + DiagnosticsReport.clipboardExpiration(after: Date()), ] ) showsCopiedConfirmation = true From 459bd06ae796d91c8b4ea89cba394e4c897903d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20Br=C3=B6nner?= Date: Sun, 16 Aug 2026 16:03:34 +0200 Subject: [PATCH 3/4] Restore Headwind state after shifting Keep the startup action fixed in place as trainer readiness changes, and document the physically reproduced build 11 regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2c3d711c-cb97-4bfa-a302-2d01fe5d2f1f --- DEVELOPMENT.md | 27 +- README.md | 2 + .../HeadwindControlPolicy.swift | 136 +++++++++++ .../HeadwindControlPolicyTests.swift | 230 ++++++++++++++++++ .../HeadwindCentralService.swift | 87 ++++++- VirtualGearsProduct/VirtualGearsApp.swift | 15 +- .../VirtualGearsHomeView.swift | 41 ++-- VirtualGearsUITests/VirtualGearsUITests.swift | 43 ++++ docs/APP_STORE.md | 17 +- docs/how-it-works.md | 6 + docs/index.md | 7 +- docs/requirements.md | 8 + docs/safety.md | 6 + docs/support.md | 4 + 14 files changed, 593 insertions(+), 36 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d046838..aa7abcc 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -44,7 +44,7 @@ xcodebuild test \ ``` `VirtualGearsUITests` launches deterministic debug fixtures rather than pretending -the simulator has Bluetooth hardware. Its 30 scenarios cover every primary +the simulator has Bluetooth hardware. Its 32 scenarios cover every primary screen, portrait and landscape status visibility, Accessibility Dynamic Type, startup failure, trainer reconnect, a riding app waiting, low Click battery, pending shifts, accepted Click press feedback, navigation, stop confirmation and @@ -56,13 +56,32 @@ confirmation must return to the ride, every equipment status must sit on one row, a low Click battery must be drawn at warning weight, the Easier/Harder buttons in Demo Mode must be drawn with the same distinct visual weight as the ride screen's (sampled by pixel colour, since button styling isn't exposed via -the accessibility tree), and the chain-position reminder must never appear or -disappear across startup states (it previously vanished the instant the -trainer connected, making the button above it jump). Screenshots are +the accessibility tree), the chain-position reminder must never appear or +disappear across startup states, and the primary action must retain the same +frame when waiting becomes ready at normal and Accessibility Dynamic Type +sizes. The reminder-only fix did not prevent the jump because the waiting and +ready cards still had different heights. Screenshots are attached to every test result. Protocol behavior and equipment lifecycle remain covered by the package tests and physical-hardware evidence. +### Headwind hand-back evidence, 16 August 2026 + +Build 11 was physically reproduced leaving the Headwind at Virtual Gears' +manual speed after **Stop Shifting**. The fan was not being stopped by the +riding app; Virtual Gears simply relinquished its own bookkeeping without +sending a restoring command. Restoration now uses the state notification +observed immediately before the first shifting command and retains it until the +fan acknowledges the complete hand-back. Hardware-independent policy and +lifecycle tests cover Off, heart-rate sensor, speed sensor, Sleep, Manual with +its exact prior percentage, command ordering, start-before-ready, repeated +start/stop, failure retry and disconnect/reconnect. + +The same physical session found that Headwind Bluetooth commands spaced 5% +apart produced audibly distinct speed steps. That is hardware evidence for the +slider's granularity even though the fan's own buttons expose four presets; it +is an audible observation, not a calibrated airflow measurement. + Open the iPhone project: ```bash diff --git a/README.md b/README.md index a6291c8..4cf0166 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,8 @@ with physical hardware. Wahoo KICKR HEADWIND. - **Optional Zwift Click shifting** from the handlebar. - **Headwind control** with Automatic, Off, 25%, 50%, 75% and 100% settings. +- **Careful Headwind hand-back** that restores the fan's exact pre-shifting + state when shifting stops, without stopping the riding app. - **Mid-ride changes** to the trainer, gears, Click or Headwind. - **Ride continuity** when you answer a call or briefly switch apps. - **A Bluetooth-free Demo Mode** for exploring the app without equipment. diff --git a/Sources/VirtualGearsCore/HeadwindControlPolicy.swift b/Sources/VirtualGearsCore/HeadwindControlPolicy.swift index 8afd943..c48a63b 100644 --- a/Sources/VirtualGearsCore/HeadwindControlPolicy.swift +++ b/Sources/VirtualGearsCore/HeadwindControlPolicy.swift @@ -69,3 +69,139 @@ public enum HeadwindControlPolicy { return commands } } + +/// One authoritative state report from a Headwind. The manual speed remains +/// meaningful outside manual mode because the fan remembers it for the next +/// time manual mode is selected. +public struct HeadwindState: Equatable, Sendable { + public var mode: HeadwindMode + public var manualSpeed: Int + + public init(mode: HeadwindMode, manualSpeed: Int) { + self.mode = mode + self.manualSpeed = min(100, max(0, manualSpeed)) + } + + public func applying(_ command: HeadwindCommand) -> Self { + switch command { + case let .setMode(mode): + return .init(mode: mode, manualSpeed: manualSpeed) + case let .setManualSpeed(speed): + return .init(mode: .manual, manualSpeed: speed) + } + } + + public func matches(_ other: Self) -> Bool { + mode == other.mode + && (mode != .manual || manualSpeed == other.manualSpeed) + } +} + +/// Plans the shortest ordered command sequence that restores an exact state. +public enum HeadwindRestorationPolicy { + public static func commands( + restoring target: HeadwindState, + from current: HeadwindState + ) -> [HeadwindCommand] { + guard !current.matches(target) else { return [] } + guard target.mode == .manual else { + return current.mode == target.mode ? [] : [.setMode(target.mode)] + } + + var commands: [HeadwindCommand] = [] + if current.mode != .manual { + commands.append(.setMode(.manual)) + } + if current.manualSpeed != target.manualSpeed { + commands.append(.setManualSpeed(target.manualSpeed)) + } + return commands + } +} + +/// Owns the pre-control snapshot for exactly one shifting lifecycle. +public struct HeadwindControlLifecycle: Equatable, Sendable { + public enum Phase: Equatable, Sendable { + case idle + case awaitingBaseline + case controlling(HeadwindState) + case restoring(HeadwindState) + } + + public private(set) var phase: Phase = .idle + + public init() {} + + public var isAwaitingBaseline: Bool { + phase == .awaitingBaseline + } + + public var restorationTarget: HeadwindState? { + guard case let .restoring(state) = phase else { return nil } + return state + } + + /// Returns true when control may be applied immediately. Repeated starts do + /// not replace the original baseline. + @discardableResult + public mutating func begin( + observedState: HeadwindState?, + hasUnsettledCommand: Bool + ) -> Bool { + switch phase { + case .controlling, .awaitingBaseline: + return false + case .idle, .restoring: + guard !hasUnsettledCommand, let observedState else { + phase = .awaitingBaseline + return false + } + phase = .controlling(observedState) + return true + } + } + + /// Captures only an authoritative state notification, never a requested or + /// projected state. + @discardableResult + public mutating func observeAuthoritative( + _ state: HeadwindState, + hasUnsettledCommand: Bool = false + ) -> Bool { + guard phase == .awaitingBaseline, !hasUnsettledCommand else { + return false + } + phase = .controlling(state) + return true + } + + @discardableResult + public mutating func beginRestoration() -> HeadwindState? { + switch phase { + case .idle: + return nil + case .awaitingBaseline: + phase = .idle + return nil + case let .controlling(state): + phase = .restoring(state) + return state + case let .restoring(state): + return state + } + } + + @discardableResult + public mutating func finishRestoration(ifObserved state: HeadwindState) -> Bool { + guard case let .restoring(target) = phase, state.matches(target) else { + return false + } + phase = .idle + return true + } + + /// A replacement fan needs its own baseline, not the removed fan's state. + public mutating func deviceChanged(whileControlling: Bool) { + phase = whileControlling ? .awaitingBaseline : .idle + } +} diff --git a/Tests/VirtualGearsCoreTests/HeadwindControlPolicyTests.swift b/Tests/VirtualGearsCoreTests/HeadwindControlPolicyTests.swift index 7de1b50..9c69c97 100644 --- a/Tests/VirtualGearsCoreTests/HeadwindControlPolicyTests.swift +++ b/Tests/VirtualGearsCoreTests/HeadwindControlPolicyTests.swift @@ -15,7 +15,237 @@ final class HeadwindControlPolicyTests: XCTestCase { ) XCTAssertEqual(HeadwindControlPolicy.commands(for: situation), []) } +} + +final class HeadwindRestorationPolicyTests: XCTestCase { + func testRestoresEveryNonManualModeExactly() { + for mode in [HeadwindMode.off, .heartRate, .speed, .sleep] { + XCTAssertEqual( + HeadwindRestorationPolicy.commands( + restoring: .init(mode: mode, manualSpeed: 35), + from: .init(mode: .manual, manualSpeed: 70) + ), + [.setMode(mode)] + ) + } + } + + func testRestoringManualModeAndSpeedUsesCorrectOrder() { + XCTAssertEqual( + HeadwindRestorationPolicy.commands( + restoring: .init(mode: .manual, manualSpeed: 35), + from: .init(mode: .heartRate, manualSpeed: 70) + ), + [.setMode(.manual), .setManualSpeed(35)] + ) + } + + func testRestoringManualAvoidsRedundantCommands() { + XCTAssertEqual( + HeadwindRestorationPolicy.commands( + restoring: .init(mode: .manual, manualSpeed: 35), + from: .init(mode: .manual, manualSpeed: 70) + ), + [.setManualSpeed(35)] + ) + XCTAssertEqual( + HeadwindRestorationPolicy.commands( + restoring: .init(mode: .manual, manualSpeed: 35), + from: .init(mode: .speed, manualSpeed: 35) + ), + [.setMode(.manual)] + ) + } + + func testAlreadyMatchingStateNeedsNoCommands() { + for mode in HeadwindMode.allCases { + let state = HeadwindState(mode: mode, manualSpeed: 55) + XCTAssertEqual( + HeadwindRestorationPolicy.commands(restoring: state, from: state), + [] + ) + } + } + + func testNonManualStateIgnoresIrrelevantManualSpeed() { + XCTAssertTrue( + HeadwindState(mode: .off, manualSpeed: 10).matches( + .init(mode: .off, manualSpeed: 90) + ) + ) + } + } + +final class HeadwindControlLifecycleTests: XCTestCase { + func testCapturesOnceAndDoesNotOverwriteBaseline() { + var lifecycle = HeadwindControlLifecycle() + let baseline = HeadwindState(mode: .speed, manualSpeed: 25) + + XCTAssertTrue( + lifecycle.begin(observedState: baseline, hasUnsettledCommand: false) + ) + XCTAssertFalse( + lifecycle.begin( + observedState: .init(mode: .manual, manualSpeed: 80), + hasUnsettledCommand: false + ) + ) + XCTAssertEqual(lifecycle.beginRestoration(), baseline) + } + + func testStartBeforeReadyWaitsForAuthoritativeState() { + var lifecycle = HeadwindControlLifecycle() + XCTAssertFalse( + lifecycle.begin(observedState: nil, hasUnsettledCommand: false) + ) + XCTAssertTrue(lifecycle.isAwaitingBaseline) + + let baseline = HeadwindState(mode: .sleep, manualSpeed: 40) + XCTAssertTrue(lifecycle.observeAuthoritative(baseline)) + XCTAssertEqual(lifecycle.beginRestoration(), baseline) + } + + func testStoppingBeforeBaselineWasCapturedIsANoOp() { + var lifecycle = HeadwindControlLifecycle() + lifecycle.begin(observedState: nil, hasUnsettledCommand: false) + XCTAssertNil(lifecycle.beginRestoration()) + XCTAssertEqual(lifecycle.phase, .idle) + XCTAssertNil(lifecycle.beginRestoration()) + } + + func testRepeatedStopKeepsRestorationTargetUntilObserved() { + var lifecycle = HeadwindControlLifecycle() + let baseline = HeadwindState(mode: .manual, manualSpeed: 30) + lifecycle.begin(observedState: baseline, hasUnsettledCommand: false) + + XCTAssertEqual(lifecycle.beginRestoration(), baseline) + XCTAssertEqual(lifecycle.beginRestoration(), baseline) + XCTAssertFalse( + lifecycle.finishRestoration( + ifObserved: .init(mode: .manual, manualSpeed: 70) + ) + ) + XCTAssertTrue(lifecycle.finishRestoration(ifObserved: baseline)) + XCTAssertEqual(lifecycle.phase, .idle) + } + + func testRestartDuringRestorationDoesNotReuseStaleBaseline() { + var lifecycle = HeadwindControlLifecycle() + lifecycle.begin( + observedState: .init(mode: .heartRate, manualSpeed: 20), + hasUnsettledCommand: false + ) + lifecycle.beginRestoration() + + XCTAssertFalse( + lifecycle.begin( + observedState: .init(mode: .heartRate, manualSpeed: 20), + hasUnsettledCommand: true + ) + ) + XCTAssertTrue(lifecycle.isAwaitingBaseline) + + let newBaseline = HeadwindState(mode: .manual, manualSpeed: 65) + XCTAssertFalse( + lifecycle.observeAuthoritative( + newBaseline, + hasUnsettledCommand: true + ) + ) + XCTAssertTrue(lifecycle.isAwaitingBaseline) + XCTAssertTrue(lifecycle.observeAuthoritative(newBaseline)) + XCTAssertEqual(lifecycle.beginRestoration(), newBaseline) + } + + func testDisconnectDoesNotDiscardRestorationTarget() { + var lifecycle = HeadwindControlLifecycle() + let baseline = HeadwindState(mode: .off, manualSpeed: 0) + lifecycle.begin(observedState: baseline, hasUnsettledCommand: false) + lifecycle.beginRestoration() + + XCTAssertEqual(lifecycle.restorationTarget, baseline) + XCTAssertFalse( + lifecycle.finishRestoration( + ifObserved: .init(mode: .manual, manualSpeed: 50) + ) + ) + XCTAssertEqual(lifecycle.restorationTarget, baseline) + } + + func testReconnectReplansRestoreFromItsAuthoritativeState() throws { + var lifecycle = HeadwindControlLifecycle() + let baseline = HeadwindState(mode: .sleep, manualSpeed: 15) + lifecycle.begin(observedState: baseline, hasUnsettledCommand: false) + lifecycle.beginRestoration() + + let reconnectedState = HeadwindState(mode: .manual, manualSpeed: 80) + XCTAssertEqual( + HeadwindRestorationPolicy.commands( + restoring: try XCTUnwrap(lifecycle.restorationTarget), + from: reconnectedState + ), + [.setMode(.sleep)] + ) + XCTAssertFalse(lifecycle.finishRestoration(ifObserved: reconnectedState)) + XCTAssertTrue( + lifecycle.finishRestoration( + ifObserved: reconnectedState.applying(.setMode(.sleep)) + ) + ) + } + + func testFailedCommandCanBeRetriedWithoutDiscardingBaseline() throws { + var lifecycle = HeadwindControlLifecycle() + let baseline = HeadwindState(mode: .heartRate, manualSpeed: 20) + lifecycle.begin(observedState: baseline, hasUnsettledCommand: false) + lifecycle.beginRestoration() + + let current = HeadwindState(mode: .manual, manualSpeed: 90) + let firstAttempt = HeadwindRestorationPolicy.commands( + restoring: try XCTUnwrap(lifecycle.restorationTarget), + from: current + ) + XCTAssertEqual(firstAttempt, [.setMode(.heartRate)]) + XCTAssertEqual(lifecycle.restorationTarget, baseline) + XCTAssertEqual( + HeadwindRestorationPolicy.commands( + restoring: try XCTUnwrap(lifecycle.restorationTarget), + from: current + ), + firstAttempt + ) + } + + func testManualBaselineLivesUntilModeAndSpeedAreBothAcknowledged() throws { + var lifecycle = HeadwindControlLifecycle() + let baseline = HeadwindState(mode: .manual, manualSpeed: 35) + lifecycle.begin(observedState: baseline, hasUnsettledCommand: false) + lifecycle.beginRestoration() + + var observed = HeadwindState(mode: .off, manualSpeed: 80) + let commands = HeadwindRestorationPolicy.commands( + restoring: try XCTUnwrap(lifecycle.restorationTarget), + from: observed + ) + XCTAssertEqual(commands, [.setMode(.manual), .setManualSpeed(35)]) + + observed = observed.applying(commands[0]) + XCTAssertFalse(lifecycle.finishRestoration(ifObserved: observed)) + XCTAssertEqual(lifecycle.restorationTarget, baseline) + + observed = observed.applying(commands[1]) + XCTAssertTrue(lifecycle.finishRestoration(ifObserved: observed)) + XCTAssertNil(lifecycle.restorationTarget) + } + + func testStopWithoutVirtualGearsTakingControlDoesNotRestore() { + var lifecycle = HeadwindControlLifecycle() + XCTAssertNil(lifecycle.beginRestoration()) + XCTAssertEqual(lifecycle.phase, .idle) + } + } +extension HeadwindControlPolicyTests { func testStartingARideRestoresTheSavedManualSpeed() { let situation = HeadwindSituation( weAreDrivingTheFan: true, diff --git a/VirtualGearsProduct/HeadwindCentralService.swift b/VirtualGearsProduct/HeadwindCentralService.swift index 493d48f..277399c 100644 --- a/VirtualGearsProduct/HeadwindCentralService.swift +++ b/VirtualGearsProduct/HeadwindCentralService.swift @@ -65,11 +65,13 @@ final class HeadwindCentralService: NSObject { private var deferredAction: DeferredAction? private var scansAfterDisconnect = false private var hasReceivedInitialState = false + private var hasAuthoritativeStateForConnection = false private var isIgnoringConflictingHeadwindState = false /// Whether a ride is what is asking for the fan. Connecting on its own is /// not, so the saved preference stays on the shelf until a ride starts. private var isDrivingTheFan = false private var hasStoredControlPreference: Bool + private var controlLifecycle = HeadwindControlLifecycle() private(set) var connectionIsStalled = false @@ -347,6 +349,8 @@ final class HeadwindCentralService: NSObject { case let .state(newMode, speed): let isInitial = !hasReceivedInitialState if !isInitial, hasStoredControlPreference, + !controlLifecycle.isAwaitingBaseline, + controlLifecycle.restorationTarget == nil, (newMode == .manual) != requestedManual { // A Headwind reports its state about once a second, so a // disagreement that lasts logs forever and buries every @@ -363,9 +367,20 @@ final class HeadwindCentralService: NSObject { } isIgnoringConflictingHeadwindState = false apply(mode: newMode, speed: speed) + hasAuthoritativeStateForConnection = true + let observed = HeadwindState(mode: newMode, manualSpeed: speed) if isInitial { hasReceivedInitialState = true state = .ready + } + if controlLifecycle.observeAuthoritative( + observed, + hasUnsettledCommand: pendingCommand != nil + ) { + reconcileControlPreference() + } else if controlLifecycle.restorationTarget != nil { + reconcileRestoration() + } else if isInitial { reconcileControlPreference() } case let .modeAcknowledged(newMode, succeeded): @@ -407,6 +422,7 @@ final class HeadwindCentralService: NSObject { speed: Int ) { mode = newMode + manualSpeed = min(100, max(0, speed)) let adoptsExistingState = !hasStoredControlPreference if adoptsExistingState { requestedManual = newMode == .manual @@ -414,7 +430,6 @@ final class HeadwindCentralService: NSObject { persistControlPreference() } if newMode == .manual { - manualSpeed = min(100, max(0, speed)) if adoptsExistingState, defaults.object(forKey: speedKey) == nil { desiredManualSpeed = manualSpeed defaults.set(manualSpeed, forKey: speedKey) @@ -429,7 +444,21 @@ final class HeadwindCentralService: NSObject { /// when the fan connects: simply opening the app must never spin a fan up. func applySavedControlPreference() { isDrivingTheFan = true - guard isReady else { return } + switch controlLifecycle.phase { + case .controlling: + if pendingCommand == nil { reconcileControlPreference() } + return + case .awaitingBaseline: + return + case .idle, .restoring: + break + } + commandQueue.removeAll() + let mayApply = controlLifecycle.begin( + observedState: isReady ? observedState : nil, + hasUnsettledCommand: pendingCommand != nil + ) + guard mayApply else { return } reconcileControlPreference() } @@ -437,9 +466,16 @@ final class HeadwindCentralService: NSObject { /// connection again rather than a reason to start blowing. func releaseFanControl() { isDrivingTheFan = false + guard controlLifecycle.restorationTarget == nil else { return } + speedDebounceTask?.cancel() + commandQueue.removeAll() + guard controlLifecycle.beginRestoration() != nil else { return } + reconcileRestoration() } private func reconcileControlPreference() { + guard controlLifecycle.restorationTarget == nil, + !controlLifecycle.isAwaitingBaseline else { return } let commands = HeadwindControlPolicy.commands( for: HeadwindSituation( weAreDrivingTheFan: isDrivingTheFan, @@ -465,11 +501,47 @@ final class HeadwindCentralService: NSObject { } } + private var observedState: HeadwindState? { + guard let mode else { return nil } + return HeadwindState(mode: mode, manualSpeed: manualSpeed) + } + + private func reconcileRestoration() { + guard let target = controlLifecycle.restorationTarget, + isReady, hasAuthoritativeStateForConnection, + var current = observedState else { return } + if let pendingCommand { + current = current.applying(pendingCommand) + } + for command in commandQueue { + current = current.applying(command) + } + let commands = HeadwindRestorationPolicy.commands( + restoring: target, + from: current + ) + for command in commands { + enqueue(command) + } + finishRestorationIfComplete() + } + + private func finishRestorationIfComplete() { + guard isReady, hasAuthoritativeStateForConnection, + pendingCommand == nil, commandQueue.isEmpty, + let observedState else { return } + if controlLifecycle.finishRestoration(ifObserved: observedState) { + commandError = nil + log("Restored Headwind state from before shifting") + } + } + private func commandConfirmed() { commandTimeoutTask?.cancel() pendingCommand = nil isCommandPending = false commandError = nil + finishRestorationIfComplete() sendNextCommand() } @@ -478,9 +550,11 @@ final class HeadwindCentralService: NSObject { pendingCommand = nil commandQueue.removeAll() isCommandPending = false - // The action was waiting on a command that never landed. Leaving it - // armed would fire it against whatever the rider does next. - deferredAction = nil + if deferredAction != nil { + // The action was waiting on a command that never landed. Leaving it + // armed would fire it against whatever the rider does next. + deferredAction = nil + } commandError = message log(message, level: .error) } @@ -567,6 +641,7 @@ final class HeadwindCentralService: NSObject { commandQueue.removeAll() pendingCommand = nil isCommandPending = false + controlLifecycle.deviceChanged(whileControlling: isDrivingTheFan) if let peripheral { state = .disconnecting central.cancelPeripheralConnection(peripheral) @@ -582,6 +657,7 @@ final class HeadwindCentralService: NSObject { isCommandPending = false controlCharacteristic = nil hasReceivedInitialState = false + hasAuthoritativeStateForConnection = false if !keepingPeripheral { peripheral = nil } } @@ -590,6 +666,7 @@ final class HeadwindCentralService: NSObject { case .remove: clearSelection() case .scan: + controlLifecycle.deviceChanged(whileControlling: isDrivingTheFan) desiredConnection = false scansAfterDisconnect = true guard let peripheral else { diff --git a/VirtualGearsProduct/VirtualGearsApp.swift b/VirtualGearsProduct/VirtualGearsApp.swift index aafe487..bdf6186 100644 --- a/VirtualGearsProduct/VirtualGearsApp.swift +++ b/VirtualGearsProduct/VirtualGearsApp.swift @@ -95,7 +95,9 @@ struct VirtualGearsApp: App { #if DEBUG enum ScreenshotFixture: String { case starting = "-shotStarting" + case startingAccessibility = "-shotStartingAccessibility" case ready = "-shotReady" + case readyAccessibility = "-shotReadyAccessibility" case failed = "-shotFailed" case ride = "-shotRide" case rideAccessibility = "-shotRideAccessibility" @@ -138,7 +140,8 @@ private struct ScreenshotFixtureView: View { var body: some View { Group { switch scenario { - case .starting, .ready, .failed: + case .starting, .startingAccessibility, .ready, + .readyAccessibility, .failed: StartupView( store: store, kickr: kickr, @@ -181,7 +184,10 @@ private struct ScreenshotFixtureView: View { } } .dynamicTypeSize( - scenario == .rideAccessibility ? .accessibility5 : .large + scenario == .rideAccessibility + || scenario == .startingAccessibility + || scenario == .readyAccessibility + ? .accessibility5 : .large ) .task { stage() @@ -210,7 +216,7 @@ private struct ScreenshotFixtureView: View { kickr.stageScreenshot( name: configuration.kickrName, - state: scenario == .starting + state: scenario == .starting || scenario == .startingAccessibility ? .connecting(name: configuration.kickrName) : .ready ) click.stageScreenshot(name: configuration.clickName, batteryLevel: 82) @@ -225,7 +231,8 @@ private struct ScreenshotFixtureView: View { click.stageScreenshotPressedButton(.plus) } - if scenario == .ready || scenario == .rideWaiting { + if scenario == .ready || scenario == .readyAccessibility + || scenario == .rideWaiting { (coordinator.peripheral as? FTMSPeripheral)? .stageScreenshotAdvertising() } else if isRideScenario { diff --git a/VirtualGearsProduct/VirtualGearsHomeView.swift b/VirtualGearsProduct/VirtualGearsHomeView.swift index 16dfdbf..fb7b94f 100644 --- a/VirtualGearsProduct/VirtualGearsHomeView.swift +++ b/VirtualGearsProduct/VirtualGearsHomeView.swift @@ -161,11 +161,8 @@ struct StartupView: View { retryButton } else if mustChoose { chooser - } else if canStart { - readyCard - retryButton } else { - searching + readinessCard retryButton } // Fixed in the layout regardless of state, so it never @@ -260,12 +257,25 @@ struct StartupView: View { kickr.selectAndConnect(id) } - private var searching: some View { + /// Waiting and ready deliberately share one layout. The invisible side of + /// each transition still participates in layout, so the primary action does + /// not move when the trainer becomes ready, including at large text sizes. + private var readinessCard: some View { VStack(spacing: 16) { - ProgressView().controlSize(.large) - Text(searchingTitle) - .font(.title3.weight(.semibold)) - .multilineTextAlignment(.center) + ProgressView() + .controlSize(.large) + .opacity(canStart ? 0 : 1) + .accessibilityHidden(canStart) + ZStack { + Text(searchingTitle) + .opacity(canStart ? 0 : 1) + .accessibilityHidden(canStart) + Text("Ready to shift") + .opacity(canStart ? 1 : 0) + .accessibilityHidden(!canStart) + } + .font(.title3.weight(.semibold)) + .multilineTextAlignment(.center) Text( "Turn the pedals if your trainer is asleep. Your riding app can " + "find Virtual Gears as soon as the trainer is connected." @@ -273,7 +283,9 @@ struct StartupView: View { .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - connectionList(includeRidingApp: false) + .opacity(canStart ? 0 : 1) + .accessibilityHidden(canStart) + connectionList(includeRidingApp: true) } .frame(maxWidth: .infinity) .accessibilityElement(children: .contain) @@ -348,15 +360,6 @@ struct StartupView: View { // MARK: - Stopping and failing - private var readyCard: some View { - VStack(spacing: 16) { - Text("Ready to shift") - .font(.title3.weight(.semibold)) - connectionList(includeRidingApp: true) - } - .frame(maxWidth: .infinity) - } - private func connectionList(includeRidingApp: Bool) -> some View { var items = [ ConnectionStatusItem( diff --git a/VirtualGearsUITests/VirtualGearsUITests.swift b/VirtualGearsUITests/VirtualGearsUITests.swift index caa87dd..aa5457c 100644 --- a/VirtualGearsUITests/VirtualGearsUITests.swift +++ b/VirtualGearsUITests/VirtualGearsUITests.swift @@ -44,6 +44,20 @@ final class VirtualGearsUITests: XCTestCase { XCTAssertEqual(ridingApp.label, "Riding app, Waiting for connection") } + func testPrimaryActionDoesNotMoveWhenTrainerBecomesReady() { + assertPrimaryActionKeepsPosition( + waitingFixture: "-shotStarting", + readyFixture: "-shotReady" + ) + } + + func testAccessibilityPrimaryActionDoesNotMoveWhenTrainerBecomesReady() { + assertPrimaryActionKeepsPosition( + waitingFixture: "-shotStartingAccessibility", + readyFixture: "-shotReadyAccessibility" + ) + } + func testRideShowsAllStatusIconsAndPrimaryControls() { launch("-shotRide") @@ -434,6 +448,35 @@ final class VirtualGearsUITests: XCTestCase { app.launch() } + private func assertPrimaryActionKeepsPosition( + waitingFixture: String, + readyFixture: String + ) { + launch(waitingFixture) + let waiting = app.buttons["Waiting for trainer"] + assertVisibleElement(waiting) + let waitingFrame = waiting.frame + app.terminate() + + launch(readyFixture) + let ready = app.buttons["Start Shifting"] + assertVisibleElement(ready) + let readyFrame = ready.frame + + XCTAssertEqual( + waitingFrame.midY, + readyFrame.midY, + accuracy: 1, + "The primary action moved when the trainer became ready" + ) + XCTAssertEqual( + waitingFrame.height, + readyFrame.height, + accuracy: 1, + "The primary action changed height when the trainer became ready" + ) + } + func testTheChainReminderNeverAppearsOrDisappearsAcrossStartupStates() { // It used to live only inside the searching and chooser cards, so it // vanished the instant the trainer connected and the button above it diff --git a/docs/APP_STORE.md b/docs/APP_STORE.md index 988a441..8198ca0 100644 --- a/docs/APP_STORE.md +++ b/docs/APP_STORE.md @@ -127,7 +127,9 @@ the iPhone and when advertising itself as a trainer. OPTIONAL HEADWIND CONTROL Turn on a Wahoo KICKR HEADWIND before opening Virtual Gears and it connects automatically. Leave fan speed with the Headwind's own sensor, or choose a manual - speed from the ride screen. The fan is optional and never blocks a ride. + speed from the ride screen. Stop Shifting restores the fan state from before + shifting without stopping the ride in your riding app. The fan is optional and + never blocks a ride. CAREFUL WITH YOUR TRAINER A gear is only shown after your trainer confirms it. Every gear stays inside a @@ -333,6 +335,19 @@ compatible. The iPhone stays awake while the trainer proxy is available so computer riding apps can discover it before connecting. +Two further problems were physically reproduced on build 11. **Stop Shifting** +left a Headwind at Virtual Gears' manual speed instead of restoring what the fan +was doing beforehand. The fix snapshots the fan immediately before control and +restores Off, heart-rate sensor, speed sensor, Sleep, or Manual at its prior +percentage, retaining the hand-back across reconnects until all commands are +confirmed. The primary action also still moved vertically when the trainer +became ready: the waiting card was taller than the ready card even after the +chain reminder stopped disappearing. Waiting and ready now share one structural +layout, with deterministic geometry checks at normal and Accessibility Dynamic +Type sizes. Physical Headwind testing also found that Bluetooth commands in 5% +steps produce audibly distinct speeds even though the fan's physical controls +offer four presets. + The live App Store description still carries the old "starts the session" sentence. It is corrected in this file and needs the same edit in App Store Connect on the next metadata change. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 34ca71a..9bfdb5e 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -17,6 +17,12 @@ adds the gears; **Stop Shifting** removes the virtual gear and restores the normal wheel circumference. Neither button starts or stops the ride in the riding app, and stopping shifting leaves that app connected. +If Virtual Gears changed an optional Headwind while shifting, **Stop Shifting** +also restores the fan state observed immediately before control began. Off, +heart-rate sensor, speed sensor, Sleep and Manual with its previous percentage +are restored exactly. This is a fan hand-back, not a signal that the riding +app's ride has ended. + The iPhone screen stays awake for as long as this trainer proxy is available. iOS changes Bluetooth advertising after the phone locks, which can make a waiting trainer disappear from riding apps on Windows and other computers. diff --git a/docs/index.md b/docs/index.md index 0e4b210..1df4b39 100644 --- a/docs/index.md +++ b/docs/index.md @@ -88,9 +88,10 @@ details](accessibility.md) cover larger text and other iPhone settings. An optional Wahoo Headwind can use Automatic control from its paired sensor or a manual speed from the ride screen. Manual offers a slider and one-tap common -speeds. It stays on the fan even after Bluetooth disconnects, so Virtual Gears -explicitly returns control to the sensor and waits for confirmation before -switching to another fan. +speeds. Virtual Gears remembers the fan's actual state before shifting and +restores that exact state when shifting stops: Off, heart-rate sensor, speed +sensor, Sleep, or Manual at its previous percentage. This changes only the fan; +the riding app remains connected and its ride continues.

Headwind manual fan control at 50 percent in portrait diff --git a/docs/requirements.md b/docs/requirements.md index 50f7d9b..60cd2c5 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -174,6 +174,14 @@ Manual mode remains on the Headwind after Bluetooth disconnects. Virtual Gears therefore sends an explicit sensor-control command and waits for the fan to confirm it before switching to another Headwind. +Starting shifting snapshots the Headwind state before Virtual Gears applies a +saved manual preference. Stopping shifting restores that exact state and waits +for every command to be confirmed, including both Manual mode and its previous +speed when needed. A temporary disconnect keeps the restoration pending for the +fan's return. Bluetooth percentage commands in 5% steps produced audibly +distinct fan-speed changes on physical hardware, even though the fan's own +buttons expose four presets. + ## Choosing your gears The starting choice is a 24-step virtual ladder with extra room for easy diff --git a/docs/safety.md b/docs/safety.md index d5a6cad..2e7094e 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -8,6 +8,12 @@ Demo Mode is separate from this hardware path. It uses local simulated gear state, does not use Bluetooth, does not run the interrupted-ride baseline reset and cannot send a command to exercise equipment. +An optional Headwind is controlled only while virtual shifting asks for it. +Virtual Gears snapshots the fan's actual state before its first control command +and restores that exact state after **Stop Shifting**. Restoration survives a +temporary disconnect and is complete only after the fan confirms every needed +command. Stopping shifting does not stop or disconnect the riding app. + ## The trainer has no wheel-size limit we could find This is worth stating plainly, because Virtual Gears got it wrong for a long diff --git a/docs/support.md b/docs/support.md index dead109..471c7fe 100644 --- a/docs/support.md +++ b/docs/support.md @@ -30,6 +30,10 @@ Most problems have a known answer already: in Virtual Gears Settings to the value you use in the Wahoo app. Virtual Gears uses 2070 mm by default. See [If you set a custom wheel circumference](requirements.md#if-you-set-a-custom-wheel-circumference). +- **The Headwind did not return to its earlier setting after Stop Shifting.** + Keep the fan powered on or let it reconnect. Virtual Gears keeps the exact + pre-shifting state pending until the Headwind confirms the hand-back; any + refusal or timeout is shown in the app and recorded in its diagnostic log. ## What to include in a bug report From ee156e366698c14476f24ccded534433b2f4e175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20Br=C3=B6nner?= Date: Sun, 16 Aug 2026 16:52:44 +0200 Subject: [PATCH 4/4] Complete Headwind lifecycle coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2c3d711c-cb97-4bfa-a302-2d01fe5d2f1f --- DEVELOPMENT.md | 4 +- .../HeadwindControlPolicy.swift | 24 ++-- .../HeadwindControlPolicyTests.swift | 133 +++++++++++++++--- .../HeadwindCentralService.swift | 44 ++++-- .../VirtualGearsHomeView.swift | 5 + VirtualGearsUITests/VirtualGearsUITests.swift | 4 +- docs/requirements.md | 4 +- 7 files changed, 175 insertions(+), 43 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index aa7abcc..459ddcf 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -75,7 +75,9 @@ observed immediately before the first shifting command and retains it until the fan acknowledges the complete hand-back. Hardware-independent policy and lifecycle tests cover Off, heart-rate sensor, speed sensor, Sleep, Manual with its exact prior percentage, command ordering, start-before-ready, repeated -start/stop, failure retry and disconnect/reconnect. +start/stop, failed-command retry, failed shifting-start hand-back and +disconnect/reconnect. Replacement/removal tests require the exact Off, Sleep or +Manual baseline to finish before the old fan's lifecycle is discarded. The same physical session found that Headwind Bluetooth commands spaced 5% apart produced audibly distinct speed steps. That is hardware evidence for the diff --git a/Sources/VirtualGearsCore/HeadwindControlPolicy.swift b/Sources/VirtualGearsCore/HeadwindControlPolicy.swift index c48a63b..63f666e 100644 --- a/Sources/VirtualGearsCore/HeadwindControlPolicy.swift +++ b/Sources/VirtualGearsCore/HeadwindControlPolicy.swift @@ -92,8 +92,7 @@ public struct HeadwindState: Equatable, Sendable { } public func matches(_ other: Self) -> Bool { - mode == other.mode - && (mode != .manual || manualSpeed == other.manualSpeed) + mode == other.mode && manualSpeed == other.manualSpeed } } @@ -104,21 +103,28 @@ public enum HeadwindRestorationPolicy { from current: HeadwindState ) -> [HeadwindCommand] { guard !current.matches(target) else { return [] } - guard target.mode == .manual else { - return current.mode == target.mode ? [] : [.setMode(target.mode)] - } - var commands: [HeadwindCommand] = [] - if current.mode != .manual { - commands.append(.setMode(.manual)) - } if current.manualSpeed != target.manualSpeed { + if current.mode != .manual { + commands.append(.setMode(.manual)) + } commands.append(.setManualSpeed(target.manualSpeed)) } + let projectedMode = commands.isEmpty ? current.mode : .manual + if projectedMode != target.mode { + commands.append(.setMode(target.mode)) + } return commands } } +public enum HeadwindShiftingPolicy { + public static func shouldReleaseControl(after failure: ShiftingFailure?) -> Bool { + guard let failure else { return false } + return !failure.happenedWhileStopping + } +} + /// Owns the pre-control snapshot for exactly one shifting lifecycle. public struct HeadwindControlLifecycle: Equatable, Sendable { public enum Phase: Equatable, Sendable { diff --git a/Tests/VirtualGearsCoreTests/HeadwindControlPolicyTests.swift b/Tests/VirtualGearsCoreTests/HeadwindControlPolicyTests.swift index 9c69c97..852d6b5 100644 --- a/Tests/VirtualGearsCoreTests/HeadwindControlPolicyTests.swift +++ b/Tests/VirtualGearsCoreTests/HeadwindControlPolicyTests.swift @@ -25,7 +25,7 @@ final class HeadwindRestorationPolicyTests: XCTestCase { restoring: .init(mode: mode, manualSpeed: 35), from: .init(mode: .manual, manualSpeed: 70) ), - [.setMode(mode)] + [.setManualSpeed(35), .setMode(mode)] ) } } @@ -40,6 +40,21 @@ final class HeadwindRestorationPolicyTests: XCTestCase { ) } + func testRestoresEveryManualSpeedExactly() { + for speed in stride(from: 0, through: 100, by: 5) { + XCTAssertEqual( + HeadwindRestorationPolicy.commands( + restoring: .init(mode: .manual, manualSpeed: speed), + from: .init( + mode: .heartRate, + manualSpeed: speed == 100 ? 95 : speed + 5 + ) + ), + [.setMode(.manual), .setManualSpeed(speed)] + ) + } + } + func testRestoringManualAvoidsRedundantCommands() { XCTAssertEqual( HeadwindRestorationPolicy.commands( @@ -67,15 +82,45 @@ final class HeadwindRestorationPolicyTests: XCTestCase { } } - func testNonManualStateIgnoresIrrelevantManualSpeed() { - XCTAssertTrue( - HeadwindState(mode: .off, manualSpeed: 10).matches( - .init(mode: .off, manualSpeed: 90) - ) + func testNonManualStateRestoresItsRememberedManualSpeedInOrder() { + let target = HeadwindState(mode: .off, manualSpeed: 10) + let commands = HeadwindRestorationPolicy.commands( + restoring: target, + from: .init(mode: .speed, manualSpeed: 90) ) + XCTAssertEqual( + commands, + [ + .setMode(.manual), + .setManualSpeed(10), + .setMode(.off), + ] + ) + + var observed = HeadwindState(mode: .speed, manualSpeed: 90) + for command in commands { + observed = observed.applying(command) + } + XCTAssertTrue(observed.matches(target)) } } +final class HeadwindShiftingPolicyTests: XCTestCase { + func testFailedStartReleasesFanControlButFailedStopDoesNotRepeatRelease() { + XCTAssertTrue( + HeadwindShiftingPolicy.shouldReleaseControl( + after: .starting(trainerNeedsWheelSizeReset: true) + ) + ) + XCTAssertFalse( + HeadwindShiftingPolicy.shouldReleaseControl( + after: .stopping(trainerNeedsWheelSizeReset: true) + ) + ) + XCTAssertFalse(HeadwindShiftingPolicy.shouldReleaseControl(after: nil)) + } +} + final class HeadwindControlLifecycleTests: XCTestCase { func testCapturesOnceAndDoesNotOverwriteBaseline() { var lifecycle = HeadwindControlLifecycle() @@ -179,18 +224,17 @@ final class HeadwindControlLifecycleTests: XCTestCase { lifecycle.beginRestoration() let reconnectedState = HeadwindState(mode: .manual, manualSpeed: 80) - XCTAssertEqual( - HeadwindRestorationPolicy.commands( - restoring: try XCTUnwrap(lifecycle.restorationTarget), - from: reconnectedState - ), - [.setMode(.sleep)] + let commands = HeadwindRestorationPolicy.commands( + restoring: try XCTUnwrap(lifecycle.restorationTarget), + from: reconnectedState ) + XCTAssertEqual(commands, [.setManualSpeed(15), .setMode(.sleep)]) XCTAssertFalse(lifecycle.finishRestoration(ifObserved: reconnectedState)) + let restored = commands.reduce(reconnectedState) { state, command in + state.applying(command) + } XCTAssertTrue( - lifecycle.finishRestoration( - ifObserved: reconnectedState.applying(.setMode(.sleep)) - ) + lifecycle.finishRestoration(ifObserved: restored) ) } @@ -205,7 +249,10 @@ final class HeadwindControlLifecycleTests: XCTestCase { restoring: try XCTUnwrap(lifecycle.restorationTarget), from: current ) - XCTAssertEqual(firstAttempt, [.setMode(.heartRate)]) + XCTAssertEqual( + firstAttempt, + [.setManualSpeed(20), .setMode(.heartRate)] + ) XCTAssertEqual(lifecycle.restorationTarget, baseline) XCTAssertEqual( HeadwindRestorationPolicy.commands( @@ -243,6 +290,60 @@ final class HeadwindControlLifecycleTests: XCTestCase { XCTAssertNil(lifecycle.beginRestoration()) XCTAssertEqual(lifecycle.phase, .idle) } + + func testCompletedLifecyclesCaptureFreshBaselines() { + var lifecycle = HeadwindControlLifecycle() + let first = HeadwindState(mode: .speed, manualSpeed: 25) + let second = HeadwindState(mode: .manual, manualSpeed: 70) + + XCTAssertTrue( + lifecycle.begin(observedState: first, hasUnsettledCommand: false) + ) + XCTAssertEqual(lifecycle.beginRestoration(), first) + XCTAssertTrue(lifecycle.finishRestoration(ifObserved: first)) + XCTAssertEqual(lifecycle.phase, .idle) + + XCTAssertTrue( + lifecycle.begin(observedState: second, hasUnsettledCommand: false) + ) + XCTAssertEqual(lifecycle.beginRestoration(), second) + XCTAssertTrue(lifecycle.finishRestoration(ifObserved: second)) + XCTAssertEqual(lifecycle.phase, .idle) + } + + func testReplacementWaitsForEveryExactBaselineBeforeChangingDevice() { + let baselines = [ + HeadwindState(mode: .off, manualSpeed: 10), + HeadwindState(mode: .sleep, manualSpeed: 45), + HeadwindState(mode: .manual, manualSpeed: 75), + ] + + for baseline in baselines { + var lifecycle = HeadwindControlLifecycle() + lifecycle.begin( + observedState: baseline, + hasUnsettledCommand: false + ) + XCTAssertEqual(lifecycle.beginRestoration(), baseline) + + var observed = HeadwindState(mode: .manual, manualSpeed: 100) + let commands = HeadwindRestorationPolicy.commands( + restoring: baseline, + from: observed + ) + for (index, command) in commands.enumerated() { + observed = observed.applying(command) + let isComplete = lifecycle.finishRestoration( + ifObserved: observed + ) + XCTAssertEqual(isComplete, index == commands.indices.last) + } + + XCTAssertEqual(lifecycle.phase, .idle) + lifecycle.deviceChanged(whileControlling: true) + XCTAssertTrue(lifecycle.isAwaitingBaseline) + } + } } extension HeadwindControlPolicyTests { diff --git a/VirtualGearsProduct/HeadwindCentralService.swift b/VirtualGearsProduct/HeadwindCentralService.swift index 277399c..977ef50 100644 --- a/VirtualGearsProduct/HeadwindCentralService.swift +++ b/VirtualGearsProduct/HeadwindCentralService.swift @@ -99,13 +99,7 @@ final class HeadwindCentralService: NSObject { func startScanning() { guard !isSuspendedForDemo else { return } if hasSavedDevice { - guard isReady else { - deferredAction = .scan - commandError = nil - resumeSavedConnection() - return - } - restoreSensors(then: .scan) + restoreState(then: .scan) return } scanWhenPoweredOn = true @@ -241,13 +235,7 @@ final class HeadwindCentralService: NSObject { /// Removing a connected fan is a state change, not just forgetting an ID. /// Manual mode survives disconnect, so sensor control must be confirmed first. func stopUsing() { - guard isReady else { - commandError = - "Reconnect the Headwind so Virtual Gears can return it to Sensors." - autoConnectSavedDevice() - return - } - restoreSensors(then: .remove) + restoreState(then: .remove) } /// Stops Bluetooth activity for Demo Mode without changing the remembered @@ -300,6 +288,28 @@ final class HeadwindCentralService: NSObject { enqueue(.setMode(lastSensorMode)) } + private func restoreState(then action: DeferredAction) { + deferredAction = action + commandError = nil + speedDebounceTask?.cancel() + commandQueue.removeAll() + if controlLifecycle.beginRestoration() != nil { + if isReady { + reconcileRestoration() + } else { + resumeSavedConnection() + } + return + } + requestedManual = false + persistControlPreference() + guard isReady else { + resumeSavedConnection() + return + } + restoreSensors(then: action) + } + private func enqueue( _ command: HeadwindCommand, replacingSpeed: Bool = false @@ -533,6 +543,12 @@ final class HeadwindCentralService: NSObject { if controlLifecycle.finishRestoration(ifObserved: observedState) { commandError = nil log("Restored Headwind state from before shifting") + if let deferredAction { + self.deferredAction = nil + requestedManual = false + persistControlPreference() + perform(deferredAction) + } } } diff --git a/VirtualGearsProduct/VirtualGearsHomeView.swift b/VirtualGearsProduct/VirtualGearsHomeView.swift index fb7b94f..244ef8f 100644 --- a/VirtualGearsProduct/VirtualGearsHomeView.swift +++ b/VirtualGearsProduct/VirtualGearsHomeView.swift @@ -43,6 +43,11 @@ struct VirtualGearsHomeView: View { guard !isDemoMode else { return } await discoverOptionalEquipment() } + .onChange(of: coordinator.failure, initial: true) { _, failure in + guard HeadwindShiftingPolicy.shouldReleaseControl(after: failure) + else { return } + headwind.releaseFanControl() + } } private func enterDemoMode() { diff --git a/VirtualGearsUITests/VirtualGearsUITests.swift b/VirtualGearsUITests/VirtualGearsUITests.swift index aa5457c..84b1133 100644 --- a/VirtualGearsUITests/VirtualGearsUITests.swift +++ b/VirtualGearsUITests/VirtualGearsUITests.swift @@ -454,13 +454,13 @@ final class VirtualGearsUITests: XCTestCase { ) { launch(waitingFixture) let waiting = app.buttons["Waiting for trainer"] - assertVisibleElement(waiting) + XCTAssertTrue(waiting.waitForExistence(timeout: 3)) let waitingFrame = waiting.frame app.terminate() launch(readyFixture) let ready = app.buttons["Start Shifting"] - assertVisibleElement(ready) + XCTAssertTrue(ready.waitForExistence(timeout: 3)) let readyFrame = ready.frame XCTAssertEqual( diff --git a/docs/requirements.md b/docs/requirements.md index 60cd2c5..d78388e 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -178,7 +178,9 @@ Starting shifting snapshots the Headwind state before Virtual Gears applies a saved manual preference. Stopping shifting restores that exact state and waits for every command to be confirmed, including both Manual mode and its previous speed when needed. A temporary disconnect keeps the restoration pending for the -fan's return. Bluetooth percentage commands in 5% steps produced audibly +fan's return. Switching or removing a Headwind during shifting completes the +same confirmed hand-back before disconnecting the old fan. Bluetooth percentage +commands in 5% steps produced audibly distinct fan-speed changes on physical hardware, even though the fan's own buttons expose four presets.