diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5aa8350..459ddcf 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,34 @@ 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, 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 +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 @@ -476,11 +497,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 +515,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..4cf0166 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) @@ -93,9 +95,13 @@ 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. +- **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..849b188 --- /dev/null +++ b/Sources/VirtualGearsCore/Diagnostics.swift @@ -0,0 +1,246 @@ +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 { + let summary = 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 + } + return DiagnosticsReport.redactingIdentifiers(in: summary) + } + + 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 { + 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" + + public static func make( + timestamp: Date, + app: AppIdentity, + operatingSystem: String, + device: String, + state: DiagnosticsState + ) -> String { + return redactingIdentifiers(in: [ + "\(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")) + } + + 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 { + 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/Sources/VirtualGearsCore/HeadwindControlPolicy.swift b/Sources/VirtualGearsCore/HeadwindControlPolicy.swift index 8afd943..63f666e 100644 --- a/Sources/VirtualGearsCore/HeadwindControlPolicy.swift +++ b/Sources/VirtualGearsCore/HeadwindControlPolicy.swift @@ -69,3 +69,145 @@ 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 && 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 [] } + var commands: [HeadwindCommand] = [] + 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 { + 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/DiagnosticsTests.swift b/Tests/VirtualGearsCoreTests/DiagnosticsTests.swift new file mode 100644 index 0000000..da71299 --- /dev/null +++ b/Tests/VirtualGearsCoreTests/DiagnosticsTests.swift @@ -0,0 +1,152 @@ +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 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( + 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/Tests/VirtualGearsCoreTests/HeadwindControlPolicyTests.swift b/Tests/VirtualGearsCoreTests/HeadwindControlPolicyTests.swift index 7de1b50..852d6b5 100644 --- a/Tests/VirtualGearsCoreTests/HeadwindControlPolicyTests.swift +++ b/Tests/VirtualGearsCoreTests/HeadwindControlPolicyTests.swift @@ -15,7 +15,338 @@ 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) + ), + [.setManualSpeed(35), .setMode(mode)] + ) + } + } + + func testRestoringManualModeAndSpeedUsesCorrectOrder() { + XCTAssertEqual( + HeadwindRestorationPolicy.commands( + restoring: .init(mode: .manual, manualSpeed: 35), + from: .init(mode: .heartRate, manualSpeed: 70) + ), + [.setMode(.manual), .setManualSpeed(35)] + ) + } + + 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( + 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 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() + 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) + 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: restored) + ) + } + + 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, + [.setManualSpeed(20), .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) + } + + 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 { func testStartingARideRestoresTheSavedManualSpeed() { let situation = HeadwindSituation( weAreDrivingTheFan: true, diff --git a/VirtualGearsProduct/HeadwindCentralService.swift b/VirtualGearsProduct/HeadwindCentralService.swift index 493d48f..977ef50 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 @@ -97,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 @@ -239,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 @@ -298,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 @@ -347,6 +359,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 +377,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 +432,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 +440,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 +454,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 +476,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 +511,53 @@ 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") + if let deferredAction { + self.deferredAction = nil + requestedManual = false + persistControlPreference() + perform(deferredAction) + } + } + } + private func commandConfirmed() { commandTimeoutTask?.cancel() pendingCommand = nil isCommandPending = false commandError = nil + finishRestorationIfComplete() sendNextCommand() } @@ -478,9 +566,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 +657,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 +673,7 @@ final class HeadwindCentralService: NSObject { isCommandPending = false controlCharacteristic = nil hasReceivedInitialState = false + hasAuthoritativeStateForConnection = false if !keepingPeripheral { peripheral = nil } } @@ -590,6 +682,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/SetupView.swift b/VirtualGearsProduct/SetupView.swift index 15be709..4526eb8 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,167 @@ 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: + DiagnosticsReport.clipboardExpiration(after: Date()), + ] + ) + 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 +1756,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..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, @@ -164,6 +167,7 @@ private struct ScreenshotFixtureView: View { kickr: kickr, click: click, headwind: headwind, + coordinator: coordinator, autoConnectsOnAppear: false ) } @@ -180,7 +184,10 @@ private struct ScreenshotFixtureView: View { } } .dynamicTypeSize( - scenario == .rideAccessibility ? .accessibility5 : .large + scenario == .rideAccessibility + || scenario == .startingAccessibility + || scenario == .readyAccessibility + ? .accessibility5 : .large ) .task { stage() @@ -209,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) @@ -224,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 e64645b..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() { @@ -161,11 +166,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 @@ -197,6 +199,7 @@ struct StartupView: View { kickr: kickr, click: click, headwind: headwind, + coordinator: coordinator, onFinish: { showsSettings = false } ) } @@ -259,12 +262,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." @@ -272,7 +288,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) @@ -347,15 +365,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( @@ -1195,6 +1204,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..84b1133 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") @@ -242,7 +256,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 +273,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() } } @@ -412,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"] + XCTAssertTrue(waiting.waitForExistence(timeout: 3)) + let waitingFrame = waiting.frame + app.terminate() + + launch(readyFixture) + let ready = app.buttons["Start Shifting"] + XCTAssertTrue(ready.waitForExistence(timeout: 3)) + 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 1bc461d..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 @@ -147,6 +149,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 +240,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,12 +326,28 @@ 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. +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/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/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 3d78c8d..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.
@@ -123,6 +124,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..d78388e 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.
@@ -168,6 +174,16 @@ 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. 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.
+
## 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 e6a585a..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
@@ -41,6 +45,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