Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 41 additions & 13 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
246 changes: 246 additions & 0 deletions Sources/VirtualGearsCore/Diagnostics.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
Comment on lines +160 to +168

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"
}
}
}
Loading