From 836ab1ee46e2d008ce2d613d80d998c0d6bd6a8b Mon Sep 17 00:00:00 2001 From: timohueser Date: Tue, 29 Sep 2026 00:11:09 +0200 Subject: [PATCH 01/17] feat: provision and identify the factory demo ride --- .gitignore | 1 + companion-ios/Packages/OBCKit/Package.swift | 1 + .../OBCKit/Sources/OBCDomain/Ride.swift | 6 +- .../OBCKit/Sources/OBCDomain/RideEdit.swift | 5 +- .../Sources/OBCDomain/RideLibrary.swift | 6 +- .../OBCFormats/Resources/grimsel-pass.gpx | 3008 +++++++++++++++++ .../OBCTransport/Codecs/RideCodec.swift | 7 +- .../Library/FileLibraryStore.swift | 4 +- .../OBCTransport/Library/RideViews.swift | 1 + .../Sources/OBCUI/Components/TrackRow.swift | 26 +- .../OBCUI/Detail/RouteDetailView.swift | 3 + .../Sources/OBCUI/Main/MainScreenModel.swift | 2 +- .../OBCUI/Main/RideSyncCoordinator.swift | 1 + .../OBCTransportTests/RideArchiveTests.swift | 6 +- .../RideCodecVectorTests.swift | 5 +- .../OBCTransportTests/RideEditTests.swift | 14 + .../OBCTransportTests/RideLibraryTests.swift | 11 + firmware/obc-formats/src/ride.rs | 14 +- firmware/obc-fw-nrf54l/Cargo.toml | 4 +- firmware/obc-fw-nrf54l/build.rs | 14 +- firmware/obc-fw-nrf54l/src/demo_rides.rs | 369 -- .../obc-fw-nrf54l/src/factory_demo_ride.rs | 112 + firmware/obc-fw-nrf54l/src/main.rs | 36 +- host/obc-host-core/tests/demo_rides.rs | 252 -- host/obc-host-core/tests/factory_demo_ride.rs | 127 + host/obc-replay/examples/gpx_to_ride.rs | 69 +- host/obc-vectors/src/lib.rs | 2 +- specs/vectors/ride-v6.bin | Bin 214 -> 214 bytes testing/suites.toml | 7 +- tools/justfile | 18 +- 30 files changed, 3427 insertions(+), 704 deletions(-) create mode 100644 companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx delete mode 100644 firmware/obc-fw-nrf54l/src/demo_rides.rs create mode 100644 firmware/obc-fw-nrf54l/src/factory_demo_ride.rs delete mode 100644 host/obc-host-core/tests/demo_rides.rs create mode 100644 host/obc-host-core/tests/factory_demo_ride.rs diff --git a/.gitignore b/.gitignore index 969693194..d441cd57c 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,7 @@ user_config.json !specs/vectors/*.gpx # ...and the iOS companion's bundled GPX import fixture (three OBCKit tests load it). !companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/sample-import.gpx +!companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx # ...and the captured route-import provider exports, the canonical copy of the same bytes. !fixtures/sources/route-import/*.gpx !fixtures/sources/kandel-ride/*.gpx diff --git a/companion-ios/Packages/OBCKit/Package.swift b/companion-ios/Packages/OBCKit/Package.swift index 9577eb222..8d721cc7a 100644 --- a/companion-ios/Packages/OBCKit/Package.swift +++ b/companion-ios/Packages/OBCKit/Package.swift @@ -47,6 +47,7 @@ let package = Package( .target( name: "OBCFormats", dependencies: ["OBCDomain"], + resources: [.process("Resources")], swiftSettings: languageMode ), .target( diff --git a/companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift b/companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift index 4fd182f2e..aeb157a00 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCDomain/Ride.swift @@ -156,6 +156,8 @@ public struct Ride: Identifiable, Equatable, Sendable { } public struct RideSummary: Identifiable, Equatable, Sendable { + /// A factory sample ride, shown in the library but excluded from totals. + public var isDemo: Bool public var source: RideSource? public let id: RideID public var name: String @@ -211,7 +213,8 @@ public struct RideSummary: Identifiable, Equatable, Sendable { bikeType: BikeType = .road, trip: RideTrip? = nil, zoneLimits: RideZoneLimits = .notSet, - source: RideSource? = nil + source: RideSource? = nil, + isDemo: Bool = false ) { self.id = id self.name = name @@ -232,6 +235,7 @@ public struct RideSummary: Identifiable, Equatable, Sendable { self.trip = trip self.zoneLimits = zoneLimits self.source = source + self.isDemo = isDemo } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideEdit.swift b/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideEdit.swift index f2975f982..ef05a5cb9 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideEdit.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideEdit.swift @@ -78,7 +78,8 @@ public enum RideEdit { /// starts within `maxGapMeters` and `maxGapSeconds` of where the first ended, on the same /// trip day or both without a trip. public static func suggestsMerge(_ first: Ride, _ second: Ride) -> Bool { - guard let end = first.points.last, let start = second.points.first else { return false } + guard first.summary.isDemo == second.summary.isDemo, + let end = first.points.last, let start = second.points.first else { return false } let gap = start.timestamp.timeIntervalSince(end.timestamp) guard gap >= 0, gap <= maxGapSeconds, end.coordinate.routeDistance(to: start.coordinate) <= maxGapMeters @@ -165,7 +166,7 @@ extension RideSummary { climbMeters: climb, descentMeters: descent, trackPreview: TrackPreview.normalizing(points.map(\.coordinate)), avgHeartRate: heartRate.average, maxHeartRate: heartRate.max, avgCadence: cadence.average, avgPower: power.average, maxPower: power.max, energyKJ: joules.map { Int($0 / 1_000) }, - bikeType: bikeType, trip: trip, zoneLimits: zoneLimits + bikeType: bikeType, trip: trip, zoneLimits: zoneLimits, isDemo: isDemo ) } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideLibrary.swift b/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideLibrary.swift index 2181fa81e..f4ba0a530 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideLibrary.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCDomain/RideLibrary.swift @@ -30,7 +30,7 @@ public struct RideFilter: Equatable, Sendable { } /// Sums over rides. The input is the rides as the list shows them, so an edited ride counts once, -/// with its edited values and bike type. +/// with its edited values and bike type. Factory demo rides do not count. public struct RideTotals: Equatable, Sendable { public var rideCount = 0 public var distanceMeters = 0.0 @@ -39,7 +39,7 @@ public struct RideTotals: Equatable, Sendable { public var climbMeters = 0.0 public init(_ rides: some Sequence) { - for ride in rides { + for ride in rides where !ride.isDemo { rideCount += 1 distanceMeters += ride.distanceMeters movingTime += ride.movingTime @@ -52,7 +52,7 @@ public struct RideTotals: Equatable, Sendable { public static func byBikeType( _ rides: some Sequence ) -> [(bikeType: BikeType, totals: RideTotals)] { - let groups = Dictionary(grouping: rides, by: \.bikeType) + let groups = Dictionary(grouping: rides.filter { !$0.isDemo }, by: \.bikeType) return BikeType.allCases.compactMap { type in groups[type].map { (type, RideTotals($0)) } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx b/companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx new file mode 100644 index 000000000..e59461696 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx @@ -0,0 +1,3008 @@ + + + Grimsel Pass + 1063.1 + 1063.2 + 1063.7 + 1063.8 + 1064.1 + 1064.1 + 1064.5 + 1064.8 + 1065.0 + 1065.1 + 1065.2 + 1065.4 + 1065.8 + 1066.1 + 1067.4 + 1067.8 + 1068.0 + 1068.6 + 1069.6 + 1069.7 + 1070.0 + 1070.6 + 1070.9 + 1071.2 + 1071.9 + 1072.1 + 1072.9 + 1073.3 + 1073.6 + 1074.0 + 1074.4 + 1074.7 + 1075.1 + 1075.2 + 1075.3 + 1075.8 + 1076.0 + 1076.3 + 1076.8 + 1077.6 + 1078.2 + 1078.5 + 1078.8 + 1079.2 + 1079.4 + 1079.5 + 1079.8 + 1080.0 + 1080.4 + 1080.8 + 1081.2 + 1081.5 + 1082.1 + 1082.4 + 1082.8 + 1083.1 + 1083.4 + 1083.6 + 1084.0 + 1084.5 + 1085.1 + 1085.5 + 1086.2 + 1086.8 + 1087.8 + 1087.9 + 1088.8 + 1089.6 + 1089.6 + 1089.7 + 1089.7 + 1089.7 + 1089.8 + 1089.8 + 1089.8 + 1089.8 + 1089.9 + 1089.9 + 1089.9 + 1089.9 + 1090.0 + 1090.0 + 1090.0 + 1090.0 + 1090.1 + 1090.2 + 1090.3 + 1090.4 + 1090.5 + 1090.5 + 1090.6 + 1090.7 + 1090.8 + 1090.9 + 1091.0 + 1091.1 + 1091.1 + 1091.4 + 1092.3 + 1092.5 + 1093.1 + 1093.4 + 1093.5 + 1094.3 + 1094.4 + 1095.2 + 1096.1 + 1097.2 + 1097.3 + 1098.1 + 1098.3 + 1098.5 + 1098.7 + 1099.4 + 1100.2 + 1100.5 + 1100.9 + 1101.6 + 1102.3 + 1102.6 + 1102.9 + 1103.4 + 1103.5 + 1103.6 + 1104.2 + 1104.6 + 1104.8 + 1105.6 + 1105.7 + 1106.3 + 1106.9 + 1107.0 + 1107.7 + 1108.1 + 1108.5 + 1108.9 + 1109.3 + 1109.4 + 1109.5 + 1109.7 + 1109.8 + 1109.9 + 1110.3 + 1110.8 + 1111.0 + 1111.7 + 1111.9 + 1112.2 + 1112.4 + 1113.0 + 1113.4 + 1114.2 + 1114.6 + 1115.2 + 1115.6 + 1116.2 + 1116.3 + 1117.2 + 1117.3 + 1118.0 + 1118.2 + 1118.6 + 1118.9 + 1119.1 + 1119.6 + 1120.2 + 1120.4 + 1121.0 + 1121.1 + 1121.2 + 1121.8 + 1122.1 + 1122.3 + 1122.8 + 1123.3 + 1123.6 + 1124.3 + 1125.3 + 1125.6 + 1126.0 + 1126.1 + 1126.7 + 1127.0 + 1127.8 + 1128.5 + 1128.9 + 1129.0 + 1129.4 + 1129.7 + 1130.1 + 1130.3 + 1131.5 + 1131.9 + 1132.2 + 1133.0 + 1133.2 + 1133.7 + 1134.3 + 1134.9 + 1135.3 + 1135.7 + 1136.1 + 1136.4 + 1136.8 + 1137.4 + 1138.2 + 1138.8 + 1139.5 + 1139.7 + 1140.1 + 1140.7 + 1140.8 + 1141.3 + 1141.7 + 1141.9 + 1142.5 + 1142.9 + 1143.5 + 1143.6 + 1143.9 + 1143.9 + 1144.3 + 1144.7 + 1145.1 + 1145.3 + 1145.5 + 1146.1 + 1146.2 + 1146.8 + 1147.0 + 1147.6 + 1148.1 + 1148.4 + 1149.2 + 1149.4 + 1149.8 + 1150.4 + 1151.1 + 1151.6 + 1151.7 + 1152.1 + 1152.7 + 1152.8 + 1153.2 + 1153.6 + 1153.9 + 1154.7 + 1154.8 + 1155.4 + 1155.8 + 1155.9 + 1156.1 + 1156.8 + 1157.0 + 1157.7 + 1158.2 + 1158.6 + 1159.1 + 1159.3 + 1160.2 + 1160.3 + 1161.2 + 1161.4 + 1162.2 + 1162.6 + 1163.1 + 1163.6 + 1163.8 + 1164.3 + 1164.6 + 1164.9 + 1165.5 + 1165.7 + 1166.3 + 1166.6 + 1166.9 + 1167.5 + 1167.6 + 1168.4 + 1168.5 + 1168.7 + 1169.3 + 1169.5 + 1170.1 + 1170.5 + 1170.7 + 1171.5 + 1171.6 + 1172.3 + 1172.4 + 1173.0 + 1173.1 + 1173.2 + 1173.4 + 1174.1 + 1174.5 + 1174.7 + 1175.3 + 1175.4 + 1175.8 + 1176.0 + 1176.5 + 1177.1 + 1177.2 + 1177.9 + 1178.2 + 1178.5 + 1179.3 + 1179.4 + 1180.3 + 1180.7 + 1181.2 + 1181.6 + 1181.9 + 1182.4 + 1183.0 + 1183.1 + 1183.8 + 1184.1 + 1184.9 + 1185.0 + 1185.4 + 1185.7 + 1185.8 + 1186.1 + 1186.5 + 1187.2 + 1187.4 + 1187.8 + 1188.1 + 1188.4 + 1188.4 + 1188.6 + 1188.7 + 1189.1 + 1189.2 + 1189.9 + 1190.5 + 1190.9 + 1191.8 + 1192.3 + 1192.9 + 1193.4 + 1193.8 + 1194.4 + 1194.8 + 1195.5 + 1195.8 + 1196.7 + 1196.9 + 1197.6 + 1197.7 + 1198.2 + 1198.7 + 1198.9 + 1199.7 + 1199.8 + 1199.9 + 1200.7 + 1200.8 + 1201.7 + 1201.9 + 1202.5 + 1202.7 + 1203.2 + 1203.7 + 1203.9 + 1204.7 + 1205.1 + 1205.3 + 1205.5 + 1205.8 + 1205.9 + 1206.3 + 1206.4 + 1207.1 + 1207.2 + 1207.6 + 1208.1 + 1208.3 + 1209.1 + 1209.5 + 1209.7 + 1209.9 + 1210.3 + 1210.6 + 1210.9 + 1211.4 + 1212.3 + 1213.0 + 1213.4 + 1213.8 + 1214.3 + 1214.5 + 1215.0 + 1215.1 + 1215.7 + 1216.0 + 1216.3 + 1216.7 + 1217.1 + 1217.1 + 1217.3 + 1217.4 + 1217.5 + 1217.6 + 1217.8 + 1218.6 + 1219.1 + 1219.4 + 1219.6 + 1220.0 + 1220.4 + 1220.5 + 1220.8 + 1220.9 + 1221.4 + 1221.5 + 1221.6 + 1221.8 + 1222.1 + 1222.6 + 1222.7 + 1222.7 + 1222.8 + 1222.8 + 1222.8 + 1223.1 + 1223.5 + 1223.9 + 1224.2 + 1224.7 + 1224.8 + 1225.1 + 1225.8 + 1226.0 + 1226.6 + 1226.7 + 1227.1 + 1227.9 + 1228.0 + 1228.6 + 1229.1 + 1229.2 + 1229.8 + 1230.1 + 1230.9 + 1231.1 + 1231.7 + 1232.2 + 1232.4 + 1233.0 + 1233.1 + 1233.4 + 1233.6 + 1233.8 + 1234.2 + 1234.3 + 1234.3 + 1234.6 + 1235.5 + 1235.7 + 1236.4 + 1236.5 + 1236.7 + 1237.5 + 1237.7 + 1238.6 + 1239.5 + 1239.6 + 1240.3 + 1240.7 + 1240.9 + 1241.1 + 1241.2 + 1241.6 + 1242.0 + 1242.5 + 1242.7 + 1242.7 + 1242.9 + 1243.0 + 1243.1 + 1244.3 + 1245.4 + 1245.6 + 1247.3 + 1248.1 + 1249.8 + 1251.4 + 1251.7 + 1252.7 + 1253.0 + 1255.1 + 1255.3 + 1256.3 + 1257.2 + 1257.7 + 1258.1 + 1258.7 + 1260.2 + 1260.5 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1261.7 + 1280.9 + 1281.3 + 1281.4 + 1281.7 + 1281.8 + 1282.3 + 1282.4 + 1282.7 + 1282.9 + 1283.1 + 1283.4 + 1283.5 + 1283.7 + 1284.0 + 1284.5 + 1284.7 + 1285.0 + 1285.4 + 1285.5 + 1285.8 + 1285.9 + 1286.3 + 1286.5 + 1286.6 + 1287.0 + 1287.3 + 1287.5 + 1287.7 + 1288.3 + 1288.3 + 1288.9 + 1289.2 + 1289.4 + 1290.0 + 1290.1 + 1290.2 + 1290.6 + 1291.2 + 1291.3 + 1291.8 + 1292.1 + 1292.3 + 1292.8 + 1293.0 + 1293.1 + 1293.8 + 1294.3 + 1294.5 + 1295.0 + 1295.8 + 1296.5 + 1297.2 + 1297.4 + 1297.9 + 1298.5 + 1298.9 + 1299.3 + 1299.8 + 1299.9 + 1300.6 + 1300.8 + 1301.2 + 1301.5 + 1301.9 + 1302.2 + 1302.7 + 1303.4 + 1303.5 + 1304.3 + 1305.0 + 1305.3 + 1305.6 + 1306.2 + 1306.3 + 1307.0 + 1307.2 + 1307.3 + 1307.6 + 1307.9 + 1308.5 + 1309.3 + 1309.6 + 1309.9 + 1310.4 + 1311.1 + 1311.7 + 1312.1 + 1312.3 + 1313.2 + 1313.8 + 1314.1 + 1314.6 + 1314.8 + 1315.3 + 1315.7 + 1316.0 + 1316.8 + 1316.9 + 1317.5 + 1317.9 + 1318.0 + 1318.1 + 1318.4 + 1318.7 + 1318.8 + 1319.4 + 1319.5 + 1320.2 + 1320.4 + 1320.7 + 1321.5 + 1321.6 + 1322.3 + 1322.4 + 1323.3 + 1324.1 + 1324.4 + 1324.7 + 1325.1 + 1325.8 + 1326.2 + 1326.5 + 1327.1 + 1327.5 + 1328.0 + 1328.3 + 1328.7 + 1329.5 + 1330.4 + 1330.6 + 1331.1 + 1331.4 + 1331.8 + 1332.5 + 1333.4 + 1334.0 + 1334.1 + 1334.5 + 1334.8 + 1334.9 + 1335.1 + 1335.4 + 1336.0 + 1336.2 + 1336.3 + 1336.5 + 1336.6 + 1337.1 + 1337.3 + 1337.5 + 1337.9 + 1338.2 + 1338.6 + 1339.3 + 1339.4 + 1339.9 + 1340.4 + 1340.7 + 1341.3 + 1341.7 + 1342.1 + 1342.5 + 1342.6 + 1343.3 + 1343.6 + 1344.1 + 1344.9 + 1345.2 + 1345.6 + 1346.2 + 1347.2 + 1348.6 + 1348.8 + 1349.6 + 1350.0 + 1350.5 + 1351.2 + 1351.4 + 1352.0 + 1352.4 + 1353.1 + 1353.7 + 1354.4 + 1354.7 + 1355.5 + 1356.7 + 1357.2 + 1357.8 + 1358.1 + 1358.5 + 1358.6 + 1359.4 + 1360.2 + 1360.7 + 1361.2 + 1361.9 + 1362.2 + 1362.5 + 1362.5 + 1362.5 + 1362.5 + 1362.5 + 1362.5 + 1362.7 + 1362.7 + 1362.9 + 1363.3 + 1363.5 + 1363.7 + 1364.0 + 1364.2 + 1364.6 + 1364.7 + 1365.0 + 1365.1 + 1365.2 + 1365.4 + 1365.4 + 1365.4 + 1365.4 + 1365.4 + 1365.4 + 1365.4 + 1365.4 + 1365.4 + 1365.4 + 1365.4 + 1365.4 + 1365.4 + 1365.4 + 1376.2 + 1376.7 + 1376.8 + 1377.3 + 1377.4 + 1377.6 + 1377.9 + 1378.1 + 1378.9 + 1379.3 + 1379.6 + 1380.3 + 1380.5 + 1381.0 + 1381.4 + 1382.0 + 1382.1 + 1382.7 + 1382.8 + 1382.9 + 1383.1 + 1383.3 + 1383.6 + 1384.1 + 1384.2 + 1384.8 + 1385.2 + 1385.6 + 1386.0 + 1386.3 + 1386.9 + 1387.1 + 1387.2 + 1387.5 + 1388.0 + 1388.1 + 1389.0 + 1389.1 + 1389.7 + 1390.0 + 1390.4 + 1390.8 + 1391.0 + 1391.7 + 1391.8 + 1392.3 + 1392.4 + 1392.9 + 1393.2 + 1393.6 + 1393.9 + 1394.0 + 1394.5 + 1394.6 + 1394.7 + 1395.3 + 1395.5 + 1395.7 + 1395.9 + 1395.9 + 1396.7 + 1397.2 + 1397.6 + 1397.9 + 1398.4 + 1399.1 + 1399.2 + 1400.2 + 1400.3 + 1400.8 + 1401.6 + 1401.7 + 1401.8 + 1401.9 + 1402.4 + 1402.7 + 1403.0 + 1403.6 + 1403.7 + 1404.0 + 1404.2 + 1404.3 + 1404.7 + 1404.7 + 1405.0 + 1405.3 + 1405.4 + 1405.5 + 1405.7 + 1406.1 + 1406.1 + 1406.4 + 1406.5 + 1406.6 + 1406.8 + 1407.3 + 1407.4 + 1407.8 + 1408.1 + 1408.4 + 1408.9 + 1409.3 + 1409.7 + 1410.0 + 1410.1 + 1410.2 + 1410.2 + 1410.4 + 1410.6 + 1410.7 + 1410.9 + 1411.2 + 1411.4 + 1411.6 + 1411.7 + 1412.0 + 1412.2 + 1412.5 + 1412.9 + 1413.3 + 1413.7 + 1414.0 + 1414.0 + 1414.3 + 1414.5 + 1414.7 + 1415.1 + 1415.1 + 1415.4 + 1415.7 + 1416.4 + 1417.6 + 1417.9 + 1418.8 + 1419.5 + 1420.0 + 1420.1 + 1420.4 + 1421.1 + 1421.2 + 1421.8 + 1421.8 + 1422.4 + 1422.6 + 1422.9 + 1423.3 + 1423.4 + 1423.8 + 1423.9 + 1424.4 + 1424.7 + 1425.5 + 1426.5 + 1427.3 + 1427.5 + 1428.1 + 1428.7 + 1428.9 + 1429.7 + 1429.9 + 1430.5 + 1430.6 + 1431.3 + 1431.9 + 1432.2 + 1432.8 + 1433.1 + 1433.8 + 1434.0 + 1434.9 + 1435.8 + 1435.9 + 1436.5 + 1437.3 + 1437.5 + 1438.1 + 1438.4 + 1438.9 + 1439.3 + 1439.6 + 1440.2 + 1440.8 + 1441.0 + 1441.3 + 1441.7 + 1442.4 + 1443.1 + 1443.4 + 1444.1 + 1444.5 + 1444.9 + 1445.6 + 1445.9 + 1446.5 + 1447.1 + 1447.4 + 1448.0 + 1448.2 + 1448.9 + 1449.4 + 1449.6 + 1450.2 + 1450.4 + 1451.0 + 1451.2 + 1451.7 + 1451.9 + 1452.4 + 1452.8 + 1453.1 + 1453.7 + 1453.8 + 1454.4 + 1454.7 + 1455.2 + 1455.4 + 1455.7 + 1456.2 + 1456.6 + 1456.9 + 1457.1 + 1457.7 + 1458.3 + 1458.5 + 1459.2 + 1459.9 + 1460.0 + 1460.2 + 1460.9 + 1461.2 + 1461.7 + 1462.3 + 1462.4 + 1463.2 + 1463.5 + 1463.9 + 1464.5 + 1464.9 + 1465.5 + 1465.6 + 1466.1 + 1466.4 + 1466.7 + 1467.2 + 1467.7 + 1468.4 + 1468.8 + 1469.6 + 1469.8 + 1470.4 + 1470.7 + 1471.1 + 1471.3 + 1471.7 + 1472.2 + 1472.8 + 1473.2 + 1473.6 + 1473.9 + 1474.3 + 1474.7 + 1474.8 + 1475.5 + 1476.4 + 1477.2 + 1477.8 + 1478.3 + 1478.7 + 1479.3 + 1480.0 + 1480.3 + 1480.5 + 1480.7 + 1480.9 + 1481.2 + 1481.5 + 1481.7 + 1482.2 + 1482.8 + 1483.4 + 1483.7 + 1483.9 + 1484.1 + 1484.7 + 1484.8 + 1484.9 + 1485.1 + 1485.1 + 1485.1 + 1485.1 + 1485.1 + 1485.1 + 1485.1 + 1485.1 + 1485.1 + 1485.1 + 1491.3 + 1491.4 + 1491.4 + 1491.8 + 1492.0 + 1492.2 + 1492.7 + 1493.2 + 1493.5 + 1493.6 + 1493.7 + 1494.1 + 1494.6 + 1494.9 + 1495.6 + 1495.9 + 1496.2 + 1496.8 + 1497.4 + 1497.7 + 1498.1 + 1498.6 + 1498.9 + 1499.5 + 1499.6 + 1500.0 + 1500.3 + 1500.4 + 1500.5 + 1500.5 + 1500.6 + 1500.6 + 1500.7 + 1500.7 + 1500.8 + 1500.9 + 1500.9 + 1500.9 + 1501.0 + 1501.0 + 1501.0 + 1501.1 + 1501.3 + 1501.4 + 1501.5 + 1501.5 + 1501.5 + 1501.5 + 1501.7 + 1501.7 + 1501.8 + 1502.0 + 1502.0 + 1502.1 + 1502.1 + 1502.2 + 1502.2 + 1502.3 + 1502.4 + 1502.4 + 1502.6 + 1502.7 + 1502.7 + 1502.7 + 1502.7 + 1503.0 + 1503.4 + 1503.9 + 1504.5 + 1504.8 + 1504.9 + 1505.7 + 1506.0 + 1506.6 + 1507.0 + 1507.2 + 1507.3 + 1507.6 + 1508.0 + 1508.2 + 1508.5 + 1509.2 + 1510.0 + 1510.3 + 1510.6 + 1511.1 + 1511.4 + 1511.5 + 1511.7 + 1512.3 + 1512.3 + 1513.1 + 1513.2 + 1513.6 + 1513.9 + 1514.0 + 1514.2 + 1514.5 + 1514.7 + 1514.8 + 1515.4 + 1515.7 + 1516.1 + 1516.7 + 1516.8 + 1516.8 + 1516.8 + 1517.1 + 1517.3 + 1517.5 + 1517.9 + 1518.3 + 1518.9 + 1519.2 + 1519.4 + 1519.5 + 1519.7 + 1520.0 + 1520.4 + 1520.5 + 1521.0 + 1521.5 + 1522.0 + 1522.3 + 1522.9 + 1523.0 + 1523.8 + 1523.9 + 1524.0 + 1524.7 + 1525.0 + 1525.5 + 1525.9 + 1526.0 + 1526.8 + 1526.9 + 1527.5 + 1528.0 + 1528.2 + 1528.3 + 1529.0 + 1529.3 + 1529.7 + 1530.2 + 1530.5 + 1530.6 + 1531.2 + 1531.4 + 1531.6 + 1531.7 + 1532.5 + 1533.0 + 1533.5 + 1533.7 + 1534.4 + 1534.5 + 1535.2 + 1535.3 + 1536.0 + 1536.4 + 1537.3 + 1537.7 + 1537.9 + 1538.3 + 1538.6 + 1538.8 + 1539.2 + 1539.3 + 1539.5 + 1539.9 + 1540.1 + 1540.2 + 1540.3 + 1540.5 + 1540.8 + 1540.9 + 1541.0 + 1541.5 + 1541.8 + 1542.3 + 1543.0 + 1543.1 + 1543.9 + 1544.2 + 1544.8 + 1544.9 + 1545.5 + 1545.7 + 1546.3 + 1546.5 + 1546.7 + 1547.1 + 1547.7 + 1547.9 + 1548.4 + 1548.5 + 1549.1 + 1549.3 + 1549.9 + 1550.7 + 1550.9 + 1551.0 + 1551.0 + 1551.0 + 1551.4 + 1551.8 + 1551.9 + 1552.1 + 1553.0 + 1553.5 + 1553.6 + 1554.0 + 1554.4 + 1554.5 + 1555.2 + 1555.5 + 1555.9 + 1556.2 + 1556.4 + 1557.2 + 1557.5 + 1557.8 + 1557.8 + 1557.8 + 1557.8 + 1557.8 + 1557.8 + 1557.8 + 1557.8 + 1557.8 + 1562.2 + 1562.3 + 1562.8 + 1563.2 + 1564.0 + 1564.5 + 1564.7 + 1565.0 + 1565.3 + 1565.4 + 1565.7 + 1565.8 + 1566.0 + 1566.4 + 1567.0 + 1567.3 + 1567.4 + 1567.5 + 1568.2 + 1568.3 + 1568.4 + 1568.9 + 1569.3 + 1569.9 + 1570.5 + 1570.9 + 1571.4 + 1572.2 + 1572.5 + 1572.7 + 1573.5 + 1574.1 + 1574.6 + 1575.4 + 1575.5 + 1575.6 + 1575.9 + 1576.0 + 1576.4 + 1577.1 + 1577.2 + 1577.5 + 1577.8 + 1577.9 + 1578.4 + 1579.0 + 1579.0 + 1579.4 + 1579.7 + 1579.7 + 1580.0 + 1580.2 + 1580.3 + 1581.1 + 1581.2 + 1582.0 + 1582.2 + 1582.9 + 1583.1 + 1583.9 + 1584.4 + 1584.6 + 1585.2 + 1585.8 + 1586.3 + 1587.0 + 1587.5 + 1587.7 + 1588.2 + 1588.8 + 1589.6 + 1590.0 + 1590.6 + 1590.9 + 1591.5 + 1591.9 + 1592.9 + 1593.9 + 1594.7 + 1595.0 + 1595.3 + 1596.1 + 1596.8 + 1597.5 + 1598.1 + 1598.4 + 1598.9 + 1599.1 + 1599.2 + 1599.6 + 1599.7 + 1600.1 + 1600.6 + 1600.7 + 1601.1 + 1601.3 + 1601.6 + 1602.1 + 1602.3 + 1602.7 + 1603.2 + 1603.6 + 1604.2 + 1604.6 + 1604.7 + 1604.9 + 1605.3 + 1605.7 + 1606.2 + 1606.5 + 1606.6 + 1607.1 + 1607.6 + 1607.8 + 1608.0 + 1608.3 + 1608.8 + 1609.5 + 1609.7 + 1610.4 + 1610.6 + 1611.3 + 1611.8 + 1612.0 + 1612.6 + 1612.8 + 1613.7 + 1614.0 + 1614.8 + 1615.0 + 1615.6 + 1615.9 + 1616.2 + 1617.0 + 1617.2 + 1617.9 + 1618.0 + 1618.8 + 1619.1 + 1619.9 + 1620.0 + 1620.7 + 1621.3 + 1621.6 + 1622.5 + 1622.6 + 1623.3 + 1624.0 + 1624.1 + 1624.7 + 1625.4 + 1625.6 + 1626.0 + 1626.5 + 1627.0 + 1627.2 + 1627.5 + 1628.2 + 1628.8 + 1629.2 + 1630.0 + 1630.2 + 1630.3 + 1631.0 + 1631.5 + 1631.8 + 1632.4 + 1633.3 + 1633.8 + 1634.2 + 1634.9 + 1635.1 + 1635.8 + 1636.1 + 1636.8 + 1637.1 + 1637.7 + 1637.9 + 1638.4 + 1638.9 + 1639.2 + 1639.9 + 1640.0 + 1640.5 + 1641.2 + 1641.3 + 1641.5 + 1641.6 + 1641.7 + 1642.1 + 1642.4 + 1642.6 + 1642.7 + 1643.7 + 1643.8 + 1644.8 + 1644.9 + 1645.6 + 1645.7 + 1646.4 + 1646.8 + 1647.2 + 1648.2 + 1648.4 + 1648.9 + 1649.3 + 1649.4 + 1650.1 + 1650.2 + 1650.6 + 1651.2 + 1651.8 + 1652.2 + 1652.7 + 1652.8 + 1653.1 + 1653.3 + 1653.7 + 1653.8 + 1654.3 + 1654.4 + 1655.1 + 1655.3 + 1655.9 + 1656.5 + 1656.7 + 1657.4 + 1657.5 + 1658.3 + 1659.2 + 1659.3 + 1660.2 + 1660.6 + 1661.1 + 1661.4 + 1662.0 + 1662.3 + 1662.6 + 1663.1 + 1663.3 + 1664.0 + 1664.0 + 1665.0 + 1665.1 + 1665.9 + 1666.1 + 1666.7 + 1666.9 + 1667.6 + 1668.1 + 1668.3 + 1668.8 + 1668.9 + 1669.1 + 1669.5 + 1670.3 + 1671.0 + 1671.3 + 1671.7 + 1672.1 + 1672.5 + 1673.1 + 1673.4 + 1673.7 + 1674.2 + 1674.8 + 1675.0 + 1675.7 + 1676.2 + 1676.5 + 1676.7 + 1677.1 + 1677.8 + 1678.0 + 1678.3 + 1678.8 + 1679.5 + 1679.7 + 1680.1 + 1680.4 + 1680.8 + 1681.6 + 1682.2 + 1682.4 + 1683.1 + 1683.7 + 1684.2 + 1684.3 + 1684.4 + 1684.9 + 1685.1 + 1685.6 + 1686.1 + 1686.4 + 1686.7 + 1687.6 + 1687.8 + 1688.3 + 1688.4 + 1689.2 + 1689.7 + 1689.8 + 1690.2 + 1690.6 + 1690.9 + 1691.2 + 1691.5 + 1691.9 + 1692.7 + 1693.3 + 1693.4 + 1694.0 + 1694.1 + 1694.2 + 1694.5 + 1694.7 + 1695.3 + 1695.5 + 1696.3 + 1696.4 + 1696.9 + 1697.4 + 1697.6 + 1697.7 + 1698.2 + 1698.4 + 1698.6 + 1699.4 + 1700.2 + 1700.6 + 1701.1 + 1701.6 + 1701.8 + 1702.7 + 1702.8 + 1703.3 + 1703.6 + 1703.9 + 1704.6 + 1705.1 + 1705.7 + 1706.0 + 1706.5 + 1707.1 + 1707.4 + 1708.0 + 1708.4 + 1708.8 + 1709.5 + 1709.8 + 1710.6 + 1711.0 + 1711.5 + 1712.1 + 1712.4 + 1712.7 + 1713.3 + 1713.5 + 1714.0 + 1714.1 + 1714.7 + 1714.9 + 1715.0 + 1715.6 + 1715.8 + 1716.0 + 1716.2 + 1716.6 + 1716.9 + 1717.0 + 1717.3 + 1717.5 + 1717.6 + 1717.7 + 1717.8 + 1717.9 + 1718.1 + 1718.7 + 1718.8 + 1719.8 + 1719.9 + 1720.8 + 1721.0 + 1721.7 + 1722.1 + 1722.6 + 1723.1 + 1723.2 + 1723.8 + 1724.1 + 1724.6 + 1725.2 + 1725.8 + 1726.1 + 1726.8 + 1727.8 + 1728.3 + 1728.7 + 1729.2 + 1729.3 + 1729.3 + 1729.3 + 1729.3 + 1729.4 + 1729.4 + 1729.4 + 1729.4 + 1729.4 + 1729.4 + 1729.7 + 1729.7 + 1730.0 + 1730.3 + 1731.2 + 1732.1 + 1732.6 + 1733.0 + 1733.8 + 1734.1 + 1735.1 + 1736.1 + 1736.4 + 1736.8 + 1737.4 + 1737.5 + 1737.8 + 1738.2 + 1738.8 + 1738.9 + 1739.7 + 1739.8 + 1740.3 + 1740.9 + 1741.0 + 1741.7 + 1741.8 + 1742.6 + 1742.9 + 1743.3 + 1743.9 + 1744.1 + 1744.9 + 1745.0 + 1745.6 + 1745.9 + 1746.1 + 1746.2 + 1746.8 + 1747.1 + 1747.6 + 1748.3 + 1748.6 + 1749.2 + 1749.5 + 1750.0 + 1750.1 + 1750.2 + 1751.0 + 1751.5 + 1751.6 + 1751.8 + 1752.3 + 1752.8 + 1753.0 + 1753.4 + 1753.6 + 1753.6 + 1753.8 + 1754.1 + 1754.2 + 1754.4 + 1754.6 + 1754.7 + 1754.9 + 1755.0 + 1755.1 + 1755.6 + 1755.7 + 1755.9 + 1756.0 + 1756.2 + 1756.4 + 1756.8 + 1756.9 + 1757.6 + 1757.7 + 1758.1 + 1758.7 + 1758.8 + 1759.3 + 1759.4 + 1759.6 + 1759.8 + 1760.2 + 1760.3 + 1760.7 + 1760.9 + 1761.0 + 1761.4 + 1761.6 + 1762.1 + 1762.6 + 1763.1 + 1763.2 + 1763.3 + 1763.3 + 1763.4 + 1763.4 + 1763.4 + 1763.4 + 1763.4 + 1763.4 + 1763.5 + 1763.5 + 1763.5 + 1763.5 + 1763.5 + 1763.5 + 1763.5 + 1763.6 + 1763.6 + 1763.6 + 1763.6 + 1763.7 + 1763.7 + 1763.7 + 1763.7 + 1763.7 + 1763.8 + 1763.8 + 1763.7 + 1763.6 + 1763.5 + 1763.5 + 1763.5 + 1763.4 + 1763.3 + 1763.3 + 1763.2 + 1763.2 + 1763.1 + 1763.0 + 1762.9 + 1762.8 + 1762.7 + 1762.7 + 1762.5 + 1762.4 + 1762.4 + 1762.4 + 1762.4 + 1762.4 + 1762.4 + 1762.6 + 1762.7 + 1762.7 + 1762.7 + 1762.8 + 1762.8 + 1762.8 + 1762.9 + 1762.9 + 1763.1 + 1763.1 + 1763.1 + 1763.1 + 1763.1 + 1763.1 + 1763.1 + 1763.1 + 1763.1 + 1763.1 + 1763.2 + 1763.2 + 1763.3 + 1763.3 + 1763.4 + 1763.4 + 1763.7 + 1764.1 + 1764.3 + 1764.6 + 1764.6 + 1764.6 + 1764.6 + 1765.0 + 1765.2 + 1765.3 + 1765.4 + 1765.4 + 1765.6 + 1765.8 + 1765.8 + 1765.9 + 1766.6 + 1767.2 + 1768.7 + 1768.9 + 1769.4 + 1769.7 + 1770.4 + 1770.7 + 1771.1 + 1771.5 + 1771.9 + 1772.2 + 1772.5 + 1772.8 + 1773.2 + 1773.7 + 1773.9 + 1774.5 + 1774.6 + 1775.1 + 1775.5 + 1775.7 + 1776.3 + 1777.1 + 1777.2 + 1777.5 + 1777.8 + 1778.1 + 1778.7 + 1778.9 + 1779.4 + 1780.0 + 1780.3 + 1781.0 + 1781.2 + 1781.7 + 1782.2 + 1782.5 + 1783.3 + 1784.0 + 1784.6 + 1785.3 + 1785.7 + 1785.8 + 1786.1 + 1786.5 + 1787.1 + 1787.4 + 1788.1 + 1788.3 + 1789.0 + 1789.1 + 1789.4 + 1789.6 + 1789.8 + 1790.5 + 1791.2 + 1791.3 + 1791.9 + 1792.5 + 1792.7 + 1793.4 + 1793.7 + 1794.0 + 1794.3 + 1794.5 + 1795.2 + 1795.4 + 1795.9 + 1796.3 + 1796.5 + 1797.1 + 1797.3 + 1797.8 + 1797.9 + 1798.5 + 1798.7 + 1799.2 + 1800.0 + 1800.8 + 1801.0 + 1801.6 + 1801.9 + 1802.4 + 1802.5 + 1802.9 + 1803.0 + 1803.6 + 1803.9 + 1804.3 + 1805.0 + 1805.7 + 1805.9 + 1806.5 + 1806.6 + 1807.1 + 1807.2 + 1807.9 + 1808.6 + 1809.1 + 1809.1 + 1809.3 + 1809.6 + 1809.7 + 1809.9 + 1810.1 + 1810.2 + 1810.5 + 1811.0 + 1811.6 + 1811.7 + 1812.4 + 1812.5 + 1813.0 + 1813.4 + 1814.1 + 1814.2 + 1814.7 + 1815.2 + 1815.4 + 1815.6 + 1815.6 + 1815.6 + 1815.8 + 1815.9 + 1816.1 + 1816.3 + 1817.0 + 1817.2 + 1817.6 + 1818.3 + 1819.0 + 1819.3 + 1819.6 + 1820.4 + 1820.4 + 1821.4 + 1822.2 + 1822.5 + 1823.0 + 1823.7 + 1824.3 + 1824.7 + 1825.0 + 1825.6 + 1825.8 + 1826.6 + 1826.7 + 1827.4 + 1827.6 + 1828.3 + 1828.9 + 1829.2 + 1829.8 + 1830.1 + 1830.8 + 1830.9 + 1831.4 + 1831.6 + 1831.8 + 1832.6 + 1833.4 + 1834.1 + 1834.4 + 1834.6 + 1835.0 + 1835.2 + 1835.8 + 1836.0 + 1836.3 + 1836.7 + 1836.9 + 1837.5 + 1837.9 + 1838.0 + 1838.1 + 1838.2 + 1838.3 + 1839.0 + 1839.1 + 1839.9 + 1840.6 + 1841.2 + 1841.4 + 1841.5 + 1841.7 + 1842.1 + 1842.8 + 1843.2 + 1843.6 + 1844.6 + 1844.9 + 1845.1 + 1845.2 + 1845.3 + 1846.1 + 1846.4 + 1846.7 + 1847.2 + 1847.4 + 1847.9 + 1848.3 + 1848.4 + 1849.0 + 1849.2 + 1849.7 + 1850.0 + 1850.6 + 1850.8 + 1851.1 + 1851.7 + 1851.9 + 1852.3 + 1852.7 + 1853.3 + 1853.5 + 1854.2 + 1854.5 + 1854.7 + 1855.4 + 1855.5 + 1856.4 + 1856.5 + 1857.4 + 1857.5 + 1858.1 + 1858.3 + 1859.1 + 1859.5 + 1859.8 + 1860.6 + 1861.3 + 1861.5 + 1861.9 + 1862.4 + 1862.7 + 1863.0 + 1863.6 + 1863.7 + 1864.2 + 1864.4 + 1864.8 + 1864.9 + 1865.0 + 1865.0 + 1865.0 + 1865.0 + 1866.2 + 1866.3 + 1866.6 + 1866.9 + 1867.4 + 1868.1 + 1868.3 + 1868.9 + 1869.2 + 1869.6 + 1870.3 + 1870.5 + 1871.1 + 1871.3 + 1871.8 + 1872.0 + 1872.5 + 1872.9 + 1873.2 + 1873.7 + 1873.8 + 1874.7 + 1875.5 + 1875.7 + 1876.0 + 1876.6 + 1876.8 + 1877.0 + 1877.5 + 1877.8 + 1878.0 + 1878.1 + 1878.3 + 1878.6 + 1879.3 + 1880.0 + 1880.1 + 1880.3 + 1880.5 + 1880.7 + 1880.8 + 1881.1 + 1881.3 + 1881.9 + 1882.7 + 1882.8 + 1883.6 + 1883.8 + 1884.3 + 1884.9 + 1885.1 + 1885.6 + 1885.9 + 1886.2 + 1886.7 + 1887.0 + 1887.6 + 1888.1 + 1888.1 + 1888.2 + 1888.7 + 1888.8 + 1889.3 + 1889.7 + 1889.8 + 1890.4 + 1890.5 + 1890.9 + 1891.1 + 1891.4 + 1891.9 + 1892.1 + 1892.3 + 1892.7 + 1893.1 + 1893.4 + 1894.0 + 1894.8 + 1895.3 + 1895.4 + 1895.4 + 1895.4 + 1895.5 + 1895.6 + 1895.6 + 1895.6 + 1895.7 + 1895.8 + 1895.8 + 1895.9 + 1896.0 + 1896.1 + 1896.2 + 1897.0 + 1897.4 + 1897.7 + 1898.5 + 1898.6 + 1899.2 + 1899.7 + 1899.8 + 1899.8 + 1899.9 + 1900.0 + 1900.0 + 1900.0 + 1900.0 + 1900.1 + 1900.1 + 1900.1 + 1900.2 + 1900.3 + 1900.3 + 1900.3 + 1900.3 + 1900.3 + 1900.4 + 1900.4 + 1900.4 + 1900.4 + 1900.4 + 1900.4 + 1900.4 + 1900.4 + 1900.4 + 1900.4 + 1900.4 + 1900.4 + 1900.4 + 1900.5 + 1900.5 + 1900.5 + 1900.5 + 1900.5 + 1900.5 + 1900.5 + 1900.5 + 1900.5 + 1900.5 + 1900.5 + 1900.5 + 1901.1 + 1901.1 + 1901.3 + 1901.3 + 1901.2 + 1901.2 + 1901.2 + 1901.1 + 1901.1 + 1901.1 + 1900.9 + 1900.9 + 1900.8 + 1900.8 + 1900.7 + 1900.7 + 1900.7 + 1900.8 + 1900.9 + 1901.0 + 1901.0 + 1901.0 + 1901.1 + 1901.4 + 1901.6 + 1901.6 + 1901.9 + 1902.4 + 1903.3 + 1903.8 + 1905.1 + 1905.3 + 1906.2 + 1906.5 + 1906.7 + 1906.8 + 1906.9 + 1907.0 + 1907.8 + 1907.9 + 1908.4 + 1908.7 + 1909.4 + 1909.5 + 1910.4 + 1910.6 + 1911.2 + 1911.5 + 1912.1 + 1912.3 + 1912.8 + 1913.1 + 1913.2 + 1913.6 + 1914.1 + 1914.4 + 1914.7 + 1915.3 + 1915.8 + 1916.4 + 1916.5 + 1917.5 + 1917.6 + 1918.5 + 1919.5 + 1919.6 + 1920.5 + 1920.7 + 1921.4 + 1921.8 + 1922.1 + 1922.9 + 1923.0 + 1923.6 + 1924.2 + 1924.6 + 1925.4 + 1925.6 + 1926.3 + 1926.5 + 1926.6 + 1926.7 + 1927.5 + 1927.7 + 1928.5 + 1928.6 + 1929.1 + 1929.2 + 1929.7 + 1929.9 + 1930.8 + 1931.4 + 1931.5 + 1932.4 + 1932.7 + 1933.4 + 1933.6 + 1933.9 + 1934.2 + 1934.3 + 1934.8 + 1935.3 + 1935.9 + 1936.0 + 1936.8 + 1936.9 + 1937.6 + 1938.3 + 1938.6 + 1939.4 + 1940.3 + 1940.4 + 1941.3 + 1941.6 + 1942.4 + 1942.5 + 1943.3 + 1943.4 + 1944.3 + 1944.5 + 1945.5 + 1945.6 + 1946.4 + 1946.7 + 1947.4 + 1947.9 + 1948.0 + 1948.6 + 1948.8 + 1949.4 + 1949.5 + 1950.3 + 1950.4 + 1950.9 + 1951.2 + 1952.0 + 1952.2 + 1952.3 + 1952.9 + 1953.1 + 1953.7 + 1954.5 + 1954.8 + 1955.2 + 1955.5 + 1955.8 + 1956.3 + 1956.8 + 1957.1 + 1957.7 + 1958.2 + 1958.4 + 1959.2 + 1959.9 + 1960.2 + 1960.7 + 1961.4 + 1961.5 + 1962.1 + 1962.9 + 1963.2 + 1963.6 + 1963.9 + 1964.1 + 1965.1 + 1966.0 + 1966.5 + 1966.8 + 1967.2 + 1967.4 + 1968.2 + 1969.0 + 1969.2 + 1969.7 + 1970.1 + 1970.7 + 1970.9 + 1971.3 + 1971.9 + 1972.2 + 1973.0 + 1973.2 + 1973.4 + 1973.6 + 1974.1 + 1974.4 + 1974.6 + 1975.2 + 1975.3 + 1975.8 + 1976.1 + 1976.4 + 1977.1 + 1977.2 + 1977.6 + 1977.8 + 1978.4 + 1978.7 + 1979.4 + 1979.6 + 1980.5 + 1980.6 + 1981.3 + 1981.4 + 1981.7 + 1982.0 + 1982.4 + 1982.5 + 1983.4 + 1983.7 + 1984.4 + 1984.5 + 1985.0 + 1985.6 + 1985.9 + 1986.3 + 1986.6 + 1987.1 + 1987.3 + 1987.5 + 1987.9 + 1988.7 + 1988.9 + 1989.6 + 1990.4 + 1990.6 + 1991.4 + 1991.5 + 1992.1 + 1992.5 + 1992.8 + 1993.4 + 1993.9 + 1994.6 + 1994.9 + 1995.4 + 1995.8 + 1996.0 + 1996.2 + 1996.8 + 1997.6 + 1998.1 + 1998.5 + 1999.0 + 1999.2 + 1999.8 + 2000.3 + 2001.1 + 2001.6 + 2001.9 + 2002.3 + 2002.5 + 2002.7 + 2003.0 + 2003.4 + 2003.8 + 2004.4 + 2004.8 + 2005.1 + 2005.4 + 2005.9 + 2006.0 + 2006.7 + 2007.1 + 2007.4 + 2007.7 + 2007.8 + 2007.9 + 2008.8 + 2008.9 + 2009.2 + 2009.5 + 2009.7 + 2010.1 + 2010.5 + 2010.9 + 2011.0 + 2011.2 + 2011.6 + 2011.7 + 2012.3 + 2012.3 + 2012.7 + 2013.2 + 2013.2 + 2013.6 + 2013.6 + 2013.9 + 2014.4 + 2014.4 + 2014.6 + 2014.8 + 2015.0 + 2015.1 + 2015.1 + 2015.5 + 2015.7 + 2015.8 + 2015.8 + 2015.8 + 2015.8 + 2015.8 + 2015.8 + 2015.9 + 2016.1 + 2016.2 + 2016.5 + 2016.7 + 2016.7 + 2016.8 + 2016.9 + 2017.0 + 2017.0 + 2017.2 + 2017.2 + 2017.3 + 2017.4 + 2017.5 + 2017.5 + 2017.6 + 2017.8 + 2017.8 + 2017.9 + 2018.0 + 2018.1 + 2018.3 + 2018.3 + 2018.4 + 2018.4 + 2018.5 + 2018.5 + 2018.6 + 2018.8 + 2018.9 + 2018.9 + 2019.1 + 2019.1 + 2019.2 + 2019.4 + 2019.5 + 2019.6 + 2019.6 + 2019.9 + 2020.1 + 2020.1 + 2020.2 + 2020.3 + 2020.3 + 2020.4 + 2020.5 + 2020.6 + 2020.6 + 2020.6 + 2020.6 + 2020.6 + 2020.6 + 2020.6 + 2020.7 + 2020.8 + 2020.8 + 2020.9 + 2020.9 + 2021.0 + 2021.0 + 2021.0 + 2021.1 + 2021.1 + 2021.9 + 2022.1 + 2022.7 + 2022.9 + 2023.2 + 2023.5 + 2023.6 + 2024.0 + 2024.1 + 2024.5 + 2024.8 + 2025.0 + 2025.1 + 2025.4 + 2025.6 + 2026.0 + 2026.1 + 2026.6 + 2026.6 + 2027.1 + 2027.5 + 2027.8 + 2028.6 + 2028.7 + 2028.8 + 2029.5 + 2029.7 + 2030.3 + 2030.8 + 2030.9 + 2031.2 + 2031.7 + 2032.1 + 2032.5 + 2033.2 + 2033.6 + 2034.3 + 2034.6 + 2035.3 + 2035.6 + 2036.4 + 2036.6 + 2037.5 + 2037.6 + 2038.7 + 2039.7 + 2040.9 + 2042.1 + 2043.2 + 2043.5 + 2044.2 + 2044.6 + 2045.1 + 2045.8 + 2046.0 + 2046.5 + 2046.7 + 2047.0 + 2047.3 + 2047.3 + 2048.0 + 2048.3 + 2048.6 + 2048.8 + 2049.0 + 2049.4 + 2050.0 + 2050.2 + 2050.9 + 2051.0 + 2051.7 + 2051.9 + 2052.7 + 2053.1 + 2053.7 + 2054.0 + 2054.5 + 2054.8 + 2055.0 + 2055.4 + 2055.7 + 2055.9 + 2056.4 + 2057.0 + 2057.1 + 2058.1 + 2059.0 + 2059.4 + 2059.9 + 2060.7 + 2060.8 + 2061.8 + 2062.3 + 2062.8 + 2062.9 + 2063.3 + 2064.3 + 2064.7 + 2065.1 + 2065.7 + 2065.9 + 2066.6 + 2066.7 + 2067.1 + 2067.2 + 2067.9 + 2068.0 + 2068.6 + 2068.8 + 2069.1 + 2069.7 + 2069.8 + 2070.4 + 2070.9 + 2071.2 + 2071.7 + 2071.9 + 2072.0 + 2072.5 + 2072.6 + 2073.0 + 2073.6 + 2074.2 + 2074.5 + 2074.8 + 2075.0 + 2075.4 + 2075.7 + 2076.3 + 2076.4 + 2076.7 + 2076.9 + 2077.1 + 2077.2 + 2077.3 + 2077.8 + 2078.2 + 2078.4 + 2079.1 + 2079.2 + 2079.9 + 2080.5 + 2080.9 + 2081.2 + 2081.3 + 2081.6 + 2081.6 + 2081.6 + 2081.8 + 2082.0 + 2082.1 + 2082.4 + 2082.6 + 2083.1 + 2083.8 + 2084.1 + 2084.4 + 2084.9 + 2085.0 + 2085.5 + 2085.9 + 2086.3 + 2087.0 + 2087.6 + 2088.2 + 2088.8 + 2089.0 + 2089.5 + 2089.9 + 2090.2 + 2090.6 + 2090.8 + 2091.7 + 2091.8 + 2092.4 + 2093.1 + 2093.3 + 2094.1 + 2094.2 + 2094.8 + 2095.0 + 2095.3 + 2095.9 + 2096.1 + 2096.7 + 2097.0 + 2097.4 + 2097.9 + 2098.3 + 2098.9 + 2099.1 + 2099.7 + 2100.2 + 2100.4 + 2101.0 + 2101.1 + 2101.4 + 2101.6 + 2101.8 + 2101.9 + 2102.4 + 2103.2 + 2103.8 + 2104.2 + 2104.5 + 2104.6 + 2105.1 + 2105.7 + 2106.4 + 2106.9 + 2107.3 + 2107.7 + 2107.9 + 2108.3 + 2108.8 + 2108.9 + 2109.6 + 2109.8 + 2110.4 + 2110.8 + 2111.0 + 2111.7 + 2111.8 + 2111.9 + 2112.2 + 2112.7 + 2112.8 + 2112.9 + 2113.5 + 2113.8 + 2114.4 + 2114.5 + 2114.7 + 2114.8 + 2115.0 + 2115.4 + 2116.0 + 2116.4 + 2116.9 + 2117.2 + 2117.7 + 2118.0 + 2118.7 + 2119.2 + 2119.6 + 2120.2 + 2120.3 + 2120.5 + 2120.6 + 2121.2 + 2121.3 + 2122.2 + 2122.4 + 2122.5 + 2122.7 + 2122.9 + 2123.2 + 2123.6 + 2123.8 + 2124.4 + 2124.6 + 2125.4 + 2125.5 + 2126.2 + 2126.6 + 2127.2 + 2127.6 + 2128.2 + 2128.8 + 2128.9 + 2129.8 + 2129.9 + 2130.0 + 2130.5 + 2130.7 + 2131.2 + 2131.9 + 2132.0 + 2132.8 + 2133.1 + 2133.4 + 2133.9 + 2134.2 + 2134.9 + 2135.0 + 2135.8 + 2136.0 + 2136.5 + 2136.6 + 2137.1 + 2137.5 + 2137.9 + 2138.5 + 2138.8 + 2139.1 + 2139.3 + 2139.9 + 2140.1 + 2140.7 + 2140.9 + 2141.5 + 2141.7 + 2142.2 + 2142.3 + 2142.5 + 2142.5 + 2142.5 + 2142.5 + 2142.5 + 2142.5 + 2143.0 + 2143.5 + 2143.5 + 2143.5 + 2143.9 + 2144.3 + 2144.4 + 2144.6 + 2145.4 + 2146.1 + 2146.3 + 2146.7 + 2147.0 + 2148.1 + 2148.3 + 2148.5 + 2148.5 + 2148.8 + 2149.1 + 2149.3 + 2149.3 + 2149.3 + 2149.4 + 2149.4 + 2149.4 + 2149.4 + 2149.5 + 2149.5 + 2149.6 + 2149.7 + 2149.7 + 2149.7 + 2149.7 + 2149.7 + 2149.7 + 2149.7 + 2149.7 + 2149.7 + 2149.7 + 2149.7 + 2149.7 + 2149.7 + 2149.8 + 2149.9 + 2149.9 + 2150.1 + 2150.1 + 2150.2 + 2150.2 + 2150.4 + 2150.5 + 2150.6 + 2150.6 + 2150.7 + 2150.8 + 2151.0 + + \ No newline at end of file diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Codecs/RideCodec.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Codecs/RideCodec.swift index 02ba143de..9de564c97 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Codecs/RideCodec.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Codecs/RideCodec.swift @@ -41,7 +41,7 @@ public enum RideObjectCodec { data.append(sensorU8(summary.avgHeartRate)) data.append(sensorU8(summary.maxHeartRate)) data.append(sensorU8(summary.avgCadence)) - data.append(0) + data.append(summary.isDemo ? 1 : 0) data.appendLE(sensorU16(summary.avgPower)) data.appendLE(sensorU16(summary.maxPower)) data.appendLE(summary.energyKJ.map { UInt32(clamping: Swift.min($0, Int(noEnergy) - 1)) } ?? noEnergy) @@ -85,7 +85,8 @@ public enum RideObjectCodec { let avgHR = optSensorU8(try footer.u8()) let maxHR = optSensorU8(try footer.u8()) let avgCadence = optSensorU8(try footer.u8()) - guard try footer.u8() == 0 else { throw DeviceError.readFailed } + let flags = try footer.u8() + guard flags & ~1 == 0 else { throw DeviceError.readFailed } let avgPower = optSensorU16(try footer.u16()) let maxPower = optSensorU16(try footer.u16()) let energy = try footer.u32() @@ -145,7 +146,7 @@ public enum RideObjectCodec { trackPreview: TrackPreview.normalizing(points.map(\.coordinate)), avgHeartRate: avgHR, maxHeartRate: maxHR, avgCadence: avgCadence, avgPower: avgPower, maxPower: maxPower, energyKJ: energy == noEnergy ? nil : Int(energy), - bikeType: bikeType, trip: trip, zoneLimits: zoneLimits) + bikeType: bikeType, trip: trip, zoneLimits: zoneLimits, isDemo: flags & 1 != 0) return Ride(summary: summary, points: points) } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift index 2baa7efab..410012902 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift @@ -943,6 +943,7 @@ private struct RidePointDTO: Codable { } private struct RideSummaryDTO: Codable { + var isDemo: Bool var source: RideSource? var id: String var name: String @@ -969,6 +970,7 @@ private struct RideSummaryDTO: Codable { init(_ summary: RideSummary) { source = summary.source + isDemo = summary.isDemo id = summary.id.rawValue name = summary.name date = summary.date @@ -998,7 +1000,7 @@ private struct RideSummaryDTO: Codable { avgHeartRate: avgHeartRate, maxHeartRate: maxHeartRate, avgCadence: avgCadence, avgPower: avgPower, maxPower: maxPower, energyKJ: energyKJ, bikeType: BikeType(rawValue: bikeType) ?? .road, trip: trip?.domain, - zoneLimits: zoneLimits ?? .notSet, source: source + zoneLimits: zoneLimits ?? .notSet, source: source, isDemo: isDemo ) } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/RideViews.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/RideViews.swift index 1d319e5ff..7bade3607 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/RideViews.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/RideViews.swift @@ -118,6 +118,7 @@ extension LibraryStore { /// bike type. @discardableResult public func mergeRides(_ first: RideSummary, _ second: RideID) -> Bool { + guard rideSummaries().first(where: { $0.id == second })?.isDemo == first.isDemo else { return false } var edit = RideEditSession(store: self) guard let head = edit.slices(of: first.id), let tail = edit.slices(of: second), let view = edit.view(id: first.id, summary: first, slices: head + tail) diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/TrackRow.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/TrackRow.swift index 01dc69bc5..d70c77daa 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/TrackRow.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/TrackRow.swift @@ -7,6 +7,7 @@ import OBCDomain /// sketch carries its day count in the corner, as the device's route menu badges a trip. At accessibility text sizes the sketch moves above the text. public struct TrackRow: View { let name: String + let isDemo: Bool let stats: Text /// The stat line as VoiceOver reads it, with the on-device state. let statsLabel: String @@ -49,7 +50,7 @@ public struct TrackRow: View { self.init( name: ride.name, stats: stats, detail: nil, sketch: TrackPreviewView(ride.trackPreview, ink: .ride, showsChrome: false), - onDevice: .notOnDevice, dayCount: nil, isSelected: nil + onDevice: .notOnDevice, dayCount: nil, isSelected: nil, isDemo: ride.isDemo ) } @@ -79,11 +80,12 @@ public struct TrackRow: View { private init( name: String, stats: RowStats, detail: String?, sketch: TrackPreviewView, - onDevice: OnDeviceState, dayCount: Int?, isSelected: Bool? + onDevice: OnDeviceState, dayCount: Int?, isSelected: Bool?, isDemo: Bool = false ) { self.name = name + self.isDemo = isDemo self.stats = stats.text - self.statsLabel = [stats.label, OnDeviceChip.label(for: onDevice)] + self.statsLabel = [stats.label, OnDeviceChip.label(for: onDevice), isDemo ? "Demo ride, excluded from totals" : nil] .compactMap { $0 } .joined(separator: ", ") self.detail = detail @@ -149,10 +151,13 @@ public struct TrackRow: View { .font(.system(.subheadline).monospacedDigit()) .foregroundStyle(OBCTheme.secondary) .accessibilityLabel(statsLabel) - if onDevice == .notOnDevice { + if onDevice == .notOnDevice && !isDemo { styled } else { - let chip = OnDeviceChip(upToDate: onDevice == .upToDate).accessibilityHidden(true) + let chip = Group { + if isDemo { DemoRideBadge() } + else { OnDeviceChip(upToDate: onDevice == .upToDate) } + }.accessibilityHidden(true) ViewThatFits(in: .horizontal) { HStack(spacing: 8) { styled.fixedSize() @@ -319,3 +324,14 @@ public struct TrackRowSkeleton: View { .background(OBCTheme.page) } #endif + +struct DemoRideBadge: View { + var body: some View { + PixelText("DEMO", scale: 2 / 3, color: OBCTheme.secondary) + .padding(.horizontal, 4) + .frame(height: OnDeviceChip.height) + .background(OBCTheme.fill, in: RoundedRectangle(cornerRadius: 4)) + .accessibilityLabel("Demo ride, excluded from totals") + .accessibilityIdentifier("ride.demoBadge") + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Detail/RouteDetailView.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Detail/RouteDetailView.swift index 85412c78f..5331748e7 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Detail/RouteDetailView.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Detail/RouteDetailView.swift @@ -291,6 +291,9 @@ public struct RouteDetailView: View { .accessibilityIdentifier("detail.rename") } } + if case let .tracked(ride) = model.dressing, ride.isDemo { + DemoRideBadge() + } if let subtitle = model.subtitle { Text(subtitle) .font(.system(.subheadline)) diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenModel.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenModel.swift index 8d02e4121..cc9d144d5 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenModel.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenModel.swift @@ -1309,7 +1309,7 @@ public final class MainScreenModel { /// The listed ride after `id` in time: the partner of Merge with next. public func nextRide(after id: RideID) -> RideSummary? { guard let ride = rides.first(where: { $0.id == id }) else { return nil } - return rides.filter { $0.date > ride.date }.min { $0.date < $1.date } + return rides.filter { $0.date > ride.date && $0.isDemo == ride.isDemo }.min { $0.date < $1.date } } /// Join the next ride onto this one. The time between the two does not count as moving time. diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/RideSyncCoordinator.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/RideSyncCoordinator.swift index 9dbd8c26a..95a81c0eb 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/RideSyncCoordinator.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/RideSyncCoordinator.swift @@ -288,6 +288,7 @@ public final class RideSyncCoordinator { let decoded = ride.summary ride.summary = summary if ride.summary.trackPreview == nil { ride.summary.trackPreview = decoded.trackPreview } + ride.summary.isDemo = decoded.isDemo ride.summary.descentMeters = decoded.descentMeters ride.summary.avgHeartRate = decoded.avgHeartRate ride.summary.maxHeartRate = decoded.maxHeartRate diff --git a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideArchiveTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideArchiveTests.swift index a069ecc77..9e859d70d 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideArchiveTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideArchiveTests.swift @@ -28,11 +28,13 @@ struct RideArchiveTests { ]) } - @Test func archiveReopensWithAllFieldsAndExactSource() throws { + @Test(arguments: [false, true]) + func archiveReopensWithAllFieldsAndExactSource(isDemo: Bool) throws { let dir = try directory() defer { try? FileManager.default.removeItem(at: dir) } let store = FileLibraryStore(directory: dir) - let ride = ride() + var ride = ride() + ride.summary.isDemo = isDemo let receipt = try #require(try store.archiveRide(ride)) #expect(receipt.source == ride.summary.source) let reopened = FileLibraryStore(directory: dir) diff --git a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideCodecVectorTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideCodecVectorTests.swift index 76dc78612..33f262e8a 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideCodecVectorTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideCodecVectorTests.swift @@ -26,6 +26,7 @@ struct RideCodecVectorTests { let ride = try RideObjectCodec.decode(bytes, id: RideID("42")) let summary = ride.summary #expect(summary.name == "Sensor Ride") + #expect(summary.isDemo) #expect(summary.date == Date(timeIntervalSince1970: 1_751_460_000)) #expect(summary.distanceMeters == 12_345) #expect(summary.movingTime == 3_600) @@ -59,9 +60,9 @@ struct RideCodecVectorTests { @Test func rejectsBadFooterAndReservedSampleFlags() throws { let bytes = try vector() let footer = bytes.count - RideObjectCodec.footerLength - // Reserved byte, a day past the count, a bike type past the four, trip-name padding, and + // Unknown flags, a day past the count, a bike type past the four, trip-name padding, and // the reserved byte after the max heart rate limit. - for (offset, value) in [(33, 1), (98, 3), (100, 4), (149, 1), (151, 1)] as [(Int, UInt8)] { + for (offset, value) in [(33, 2), (98, 3), (100, 4), (149, 1), (151, 1)] as [(Int, UInt8)] { var badFooter = bytes badFooter[footer + offset] = value #expect(throws: (any Error).self) { diff --git a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideEditTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideEditTests.swift index 8cdfb8300..40b9a4701 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideEditTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideEditTests.swift @@ -32,6 +32,20 @@ struct RideEditTests { return store } + @Test + func demoEditsStayMarkedAndCannotMergeWithRecordedRides() throws { + var demo = Self.ride("demo", start: t0, seconds: 60) + demo.summary.isDemo = true + let real = Self.ride("real", start: t0.addingTimeInterval(61), seconds: 60) + let store = store(demo, real) + #expect(!RideEdit.suggestsMerge(demo, real)) + #expect(!store.mergeRides(demo.summary, real.id)) + #expect(!store.mergeRides(real.summary, demo.id)) + #expect(store.trimRide(demo.id, to: t0...t0.addingTimeInterval(30), summary: demo.summary)) + #expect(try #require(store.rideSummaries().first { $0.id == demo.id }).isDemo) + #expect(RideTotals(store.rideSummaries()) == RideTotals([real.summary])) + } + @Test func statsEstimateTheEnergyFromTheStoredPointsAndCountTheDescent() { // One point every 5 s at 500 W, one of them without power. Each interval counts at most diff --git a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideLibraryTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideLibraryTests.swift index ab82d3176..d11b304d9 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideLibraryTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideLibraryTests.swift @@ -55,4 +55,15 @@ struct RideLibraryTests { #expect(byType[0].totals.distanceMeters == 120_000) #expect(byType[0].totals.rideCount == 2) } + @Test + func demoRemainsInTheLibraryButDoesNotCountInTotals() { + var demo = ride("Grimsel Pass", "2026-01-01T09:00:00Z", km: 15, hours: 2, climb: 700) + demo.isDemo = true + let recorded = ride("mine", "2026-08-01T09:00:00Z", km: 10, climb: 100, type: .gravel) + #expect(RideFilter(year: 2026).includes(demo, calendar: zurich)) + #expect(RideTotals([demo, recorded]) == RideTotals([recorded])) + #expect(RideTotals([demo]).rideCount == 0) + #expect(RideTotals.byBikeType([demo, recorded]).map(\.bikeType) == [.gravel]) + } + } diff --git a/firmware/obc-formats/src/ride.rs b/firmware/obc-formats/src/ride.rs index f90f1d34c..6b04482e8 100644 --- a/firmware/obc-formats/src/ride.rs +++ b/firmware/obc-formats/src/ride.rs @@ -116,6 +116,8 @@ pub struct EffortLimits { /// The fixed summary at the end of every finished ride object. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Footer { + /// Factory sample ride; excluded from the companion app's totals. + pub is_demo: bool, pub start_time: u32, pub distance_m: u32, pub moving_time_s: u32, @@ -159,6 +161,7 @@ impl Footer { max_power: Option, ) -> Footer { Footer { + is_demo: false, start_time, distance_m, moving_time_s, @@ -218,7 +221,7 @@ pub fn encode_footer(footer: &Footer) -> [u8; FOOTER_LEN] { b[30] = footer.avg_hr.unwrap_or(HR_NONE); b[31] = footer.max_hr.unwrap_or(HR_NONE); b[32] = footer.avg_cadence.unwrap_or(CAD_NONE); - // byte 33 is reserved and remains zero, aligning the following u16 values. + b[33] = u8::from(footer.is_demo); b[34..36].copy_from_slice(&footer.avg_power.unwrap_or(PWR_NONE).to_le_bytes()); b[36..38].copy_from_slice(&footer.max_power.unwrap_or(PWR_NONE).to_le_bytes()); b[38..42].copy_from_slice(&footer.energy_kj.unwrap_or(KJ_NONE).to_le_bytes()); @@ -247,7 +250,7 @@ pub fn decode_footer(b: &[u8; FOOTER_LEN]) -> Result { if b[4] != VERSION { return Err(DecodeError::Version); } - if u16::from_le_bytes([b[6], b[7]]) as usize != FOOTER_LEN || b[33] != 0 || b[LIMITS_AT + 1] != 0 { + if u16::from_le_bytes([b[6], b[7]]) as usize != FOOTER_LEN || b[33] & !1 != 0 || b[LIMITS_AT + 1] != 0 { return Err(DecodeError::Layout); } let name = Name::decode(b[5], &b[NAME_AT..TRIP_AT])?; @@ -262,6 +265,7 @@ pub fn decode_footer(b: &[u8; FOOTER_LEN]) -> Result { } Ok(Footer { + is_demo: b[33] & 1 != 0, start_time: u32::from_le_bytes(b[8..12].try_into().unwrap()), distance_m: u32::from_le_bytes(b[12..16].try_into().unwrap()), moving_time_s: u32::from_le_bytes(b[16..20].try_into().unwrap()), @@ -335,6 +339,7 @@ mod tests { let bytes = encode_footer(&footer); assert_eq!(&bytes[..8], b"OBRF\x06\x0b\x9a\0"); assert_eq!(&bytes[22..30], &[0x2A, 3, 0x80, 2, 3, 0, 0, 0], "climb, descent, point count"); + assert_eq!(bytes[33], 0, "recorded rides are not demos"); assert_eq!(&bytes[34..42], &[210, 0, 224, 1, 0xF4, 2, 0, 0], "power, then energy"); assert_eq!(&bytes[90..102], &[0xEF, 0xCD, 0xAB, 0x89, 0x67, 0x45, 0x23, 0x01, 1, 3, 1, 14]); assert_eq!(&bytes[150..], &[185, 0, 250, 0], "max HR, reserved, FTP"); @@ -371,6 +376,7 @@ mod tests { let decoded = decode_footer(footer).unwrap(); assert_eq!(decoded.name(), "Sensor Ride"); assert_eq!(decoded.point_count, 3); + assert!(decoded.is_demo); assert_eq!(decoded.bike, BikeType::Gravel); assert_eq!(decoded.trip(), Some(TRIP)); assert_eq!(decoded.trip_name(), "Alpen Traverse"); @@ -381,9 +387,9 @@ mod tests { #[test] fn footer_rejects_noncanonical_fixed_bytes() { let bytes = encode_footer(&example()); - // Magic, version, length, reserved, name padding, a day past the count, a bike type past + // Magic, version, length, unknown flags, name padding, a day past the count, a bike type past // the four, trip-name padding, and the reserved byte after max HR. - for (offset, value) in [(0, 0), (4, 5), (6, 150), (33, 1), (89, 1), (98, 3), (100, 4), (149, 1), (151, 1)] { + for (offset, value) in [(0, 0), (4, 5), (6, 150), (33, 2), (89, 1), (98, 3), (100, 4), (149, 1), (151, 1)] { let mut bad = bytes; bad[offset] = value; assert!(decode_footer(&bad).is_err(), "offset {offset}"); diff --git a/firmware/obc-fw-nrf54l/Cargo.toml b/firmware/obc-fw-nrf54l/Cargo.toml index f2c82f0a3..8e5d30e1a 100644 --- a/firmware/obc-fw-nrf54l/Cargo.toml +++ b/firmware/obc-fw-nrf54l/Cargo.toml @@ -50,8 +50,8 @@ synth = [] # Seed a Kleine Scheidegg test fix and open the selected map's Peak View at boot. peak-view-demo = [] -# Development-only: add three finished demo rides at boot, before the storage writer starts. -seed-rides = [] +# Factory station only: write the marked demo ride, then halt until normal firmware is flashed. +factory-demo-ride = [] # On-device SD read profiler. Adds relaxed counters at the concrete BlockDevice boundary and one # RTT line per map redraw, splitting the renderer's logical ByteSource requests from the physical diff --git a/firmware/obc-fw-nrf54l/build.rs b/firmware/obc-fw-nrf54l/build.rs index 38f95332a..4ba4e4850 100644 --- a/firmware/obc-fw-nrf54l/build.rs +++ b/firmware/obc-fw-nrf54l/build.rs @@ -165,15 +165,11 @@ fn main() { let manifest = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); println!("cargo:rerun-if-env-changed=OBC_DEMO_RIDE_FILE"); - println!("cargo:rustc-check-cfg=cfg(demo_ride_file)"); - if env::var_os("CARGO_FEATURE_SEED_RIDES").is_some() { - if let Some(path) = env::var_os("OBC_DEMO_RIDE_FILE") { - let path = PathBuf::from(path); - assert!(path.is_absolute(), "OBC_DEMO_RIDE_FILE must be an absolute path"); - println!("cargo:rerun-if-changed={}", path.display()); - fs::copy(path, out.join("demo_ride.obcr")).expect("copy OBC_DEMO_RIDE_FILE"); - println!("cargo:rustc-cfg=demo_ride_file"); - } + if env::var_os("CARGO_FEATURE_FACTORY_DEMO_RIDE").is_some() { + let path = PathBuf::from(env::var_os("OBC_DEMO_RIDE_FILE").expect("use obc flash factory-demo-ride")); + assert!(path.is_absolute(), "OBC_DEMO_RIDE_FILE must be an absolute path"); + println!("cargo:rerun-if-changed={}", path.display()); + fs::copy(path, out.join("demo_ride.obcr")).expect("copy OBC_DEMO_RIDE_FILE"); } // Cargo sets CARGO_FEATURE_ for the build script when a feature is enabled. diff --git a/firmware/obc-fw-nrf54l/src/demo_rides.rs b/firmware/obc-fw-nrf54l/src/demo_rides.rs deleted file mode 100644 index d6026ca27..000000000 --- a/firmware/obc-fw-nrf54l/src/demo_rides.rs +++ /dev/null @@ -1,369 +0,0 @@ -//! Development-only finished rides. Called before the board starts its storage writer. - -use obc_crc::Crc32; -use obc_formats::io::{ByteSource, SliceSource}; -use obc_formats::ride::{encode_footer, EffortLimits, Footer, FOOTER_LEN, SAMPLE_LEN}; -use obc_formats::track::{decode_record, encode_record, FLAG_SEGMENT_START}; -use obc_ports::TrackPoint; -use obc_route::RideInfo; -use obc_storage::flat::{ - Allocation, BlockDevice, DisplayName, EntryFlags, EntryMeta, FlatStore, Handle, Mode, Mutation, ObjectId, - ObjectKind, PutSource, Revision, Store, StoreError, -}; - -pub const NAMES: [&str; 3] = ["Demo GPS", "Demo Heart Rate", "Demo Heart Rate + Power"]; -const INTERVALS: u32 = 360; -const STEP_SECONDS: u32 = 5; -const PAYLOAD_LEN: u64 = (INTERVALS as u64 + 1) * SAMPLE_LEN as u64 + FOOTER_LEN as u64; - -// A synthetic loop near Freiburg, in microdegrees and metres. It is not a navigable route. -const LOOP: [(i32, i32, i16); 12] = [ - (7_820_000, 48_000_000, 230), - (7_834_000, 48_008_000, 242), - (7_850_000, 48_009_000, 265), - (7_868_000, 48_005_000, 310), - (7_880_000, 47_998_000, 285), - (7_879_000, 47_990_000, 270), - (7_864_000, 47_987_000, 295), - (7_850_000, 47_985_000, 265), - (7_833_000, 47_987_000, 250), - (7_820_000, 47_990_000, 238), - (7_815_000, 47_994_000, 225), - (7_820_000, 48_000_000, 230), -]; - -fn point(index: u32, sensors: usize) -> TrackPoint { - let scaled = index * (LOOP.len() as u32 - 1); - let segment = (scaled / INTERVALS).min(LOOP.len() as u32 - 2); - let fraction = (scaled - segment * INTERVALS) as i32; - let a = LOOP[segment as usize]; - let b = LOOP[segment as usize + 1]; - let lerp = |a, b| a + (b - a) * fraction / INTERVALS as i32; - let effort = (index % 80).min(80 - index % 80); - TrackPoint { - lon: lerp(a.0, b.0), - lat: lerp(a.1, b.1), - ele: lerp(i32::from(a.2), i32::from(b.2)) as i16, - t_ms: index * STEP_SECONDS * 1000, - segment_start: index == 0, - hr: (sensors >= 1).then_some(120 + effort as u8), - cadence: None, - power: (sensors == 2).then_some(if index % 90 < 5 { 0 } else { 130 + effort as u16 * 3 }), - } -} - -/// Add missing demo names only. Never initialize a card, replace an object, or touch a live ride. -pub fn seed(store: &FlatStore, first_start: u32) -> Result<[ObjectId; 3], StoreError> { - check_store(store)?; - if first_start == 0 || first_start.checked_add(2 * 86_400).is_none() { - return Err(StoreError::Invalid); - } - let mut ids = [ObjectId(0); 3]; - let mut rides = 0; - for entry in store.entries() { - if entry.flags == EntryFlags::RECORDING { - return Err(StoreError::Busy); - } - if entry.kind != ObjectKind::Ride || entry.flags != EntryFlags::NONE { - continue; - } - rides += 1; - if let Some(index) = NAMES.iter().position(|name| Some(*name) == entry.name.as_str()) { - ids[index] = entry.id; - } - } - if !store.entries_ok() { - return Err(StoreError::Media); - } - upgrade_v5_rides(store)?; - if rides + ids.iter().filter(|id| id.0 == 0).count() > obc_app::MAX_RIDES { - return Err(StoreError::CatalogFull); - } - for (index, id) in ids.iter_mut().enumerate() { - if id.0 == 0 { - *id = write_ride(store, index, first_start + index as u32 * 86_400)?; - } - } - Ok(ids) -} - -/// Add one finished ride from validated bytes. An existing name must hold the same payload. -pub fn seed_file(store: &FlatStore, bytes: &[u8]) -> Result { - check_store(store)?; - let info = RideInfo::read(&SliceSource(bytes)).map_err(|_| StoreError::Invalid)?; - let mut last_time = None; - for sample in bytes[..bytes.len() - FOOTER_LEN].as_chunks::().0 { - let flags = u16::from_le_bytes([sample[10], sample[11]]); - let point = decode_record(sample); - if flags & !FLAG_SEGMENT_START != 0 - || !(-180_000_000..=180_000_000).contains(&point.lon) - || !(-90_000_000..=90_000_000).contains(&point.lat) - || last_time.is_some_and(|time| point.t_ms < time) - { - return Err(StoreError::Invalid); - } - last_time = Some(point.t_ms); - } - let name = DisplayName::new(info.name.as_str()).ok_or(StoreError::Invalid)?; - let mut rides = 0; - let mut existing = None; - for entry in store.entries() { - if entry.flags == EntryFlags::RECORDING { - return Err(StoreError::Busy); - } - if entry.kind == ObjectKind::Ride && entry.flags == EntryFlags::NONE { - rides += 1; - if entry.name == name { - if existing.is_some() { - return Err(StoreError::Invalid); - } - existing = Some(entry); - } - } - } - if !store.entries_ok() { - return Err(StoreError::Media); - } - if let Some(entry) = existing { - // A v5 copy of this fixture is replaced in place; any other different payload is refused. - if !payload_matches(store, entry.id, bytes)? { - if !is_v5(store, &entry)? { - return Err(StoreError::Invalid); - } - replace(store, &entry, |allocation| store.write(allocation, bytes).map(|_| ()), bytes.len() as u64)?; - } - upgrade_v5_rides(store)?; - return if payload_matches(store, entry.id, bytes)? { Ok(entry.id) } else { Err(StoreError::Media) }; - } - upgrade_v5_rides(store)?; - if rides >= obc_app::MAX_RIDES { - return Err(StoreError::CatalogFull); - } - - let mut allocation = store.allocate(bytes.len() as u64)?; - for chunk in bytes.chunks(512) { - store.write(&mut allocation, chunk)?; - } - let id = store.next_object_id(); - store.commit(&[Mutation::Put { - meta: EntryMeta { - id, - revision: Revision(1), - kind: ObjectKind::Ride, - flags: EntryFlags::NONE, - payload_len: bytes.len() as u64, - payload_crc: obc_crc::crc32(bytes), - name, - added_at_utc: info.start_time, - }, - source: PutSource::Fresh(allocation), - }])?; - if !payload_matches(store, id, bytes)? { - return Err(StoreError::Media); - } - Ok(id) -} - -/// The footer length of the previous ride-object version. -const V5_FOOTER_LEN: usize = 150; - -/// Rewrite every finished v5 ride on the card as a v6 ride with no effort limits: the same object, -/// one revision on, with its samples unchanged. The v6 board rejects a v5 ride, and one unreadable -/// ride keeps the whole Rides menu from loading, so a development card is upgraded in place. -fn upgrade_v5_rides(store: &FlatStore) -> Result<(), StoreError> { - loop { - let mut found = None; - for entry in store.entries().filter(|e| e.kind == ObjectKind::Ride && e.flags == EntryFlags::NONE) { - if is_v5(store, &entry)? { - found = Some(entry); - break; - } - } - if !store.entries_ok() { - return Err(StoreError::Media); - } - let Some(entry) = found else { return Ok(()) }; - let samples = entry.payload_len - V5_FOOTER_LEN as u64; - let mut footer = [0; FOOTER_LEN]; - let handle = store.open(entry.id, Some(entry.revision))?; - read_exact(store, &handle, samples, &mut footer[..V5_FOOTER_LEN])?; - footer[4] = obc_formats::ride::VERSION; - footer[6..8].copy_from_slice(&(FOOTER_LEN as u16).to_le_bytes()); - obc_formats::ride::decode_footer(&footer).map_err(|_| StoreError::Invalid)?; - let copy = |allocation: &mut Allocation| { - let mut buffer = [0; 500]; - let mut offset = 0; - while offset < samples { - let count = (samples - offset).min(buffer.len() as u64) as usize; - read_exact(store, &handle, offset, &mut buffer[..count])?; - store.write(allocation, &buffer[..count])?; - offset += count as u64; - } - store.write(allocation, &footer).map(|_| ()) - }; - let replaced = replace(store, &entry, copy, samples + FOOTER_LEN as u64); - store.close(handle); - replaced?; - } -} - -/// Whether a finished ride ends in a v5 footer over whole samples. -fn is_v5(store: &FlatStore, entry: &EntryMeta) -> Result { - let len = entry.payload_len; - if len < V5_FOOTER_LEN as u64 || !(len - V5_FOOTER_LEN as u64).is_multiple_of(SAMPLE_LEN as u64) { - return Ok(false); - } - let mut head = [0; 8]; - let handle = store.open(entry.id, Some(entry.revision))?; - let read = read_exact(store, &handle, len - V5_FOOTER_LEN as u64, &mut head); - store.close(handle); - read?; - Ok(head[..5] == *b"OBRF\x05" && u16::from_le_bytes([head[6], head[7]]) as usize == V5_FOOTER_LEN) -} - -/// Replace `entry` with `len` bytes that `fill` writes: the same object and name, one revision on. -fn replace( - store: &FlatStore, - entry: &EntryMeta, - fill: impl FnOnce(&mut Allocation) -> Result<(), StoreError>, - len: u64, -) -> Result<(), StoreError> { - let mut allocation = store.allocate(len)?; - fill(&mut allocation)?; - let revision = Revision(entry.revision.0.checked_add(1).ok_or(StoreError::Invalid)?); - let meta = EntryMeta { revision, payload_len: len, payload_crc: store.allocation_crc(&allocation)?, ..*entry }; - store - .commit(&[ - Mutation::Remove { id: entry.id, revision: entry.revision }, - Mutation::Put { meta, source: PutSource::Fresh(allocation) }, - ]) - .map(|_| ()) -} - -fn read_exact( - store: &FlatStore, - handle: &Handle, - offset: u64, - out: &mut [u8], -) -> Result<(), StoreError> { - let mut done = 0; - while done < out.len() { - let count = store.read(handle, offset + done as u64, &mut out[done..])?; - if count == 0 { - return Err(StoreError::Media); - } - done += count; - } - Ok(()) -} - -fn check_store(store: &FlatStore) -> Result<(), StoreError> { - if store.mode() != Mode::ReadWrite { - return Err(StoreError::ReadOnly); - } - if store.recovered_ride().is_some() { - return Err(StoreError::Busy); - } - Ok(()) -} - -fn payload_matches(store: &FlatStore, id: ObjectId, bytes: &[u8]) -> Result { - store - .with_source(id, None, |source| -> Result { - if source.len() != bytes.len() as u64 { - return Ok(false); - } - let mut buffer = [0; 512]; - for (index, chunk) in bytes.chunks(512).enumerate() { - source.read_at((index * 512) as u64, &mut buffer[..chunk.len()])?; - if &buffer[..chunk.len()] != chunk { - return Ok(false); - } - } - Ok(true) - })? - .map_err(|_| StoreError::Media) -} - -fn write_ride(store: &FlatStore, sensors: usize, start: u32) -> Result { - let mut allocation = store.allocate(PAYLOAD_LEN)?; - let mut crc = Crc32::new(); - let mut distance = 0.0; - let (mut climb, mut descent, mut hr_sum, mut power_sum) = (0, 0, 0, 0); - let (mut max_hr, mut max_power) = (0, 0); - let mut previous = point(0, sensors); - for index in 0..=INTERVALS { - let sample = point(index, sensors); - if index > 0 { - distance += obc_map_scene::ground_dist_m((previous.lon, previous.lat), (sample.lon, sample.lat)); - let rise = i32::from(sample.ele) - i32::from(previous.ele); - climb += rise.max(0) as u16; - descent += (-rise).max(0) as u16; - } - if index < INTERVALS { - hr_sum += u32::from(sample.hr.unwrap_or(0)); - power_sum += u32::from(sample.power.unwrap_or(0)); - } - max_hr = max_hr.max(sample.hr.unwrap_or(0)); - max_power = max_power.max(sample.power.unwrap_or(0)); - previous = sample; - let bytes = encode_record(&sample); - store.write(&mut allocation, &bytes)?; - crc.update(&bytes); - } - let duration = INTERVALS * STEP_SECONDS; - let mut footer = Footer::new( - NAMES[sensors], - start, - distance as u32, - duration, - (distance * 100.0 / duration as f32) as u16, - climb, - INTERVALS + 1, - (sensors >= 1).then_some((hr_sum / INTERVALS) as u8), - (sensors >= 1).then_some(max_hr), - None, - (sensors == 2).then_some((power_sum / INTERVALS) as u16), - (sensors == 2).then_some(max_power), - ); - footer.descent_m = descent; - footer.energy_kj = (sensors == 2).then_some(power_sum * STEP_SECONDS / 1000); - if sensors >= 1 { - footer.limits = EffortLimits { max_hr: 185, ftp_w: 250 }; - } - let bytes = encode_footer(&footer); - store.write(&mut allocation, &bytes)?; - crc.update(&bytes); - let id = store.next_object_id(); - let payload_crc = crc.finalize(); - store.commit(&[Mutation::Put { - meta: EntryMeta { - id, - revision: Revision(1), - kind: ObjectKind::Ride, - flags: EntryFlags::NONE, - payload_len: PAYLOAD_LEN, - payload_crc, - name: DisplayName::new(NAMES[sensors]).ok_or(StoreError::Invalid)?, - added_at_utc: start, - }, - source: PutSource::Fresh(allocation), - }])?; - let handle = store.open(id, None)?; - let mut check = Crc32::new(); - let mut buffer = [0; 512]; - let mut offset = 0; - while offset < PAYLOAD_LEN { - let count = store.read(&handle, offset, &mut buffer)?; - if count == 0 { - return Err(StoreError::Media); - } - check.update(&buffer[..count]); - offset += count as u64; - } - store.close(handle); - if check.finalize() != payload_crc { - return Err(StoreError::Media); - } - Ok(id) -} diff --git a/firmware/obc-fw-nrf54l/src/factory_demo_ride.rs b/firmware/obc-fw-nrf54l/src/factory_demo_ride.rs new file mode 100644 index 000000000..76a256e1d --- /dev/null +++ b/firmware/obc-fw-nrf54l/src/factory_demo_ride.rs @@ -0,0 +1,112 @@ +//! Factory-only demo ride writer. The factory image halts after this step. + +use obc_formats::io::ByteSource; +use obc_formats::ride::{checked_object_len, decode_footer, FOOTER_LEN, SAMPLE_LEN}; +use obc_formats::track::{decode_record, FLAG_SEGMENT_START}; +use obc_storage::flat::{ + BlockDevice, DisplayName, EntryFlags, EntryMeta, FlatStore, Mode, Mutation, ObjectId, ObjectKind, PutSource, + Revision, Store, StoreError, +}; + +/// Add one finished ride from validated bytes. An existing name must hold the same payload. +pub fn seed(store: &FlatStore, bytes: &[u8]) -> Result { + check_store(store)?; + let footer = decode_footer(bytes.last_chunk().ok_or(StoreError::Invalid)?).map_err(|_| StoreError::Invalid)?; + if !footer.is_demo + || footer.start_time == 0 + || footer.point_count < 2 + || checked_object_len(footer.point_count).map_err(|_| StoreError::Invalid)? != bytes.len() as u64 + { + return Err(StoreError::Invalid); + } + let mut last_time = None; + for sample in bytes[..bytes.len() - FOOTER_LEN].as_chunks::().0 { + let flags = u16::from_le_bytes([sample[10], sample[11]]); + let point = decode_record(sample); + if flags & !FLAG_SEGMENT_START != 0 + || !(-180_000_000..=180_000_000).contains(&point.lon) + || !(-90_000_000..=90_000_000).contains(&point.lat) + || last_time.is_some_and(|time| point.t_ms < time) + { + return Err(StoreError::Invalid); + } + last_time = Some(point.t_ms); + } + let name = DisplayName::new(footer.name()).ok_or(StoreError::Invalid)?; + let mut rides = 0; + let mut existing = None; + for entry in store.entries() { + if entry.flags == EntryFlags::RECORDING { + return Err(StoreError::Busy); + } + if entry.kind == ObjectKind::Ride && entry.flags == EntryFlags::NONE { + rides += 1; + if entry.name == name { + if existing.is_some() { + return Err(StoreError::Invalid); + } + existing = Some(entry); + } + } + } + if !store.entries_ok() { + return Err(StoreError::Media); + } + if let Some(entry) = existing { + return if payload_matches(store, entry.id, bytes)? { Ok(entry.id) } else { Err(StoreError::Invalid) }; + } + if rides >= obc_app::MAX_RIDES { + return Err(StoreError::CatalogFull); + } + + let mut allocation = store.allocate(bytes.len() as u64)?; + for chunk in bytes.chunks(512) { + store.write(&mut allocation, chunk)?; + } + let id = store.next_object_id(); + store.commit(&[Mutation::Put { + meta: EntryMeta { + id, + revision: Revision(1), + kind: ObjectKind::Ride, + flags: EntryFlags::NONE, + payload_len: bytes.len() as u64, + payload_crc: obc_crc::crc32(bytes), + name, + added_at_utc: footer.start_time, + }, + source: PutSource::Fresh(allocation), + }])?; + if !payload_matches(store, id, bytes)? { + return Err(StoreError::Media); + } + Ok(id) +} + +fn check_store(store: &FlatStore) -> Result<(), StoreError> { + if store.mode() != Mode::ReadWrite { + return Err(StoreError::ReadOnly); + } + if store.recovered_ride().is_some() { + return Err(StoreError::Busy); + } + Ok(()) +} + +fn payload_matches(store: &FlatStore, id: ObjectId, bytes: &[u8]) -> Result { + store + .with_source(id, None, |source| -> Result { + if source.len() != bytes.len() as u64 { + return Ok(false); + } + let mut buffer = [0; 512]; + for (index, chunk) in bytes.chunks(512).enumerate() { + source.read_at((index * 512) as u64, &mut buffer[..chunk.len()])?; + if &buffer[..chunk.len()] != chunk { + return Ok(false); + } + } + Ok(true) + })? + .map_err(|_| StoreError::Media) +} diff --git a/firmware/obc-fw-nrf54l/src/main.rs b/firmware/obc-fw-nrf54l/src/main.rs index c4927489c..adc281c6f 100644 --- a/firmware/obc-fw-nrf54l/src/main.rs +++ b/firmware/obc-fw-nrf54l/src/main.rs @@ -20,9 +20,8 @@ mod board; mod buzzer; // The raw-card flat store and the board adapter that binds it to sEMMC. -#[cfg(feature = "seed-rides")] -#[allow(dead_code)] -mod demo_rides; +#[cfg(feature = "factory-demo-ride")] +mod factory_demo_ride; mod flat_ride; mod flat_store; // The microSD host over Nordic's sEMMC soft peripheral on the FLPR: native 4-bit SD mode, @@ -865,34 +864,13 @@ async fn main(_spawner: Spawner) { // mount is bounded by the catalog it reads. let flat_started = embassy_time::Instant::now(); let flat = flat_store::mount_at_boot(); - #[cfg(feature = "seed-rides")] + #[cfg(feature = "factory-demo-ride")] { - #[cfg(demo_ride_file)] - let result = demo_rides::seed_file(flat, include_bytes!(concat!(env!("OUT_DIR"), "/demo_ride.obcr"))); - #[cfg(not(demo_ride_file))] - let result = { - let start = option_env!("OBC_DEMO_RIDE_START").unwrap_or("0").parse().unwrap_or(0); - demo_rides::seed(flat, start) - }; - match result { - #[cfg(demo_ride_file)] - Ok(id) => { - info!("demo ride: object {}", id.0); - info!("demo rides: complete"); - } - #[cfg(not(demo_ride_file))] - Ok(ids) => { - for (name, id) in demo_rides::NAMES.iter().zip(ids) { - info!("demo rides: {} = object {}", name, id.0); - } - info!("demo rides: complete"); - } - Err(error) => { - defmt::error!("demo rides: refused: {:?}", defmt::Debug2Format(&error)); - idle_blink(&mut led).await - } + match factory_demo_ride::seed(flat, include_bytes!(concat!(env!("OUT_DIR"), "/demo_ride.obcr"))) { + Ok(id) => info!("factory demo ride: complete, object {}", id.0), + Err(error) => defmt::error!("factory demo ride: refused: {:?}", defmt::Debug2Format(&error)), } - if cfg!(demo_ride_file) { + if cfg!(feature = "factory-demo-ride") { idle_blink(&mut led).await } } diff --git a/host/obc-host-core/tests/demo_rides.rs b/host/obc-host-core/tests/demo_rides.rs deleted file mode 100644 index 92f79463c..000000000 --- a/host/obc-host-core/tests/demo_rides.rs +++ /dev/null @@ -1,252 +0,0 @@ -#[path = "../../../firmware/obc-fw-nrf54l/src/demo_rides.rs"] -mod demo_rides; - -use obc_formats::io::SliceSource; -use obc_formats::ride::{decode_footer, encode_footer, EffortLimits, Footer, FOOTER_LEN}; -use obc_formats::track::{decode_record, RECORD_LEN}; -use obc_storage::flat::{ - sim::SparseDisk, DisplayName, EntryFlags, EntryMeta, FlatStore, Mutation, ObjectId, ObjectKind, PutSource, - Revision, Store, StoreError, StoreId, -}; - -fn finished_file(name: &str, start: u32) -> Vec { - let sample = obc_formats::track::encode_record(&obc_ports::TrackPoint { - lon: 7_800_000, - lat: 48_000_000, - ele: 200, - t_ms: 0, - segment_start: true, - hr: None, - cadence: None, - power: None, - }); - let footer = encode_footer(&Footer::new(name, start, 0, 0, 0, 0, 40, None, None, None, None, None)); - let mut bytes = sample.repeat(40); - bytes.extend_from_slice(&footer); - bytes -} - -#[test] -fn finished_rides_survive_remount_with_distinct_sensor_data_and_no_duplicates() { - let disk = SparseDisk::blank(131_072, 7); - let store = FlatStore::initialize(&disk, StoreId([7; 16])).unwrap(); - let mut allocation = store.allocate(4).unwrap(); - store.write(&mut allocation, b"keep").unwrap(); - let existing = EntryMeta { - id: store.next_object_id(), - revision: Revision(1), - kind: ObjectKind::Route, - flags: EntryFlags::NONE, - payload_len: 4, - payload_crc: obc_crc::crc32(b"keep"), - name: DisplayName::new("Existing route").unwrap(), - added_at_utc: 123, - }; - store.commit(&[Mutation::Put { meta: existing, source: PutSource::Fresh(allocation) }]).unwrap(); - let ids = demo_rides::seed(&store, 1_700_000_000).unwrap(); - let sequence = store.sequence(); - let store = FlatStore::mount(&disk); - assert_eq!(demo_rides::seed(&store, 1_800_000_000).unwrap(), ids); - assert_eq!(store.sequence(), sequence); - assert_eq!(store.entries().find(|entry| entry.id == existing.id), Some(existing)); - let mut preserved = [0; 4]; - store.read(&store.open(existing.id, None).unwrap(), 0, &mut preserved).unwrap(); - assert_eq!(&preserved, b"keep"); - for (index, id) in ids.into_iter().enumerate() { - let entry = store.entries().find(|entry| entry.id == id).unwrap(); - assert_eq!(entry.kind, ObjectKind::Ride); - let mut bytes = vec![0; entry.payload_len as usize]; - let handle = store.open(id, None).unwrap(); - assert_eq!(store.read(&handle, 0, &mut bytes).unwrap(), bytes.len()); - assert_eq!(obc_crc::crc32(&bytes), entry.payload_crc); - let info = obc_route::RideInfo::read(&SliceSource(&bytes)).unwrap(); - assert_eq!(info.name.as_str(), demo_rides::NAMES[index]); - assert_eq!(info.moving_time_s, 1800); - assert!((8_000..12_000).contains(&info.distance_m), "{} m", info.distance_m); - assert_eq!(info.start_time, 1_700_000_000 + index as u32 * 86_400); - assert_eq!(info.avg_hr.is_some(), index >= 1); - assert_eq!(info.avg_power.is_some(), index == 2); - assert_eq!(info.energy_kj.is_some(), index == 2); - let points: Vec<_> = bytes[..info.point_count as usize * RECORD_LEN] - .as_chunks::() - .0 - .iter() - .map(decode_record) - .collect(); - assert_eq!(points.last().unwrap().t_ms, 1_800_000); - for (i, point) in points.iter().enumerate() { - assert_eq!(point.t_ms, i as u32 * 5000); - assert_eq!(point.hr.is_some(), index >= 1); - assert_eq!(point.power.is_some(), index == 2); - assert_eq!(point.cadence, None); - assert_eq!(point.segment_start, i == 0); - } - } -} - -#[test] -fn unformatted_card_is_refused_without_writes() { - let disk = SparseDisk::blank(131_072, 8); - let store = FlatStore::mount(&disk); - assert_eq!(demo_rides::seed(&store, 1_700_000_000), Err(StoreError::ReadOnly)); - assert_eq!(demo_rides::seed_file(&store, &finished_file("Kandel", 1_700_000_000)), Err(StoreError::ReadOnly)); - assert!(disk.write_log().is_empty()); -} - -#[test] -fn active_recording_is_refused_without_writes() { - let disk = SparseDisk::blank(131_072, 9); - let store = FlatStore::initialize(&disk, StoreId([9; 16])).unwrap(); - let allocation = store.allocate(1024 * 1024).unwrap(); - store - .commit(&[Mutation::Put { - meta: EntryMeta { - id: ObjectId(1), - revision: Revision(1), - kind: ObjectKind::Ride, - flags: EntryFlags::RECORDING, - payload_len: 0, - payload_crc: 0, - name: DisplayName::new("Recording").unwrap(), - added_at_utc: 0, - }, - source: PutSource::Fresh(allocation), - }]) - .unwrap(); - let writes = disk.write_log().len(); - assert_eq!(demo_rides::seed(&store, 1_700_000_000), Err(StoreError::Busy)); - assert_eq!(demo_rides::seed_file(&store, &finished_file("Kandel", 1_700_000_000)), Err(StoreError::Busy)); - assert_eq!(disk.write_log().len(), writes); -} - -#[test] -fn file_seed_survives_remount_and_preserves_existing_objects() { - let disk = SparseDisk::blank(131_072, 10); - let store = FlatStore::initialize(&disk, StoreId([10; 16])).unwrap(); - let mut allocation = store.allocate(4).unwrap(); - store.write(&mut allocation, b"keep").unwrap(); - let existing = EntryMeta { - id: store.next_object_id(), - revision: Revision(1), - kind: ObjectKind::Route, - flags: EntryFlags::NONE, - payload_len: 4, - payload_crc: obc_crc::crc32(b"keep"), - name: DisplayName::new("Existing route").unwrap(), - added_at_utc: 123, - }; - store.commit(&[Mutation::Put { meta: existing, source: PutSource::Fresh(allocation) }]).unwrap(); - - let bytes = finished_file("Kandel", 1_700_000_000); - let id = demo_rides::seed_file(&store, &bytes).unwrap(); - let sequence = store.sequence(); - let store = FlatStore::mount(&disk); - assert_eq!(demo_rides::seed_file(&store, &bytes), Ok(id)); - assert_eq!(store.sequence(), sequence); - assert_eq!(store.entries().find(|entry| entry.id == existing.id), Some(existing)); - let mut preserved = [0; 4]; - store.read(&store.open(existing.id, None).unwrap(), 0, &mut preserved).unwrap(); - assert_eq!(&preserved, b"keep"); - let entry = store.entries().find(|entry| entry.id == id).unwrap(); - assert_eq!(entry.payload_crc, obc_crc::crc32(&bytes)); - let mut stored = vec![0; bytes.len()]; - store.read(&store.open(id, None).unwrap(), 0, &mut stored).unwrap(); - assert_eq!(stored, bytes); - assert_eq!(obc_route::RideInfo::read(&SliceSource(&stored)).unwrap().name.as_str(), "Kandel"); -} - -#[test] -fn file_seed_refuses_invalid_or_conflicting_payload_without_writes() { - let disk = SparseDisk::blank(131_072, 11); - let store = FlatStore::initialize(&disk, StoreId([11; 16])).unwrap(); - let bytes = finished_file("Kandel", 1_700_000_000); - let mut invalid = bytes.clone(); - invalid.pop(); - let mut reserved_flags = bytes.clone(); - reserved_flags[10] = 2; - let mut bad_latitude = bytes.clone(); - bad_latitude[4..8].copy_from_slice(&91_000_000i32.to_le_bytes()); - let mut backward_time = bytes.clone(); - backward_time[RECORD_LEN + 12..RECORD_LEN + 16].copy_from_slice(&1u32.to_le_bytes()); - let writes = disk.write_log().len(); - for invalid in [&invalid, &reserved_flags, &bad_latitude, &backward_time] { - assert_eq!(demo_rides::seed_file(&store, invalid), Err(StoreError::Invalid)); - assert_eq!(disk.write_log().len(), writes); - } - - demo_rides::seed_file(&store, &bytes).unwrap(); - let conflicting = finished_file("Kandel", 1_800_000_000); - let writes = disk.write_log().len(); - assert_eq!(demo_rides::seed_file(&store, &conflicting), Err(StoreError::Invalid)); - assert_eq!(disk.write_log().len(), writes); -} - -/// A v5 ride from the previous firmware: the same samples under the 150-byte v5 footer. -fn v5_file(name: &str, start: u32) -> Vec { - let mut bytes = finished_file(name, start); - bytes.truncate(bytes.len() - 4); - let footer = bytes.len() - 150; - bytes[footer + 4] = 5; - bytes[footer + 6..footer + 8].copy_from_slice(&150u16.to_le_bytes()); - bytes -} - -fn put_ride(store: &FlatStore<&SparseDisk>, bytes: &[u8], name: &str) -> EntryMeta { - let mut allocation = store.allocate(bytes.len() as u64).unwrap(); - store.write(&mut allocation, bytes).unwrap(); - let meta = EntryMeta { - id: store.next_object_id(), - revision: Revision(1), - kind: ObjectKind::Ride, - flags: EntryFlags::NONE, - payload_len: bytes.len() as u64, - payload_crc: obc_crc::crc32(bytes), - name: DisplayName::new(name).unwrap(), - added_at_utc: 1, - }; - store.commit(&[Mutation::Put { meta, source: PutSource::Fresh(allocation) }]).unwrap(); - meta -} - -fn stored(store: &FlatStore<&SparseDisk>, id: ObjectId) -> (Revision, Vec) { - let entry = store.entries().find(|entry| entry.id == id).unwrap(); - let mut bytes = vec![0; entry.payload_len as usize]; - let handle = store.open(id, None).unwrap(); - store.read(&handle, 0, &mut bytes).unwrap(); - store.close(handle); - (entry.revision, bytes) -} - -/// A card from the v5 firmware: its copy of the fixture is replaced in place, and every other v5 -/// ride becomes the same object one revision on, with its samples and a v6 footer without limits. -#[test] -fn file_seed_replaces_the_v5_fixture_and_upgrades_other_v5_rides_in_place() { - let disk = SparseDisk::blank(131_072, 12); - let store = FlatStore::initialize(&disk, StoreId([12; 16])).unwrap(); - let old_fixture = put_ride(&store, &v5_file("Kandel", 1_700_000_000), "Kandel"); - let recorded = v5_file("Morning ride", 1_600_000_000); - let other = put_ride(&store, &recorded, "Morning ride"); - // More rides than the store has open-object slots, so a leaked handle would refuse the seed. - let more: Vec<_> = - (0..6).map(|i| put_ride(&store, &v5_file("Older ride", 1_500_000_000 + i), "Older ride")).collect(); - - let mut fixture = finished_file("Kandel", 1_700_000_000); - let limits = fixture.len() - 4; - fixture[limits] = 185; - fixture[limits + 2..].copy_from_slice(&250u16.to_le_bytes()); - assert_eq!(demo_rides::seed_file(&store, &fixture), Ok(old_fixture.id)); - assert_eq!(stored(&store, old_fixture.id), (Revision(2), fixture.clone())); - - let (revision, upgraded) = stored(&store, other.id); - assert_eq!(revision, Revision(2)); - assert_eq!(upgraded[..40 * RECORD_LEN], recorded[..40 * RECORD_LEN], "the samples are unchanged"); - let footer = decode_footer(upgraded[upgraded.len() - FOOTER_LEN..].try_into().unwrap()).unwrap(); - assert_eq!((footer.name(), footer.start_time), ("Morning ride", 1_600_000_000)); - assert_eq!(footer.limits, EffortLimits::default()); - assert!(more.iter().all(|ride| stored(&store, ride.id).0 == Revision(2)), "every v5 ride is upgraded"); - - let sequence = store.sequence(); - let store = FlatStore::mount(&disk); - assert_eq!(demo_rides::seed_file(&store, &fixture), Ok(old_fixture.id)); - assert_eq!(store.sequence(), sequence, "a second run writes nothing"); -} diff --git a/host/obc-host-core/tests/factory_demo_ride.rs b/host/obc-host-core/tests/factory_demo_ride.rs new file mode 100644 index 000000000..c87fe61ff --- /dev/null +++ b/host/obc-host-core/tests/factory_demo_ride.rs @@ -0,0 +1,127 @@ +#[path = "../../../firmware/obc-fw-nrf54l/src/factory_demo_ride.rs"] +mod factory_demo_ride; + +use obc_formats::io::SliceSource; +use obc_formats::ride::{encode_footer, Footer}; +use obc_formats::track::RECORD_LEN; +use obc_storage::flat::{ + sim::SparseDisk, DisplayName, EntryFlags, EntryMeta, FlatStore, Mutation, ObjectId, ObjectKind, PutSource, + Revision, Store, StoreError, StoreId, +}; + +fn finished_file(name: &str, start: u32) -> Vec { + let sample = obc_formats::track::encode_record(&obc_ports::TrackPoint { + lon: 7_800_000, + lat: 48_000_000, + ele: 200, + t_ms: 0, + segment_start: true, + hr: None, + cadence: None, + power: None, + }); + let mut footer = Footer::new(name, start, 0, 0, 0, 0, 40, None, None, None, None, None); + footer.is_demo = true; + let footer = encode_footer(&footer); + let mut bytes = sample.repeat(40); + bytes.extend_from_slice(&footer); + bytes +} + +#[test] +fn unformatted_card_is_refused_without_writes() { + let disk = SparseDisk::blank(131_072, 8); + let store = FlatStore::mount(&disk); + assert_eq!(factory_demo_ride::seed(&store, &finished_file("Kandel", 1_700_000_000)), Err(StoreError::ReadOnly)); + assert!(disk.write_log().is_empty()); +} + +#[test] +fn active_recording_is_refused_without_writes() { + let disk = SparseDisk::blank(131_072, 9); + let store = FlatStore::initialize(&disk, StoreId([9; 16])).unwrap(); + let allocation = store.allocate(1024 * 1024).unwrap(); + store + .commit(&[Mutation::Put { + meta: EntryMeta { + id: ObjectId(1), + revision: Revision(1), + kind: ObjectKind::Ride, + flags: EntryFlags::RECORDING, + payload_len: 0, + payload_crc: 0, + name: DisplayName::new("Recording").unwrap(), + added_at_utc: 0, + }, + source: PutSource::Fresh(allocation), + }]) + .unwrap(); + let writes = disk.write_log().len(); + assert_eq!(factory_demo_ride::seed(&store, &finished_file("Kandel", 1_700_000_000)), Err(StoreError::Busy)); + assert_eq!(disk.write_log().len(), writes); +} + +#[test] +fn file_seed_survives_remount_and_preserves_existing_objects() { + let disk = SparseDisk::blank(131_072, 10); + let store = FlatStore::initialize(&disk, StoreId([10; 16])).unwrap(); + let mut allocation = store.allocate(4).unwrap(); + store.write(&mut allocation, b"keep").unwrap(); + let existing = EntryMeta { + id: store.next_object_id(), + revision: Revision(1), + kind: ObjectKind::Route, + flags: EntryFlags::NONE, + payload_len: 4, + payload_crc: obc_crc::crc32(b"keep"), + name: DisplayName::new("Existing route").unwrap(), + added_at_utc: 123, + }; + store.commit(&[Mutation::Put { meta: existing, source: PutSource::Fresh(allocation) }]).unwrap(); + + let bytes = finished_file("Kandel", 1_700_000_000); + let id = factory_demo_ride::seed(&store, &bytes).unwrap(); + let sequence = store.sequence(); + let store = FlatStore::mount(&disk); + assert_eq!(factory_demo_ride::seed(&store, &bytes), Ok(id)); + assert_eq!(store.sequence(), sequence); + assert_eq!(store.entries().find(|entry| entry.id == existing.id), Some(existing)); + let mut preserved = [0; 4]; + store.read(&store.open(existing.id, None).unwrap(), 0, &mut preserved).unwrap(); + assert_eq!(&preserved, b"keep"); + let entry = store.entries().find(|entry| entry.id == id).unwrap(); + assert_eq!(entry.payload_crc, obc_crc::crc32(&bytes)); + let mut stored = vec![0; bytes.len()]; + store.read(&store.open(id, None).unwrap(), 0, &mut stored).unwrap(); + assert_eq!(stored, bytes); + assert_eq!(obc_route::RideInfo::read(&SliceSource(&stored)).unwrap().name.as_str(), "Kandel"); +} + +#[test] +fn file_seed_refuses_invalid_or_conflicting_payload_without_writes() { + let disk = SparseDisk::blank(131_072, 11); + let store = FlatStore::initialize(&disk, StoreId([11; 16])).unwrap(); + let bytes = finished_file("Kandel", 1_700_000_000); + let mut invalid = bytes.clone(); + invalid.pop(); + let mut reserved_flags = bytes.clone(); + reserved_flags[10] = 2; + let mut bad_latitude = bytes.clone(); + bad_latitude[4..8].copy_from_slice(&91_000_000i32.to_le_bytes()); + let mut backward_time = bytes.clone(); + backward_time[RECORD_LEN + 12..RECORD_LEN + 16].copy_from_slice(&1u32.to_le_bytes()); + let mut not_demo = bytes.clone(); + let flags = not_demo.len() - obc_formats::ride::FOOTER_LEN + 33; + not_demo[flags] = 0; + let writes = disk.write_log().len(); + for invalid in [&invalid, &reserved_flags, &bad_latitude, &backward_time, ¬_demo] { + assert_eq!(factory_demo_ride::seed(&store, invalid), Err(StoreError::Invalid)); + assert_eq!(disk.write_log().len(), writes); + } + + factory_demo_ride::seed(&store, &bytes).unwrap(); + let conflicting = finished_file("Kandel", 1_800_000_000); + let writes = disk.write_log().len(); + assert_eq!(factory_demo_ride::seed(&store, &conflicting), Err(StoreError::Invalid)); + assert_eq!(disk.write_log().len(), writes); +} diff --git a/host/obc-replay/examples/gpx_to_ride.rs b/host/obc-replay/examples/gpx_to_ride.rs index 174ebc786..7e7b1fbf5 100644 --- a/host/obc-replay/examples/gpx_to_ride.rs +++ b/host/obc-replay/examples/gpx_to_ride.rs @@ -1,4 +1,4 @@ -//! Build a bounded, deterministic finished ride for device development. +//! Build a bounded, deterministic finished ride with synthetic sensors. use std::{env, fs, path::Path}; @@ -18,9 +18,15 @@ const EARTH_M: f64 = 6_371_000.0; const DEFAULT_LIMITS: EffortLimits = EffortLimits { max_hr: 185, ftp_w: 250 }; fn main() -> Result<(), String> { - let args: Vec<_> = env::args().collect(); + let mut args: Vec<_> = env::args().collect(); + let is_demo = args.last().is_some_and(|arg| arg == "--demo"); + if is_demo { + args.pop(); + } if args.len() != 5 && args.len() != 7 { - return Err("usage: gpx_to_ride INPUT.gpx OUTPUT.obcr START_UNIX_S RIDE_NAME [MAX_HR_BPM FTP_W]".into()); + return Err( + "usage: gpx_to_ride INPUT.gpx OUTPUT.obcr START_UNIX_S RIDE_NAME [MAX_HR_BPM FTP_W] [--demo]".into() + ); } let start_time = args[3].parse::().map_err(|_| "START_UNIX_S must be a Unix timestamp in seconds")?; let limits = match args.get(5..7) { @@ -35,7 +41,7 @@ fn main() -> Result<(), String> { return Err(format!("GPX exceeds {MAX_GPX_BYTES} bytes")); } let track = Track::load(input)?; - let bytes = build_ride(&track, start_time, &args[4], limits)?; + let bytes = build_ride(&track, start_time, &args[4], limits, is_demo)?; let output = Path::new(&args[2]); if let Some(parent) = output.parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; @@ -45,11 +51,27 @@ fn main() -> Result<(), String> { Ok(()) } -fn build_ride(track: &Track, start_time: u32, name: &str, limits: EffortLimits) -> Result, String> { - let points = &track.points; - if !(2..=MAX_POINTS).contains(&points.len()) { +fn build_ride( + track: &Track, + start_time: u32, + name: &str, + limits: EffortLimits, + is_demo: bool, +) -> Result, String> { + if !(2..=MAX_POINTS).contains(&track.points.len()) { return Err(format!("ride needs 2..={MAX_POINTS} track points")); } + let mut points = track.points.clone(); + if is_demo { + if points.windows(2).any(|pair| pair[1].t < pair[0].t) { + return Err("track times must not go backwards".into()); + } + // Whole-second GPX timestamps can repeat. Keep every demo point and give its + // synthetic sensor interval at least one second. + for i in 1..points.len() { + points[i].t = points[i].t.max(points[i - 1].t + 1.0); + } + } let mut distance = vec![0.0; points.len()]; let mut climb = DeadBand::::new(); for (i, p) in points.iter().enumerate() { @@ -144,6 +166,7 @@ fn build_ride(track: &Track, start_time: u32, name: &str, limits: EffortLimits) Some(avg(power_sum) as u16), Some(max_power), ); + footer.is_demo = is_demo; footer.descent_m = climb.descent().round() as u16; footer.energy_kj = Some((energy_j / 1000.0).round() as u32); footer.bike = BikeType::Road; @@ -185,13 +208,14 @@ mod tests { }) .collect(), }; - let bytes = build_ride(&track, 1_790_236_800, "Kandel (simulated)", DEFAULT_LIMITS).unwrap(); + let bytes = build_ride(&track, 1_790_236_800, "Kandel (simulated)", DEFAULT_LIMITS, false).unwrap(); let footer = decode_footer(bytes[bytes.len() - FOOTER_LEN..].try_into().unwrap()).unwrap(); assert_eq!(bytes.len() as u64, checked_object_len(track.points.len() as u32).unwrap()); assert_eq!(footer.point_count, track.points.len() as u32); assert_eq!(footer.name(), "Kandel (simulated)"); assert_eq!(footer.start_time, 1_790_236_800); assert_eq!(footer.limits, DEFAULT_LIMITS); + assert!(!footer.is_demo); assert!(footer.climb_m >= 20 && footer.descent_m >= 20); let records: Vec<_> = bytes[..bytes.len() - FOOTER_LEN].as_chunks::().0.iter().map(decode_record).collect(); @@ -203,7 +227,30 @@ mod tests { assert!(records[1].power.unwrap() > records[6].power.unwrap()); assert_eq!((records[6].power, records[6].cadence), (Some(0), Some(0))); assert!(records[6].hr.unwrap() > 105, "heart rate should lag the descent"); - assert_eq!(build_ride(&track, 1_790_236_800, "Kandel (simulated)", DEFAULT_LIMITS).unwrap(), bytes); + assert_eq!(build_ride(&track, 1_790_236_800, "Kandel (simulated)", DEFAULT_LIMITS, false).unwrap(), bytes); + } + + #[test] + fn factory_ride_uses_the_complete_app_route_and_marks_its_synthetic_sensors() { + let input = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx"); + let track = Track::load(&input).unwrap(); + let bytes = build_ride(&track, 1_767_258_000, "Grimsel Pass", DEFAULT_LIMITS, true).unwrap(); + let footer = decode_footer(bytes[bytes.len() - FOOTER_LEN..].try_into().unwrap()).unwrap(); + assert_eq!(track.points.len(), 3_003); + assert_eq!(footer.point_count, 3_003); + assert_eq!(bytes.len(), 60_214); + assert!(footer.is_demo); + assert_eq!(footer.name(), "Grimsel Pass"); + assert_eq!(footer.start_time, 1_767_258_000); + assert_eq!(footer.limits, DEFAULT_LIMITS); + let records = bytes[..bytes.len() - FOOTER_LEN].as_chunks::().0; + assert!(records.windows(2).all(|pair| decode_record(&pair[0]).t_ms < decode_record(&pair[1]).t_ms)); + for (source, record) in track.points.iter().zip(records) { + let sample = decode_record(record); + assert_eq!((sample.lat, sample.lon), (source.lat, source.lon)); + assert!(sample.hr.is_some() && sample.cadence.is_some() && sample.power.is_some()); + } } #[test] @@ -214,9 +261,9 @@ mod tests { GpxPoint { lat: 48_000_000, lon: 8_001_000, ele: None, t: 10.0 }, ], }; - assert!(build_ride(&track, 1, "test", DEFAULT_LIMITS).is_err()); + assert!(build_ride(&track, 1, "test", DEFAULT_LIMITS, false).is_err()); track.points[1].ele = Some(200.0); track.points[1].t = 0.0; - assert!(build_ride(&track, 1, "test", DEFAULT_LIMITS).is_err()); + assert!(build_ride(&track, 1, "test", DEFAULT_LIMITS, false).is_err()); } } diff --git a/host/obc-vectors/src/lib.rs b/host/obc-vectors/src/lib.rs index 0e9782e9f..0d445c5f4 100644 --- a/host/obc-vectors/src/lib.rs +++ b/host/obc-vectors/src/lib.rs @@ -212,7 +212,7 @@ pub fn ride_v6() -> Vec { v.push(142); // avg_hr v.push(176); // max_hr v.push(85); // avg_cad - v.push(0); // reserved + v.push(1); // flags: demo ride v.extend_from_slice(&le16(210)); // avg_pwr v.extend_from_slice(&le16(480)); // max_pwr v.extend_from_slice(&le32(756)); // energy kJ diff --git a/specs/vectors/ride-v6.bin b/specs/vectors/ride-v6.bin index 312145c752269ab90b5bf8aed9f0a29724c2ba81..1e9e13220e494de9e8ce43671961da6148a359e4 100644 GIT binary patch delta 11 Scmcb{c#UyFEF Date: Tue, 29 Sep 2026 00:11:09 +0200 Subject: [PATCH 02/17] docs: describe factory demo ride provisioning --- firmware/obc-fw-nrf54l/README.md | 13 +++++++------ fixtures/sources/kandel-ride/README.md | 12 +++++------- specs/obc-ble-interface-spec.md | 7 +++++-- specs/vectors/README.md | 2 +- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/firmware/obc-fw-nrf54l/README.md b/firmware/obc-fw-nrf54l/README.md index 171d3a45a..0f4d6bf6d 100644 --- a/firmware/obc-fw-nrf54l/README.md +++ b/firmware/obc-fw-nrf54l/README.md @@ -106,11 +106,12 @@ riscv64-elf-gcc`, or the apt package `gcc-riscv64-unknown-elf`, or `RISCV_GCC=

Date: Tue, 29 Sep 2026 00:13:16 +0200 Subject: [PATCH 03/17] fix: resolve the factory generator from the workspace root --- tools/justfile | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/justfile b/tools/justfile index 843d7de6c..de5663c5d 100644 --- a/tools/justfile +++ b/tools/justfile @@ -134,6 +134,7 @@ flash *args: if [[ -z "${OBC_DEMO_RIDE_FILE:-}" ]]; then export OBC_DEMO_RIDE_FILE="$OBC_ROOT/target/factory/grimsel-pass.obcr" start="$(python3 -c 'from datetime import datetime, timezone; print(int(datetime(datetime.now(timezone.utc).year, 1, 1, 9, tzinfo=timezone.utc).timestamp()))')" + cd "$OBC_ROOT" _run cargo run --release -p obc-replay --example gpx_to_ride -- \ "$OBC_ROOT/companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx" \ "$OBC_DEMO_RIDE_FILE" "$start" "Grimsel Pass" --demo From b3a5867d78de5db6a56cdc5c3b57a44f4cd609bc Mon Sep 17 00:00:00 2001 From: timohueser Date: Tue, 29 Sep 2026 00:17:28 +0200 Subject: [PATCH 04/17] feat(ios): guide first-use pairing and device naming --- .../OBCKit/Sources/OBCMock/MockControl.swift | 10 +- .../Sources/OBCMock/MockTransport.swift | 24 + .../OBCTransport/BLE/BLETransport.swift | 156 +++++-- .../Sources/OBCTransport/DevicePairing.swift | 21 + .../Sources/OBCTransport/DeviceRenaming.swift | 19 + .../OBCTransport/DeviceTransport.swift | 4 +- .../OBCUI/Components/DeviceGlyphView.swift | 16 + .../OBCUI/Gallery/OBCComponentGallery.swift | 10 +- .../OBCUI/Launch/LaunchFlowModel.swift | 338 +++++++------- .../Sources/OBCUI/Launch/LaunchFlowView.swift | 76 ++- .../OBCUI/Launch/LaunchStateScreens.swift | 3 + .../Sources/OBCUI/Launch/PairingScreens.swift | 178 +++----- .../Sources/OBCUI/Launch/WelcomeScreens.swift | 99 ++++ .../OBCUI/Settings/SettingsModel.swift | 9 +- .../OBCUITests/LaunchFlowModelTests.swift | 431 +++++++++--------- 15 files changed, 800 insertions(+), 594 deletions(-) create mode 100644 companion-ios/Packages/OBCKit/Sources/OBCTransport/DevicePairing.swift create mode 100644 companion-ios/Packages/OBCKit/Sources/OBCTransport/DeviceRenaming.swift create mode 100644 companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/WelcomeScreens.swift diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/MockControl.swift b/companion-ios/Packages/OBCKit/Sources/OBCMock/MockControl.swift index b23581dfb..7810d8cad 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCMock/MockControl.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCMock/MockControl.swift @@ -45,6 +45,7 @@ public final class MockControl: @unchecked Sendable { private var _latency: Duration private var _throughput: Int private var _radio: RadioState + private var _pairingDevices: [PairingDevice]? private var _bonded: Bool private var _bondedName: String? private var _pairingFail: PairingFail? @@ -151,6 +152,12 @@ public final class MockControl: @unchecked Sendable { set { lock.withLocked { _bondedName = newValue } } } + /// Nil uses the fixture device. An empty list models a scan with no nearby OBC. + public var pairingDevices: [PairingDevice]? { + get { lock.withLocked { _pairingDevices } } + set { lock.withLocked { _pairingDevices = newValue } } + } + public var deviceInfo: DeviceInfo { get { lock.withLocked { _fixtures.deviceInfo } } set { lock.withLocked { _fixtures.deviceInfo = newValue } } @@ -174,6 +181,7 @@ public final class MockControl: @unchecked Sendable { _radio = preset.radio _bonded = preset.bonded _bondedName = nil + _pairingDevices = nil _pairingFail = preset.pairingFail _pendingFailures = preset.pendingFailure.map { [$0] } ?? [] _dropFraction = preset.dropAtFraction @@ -725,7 +733,7 @@ extension NSLock { } extension DeviceInfo { - fileprivate func renamed(_ name: String) -> DeviceInfo { + func renamed(_ name: String) -> DeviceInfo { DeviceInfo(name: name, firmwareVersion: firmwareVersion, hardwareVersion: hardwareVersion, serial: serial, protocolVersion: protocolVersion, storeID: storeID, obcmVersion: obcmVersion) diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/MockTransport.swift b/companion-ios/Packages/OBCKit/Sources/OBCMock/MockTransport.swift index d459cf6f8..1a4e819ae 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCMock/MockTransport.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCMock/MockTransport.swift @@ -40,9 +40,32 @@ public struct MockTransport: DeviceTransport { try await authenticate() } + public func scanForPairing() async throws -> [PairingDevice] { + await control.delay() + try Task.checkCancellation() + try control.radioGate() + try control.takePendingFailure() + return control.pairingDevices ?? [PairingDevice( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + name: "OBC-\(control.deviceInfo.name)" + )] + } + + public func discover(_ candidate: PairingDevice) async throws { + let candidates = try await scanForPairing() + guard candidates.contains(where: { $0.id == candidate.id }) else { throw DeviceError.deviceNotFound } + try await discover() + if control.pairingDevices != nil { + var config = control.fixtures.config + config.name = candidate.name.hasPrefix("OBC-") ? String(candidate.name.dropFirst(4)) : candidate.name + control.setConfig(config) + } + } + public func discover() async throws { control.connection = .connecting await control.delay() + try Task.checkCancellation() do { try control.radioGate() try control.takePendingFailure() @@ -55,6 +78,7 @@ public struct MockTransport: DeviceTransport { public func authenticate() async throws { // The pairing gate stands in for the real path's LESC passkey sheet. await control.delay() + try Task.checkCancellation() do { try control.pairingGate() } catch { diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/BLE/BLETransport.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/BLE/BLETransport.swift index 89f821fe4..4feb0f712 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCTransport/BLE/BLETransport.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/BLE/BLETransport.swift @@ -20,12 +20,19 @@ import OBCProtocolV4 /// CoreBluetooth delivers every delegate callback on that queue. public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable { private let queue = DispatchQueue(label: "com.openbikecomputer.ble") - private lazy var central = CBCentralManager( - delegate: self, - queue: queue - ) + private var centralStorage: CBCentralManager? + private var central: CBCentralManager { + if let centralStorage { return centralStorage } + let manager = CBCentralManager(delegate: self, queue: queue) + centralStorage = manager + return manager + } private let discoveryStore: any BLEDiscoveryStore private var discoveryPolicy = BLEDiscoveryIntentPolicy() + private var pairingScan: AsyncPromise>? + private var pairingCandidates: [UUID: PairingDevice] = [:] + private var pairingSettle: DispatchWorkItem? + private var selectedPairingID: UUID? private let stateMulticast = AsyncMulticast(.disconnected) /// `nil` until the first real BAS value — the seed must not replay as "0%". @@ -60,9 +67,9 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable // Outstanding operations, touched only on `queue`. Connecting is two phases: `discover()` // un-gated, then `authenticate()` gated, which raises the passkey sheet. private var discoveryAttempt: AsyncPromise>? - private var authenticateContinuation: CheckedContinuation? + private var authenticateAttempt: AsyncPromise>? - /// True only across the gated-phase retry beat, where `authenticateContinuation` is + /// True only across the gated-phase retry beat, where `authenticateAttempt` is /// momentarily nil. A disconnect in this window is terminal: kicking the reconnect loop /// could re-raise the passkey sheet. private var awaitingGatedRetry = false @@ -107,8 +114,6 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable init(discoveryStore: any BLEDiscoveryStore) { self.discoveryStore = discoveryStore super.init() - - _ = central // force manager creation (and a state callback) } // MARK: DeviceTransport — lifecycle @@ -140,7 +145,38 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable try await authenticate() } + public func scanForPairing() async throws -> [PairingDevice] { + let attempt = AsyncPromise>() + return try await withTaskCancellationHandler { + queue.async { [self] in + guard attempt.current == nil else { return } + guard pairingScan == nil, !discoveryPolicy.hasIntent else { + attempt.fulfill(.failure(DeviceError.notConnected)) + return + } + pairingCandidates = [:] + pairingScan = attempt + startConnectIfReady() + } + return try await attempt.value.get() + } onCancel: { + attempt.fulfill(.failure(CancellationError())) + self.queue.async { [self] in + guard pairingScan === attempt else { return } + finishPairingScan(.failure(CancellationError())) + } + } + } + + public func discover(_ candidate: PairingDevice) async throws { + try await discover(selecting: candidate.id) + } + public func discover() async throws { + try await discover(selecting: nil) + } + + private func discover(selecting candidateID: UUID?) async throws { // Phase 1: scan, connect, and discover the services and the un-gated characteristics // only. Resolves once every service's characteristics are in hand, so `deviceInfo()` can // read them, and never touches a gated characteristic. @@ -148,10 +184,15 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable try await withTaskCancellationHandler { queue.async { [self] in guard attempt.current == nil else { return } - guard discoveryAttempt == nil else { + guard discoveryAttempt == nil, pairingScan == nil else { attempt.fulfill(.failure(DeviceError.notConnected)) return } + if let candidateID, pairingCandidates[candidateID] == nil { + attempt.fulfill(.failure(DeviceError.deviceNotFound)) + return + } + selectedPairingID = candidateID discoveryAttempt = attempt discoveryPolicy.requestForeground() startConnectIfReady() @@ -195,17 +236,28 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable // A terminal `DeviceError` already tore the intent down and is rethrown straight through. } - /// One gated-phase attempt: park the authenticate continuation and kick `beginAuthenticate()`. + /// One cancellable gated-phase attempt. Only its own cancellation can tear it down. /// Throws `GatedPairingWindowError` on a retryable failure, a `DeviceError` on a terminal one. private func runGatedPhaseOnce() async throws { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + let attempt = AsyncPromise>() + try await withTaskCancellationHandler { queue.async { [self] in - // This attempt is now live; from here `authenticateContinuation` - // (not `awaitingGatedRetry`) owns drop handling. + guard attempt.current == nil else { return } + guard authenticateAttempt == nil else { + attempt.fulfill(.failure(DeviceError.notConnected)) + return + } awaitingGatedRetry = false - authenticateContinuation = cont + authenticateAttempt = attempt beginAuthenticate() } + try await attempt.value.get() + } onCancel: { + attempt.fulfill(.failure(CancellationError())) + self.queue.async { [self] in + guard authenticateAttempt === attempt else { return } + failAuthenticate(.notConnected) + } } } @@ -213,10 +265,11 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable await withCheckedContinuation { (cont: CheckedContinuation) in queue.async { [self] in let cancelForegroundConnection = discoveryPolicy.cancelForeground() - if cancelForegroundConnection, let peripheral { central.cancelPeripheralConnection(peripheral) } - if central.isScanning, !discoveryPolicy.hasIntent { central.stopScan() } + if pairingScan != nil { finishPairingScan(.failure(DeviceError.notConnected)) } + if cancelForegroundConnection, let peripheral { centralStorage?.cancelPeripheralConnection(peripheral) } + if centralStorage?.isScanning == true, !discoveryPolicy.hasIntent { centralStorage?.stopScan() } if discoveryAttempt != nil { failDiscover(.notConnected) } - if authenticateContinuation != nil { failAuthenticate(.notConnected) } + if authenticateAttempt != nil { failAuthenticate(.notConnected) } stateMulticast.send(.disconnected) if discoveryPolicy.hasIntent { startConnectIfReady() } cont.resume() @@ -667,15 +720,15 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable // MARK: Connect flow (queue-confined) private func startConnectIfReady() { - guard discoveryPolicy.hasIntent else { return } + guard pairingScan != nil || discoveryPolicy.hasIntent else { return } guard discoveryPolicy.phase == .scanning || discoveryPolicy.phase == .idle else { return } switch central.state { case .poweredOn: - stateMulticast.send(.connecting) + if pairingScan == nil { stateMulticast.send(.connecting) } guard !central.isScanning else { return } // A known device is selected by its identifier in `discovered`. - let scanServices: [CBUUID]? = discoveryStore.knownPeripheralID() == nil - ? [GATT.obcControlService] : nil + let scanServices: [CBUUID]? = pairingScan != nil || selectedPairingID != nil + || discoveryStore.knownPeripheralID() == nil ? [GATT.obcControlService] : nil central.scanForPeripherals(withServices: scanServices) case .poweredOff: failRadioUnavailable(.bluetoothUnavailable(.poweredOff)) @@ -688,8 +741,17 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable } } - private func failRadioUnavailable(_ error: DeviceError) { + private func finishPairingScan(_ result: Result<[PairingDevice], Error>) { + pairingSettle?.cancel() + pairingSettle = nil + centralStorage?.stopScan() + pairingScan?.fulfill(result) + pairingScan = nil + if case .failure = result { pairingCandidates = [:] } + } + private func failRadioUnavailable(_ error: DeviceError) { + if pairingScan != nil { finishPairingScan(.failure(error)) } if discoveryAttempt != nil { failDiscover(error) } else { @@ -710,7 +772,7 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable } // Finish the protected subscription before opening the stream. A live CoC alone does // not prove the peer can deliver the result of any object operation. - armChannelWatchdog(after: authenticateContinuation != nil ? Self.pairingTimeout : Self.phaseTimeout) + armChannelWatchdog(after: authenticateAttempt != nil ? Self.pairingTimeout : Self.phaseTimeout) if control.isNotifying { openAuthenticatedChannel() } else { @@ -735,7 +797,7 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable let waiters = channelWaiters channelWaiters.removeAll() for waiter in waiters { waiter.resume(throwing: error) } - if authenticateContinuation != nil { + if authenticateAttempt != nil { failAuthenticate(error) } else if let peripheral { central.cancelPeripheralConnection(peripheral) @@ -803,8 +865,8 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable awaitingGatedRetry = false _ = discoveryPolicy.cancelForeground() stateMulticast.send(.disconnected) - authenticateContinuation?.resume(throwing: error) - authenticateContinuation = nil + authenticateAttempt?.fulfill(.failure(error)) + authenticateAttempt = nil if let peripheral { central.cancelPeripheralConnection(peripheral) } } @@ -815,8 +877,8 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable private func resolveAuthenticateRetryable() { disarmChannelWatchdog() awaitingGatedRetry = true - authenticateContinuation?.resume(throwing: GatedPairingWindowError()) - authenticateContinuation = nil + authenticateAttempt?.fulfill(.failure(GatedPairingWindowError())) + authenticateAttempt = nil } /// The single retry also failed with the pairing-window error, so the link may still be up @@ -884,8 +946,8 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable discoveryStore.saveKnownPeripheralID(peripheral.identifier) } awaitingGatedRetry = false - authenticateContinuation?.resume() - authenticateContinuation = nil + authenticateAttempt?.fulfill(.success(())) + authenticateAttempt = nil } @@ -1366,16 +1428,36 @@ extension BLETransport: CBCentralManagerDelegate { if central.state != .poweredOn { clearPhysicalLink() discoveryPolicy.didDisconnect() - if authenticateContinuation != nil { failAuthenticate(.notConnected) } + if authenticateAttempt != nil { failAuthenticate(.notConnected) } } startConnectIfReady() } public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { + if pairingScan != nil { + let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String + ?? peripheral.name ?? DeviceInfo.unnamed + pairingCandidates[peripheral.identifier] = PairingDevice(id: peripheral.identifier, name: name) + if pairingSettle == nil { + // Collect a short window after the first advertisement so nearby devices can + // appear together. Choosing the first radio response can pair the wrong OBC. + let attempt = pairingScan + let settle = DispatchWorkItem { [weak self] in + guard let self, pairingScan === attempt else { return } + let candidates = pairingCandidates.values.sorted { + $0.name == $1.name ? $0.id.uuidString < $1.id.uuidString : $0.name < $1.name + } + finishPairingScan(.success(candidates)) + } + pairingSettle = settle + queue.asyncAfter(deadline: .now() + 2, execute: settle) + } + return + } let action = discoveryPolicy.discovered( peripheralID: peripheral.identifier, - knownPeripheralID: discoveryStore.knownPeripheralID() + knownPeripheralID: selectedPairingID ?? discoveryStore.knownPeripheralID() ) switch action { case .ignore: @@ -1410,7 +1492,7 @@ extension BLETransport: CBCentralManagerDelegate { clearPhysicalLink() if discoveryAttempt != nil { failDiscover(.notConnected) - } else if discoveryPolicy.foregroundRequested { + } else if discoveryPolicy.foregroundRequested || pairingScan != nil { startConnectIfReady() } } @@ -1428,7 +1510,7 @@ extension BLETransport: CBCentralManagerDelegate { failDiscover(.notConnected) return } - if authenticateContinuation != nil { + if authenticateAttempt != nil { failAuthenticate(.pairingFailed) return } @@ -1442,7 +1524,7 @@ extension BLETransport: CBCentralManagerDelegate { return } stateMulticast.send(discoveryPolicy.foregroundRequested ? .outOfRange : .disconnected) - if discoveryPolicy.hasIntent { + if discoveryPolicy.hasIntent || pairingScan != nil { startConnectIfReady() } } @@ -1530,7 +1612,7 @@ extension BLETransport: CBPeripheralDelegate { // waiters fail as before; the retry re-opens the CoC. if Self.isRetryableGatedFailure( error, peripheralConnected: peripheral.state == .connected, - authenticatePending: authenticateContinuation != nil + authenticatePending: authenticateAttempt != nil ) { for cont in waiters { cont.resume(throwing: DeviceError.channelOpenFailed) } resolveAuthenticateRetryable() @@ -1602,7 +1684,7 @@ extension BLETransport: CBPeripheralDelegate { openAuthenticatedChannel() } else if Self.isRetryableGatedFailure( error, peripheralConnected: peripheral.state == .connected, - authenticatePending: authenticateContinuation != nil + authenticatePending: authenticateAttempt != nil ) { resolveAuthenticateRetryable() } else { diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/DevicePairing.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/DevicePairing.swift new file mode 100644 index 000000000..b58f37cb6 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/DevicePairing.swift @@ -0,0 +1,21 @@ +import Foundation +import OBCDomain + +/// An advertisement from one nearby OBC. The identifier selects the radio peer, never its name. +public struct PairingDevice: Identifiable, Equatable, Sendable { + public let id: UUID + public let name: String + + public init(id: UUID, name: String) { + self.id = id + self.name = name + } +} + +/// First-use discovery keeps the candidate scan separate from connecting and authentication. +public protocol DevicePairing: Sendable { + /// Collect nearby OBC advertisements before choosing. Cancellation stops the scan. + func scanForPairing() async throws -> [PairingDevice] + /// Connect only the selected candidate and discover its unprotected characteristics. + func discover(_ candidate: PairingDevice) async throws +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/DeviceRenaming.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/DeviceRenaming.swift new file mode 100644 index 000000000..2a7e9f5c6 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/DeviceRenaming.swift @@ -0,0 +1,19 @@ +import Foundation +import OBCDomain + +public enum DeviceRenaming { + public static func normalized(_ name: String) -> String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + .truncatedToUTF8Bytes(DeviceConfig.maxNameUTF8Bytes) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Rename through the existing config command, preserving every other setting. + public static func save(_ name: String, to transport: any DeviceConfiguration) async throws { + let name = normalized(name) + guard !name.isEmpty else { throw DeviceError.writeFailed } + var config = try await transport.readConfig() + config.name = name + try await transport.writeConfig(config) + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/DeviceTransport.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/DeviceTransport.swift index de991877b..c07618692 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCTransport/DeviceTransport.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/DeviceTransport.swift @@ -18,7 +18,7 @@ public protocol DeviceLink: Sendable { func discover() async throws /// First-time-pairing phase 2: the gated operations that establish the encrypted, /// LESC-authenticated link. This is what raises the system passkey sheet, so the launch flow - /// calls it on the device-row tap. Requires a prior `discover()`. + /// calls it after selecting a nearby device. Requires a prior `discover()`. func authenticate() async throws /// Tear the link down. func disconnect() async @@ -133,7 +133,7 @@ public protocol DeviceUpdates: Sendable { func installFirmware() async throws -> FirmwareInstallResult } -public protocol DeviceTransport: DeviceLink, DeviceBattery, DeviceConfiguration, +public protocol DeviceTransport: DeviceLink, DevicePairing, DeviceBattery, DeviceConfiguration, DeviceBonding, DeviceObjects, DeviceClock, DeviceUpdates { // MARK: Control plane func readDiagnostics() async throws -> Data diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/DeviceGlyphView.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/DeviceGlyphView.swift index 2ebdea0cb..028eef88c 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/DeviceGlyphView.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/DeviceGlyphView.swift @@ -7,6 +7,7 @@ import SwiftUI /// The screen draws a device page in its exact on-glass colours and Terminus. struct DeviceGlyphView: View { enum Variant { + case welcome /// The title bar with the device's name and the amber track squiggle. case home(name: String) /// The device's pairing card. The app never knows the code, so the digits are blanks. @@ -89,6 +90,8 @@ struct DeviceGlyphView: View { @ViewBuilder private var screenContent: some View { switch variant { + case .welcome: + DeviceWelcomeScreen() case .home(let name): DeviceHomeScreen(name: name) case .passkey: @@ -104,6 +107,7 @@ struct DeviceGlyphView: View { private var accessibilityText: String { switch variant { + case .welcome: "The bike computer's welcome screen" case .home(let name): "\(name), the bike computer" case .passkey: "The bike computer's pairing screen, which shows a six-digit code" case .routeOverview(let overview): "The bike computer's screen showing \(overview.name)" @@ -146,3 +150,15 @@ private struct DeviceHomeScreen: View { } } } + +private struct DeviceWelcomeScreen: View { + var body: some View { + Canvas { context, size in + context.deviceFrame(size: size, title: "WELCOME") + for (index, greeting) in ["Hello", "Hallo", "Bonjour", "Hola"].enumerated() { + context.pixelText(greeting, .display, x: 120, capTop: CGFloat(70 + index * 44), + color: OBCTheme.deviceInk, centered: true) + } + } + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Gallery/OBCComponentGallery.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Gallery/OBCComponentGallery.swift index 3acef3d46..73cd41523 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Gallery/OBCComponentGallery.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Gallery/OBCComponentGallery.swift @@ -231,16 +231,18 @@ public struct OBCComponentGallery: View { section("Launch & Pairing") { launchScreen { LaunchConnectingView(deviceName: "Trailhead") } launchScreen { LaunchConnectFailedView(deviceName: "Trailhead", onRetry: {}, onGoToRoutes: {}) } - launchScreen { PairIntroView(onStart: {}) } + launchScreen { WelcomeView(onStart: {}, onBrowse: {}) } + launchScreen { SwitchOnView(onFind: {}, onBack: {}) } + launchScreen { BluetoothPermissionView(onAllow: {}, onBack: {}, onBrowse: {}) } launchScreen { PairScanningView( - discovered: .init(name: "Trailhead"), - onTapDevice: {}, + devices: [], + onTapDevice: { _ in }, onCancel: {} ) } launchScreen { PairingBackdropView() } - launchScreen { PairedView(deviceName: "Trailhead", onContinue: {}) } + launchScreen { PairedView(deviceName: "Trailhead", name: .constant("Trailhead"), onSave: {}, onKeepName: {}) } launchScreen { PairFailedView(failure: .timeout, onRetry: {}, onHelp: {}) } launchScreen { PairFailedView(failure: .rejected, onRetry: {}, onHelp: {}) } launchScreen { RadioBlockedView(block: .off, onRetry: {}, onBrowseLibrary: {}) } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowModel.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowModel.swift index 77b1a038c..cd96a4e3a 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowModel.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowModel.swift @@ -3,27 +3,13 @@ import Observation import OBCDomain import OBCTransport -/// The launch branch and the first-run pairing flow, as one `@Observable` state machine the -/// host view renders; `Phase` below lists the screens. -/// -/// Depends only on `DeviceLink` and `BondStore`. Pairing is a two-phase connect: `startPairing` -/// runs the un-gated `discover()`, which surfaces the device row, and the row tap runs the gated -/// `authenticate()`, the op that raises the system passkey sheet. The sheet therefore lands in -/// the pairing beat, not on the scanning screen. "Have we bonded before" comes from the -/// `BondStore`, never from a CoreBluetooth detour. +/// Launch, nearby-device selection and pairing. Optional setup owns its own progress after `.setup`. @MainActor @Observable public final class LaunchFlowModel { - /// Why pairing failed; it selects the copy variant. public enum PairingFailure: Equatable, Sendable { - /// Scan ended without finding the device (`DeviceError.deviceNotFound`). case timeout - /// Found it, but pairing did not complete. Covers a declined or wrong passkey and a - /// device that refuses because it is already bonded to another phone: the device - /// suppresses its passkey and drops the link, and no distinguishable reason reaches the - /// app, so this one case carries the combined copy. case rejected - /// The headline for this failure. On the model, so the copy is testable without a view. public var title: String { switch self { case .timeout: "Couldn't find your OBC" @@ -31,9 +17,6 @@ public final class LaunchFlowModel { } } - /// The body copy. For `.rejected` it is deliberately combined: a declined passkey and an - /// already-bonded refusal arrive identically over the wire, so it offers both recoveries - /// without claiming which one happened. public var reason: String { switch self { case .timeout: @@ -44,64 +27,31 @@ public final class LaunchFlowModel { } } - /// Why the radio is unusable. - public enum RadioBlock: Equatable, Sendable { - case off - case denied - } - - /// The device row that slides into the scanning screen. - public struct DiscoveredDevice: Equatable, Sendable { - /// The clean device name, which the greeting shows and the bond record stores. - public var name: String + public enum RadioBlock: Equatable, Sendable { case off, denied } - public init(name: String) { - self.name = name - } - - /// What the device advertises. - public var advertisedName: String { - name.hasPrefix("OBC-") ? name : "OBC-\(name)" - } - } - - /// The screen being shown. The host view switches over this exhaustively. public enum Phase: Equatable, Sendable { - /// Pre-`start()` blank; it reads as the launch screen. case idle - /// Bonded and quietly reconnecting. Resolves to `.main`, or to `.connectFailed` when the - /// grace expires with the device silent. case connecting(deviceName: String) - /// The bonded device did not answer within the grace window. The background attempt - /// keeps listening either way. case connectFailed(deviceName: String) + case welcome case pairIntro - /// Scanning; the row slides in when `discovered` is non-nil. - case scanning(discovered: DiscoveredDevice?) - /// The beat while the system pairing completes. + case bluetoothPermission + case scanning(devices: [PairingDevice]) case pairing case paired(deviceName: String) case pairFailed(PairingFailure) - /// The radio is off, or Bluetooth access was denied. case radioBlocked(RadioBlock) - /// Hand over to the main screen. + case setup case main } - /// Flow pacing, injectable so the model tests run in milliseconds. public struct Timing: Sendable { - /// How long the connecting state may hold before it resolves: never a blocking - /// full-screen spinner. The connect attempt keeps trying in the background either way. public var connectGrace: Duration - /// The scan window; expiry drives the "we scanned for 30 seconds" copy. public var scanTimeout: Duration - /// The minimum dwell after the gated `authenticate()` resolves, so the pairing beat is - /// perceptible even when the mock authenticates instantly. public var pairingBeat: Duration public init( - connectGrace: Duration = .seconds(8), - scanTimeout: Duration = .seconds(30), + connectGrace: Duration = .seconds(8), scanTimeout: Duration = .seconds(30), pairingBeat: Duration = .milliseconds(700) ) { self.connectGrace = connectGrace @@ -111,67 +61,60 @@ public final class LaunchFlowModel { } public private(set) var phase: Phase = .idle + public var deviceNameDraft = "" + public private(set) var savingName = false + public private(set) var nameSaveError: String? + public var canSaveName: Bool { !savingName && !DeviceRenaming.normalized(deviceNameDraft).isEmpty } - private let transport: any DeviceLink + private let transport: any DeviceLink & DevicePairing & DeviceConfiguration private let bondStore: any BondStore private let timing: Timing + private let onboardingPending: @MainActor () -> Bool @ObservationIgnored private var flowTask: Task? - /// The one background bonded-connect attempt (see `startConnectAttemptIfNeeded`). @ObservationIgnored private var connectAttempt: Task? + @ObservationIgnored private var teardownTask: Task? public init( - transport: any DeviceLink, - bondStore: any BondStore, - timing: Timing = Timing() + transport: any DeviceLink & DevicePairing & DeviceConfiguration, + bondStore: any BondStore, timing: Timing = Timing(), + onboardingPending: @escaping @MainActor () -> Bool = { false } ) { self.transport = transport self.bondStore = bondStore self.timing = timing + self.onboardingPending = onboardingPending } deinit { flowTask?.cancel() connectAttempt?.cancel() + teardownTask?.cancel() } - // MARK: The launch branch - - /// Check the bond and branch. Call once. public func start() { guard phase == .idle else { return } - if let bond = bondStore.load() { - beginBondedConnect(bond) - } else { - phase = .pairIntro - } + if let bond = bondStore.load() { beginBondedConnect(bond) } else { phase = .welcome } } + private var connectedDestination: Phase { onboardingPending() ? .setup : .main } + private func beginBondedConnect(_ bond: BondRecord) { phase = .connecting(deviceName: bond.deviceName) flowTask = Task { [transport, timing] in - // The state stream replays its latest value: already connected, or degraded but - // known, means there is nothing to wait for, and out of range means the transport's - // own reconnect loop is already on it. var current: ConnectionState? for await state in transport.state { current = state; break } + guard !Task.isCancelled else { return } if current == .connected || current == .outOfRange { - phase = .main + phase = connectedDestination return } startConnectAttemptIfNeeded() - // Watch for the link under the grace cap. The connect attempt runs unstructured, - // because the real `connect()` is not cancellation-responsive while it scans for an - // absent device: racing it inside a task group wedged the group's implicit drain. let connected = await Self.linkCameUp(transport.state, within: timing.connectGrace) guard !Task.isCancelled else { return } - phase = connected ? .main : .connectFailed(deviceName: bond.deviceName) + phase = connected ? connectedDestination : .connectFailed(deviceName: bond.deviceName) } } - /// The background bonded-connect attempt, started at most once. While the device is out of - /// reach the transport keeps scanning, so "Try again" re-watches this same attempt under a - /// fresh grace window instead of stacking scans. When it resolves it finishes a still-waiting - /// screen: a hard failure degrades to main, because the library never locks. private func startConnectAttemptIfNeeded() { guard connectAttempt == nil else { return } connectAttempt = Task { [transport, weak self] in @@ -181,19 +124,13 @@ public final class LaunchFlowModel { switch phase { case .connecting, .connectFailed: flowTask?.cancel() - phase = .main - default: - break + phase = connectedDestination + default: break } } } - /// Whether `states` reports `.connected` within `grace`. Both children are - /// cancellation-responsive, so the group's implicit drain cannot wedge. - private static func linkCameUp( - _ states: AsyncStream, - within grace: Duration - ) async -> Bool { + private static func linkCameUp(_ states: AsyncStream, within grace: Duration) async -> Bool { await withTaskGroup(of: Bool.self) { group in group.addTask { for await state in states where state == .connected { return true } @@ -209,133 +146,200 @@ public final class LaunchFlowModel { } } - /// "Try again": back to connecting under a fresh grace window. A bond that vanished - /// underneath, from a raced forget, falls back to the pairing prompt. public func retryConnect() { - guard let bond = bondStore.load() else { - phase = .pairIntro - return - } + guard let bond = bondStore.load() else { phase = .welcome; return } flowTask?.cancel() beginBondedConnect(bond) } - // MARK: The pairing flow + public static func acceptsPairingLink(_ url: URL) -> Bool { + guard let link = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } + return link.scheme?.lowercased() == "https" && link.host?.lowercased() == "openbikecomputer.com" + && link.percentEncodedPath == "/app" && link.query == nil && link.fragment == nil + && link.user == nil && link.password == nil && link.port == nil + } - /// Scan and discover the un-gated surface under the scan window, then show the found device - /// as a row. This is `discover()`, not `connect()`: touching a gated characteristic raises - /// the passkey sheet, and that is deferred to the row tap. + /// The fixed QR link selects the pairing door, never a different device or an existing bond. + public func openPairingLink() { + switch phase { + case .scanning, .pairing, .paired, .setup: return + default: break + } + if bondStore.load() != nil { + if phase == .idle { start() } + } else { + startPairing() + } + } + + public func showSwitchOn() { + cancelPairingWork() + phase = .pairIntro + } + + public func showWelcome() { + cancelPairingWork() + phase = .welcome + } + + /// Present the explanation before anything can instantiate the Bluetooth manager. public func startPairing() { - flowTask?.cancel() - phase = .scanning(discovered: nil) + guard bondStore.load() == nil else { return } + cancelPairingWork() + phase = .bluetoothPermission + } + + public func allowBluetooth() { + guard phase == .bluetoothPermission || isRetryScreen else { return } + cancelPairingWork() + let teardown = teardownTask + phase = .scanning(devices: []) flowTask = Task { [transport, timing] in + await teardown?.value + guard !Task.isCancelled else { return } do { - try await Self.withScanWindow(timing.scanTimeout) { - try await transport.discover() + let devices = try await Self.withScanWindow(timing.scanTimeout) { + try await transport.scanForPairing() } - // The device exists; let the row slide in and wait for the rider's tap. - let name = (try? await transport.deviceInfo().name) ?? DeviceInfo.unnamed guard !Task.isCancelled else { return } - phase = .scanning(discovered: DiscoveredDevice(name: name)) + guard !devices.isEmpty else { throw DeviceError.deviceNotFound } + if devices.count == 1 { + await pair(devices[0]) + } else { + phase = .scanning(devices: devices) + } } catch { + guard !Task.isCancelled else { return } + await transport.disconnect() guard !Task.isCancelled else { return } phase = Self.failurePhase(for: error) } } } - /// The row tap: run the gated `authenticate()`, the operation that raises the system passkey - /// sheet, inside the pairing beat that is already on screen. On success record the bond and - /// celebrate; a decline drops to the failure screen. - public func confirmPairing() { - guard case .scanning(.some(let device)) = phase else { return } + public func confirmPairing(_ device: PairingDevice) { + guard case .scanning(let devices) = phase, devices.contains(device) else { return } + phase = .pairing + flowTask = Task { await pair(device) } + } + + private func pair(_ device: PairingDevice) async { phase = .pairing - flowTask = Task { [transport, bondStore, timing] in + do { + try await Self.withScanWindow(timing.scanTimeout) { [transport] in + try await transport.discover(device) + } + try Task.checkCancellation() + let name = (try? await transport.deviceInfo())?.name ?? device.name + try await Task.sleep(for: timing.pairingBeat) + try await transport.authenticate() + try Task.checkCancellation() + // Store the bond and advance together: Cancel cannot land in a success animation + // after pairing has already succeeded. + bondStore.save(BondRecord(deviceName: name)) + deviceNameDraft = name + nameSaveError = nil + phase = .paired(deviceName: name) + } catch { + guard !Task.isCancelled else { return } + await transport.disconnect() + guard !Task.isCancelled else { return } + phase = Self.failurePhase(for: error) + } + } + + /// The name is committed on the OBC before the phone records it or advances. + public func saveNameAndContinue() { + guard case .paired(let current) = phase, canSaveName else { return } + let name = DeviceRenaming.normalized(deviceNameDraft) + guard name != current else { finishPairing(); return } + savingName = true + nameSaveError = nil + flowTask = Task { [transport, bondStore] in do { - try await transport.authenticate() + try await DeviceRenaming.save(name, to: transport) + guard !Task.isCancelled else { return } + bondStore.save(BondRecord(deviceName: name)) + deviceNameDraft = name + savingName = false + phase = .setup } catch { guard !Task.isCancelled else { return } - phase = Self.failurePhase(for: error) - return + savingName = false + nameSaveError = "The name did not save. Keep your OBC nearby and try again, or keep its current name." } - // A minimum dwell, so success does not snap straight to the greeting. - try? await Task.sleep(for: timing.pairingBeat) - guard !Task.isCancelled else { return } - bondStore.save(BondRecord(deviceName: device.name)) - phase = .paired(deviceName: device.name) } } public func finishPairing() { - phase = .main + guard case .paired = phase, !savingName else { return } + phase = .setup } - public func retryPairing() { - startPairing() - } + public func finishSetup() { phase = .main } - public func showPairingHelp() { - flowTask?.cancel() - phase = .pairIntro + public func replaySetup() { + cancelPairingWork() + phase = bondStore.load() == nil ? .welcome : .setup } - /// Stop the scan, or drop a half-open link, and step back. - public func cancelScanning() { - flowTask?.cancel() - flowTask = Task { [transport] in - await transport.disconnect() - } - phase = .pairIntro - } + public func retryPairing() { allowBluetooth() } + public func showPairingHelp() { showSwitchOn() } + public func cancelScanning() { showSwitchOn() } - /// The library never locks. A still-running connect attempt keeps listening, so the link - /// comes up on its own once the device is nearby. public func browseLibrary() { - flowTask?.cancel() + cancelPairingWork() phase = .main } - /// The bond record is already cleared and the link dropped by the Settings flow; cancel - /// anything in flight and return to the pairing prompt. public func forgetDevice() { - flowTask?.cancel() - connectAttempt?.cancel() // its completion must not touch the phase now + cancelPairingWork() + connectAttempt?.cancel() connectAttempt = nil - phase = .pairIntro + phase = .welcome } - // MARK: Helpers + private var isRetryScreen: Bool { + switch phase { + case .pairFailed, .radioBlocked: true + default: false + } + } + + private func cancelPairingWork() { + flowTask?.cancel() + flowTask = nil + switch phase { + case .scanning, .pairing: + let previous = teardownTask + teardownTask = Task { [transport] in + await previous?.value + await transport.disconnect() + } + default: break + } + } - /// Run `connect` under the scan window; expiry throws `deviceNotFound`. - private static func withScanWindow( - _ window: Duration, - _ connect: @escaping @Sendable () async throws -> Void - ) async throws { - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { try await connect() } + private static func withScanWindow( + _ window: Duration, _ operation: @escaping @Sendable () async throws -> T + ) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } group.addTask { try await Task.sleep(for: window) throw DeviceError.deviceNotFound } - // First child to finish decides; the loser's error is discarded with the group. - try await group.next() - group.cancelAll() + defer { group.cancelAll() } + return try await group.next()! } } private static func failurePhase(for error: Error) -> Phase { switch error { - case DeviceError.bluetoothUnavailable(.unauthorized): - return .radioBlocked(.denied) - case DeviceError.bluetoothUnavailable: - return .radioBlocked(.off) - case DeviceError.deviceNotFound: - return .pairFailed(.timeout) - case DeviceError.pairingFailed: - // Declined or wrong passkey, or the encrypted link was refused. - return .pairFailed(.rejected) - default: - return .pairFailed(.rejected) + case DeviceError.bluetoothUnavailable(.unauthorized): .radioBlocked(.denied) + case DeviceError.bluetoothUnavailable: .radioBlocked(.off) + case DeviceError.deviceNotFound: .pairFailed(.timeout) + default: .pairFailed(.rejected) } } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowView.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowView.swift index 7e68adcb0..8803ddd9e 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowView.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowView.swift @@ -1,29 +1,24 @@ import SwiftUI -import OBCDomain -import OBCTransport -/// The launch gate: renders whatever `LaunchFlowModel` says, from the connecting -/// state through the pairing flow, and hands over to `main` once the flow lands. -/// Own the model at the composition root and pass the main screen as the trailing -/// closure. -public struct LaunchFlowView: View { - private let model: LaunchFlowModel +public struct LaunchFlowView: View { + @Bindable private var model: LaunchFlowModel private let main: Main + private let setup: Setup + @Environment(\.accessibilityReduceMotion) private var reduceMotion - public init(model: LaunchFlowModel, @ViewBuilder main: () -> Main) { + public init( + model: LaunchFlowModel, @ViewBuilder setup: () -> Setup, @ViewBuilder main: () -> Main + ) { self.model = model self.main = main() + self.setup = setup() } public var body: some View { ZStack { - // ZStack and a transition, rather than a bare switch, so phase changes - // cross-fade instead of hard-cutting. - screen - .id(phaseKey) - .transition(.opacity) + screen.id(phaseKey).transition(.opacity) } - .animation(.easeInOut(duration: 0.25), value: phaseKey) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.25), value: phaseKey) .task { model.start() } } @@ -36,53 +31,54 @@ public struct LaunchFlowView: View { LaunchConnectingView(deviceName: deviceName) case .connectFailed(let deviceName): LaunchConnectFailedView( - deviceName: deviceName, - onRetry: { model.retryConnect() }, - onGoToRoutes: { model.browseLibrary() } - ) + deviceName: deviceName, onRetry: model.retryConnect, onGoToRoutes: model.browseLibrary) + case .welcome: + WelcomeView(onStart: model.showSwitchOn, onBrowse: model.browseLibrary) case .pairIntro: - PairIntroView(onStart: { model.startPairing() }) - case .scanning(let discovered): - PairScanningView( - discovered: discovered, - onTapDevice: { model.confirmPairing() }, - onCancel: { model.cancelScanning() } - ) + SwitchOnView(onFind: model.startPairing, onBack: model.showWelcome) + case .bluetoothPermission: + BluetoothPermissionView( + onAllow: model.allowBluetooth, onBack: model.showSwitchOn, onBrowse: model.browseLibrary) + case .scanning(let devices): + PairScanningView(devices: devices, onTapDevice: model.confirmPairing, onCancel: model.cancelScanning) case .pairing: - PairingBackdropView() + PairingBackdropView(onCancel: model.cancelScanning) case .paired(let deviceName): - PairedView(deviceName: deviceName, onContinue: { model.finishPairing() }) + PairedView( + deviceName: deviceName, name: $model.deviceNameDraft, saving: model.savingName, + error: model.nameSaveError, onSave: model.saveNameAndContinue, onKeepName: model.finishPairing) case .pairFailed(let failure): - PairFailedView( - failure: failure, - onRetry: { model.retryPairing() }, - onHelp: { model.showPairingHelp() } - ) + PairFailedView(failure: failure, onRetry: model.retryPairing, onHelp: model.showPairingHelp) case .radioBlocked(let block): - RadioBlockedView( - block: block, - onRetry: { model.retryPairing() }, - onBrowseLibrary: { model.browseLibrary() } - ) + RadioBlockedView(block: block, onRetry: model.retryPairing, onBrowseLibrary: model.browseLibrary) + case .setup: + setup case .main: main } } - /// A stable per-screen key: the scanning row appearing must animate inside the - /// screen, not re-create it. private var phaseKey: String { switch model.phase { case .idle: "idle" case .connecting: "connecting" case .connectFailed: "connectFailed" + case .welcome: "welcome" case .pairIntro: "pairIntro" + case .bluetoothPermission: "bluetoothPermission" case .scanning: "scanning" case .pairing: "pairing" case .paired: "paired" case .pairFailed(let failure): "pairFailed.\(failure)" case .radioBlocked(let block): "radioBlocked.\(block)" + case .setup: "setup" case .main: "main" } } } + +extension LaunchFlowView where Setup == EmptyView { + public init(model: LaunchFlowModel, @ViewBuilder main: () -> Main) { + self.init(model: model, setup: { EmptyView() }, main: main) + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchStateScreens.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchStateScreens.swift index 3e3d57460..a90f9f9df 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchStateScreens.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchStateScreens.swift @@ -106,6 +106,9 @@ struct RadioBlockedView: View { Button("Open Settings", action: openSettings) .buttonStyle(.obcPrimary) .accessibilityIdentifier("radio.openSettings") + Button("Try again", action: onRetry) + .buttonStyle(.obcGhost) + .accessibilityIdentifier("radio.tryAgain") } Button("Browse library", action: onBrowseLibrary) .buttonStyle(.obcGhost) diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/PairingScreens.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/PairingScreens.swift index f6373ed5e..85c37cf47 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/PairingScreens.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/PairingScreens.swift @@ -1,4 +1,5 @@ import SwiftUI +import OBCTransport // The pairing screens. Dumb views: copy and callbacks, no transport; // `LaunchFlowView` binds them to `LaunchFlowModel`. @@ -60,122 +61,50 @@ struct LaunchMessage: View { } } -/// The pairing prompt: the device's pairing card, three literal steps, then one action. -struct PairIntroView: View { - let onStart: () -> Void - - var body: some View { - LaunchScreenScaffold { - VStack(spacing: 0) { - DeviceGlyphView(variant: .passkey) - .padding(.bottom, 28) - - LaunchTitle("Pair your OBC") - .accessibilityIdentifier("pair.introTitle") - .padding(.bottom, 20) - - VStack(spacing: 14) { - step(1, "On the OBC, open **Settings ▸ Connections** and check that Bluetooth is on.") - step(2, "Keep the OBC near your phone.") - step(3, "Tap it in the list, then enter the code it shows.") - } - } - } actions: { - Button("Start pairing", action: onStart) - .buttonStyle(.obcPrimary) - .accessibilityIdentifier("pair.start") - } - } - - private func step(_ number: Int, _ text: LocalizedStringKey) -> some View { - HStack(alignment: .top, spacing: 13) { - Text("\(number)") - .font(.system(.footnote, weight: .semibold).monospacedDigit()) - .foregroundStyle(OBCTheme.surface) - .frame(width: 26, height: 26) - .background(OBCTheme.secondary, in: Circle()) - .obcFixedGeometryType() - Text(text) - .font(.system(.subheadline)) - .foregroundStyle(OBCTheme.ink) - .lineSpacing(3) - .frame(maxWidth: .infinity, alignment: .leading) - } - .accessibilityElement(children: .combine) - } -} - -/// Scanning: the rings, and the found device slides in as a row to tap. +/// A scan with several nearby OBCs. Their advertised names also appear under the device QR code. struct PairScanningView: View { - let discovered: LaunchFlowModel.DiscoveredDevice? - let onTapDevice: () -> Void + let devices: [PairingDevice] + let onTapDevice: (PairingDevice) -> Void let onCancel: () -> Void var body: some View { LaunchScreenScaffold { VStack(spacing: 0) { ZStack { - PulsingRings() + if devices.isEmpty { PulsingRings() } BluetoothTile() } - .frame(width: 200, height: 200) - .padding(.bottom, 8) - - LaunchTitle("Looking for your OBC") + .frame(width: 200, height: 180) + .padding(.bottom, 12) + LaunchTitle(devices.isEmpty ? "Looking for your OBC" : "Which OBC is yours?") .accessibilityIdentifier("pair.scanningTitle") + .padding(.bottom, 12) + LaunchMessage(devices.isEmpty + ? "Keep your OBC awake and nearby." + : "Choose the name shown under the QR code on your OBC.") .padding(.bottom, 24) - - if let discovered { - deviceRow(discovered) - .transition(.move(edge: .bottom).combined(with: .opacity)) + if !devices.isEmpty { + OBCGroupedSection { + ForEach(devices) { device in + OBCListRow(label: device.name, showsChevron: true) { onTapDevice(device) } + .accessibilityIdentifier("pair.deviceRow.\(device.id.uuidString)") + } + } } } - .animation(.spring(duration: 0.45), value: discovered) } actions: { Button("Cancel", action: onCancel) .buttonStyle(.obcGhost) .accessibilityIdentifier("pair.cancel") } } - - private func deviceRow(_ device: LaunchFlowModel.DiscoveredDevice) -> some View { - Button(action: onTapDevice) { - HStack(spacing: 12) { - RoundedRectangle(cornerRadius: 9) - .fill(OBCTheme.fill) - .frame(width: 36, height: 36) - .overlay { - BluetoothRune() - .stroke(OBCTheme.ink, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) - .frame(width: 18, height: 18) - } - VStack(alignment: .leading, spacing: 2) { - Text(device.advertisedName) - .font(.system(.body, weight: .semibold)) - .foregroundStyle(OBCTheme.ink) - Text("Tap to pair") - .font(.system(.footnote)) - .foregroundStyle(OBCTheme.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - Image(systemName: "chevron.right") - .font(.system(.footnote, weight: .semibold)) - .foregroundStyle(OBCTheme.secondary) - } - .padding(.vertical, 14) - .padding(.horizontal, 16) - .frame(minHeight: 60) - .background(OBCTheme.surface, in: RoundedRectangle(cornerRadius: OBCTheme.radiusPanel)) - .contentShape(RoundedRectangle(cornerRadius: OBCTheme.radiusPanel)) - } - .buttonStyle(.plain) - .accessibilityIdentifier("pair.deviceRow") - } } /// The beat while pairing completes. On the real path the iOS pairing alert sits over this and /// asks for the code the device shows. struct PairingBackdropView: View { + var onCancel: () -> Void = {} + var body: some View { LaunchScreenScaffold { VStack(spacing: 0) { @@ -183,29 +112,68 @@ struct PairingBackdropView: View { .padding(.bottom, 28) LaunchTitle("Enter the code the OBC shows") .accessibilityIdentifier("pair.pairingTitle") + .padding(.bottom, 12) + LaunchMessage("Use the iOS pairing window to enter the six-digit code.") } } actions: { + Button("Cancel", action: onCancel) + .buttonStyle(.obcGhost) + .accessibilityIdentifier("pair.cancel") } } } -/// Paired: the device with its name, and one way forward. struct PairedView: View { let deviceName: String - let onContinue: () -> Void + @Binding var name: String + var saving = false + var error: String? + let onSave: () -> Void + let onKeepName: () -> Void + var canSave: Bool { !saving && !DeviceRenaming.normalized(name).isEmpty } var body: some View { LaunchScreenScaffold { VStack(spacing: 0) { DeviceGlyphView(variant: .home(name: deviceName)) - .padding(.bottom, 34) - LaunchTitle("Paired with \(deviceName)") + .padding(.bottom, 28) + LaunchTitle("Your OBC is paired") .accessibilityIdentifier("pair.pairedTitle") + .padding(.bottom, 12) + LaunchMessage("Give it a name, or keep \(deviceName). You can change this later in Settings.") + .padding(.bottom, 24) + VStack(alignment: .leading, spacing: 8) { + Text("Device name") + .font(.system(.subheadline, weight: .semibold)) + .foregroundStyle(OBCTheme.ink) + TextField("Device name", text: $name) + .font(.system(.body)) + .foregroundStyle(OBCTheme.ink) + .padding(16) + .background(OBCTheme.surface, in: RoundedRectangle(cornerRadius: OBCTheme.radiusPanel)) + .autocorrectionDisabled() + .submitLabel(.continue) + .onSubmit { if canSave { onSave() } } + .disabled(saving) + .accessibilityIdentifier("pairing.name") + if let error { + Text(error) + .font(.system(.footnote)) + .foregroundStyle(OBCTheme.danger) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("pairing.nameError") + } + } } } actions: { - Button("Open Library", action: onContinue) + Button(saving ? "Saving name…" : "Continue", action: onSave) .buttonStyle(.obcPrimary) - .accessibilityIdentifier("pair.goToRoutes") + .disabled(!canSave) + .accessibilityIdentifier("pairing.saveName") + Button("Keep \(deviceName)", action: onKeepName) + .buttonStyle(.obcGhost) + .disabled(saving) + .accessibilityIdentifier("pairing.keepName") } } } @@ -264,20 +232,12 @@ struct PairFailedView: View { } } -#Preview("Prompt") { - PairIntroView(onStart: {}) -} - -#Preview("Scanning, found") { - PairScanningView(discovered: .init(name: "Trailhead"), onTapDevice: {}, onCancel: {}) -} - -#Preview("Pairing") { - PairingBackdropView() +#Preview("Scanning") { + PairScanningView(devices: [], onTapDevice: { _ in }, onCancel: {}) } #Preview("Paired") { - PairedView(deviceName: "Trailhead", onContinue: {}) + PairedView(deviceName: "Trailhead", name: .constant("Trailhead"), onSave: {}, onKeepName: {}) } #Preview("Timeout") { diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/WelcomeScreens.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/WelcomeScreens.swift new file mode 100644 index 000000000..9e6b38cb6 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/WelcomeScreens.swift @@ -0,0 +1,99 @@ +import SwiftUI + +struct WelcomeView: View { + let onStart: () -> Void + let onBrowse: () -> Void + + var body: some View { + LaunchScreenScaffold { + VStack(spacing: 0) { + DeviceGlyphView(variant: .home(name: "OpenBikeComputer")) + .padding(.bottom, 32) + LaunchTitle("Your next ride starts here") + .accessibilityIdentifier("onboarding.welcomeTitle") + .padding(.bottom, 12) + LaunchMessage("Send routes to your OBC. Bring your rides home.") + .padding(.bottom, 24) + Text("No account. No subscription. No cloud.") + .font(.system(.footnote, weight: .medium)) + .foregroundStyle(OBCTheme.ink) + .multilineTextAlignment(.center) + } + } actions: { + Button("Set up my OBC", action: onStart) + .buttonStyle(.obcPrimary) + .accessibilityIdentifier("onboarding.getStarted") + Button("Browse the library first", action: onBrowse) + .buttonStyle(.obcGhost) + .accessibilityIdentifier("onboarding.browse") + } + } +} + +struct SwitchOnView: View { + let onFind: () -> Void + let onBack: () -> Void + + var body: some View { + LaunchScreenScaffold { + VStack(spacing: 0) { + DeviceGlyphView(variant: .welcome) + .padding(.bottom, 28) + LaunchTitle("Switch on your OBC") + .accessibilityIdentifier("onboarding.switchOnTitle") + .padding(.bottom, 12) + LaunchMessage("Pick a language on your OBC and follow its setup steps.") + .padding(.bottom, 22) + Label { + Text("When the QR code appears, scan it with your iPhone camera. Or find your OBC below.") + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "qrcode.viewfinder") + .font(.system(.title2)) + } + .font(.system(.subheadline)) + .foregroundStyle(OBCTheme.secondary) + .frame(maxWidth: 320) + .lineSpacing(3) + } + } actions: { + Button("Find my OBC", action: onFind) + .buttonStyle(.obcPrimary) + .accessibilityIdentifier("pair.start") + Button("Back", action: onBack) + .buttonStyle(.obcGhost) + .accessibilityIdentifier("onboarding.back") + } + } +} + +struct BluetoothPermissionView: View { + let onAllow: () -> Void + let onBack: () -> Void + let onBrowse: () -> Void + + var body: some View { + LaunchScreenScaffold { + VStack(spacing: 0) { + BluetoothTile() + .padding(.bottom, 32) + LaunchTitle("Connect with Bluetooth") + .accessibilityIdentifier("onboarding.bluetoothTitle") + .padding(.bottom, 12) + LaunchMessage("Bluetooth sends routes, rides and updates between your iPhone and your OBC.") + .padding(.bottom, 20) + LaunchMessage("Keep your OBC nearby. If iOS asks, allow Bluetooth access.") + } + } actions: { + Button("Continue", action: onAllow) + .buttonStyle(.obcPrimary) + .accessibilityIdentifier("onboarding.allowBluetooth") + Button("Back", action: onBack) + .buttonStyle(.obcGhost) + .accessibilityIdentifier("onboarding.bluetoothBack") + Button("Browse library", action: onBrowse) + .buttonStyle(.obcGhost) + .accessibilityIdentifier("onboarding.browse") + } + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Settings/SettingsModel.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Settings/SettingsModel.swift index 53bcf7217..fbb538621 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Settings/SettingsModel.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Settings/SettingsModel.swift @@ -143,10 +143,7 @@ public final class SettingsModel { public func rename(to newName: String) -> Bool { // Cap at the device's name limit, so the app-side name matches what the codec writes, and // trim again in case truncation left a trailing space. - let trimmed = newName - .trimmingCharacters(in: .whitespacesAndNewlines) - .truncatedToUTF8Bytes(DeviceConfig.maxNameUTF8Bytes) - .trimmingCharacters(in: .whitespacesAndNewlines) + let trimmed = DeviceRenaming.normalized(newName) guard !trimmed.isEmpty, canRename else { return false } deviceName = trimmed bondStore.save(BondRecord(deviceName: trimmed)) @@ -155,9 +152,7 @@ public final class SettingsModel { // The name rides in the config blob, so read-modify-write and the other fields // survive the rename. do { - var config = try await transport.readConfig() - config.name = trimmed - try await transport.writeConfig(config) + try await DeviceRenaming.save(trimmed, to: transport) } catch { // Either leg failing means the device never got the name. Flag it once for the // view's toast; the reconcile pass self-heals on the next connect, and the bond diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/LaunchFlowModelTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/LaunchFlowModelTests.swift index 6cc276765..91008109c 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/LaunchFlowModelTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/LaunchFlowModelTests.swift @@ -1,274 +1,251 @@ -import XCTest +import Foundation +import Testing import OBCDomain import OBCMock import OBCTransport @testable import OBCUI -/// The launch and pairing state machine driven through `MockTransport` scenarios: every design -/// branch, plus the non-blocking guarantees (out of range lands on main; a silent device caps the -/// grace window onto connect-failed instead of spinning forever). @MainActor -final class LaunchFlowModelTests: XCTestCase { - /// Short pacing so the timers fire in test time, with enough slack that they never race a - /// healthy mock op. - private static let fastTiming = LaunchFlowModel.Timing( - connectGrace: .seconds(2), - scanTimeout: .seconds(2), - pairingBeat: .milliseconds(10) - ) - - private func makeModel( - _ scenario: Scenario, - timing: LaunchFlowModel.Timing = fastTiming +struct LaunchFlowModelTests { + private static let timing = LaunchFlowModel.Timing( + connectGrace: .milliseconds(50), scanTimeout: .seconds(2), pairingBeat: .zero) + + private func make( + _ scenario: Scenario = .noDevice, + timing: LaunchFlowModel.Timing = Self.timing, + pending: @escaping @MainActor () -> Bool = { false } ) -> (LaunchFlowModel, MockControl) { let control = MockControl(scenario: scenario) control.latency = .zero - let model = LaunchFlowModel( - transport: MockTransport(control: control), - bondStore: MockBondStore(control: control), - timing: timing - ) - return (model, control) - } - - // MARK: The launch branch - - func testFirstRunBranchesToPairIntro() { - let (model, _) = makeModel(.noDevice) - model.start() - XCTAssertEqual(model.phase, .pairIntro) - } - - func testForgetDeviceReturnsToPairIntro() async throws { - let (model, _) = makeModel(.happyPath) - model.start() - try await waitFor("main", timeout: .seconds(5)) { model.phase == .main } - - model.forgetDevice() - XCTAssertEqual(model.phase, .pairIntro) + return (LaunchFlowModel( + transport: MockTransport(control: control), bondStore: MockBondStore(control: control), + timing: timing, onboardingPending: pending), control) } - func testBondedColdLaunchShowsConnectingThenMain() async throws { - let (model, control) = makeModel(.happyPath) - control.connection = .disconnected // cold boot: bonded but link down - model.start() - XCTAssertEqual(model.phase, .connecting(deviceName: "Trailhead")) - try await waitFor("main", timeout: .seconds(5)) { model.phase == .main } - XCTAssertEqual(control.connection, .connected) - } - - func testBondedLaunchAlreadyConnectedGoesStraightToMain() async throws { - let (model, _) = makeModel(.happyPath) - model.start() - try await waitFor("main", timeout: .seconds(5)) { model.phase == .main } - } - - func testOutOfRangeLandsOnMainNotAnError() async throws { - let (model, control) = makeModel(.outOfRange) - model.start() - try await waitFor("main", timeout: .seconds(5)) { model.phase == .main } - // No connect attempt: the degraded link is the banner's story. - XCTAssertEqual(control.connection, .outOfRange) + private func wait(until condition: () -> Bool) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(3)) + while !condition(), ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(5)) } + #expect(condition()) } - func testBondedConnectFailureStillLandsOnMain() async throws { - let (model, control) = makeModel(.happyPath) - control.connection = .disconnected - control.radio = .off // connect() will throw — must degrade, not error + private func pair(_ model: LaunchFlowModel) async throws { model.start() - try await waitFor("main", timeout: .seconds(5)) { model.phase == .main } + model.startPairing() + model.allowBluetooth() + try await wait { if case .paired = model.phase { true } else { false } } } - func testConnectGraceExpiryShowsConnectFailedAndRoutesStayReachable() async throws { - let (model, control) = makeModel( - .happyPath, - timing: .init(connectGrace: .milliseconds(50), scanTimeout: .seconds(2), pairingBeat: .zero) - ) - control.connection = .disconnected - control.latency = .seconds(30) // connect() parks far past the cap + @Test func welcomeAndPermissionPrecedeRadioUse() { + let (model, control) = make() model.start() - try await waitFor("connect-failed despite a hung connect", timeout: .seconds(5)) { - model.phase == .connectFailed(deviceName: "Trailhead") - } - + #expect(model.phase == .welcome) + model.showSwitchOn() + #expect(model.phase == .pairIntro) + model.startPairing() + #expect(model.phase == .bluetoothPermission) + #expect(control.connection == .disconnected) + #expect(!control.bonded) model.browseLibrary() - XCTAssertEqual(model.phase, .main) + #expect(model.phase == .main) + } + + @Test func qrSkipsSwitchOnAndPreservesExistingBond() async throws { + let (fresh, _) = make() + fresh.openPairingLink() + #expect(fresh.phase == .bluetoothPermission) + fresh.start() + #expect(fresh.phase == .bluetoothPermission) + let (bonded, control) = make(.happyPath) + bonded.openPairingLink() + try await wait { bonded.phase == .main } + #expect(control.bonded) + bonded.openPairingLink() + #expect(bonded.phase == .main) + } + + @Test(arguments: [ + "http://openbikecomputer.com/app", "https://other.example/app", + "https://openbikecomputer.com/app/", "https://openbikecomputer.com/app?device=123", + "https://openbikecomputer.com/app#pair", "https://user@openbikecomputer.com/app", + "https://openbikecomputer.com:443/app", "file:///app", "https://openbikecomputer.com/application" + ]) + func unrelatedLinksDoNotStartPairing(_ value: String) throws { + #expect(!LaunchFlowModel.acceptsPairingLink(try #require(URL(string: value)))) + } + + @Test func fixedLinkIsAccepted() throws { + #expect(LaunchFlowModel.acceptsPairingLink(try #require(URL(string: "https://openbikecomputer.com/app")))) + } + + @Test func oneCandidatePairsDirectlyThenHandsOffToSetup() async throws { + let (model, control) = make() + try await pair(model) + #expect(model.phase == .paired(deviceName: "Trailhead")) + #expect(control.bonded) + #expect(model.deviceNameDraft == "Trailhead") + model.finishPairing() + #expect(model.phase == .setup) + model.finishSetup() + #expect(model.phase == .main) } - func testRetryConnectFromConnectFailedLandsOnMainOnceReachable() async throws { - let (model, control) = makeModel( - .happyPath, - timing: .init(connectGrace: .milliseconds(50), scanTimeout: .seconds(2), pairingBeat: .zero) - ) - control.connection = .disconnected - control.latency = .seconds(30) - model.start() - try await waitFor("connect-failed", timeout: .seconds(5)) { model.phase == .connectFailed(deviceName: "Trailhead") } - - control.connection = .connected // the device woke up / came into range - model.retryConnect() - XCTAssertEqual(model.phase, .connecting(deviceName: "Trailhead")) - try await waitFor("main after retry", timeout: .seconds(5)) { model.phase == .main } + @Test func multipleCandidatesRequireExactSelection() async throws { + let (model, control) = make() + let first = PairingDevice(id: UUID(), name: "OBC-First") + let second = PairingDevice(id: UUID(), name: "OBC-Second") + control.pairingDevices = [first, second] + model.startPairing() + model.allowBluetooth() + try await wait { model.phase == .scanning(devices: [first, second]) } + #expect(!control.bonded) + model.confirmPairing(PairingDevice(id: UUID(), name: first.name)) + #expect(model.phase == .scanning(devices: [first, second])) + model.confirmPairing(second) + model.confirmPairing(first) + try await wait { model.phase == .paired(deviceName: "Second") } + #expect(control.deviceInfo.name == "Second") + #expect(control.bondedName == "Second") + } + + @Test func namingWritesExistingConfigAndPersistsOnlySuccess() async throws { + let (model, control) = make() + try await pair(model) + let original = control.fixtures.config + model.deviceNameDraft = " New bike " + model.saveNameAndContinue() + try await wait { model.phase == .setup } + var expected = original + expected.name = "New bike" + #expect(control.fixtures.config == expected) + #expect(control.bondedName == "New bike") + } + + @Test func failedNameCanRetryOrKeepFactoryName() async throws { + let (model, control) = make() + try await pair(model) + control.failNextOp(.writeFailed) + model.deviceNameDraft = "New name" + model.saveNameAndContinue() + try await wait { model.nameSaveError != nil } + #expect(model.phase == .paired(deviceName: "Trailhead")) + #expect(control.bondedName == "Trailhead") + #expect(control.fixtures.config.name == "Trailhead") + model.saveNameAndContinue() + try await wait { model.phase == .setup } + #expect(control.bondedName == "New name") + } + + @Test func emptyNameDoesNotAdvanceAndKeepingNameMakesNoWrite() async throws { + let (model, control) = make() + try await pair(model) + model.deviceNameDraft = " " + model.saveNameAndContinue() + #expect(model.phase == .paired(deviceName: "Trailhead")) + #expect(!model.canSaveName) + control.failNextOp(.writeFailed) + model.finishPairing() + #expect(model.phase == .setup) + #expect(control.bondedName == "Trailhead") } - func testLateConnectWhileOnConnectFailedAdvancesToMain() async throws { - let (model, control) = makeModel( - .happyPath, - timing: .init(connectGrace: .milliseconds(50), scanTimeout: .seconds(2), pairingBeat: .zero) - ) - control.connection = .disconnected - control.latency = .milliseconds(300) // slower than the grace, but finite - model.start() - try await waitFor("connect-failed first", timeout: .seconds(5)) { model.phase == .connectFailed(deviceName: "Trailhead") } - try await waitFor("main once the late connect lands", timeout: .seconds(5)) { model.phase == .main } + @Test func unicodeNameFitsDeviceByteLimit() { + let normalized = DeviceRenaming.normalized(" " + String(repeating: "🚲", count: 30) + " ") + #expect(normalized.utf8.count <= DeviceConfig.maxNameUTF8Bytes) + #expect(!normalized.isEmpty) + #expect(normalized.allSatisfy { $0 == "🚲" }) } - // MARK: The pairing flow - - func testPairingHappyPathThroughAllScreens() async throws { - let (model, control) = makeModel(.noDevice) - model.start() - XCTAssertEqual(model.phase, .pairIntro) - + @Test func emptyScanAndRetryUseTimeoutRecovery() async throws { + let (model, control) = make() + control.pairingDevices = [] model.startPairing() - guard case .scanning = model.phase else { - return XCTFail("expected scanning, got \(model.phase)") - } - try await waitFor("discovered row", timeout: .seconds(5)) { - model.phase == .scanning(discovered: .init(name: "Trailhead")) + model.allowBluetooth() + try await wait { model.phase == .pairFailed(.timeout) } + control.pairingDevices = nil + model.retryPairing() + try await wait { model.phase == .paired(deviceName: "Trailhead") } + } + + @Test func radioAndPairingFailuresStayActionable() async throws { + for (scenario, expected) in [ + (Scenario.bluetoothOff, LaunchFlowModel.Phase.radioBlocked(.off)), + (.permissionDenied, .radioBlocked(.denied)), + (.pairingRejected, .pairFailed(.rejected)) + ] { + let (model, _) = make(scenario) + model.startPairing() + model.allowBluetooth() + try await wait { model.phase == expected } + model.browseLibrary() + #expect(model.phase == .main) } - XCTAssertEqual( - LaunchFlowModel.DiscoveredDevice(name: "Trailhead").advertisedName, - "OBC-Trailhead" - ) - - model.confirmPairing() - XCTAssertEqual(model.phase, .pairing) - try await waitFor("paired", timeout: .seconds(5)) { model.phase == .paired(deviceName: "Trailhead") } - XCTAssertTrue(control.bonded, "pairing success must record the bond") - - model.finishPairing() - XCTAssertEqual(model.phase, .main) } - func testPairingTimeoutShowsD5AndRetryLoopsToScanning() async throws { - let (model, _) = makeModel(.pairingTimeout) - model.start() + @Test func scanTimeoutAndCancellationDoNotLeaveStaleWork() async throws { + let (model, control) = make(timing: .init(scanTimeout: .milliseconds(30), pairingBeat: .zero)) + control.latency = .seconds(30) model.startPairing() - try await waitFor("D5 timeout", timeout: .seconds(5)) { model.phase == .pairFailed(.timeout) } - + model.allowBluetooth() + try await wait { model.phase == .pairFailed(.timeout) } model.retryPairing() - guard case .scanning = model.phase else { - return XCTFail("retry must loop back to scanning, got \(model.phase)") - } - try await waitFor("D5 again", timeout: .seconds(5)) { model.phase == .pairFailed(.timeout) } - } - - /// A declined passkey is a gated failure: it surfaces on the row tap, not during the scan. - func testPairingRejectedShowsD5RejectedVariant() async throws { - let (model, _) = makeModel(.pairingRejected) - model.start() + model.cancelScanning() + #expect(model.phase == .pairIntro) + control.latency = .zero model.startPairing() - try await waitFor("discovered row", timeout: .seconds(5)) { - model.phase == .scanning(discovered: .init(name: "Trailhead")) - } - model.confirmPairing() - XCTAssertEqual(model.phase, .pairing) - try await waitFor("D5 rejected", timeout: .seconds(5)) { model.phase == .pairFailed(.rejected) } + model.allowBluetooth() + try await wait { model.phase == .paired(deviceName: "Trailhead") } + #expect(control.connection == .connected) } - /// An already-bonded refusal is indistinguishable on the wire from a declined passkey: the - /// device drops the link and CoreBluetooth surfaces a generic `DeviceError.pairingFailed`. It - /// lands on the same `.pairFailed(.rejected)` screen, whose copy covers both. - func testGenericPairingFailureLandsOnRejectedForBondedCase() async throws { - let (model, _) = makeModel(.pairingRejected) - model.start() + @Test func cancellingAuthenticationCannotAdvanceOrReopenLink() async throws { + let (model, control) = make() + control.latency = .milliseconds(80) model.startPairing() - try await waitFor("discovered row", timeout: .seconds(5)) { - model.phase == .scanning(discovered: .init(name: "Trailhead")) - } - model.confirmPairing() - try await waitFor("generic pairing failure → rejected", timeout: .seconds(5)) { - model.phase == .pairFailed(.rejected) - } - } - - // MARK: The rejected-pairing copy - - /// The `.rejected` copy must offer the code retry and name the already-paired possibility - /// with its recovery, without asserting which failure happened. - func testRejectedCopyCoversBothPasskeyAndAlreadyBonded() { - let reason = LaunchFlowModel.PairingFailure.rejected.reason - - XCTAssertTrue( - reason.contains("If the code was wrong") && reason.contains("If the OBC is already paired to another phone"), - "must present both as possibilities, not assert one" - ) - - XCTAssertTrue( - reason.contains("Forget phone"), - "must point at Forget phone as the re-pair recovery" - ) - - XCTAssertEqual(LaunchFlowModel.PairingFailure.rejected.title, "Pairing didn't finish") + model.allowBluetooth() + try await wait { model.phase == .pairing } + model.cancelScanning() + try await Task.sleep(for: .milliseconds(350)) + #expect(model.phase == .pairIntro) + #expect(control.connection == .disconnected) + #expect(!control.bonded) } - func testTimeoutCopyUnchanged() { - let timeout = LaunchFlowModel.PairingFailure.timeout - XCTAssertEqual(timeout.title, "Couldn't find your OBC") - XCTAssertTrue(timeout.reason.contains("scanned for 30 seconds")) - XCTAssertFalse(timeout.reason.contains("Forget phone")) + @Test func incompatibleDeviceStillReachesOptionalSetup() async throws { + let (model, control) = make() + control.deviceInfo = DeviceInfo(name: "Trailhead", firmwareVersion: "0.1.0", protocolVersion: 3) + try await pair(model) + model.finishPairing() + #expect(model.phase == .setup) } - func testScanWindowExpiryIsATimeout() async throws { - let (model, control) = makeModel( - .noDevice, - timing: .init(connectGrace: .seconds(2), scanTimeout: .milliseconds(50), pairingBeat: .zero) - ) - control.latency = .seconds(30) // the mock never "finds" the device in time - model.start() - model.startPairing() - try await waitFor("scan-window timeout", timeout: .seconds(5)) { model.phase == .pairFailed(.timeout) } + @Test func bondedLaunchResumesOnlyPendingSetup() async throws { + let (normal, _) = make(.happyPath) + normal.start() + try await wait { normal.phase == .main } + let (pending, _) = make(.happyPath, pending: { true }) + pending.start() + try await wait { pending.phase == .setup } + pending.finishSetup() + pending.replaySetup() + #expect(pending.phase == .setup) } - func testBluetoothOffShowsH8AndLibraryStaysReachable() async throws { - let (model, _) = makeModel(.bluetoothOff) + @Test func bondedUnreachableDeviceCanUseLibraryAndLaterReconnect() async throws { + let (model, control) = make(.happyPath) + control.connection = .disconnected + control.latency = .milliseconds(100) model.start() - model.startPairing() - try await waitFor("H8", timeout: .seconds(5)) { model.phase == .radioBlocked(.off) } - + try await wait { model.phase == .connectFailed(deviceName: "Trailhead") } model.browseLibrary() - XCTAssertEqual(model.phase, .main) - } - - func testPermissionDeniedShowsH7State() async throws { - let (model, _) = makeModel(.permissionDenied) - model.start() - model.startPairing() - try await waitFor("H7", timeout: .seconds(5)) { model.phase == .radioBlocked(.denied) } + try await wait { control.connection == .connected } + #expect(model.phase == .main) } - func testCancelScanningStepsBackAndDropsTheLink() async throws { - let (model, control) = makeModel(.noDevice) - control.latency = .milliseconds(200) + @Test func forgetReturnsToWelcome() async throws { + let (model, _) = make(.happyPath) model.start() - model.startPairing() - model.cancelScanning() - XCTAssertEqual(model.phase, .pairIntro) - try await waitFor("link down", timeout: .seconds(5)) { control.connection == .disconnected } - } - - func testPairingHelpReturnsToTheIntroSteps() async throws { - let (model, _) = makeModel(.pairingRejected) - model.start() - model.startPairing() - try await waitFor("discovered row", timeout: .seconds(5)) { - model.phase == .scanning(discovered: .init(name: "Trailhead")) - } - model.confirmPairing() // the decline lands on the gated row tap - try await waitFor("D5", timeout: .seconds(5)) { model.phase == .pairFailed(.rejected) } - - model.showPairingHelp() - XCTAssertEqual(model.phase, .pairIntro) + try await wait { model.phase == .main } + model.forgetDevice() + #expect(model.phase == .welcome) } } From 594e284a72db894ec3847060475b9dfcb111b9c5 Mon Sep 17 00:00:00 2001 From: timohueser Date: Tue, 29 Sep 2026 00:28:53 +0200 Subject: [PATCH 05/17] test(ios): exercise onboarding device and update branches --- .../OnboardingBranchesTests.swift | 108 ++++++++++++++++++ .../OBCKit/Sources/OBCMock/MockControl.swift | 4 +- .../Sources/OBCMock/MockTransport.swift | 5 +- .../Sources/OBCMock/OnboardingFixtures.swift | 53 +++++++++ .../OBCMockTests/MockBondStoreTests.swift | 1 + .../OBCMockTests/MockLaunchOptionsTests.swift | 2 +- .../OnboardingFixturesTests.swift | 37 ++++++ .../OBCUITests/LaunchFlowModelTests.swift | 6 +- 8 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 companion-ios/OBCCompanionUITests/OnboardingBranchesTests.swift create mode 100644 companion-ios/Packages/OBCKit/Sources/OBCMock/OnboardingFixtures.swift create mode 100644 companion-ios/Packages/OBCKit/Tests/OBCMockTests/OnboardingFixturesTests.swift diff --git a/companion-ios/OBCCompanionUITests/OnboardingBranchesTests.swift b/companion-ios/OBCCompanionUITests/OnboardingBranchesTests.swift new file mode 100644 index 000000000..18b8b258e --- /dev/null +++ b/companion-ios/OBCCompanionUITests/OnboardingBranchesTests.swift @@ -0,0 +1,108 @@ +import XCTest + +final class OnboardingBranchesTests: XCTestCase { + override func setUp() { + continueAfterFailure = false + } + + @MainActor + private func launch(_ scenario: String) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = [ + "-OBCScenario", scenario, "-OBCNetwork", "offline", + "-OBCHideMockHUD", "-OBCDisableAnimations", "-OBCHoldConfirmations", + "-AppleLanguages", "(en)", "-AppleLocale", "en_US" + ] + app.launchEnvironment["TZ"] = "UTC" + app.launch() + tap(app, "onboarding.getStarted") + tap(app, "pair.start") + tap(app, "onboarding.allowBluetooth") + return app + } + + @MainActor + private func tap(_ app: XCUIApplication, _ identifier: String) { + let button = app.buttons[identifier] + XCTAssertTrue(button.waitForExistence(timeout: 15), identifier) + let enabled = expectation(for: NSPredicate(format: "enabled == true"), evaluatedWith: button) + wait(for: [enabled], timeout: 15) + button.tap() + } + + @MainActor + private func reachUpdate(_ app: XCUIApplication) { + XCTAssertTrue(app.staticTexts["pair.pairedTitle"].waitForExistence(timeout: 15)) + tap(app, "pairing.keepName") + tap(app, "onboarding.sensorsContinue") + XCTAssertTrue(app.staticTexts["onboarding.updateTitle"].waitForExistence(timeout: 15)) + } + + @MainActor + private func capture(_ app: XCUIApplication, _ name: String) { + var previous = app.screenshot() + for _ in 0..<40 { + let next = app.screenshot() + if previous.pngRepresentation == next.pngRepresentation { + let attachment = XCTAttachment(screenshot: next) + attachment.name = "onboarding-\(name)" + attachment.lifetime = .keepAlways + add(attachment) + return + } + previous = next + } + XCTFail("\(name) did not settle") + } + + @MainActor + func testSeveralOBCsRequireSelectingTheMatchingName() { + let app = launch("onboardingNearby") + let first = "pair.deviceRow.00000000-0000-0000-0000-000000000001" + let second = "pair.deviceRow.00000000-0000-0000-0000-000000000002" + XCTAssertTrue(app.buttons[first].waitForExistence(timeout: 15)) + XCTAssertTrue(app.buttons[second].exists) + XCTAssertTrue(app.buttons[first].label.contains("OBC-7A2F")) + XCTAssertTrue(app.buttons[second].label.contains("OBC-9C41")) + XCTAssertFalse(app.staticTexts["pair.pairedTitle"].exists) + capture(app, "P04-choose-your-OBC") + tap(app, second) + XCTAssertTrue(app.staticTexts["pair.pairedTitle"].waitForExistence(timeout: 15)) + XCTAssertEqual(app.textFields["pairing.name"].value as? String, "OBC-9C41") + capture(app, "P06-selected-OBC") + tap(app, "pairing.keepName") + XCTAssertTrue(app.staticTexts["onboarding.sensorsTitle"].waitForExistence(timeout: 10)) + } + + @MainActor + func testCompatibleUpdateCanWaitUntilLater() { + let app = launch("onboardingUpdate") + reachUpdate(app) + XCTAssertTrue(app.staticTexts["v0.4.0 → v0.5.0"].waitForExistence(timeout: 15)) + XCTAssertEqual(app.staticTexts["onboarding.updateTitle"].label, "Update your OBC") + XCTAssertTrue(app.buttons["onboarding.updateNow"].exists) + XCTAssertEqual(app.buttons["onboarding.updateLater"].label, "Later") + capture(app, "P09-update-or-later") + tap(app, "onboarding.updateLater") + XCTAssertTrue(app.staticTexts["onboarding.routeTitle"].waitForExistence(timeout: 10)) + } + + @MainActor + func testIncompatibleFirmwareCanFinishSetupWithoutTransfers() { + let app = launch("onboardingUpdateNeeded") + reachUpdate(app) + let title = app.staticTexts["onboarding.updateTitle"] + let mismatch = expectation( + for: NSPredicate(format: "label == 'An update is needed'"), evaluatedWith: title) + wait(for: [mismatch], timeout: 15) + XCTAssertTrue(app.staticTexts[ + "Routes and ride sync need newer OBC firmware. Riding still works, and you can finish setup now." + ].exists) + XCTAssertEqual(app.buttons["onboarding.updateLater"].label, "Finish setup") + capture(app, "P13-update-needed") + tap(app, "onboarding.updateLater") + XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.staticTexts["onboarding.routeTitle"].exists) + XCTAssertFalse(app.staticTexts["onboarding.rideTitle"].exists) + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/MockControl.swift b/companion-ios/Packages/OBCKit/Sources/OBCMock/MockControl.swift index 7810d8cad..05228ae09 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCMock/MockControl.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCMock/MockControl.swift @@ -93,6 +93,7 @@ public final class MockControl: @unchecked Sendable { self._dropFraction = preset.dropAtFraction self._supportsClockSync = preset.supportsClockSync self._fixtures = fixtures + OnboardingFixtures.configure(self) } /// Start from `happyPath` but override the reported device identity. @@ -189,6 +190,7 @@ public final class MockControl: @unchecked Sendable { _setClockSamples = [] _fixtures = fixtures } + OnboardingFixtures.configure(self) stateMulticast.send(preset.connection) batteryMulticast.send(fixtures.battery) } @@ -636,7 +638,7 @@ public final class MockControl: @unchecked Sendable { deviceInfo = DeviceInfo( name: current.name, firmwareVersion: version, hardwareVersion: current.hardwareVersion, serial: current.serial, - protocolVersion: current.protocolVersion, + protocolVersion: scenario == .onboardingUpdateNeeded ? OBCProtocol.version : current.protocolVersion, // Firmware replacement keeps the mounted store's identity. storeID: current.storeID, obcmVersion: current.obcmVersion diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/MockTransport.swift b/companion-ios/Packages/OBCKit/Sources/OBCMock/MockTransport.swift index 1a4e819ae..b843f9fd8 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCMock/MockTransport.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCMock/MockTransport.swift @@ -45,9 +45,10 @@ public struct MockTransport: DeviceTransport { try Task.checkCancellation() try control.radioGate() try control.takePendingFailure() + let name = control.deviceInfo.name return control.pairingDevices ?? [PairingDevice( id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, - name: "OBC-\(control.deviceInfo.name)" + name: name.hasPrefix("OBC-") ? name : "OBC-\(name)" )] } @@ -57,7 +58,7 @@ public struct MockTransport: DeviceTransport { try await discover() if control.pairingDevices != nil { var config = control.fixtures.config - config.name = candidate.name.hasPrefix("OBC-") ? String(candidate.name.dropFirst(4)) : candidate.name + config.name = candidate.name control.setConfig(config) } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/OnboardingFixtures.swift b/companion-ios/Packages/OBCKit/Sources/OBCMock/OnboardingFixtures.swift new file mode 100644 index 000000000..f1bd6b1d3 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Sources/OBCMock/OnboardingFixtures.swift @@ -0,0 +1,53 @@ +#if DEBUG +import Foundation +import OBCDomain +import OBCTransport + +/// Named first-use branches exercise the real pairing and firmware models with local data. +public enum OnboardingFixtures { + static func configure(_ control: MockControl) { + switch control.scenario { + case .onboardingNearby: + control.pairingDevices = [ + PairingDevice(id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, name: "OBC-7A2F"), + PairingDevice(id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, name: "OBC-9C41") + ] + case .onboardingUpdate, .onboardingUpdateNeeded: + let info = control.deviceInfo + control.deviceInfo = DeviceInfo( + name: info.name, firmwareVersion: "0.4.0", hardwareVersion: info.hardwareVersion, + serial: info.serial, + protocolVersion: control.scenario == .onboardingUpdateNeeded ? OBCProtocol.version - 1 : OBCProtocol.version, + storeID: info.storeID, obcmVersion: info.obcmVersion) + default: break + } + } + + /// Both the cached offer and an explicit recheck/download stay in memory. The reserved URLs + /// identify fixture objects; no request leaves this fetcher, and no release-notes link is shown. + public static func updateChecker(for scenario: Scenario) -> UpdateChecker? { + guard scenario == .onboardingUpdate || scenario == .onboardingUpdateNeeded else { return nil } + let payload = SampleFirmwareFile.container(version: "0.5.0") + let release = FirmwareRelease( + version: "0.5.0", bytes: payload.count, sha256: UpdateChecker.sha256Hex(payload), + url: URL(string: "https://example.invalid/onboarding/UPDATE.BIN")!) + let fetcher = OnboardingFirmwareFetcher(release: release, payload: payload) + return UpdateChecker( + manifestURL: OnboardingFirmwareFetcher.manifestURL, + fetcher: fetcher, + store: InMemoryUpdateCheckStore(record: UpdateCheckRecord(release: release, checkedAt: Date()))) + } +} + +private struct OnboardingFirmwareFetcher: ManifestFetching { + static let manifestURL = URL(string: "https://example.invalid/onboarding/manifest.json")! + let release: FirmwareRelease + let payload: Data + + func get(_ url: URL) async throws -> (status: Int, body: Data) { + if url == release.url { return (200, payload) } + if url == Self.manifestURL { return (200, try JSONEncoder().encode(release)) } + return (404, Data()) + } +} +#endif diff --git a/companion-ios/Packages/OBCKit/Tests/OBCMockTests/MockBondStoreTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCMockTests/MockBondStoreTests.swift index 725329ff3..c20b7fef0 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCMockTests/MockBondStoreTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCMockTests/MockBondStoreTests.swift @@ -8,6 +8,7 @@ final class MockBondStoreTests: XCTestCase { func testPairingScenariosBootUnbonded() { let unbonded: [Scenario] = [ .noDevice, .pairingTimeout, .pairingRejected, .bluetoothOff, .permissionDenied, + .onboarding, .onboardingNearby, .onboardingUpdate, .onboardingUpdateNeeded, ] for scenario in Scenario.allCases { XCTAssertEqual( diff --git a/companion-ios/Packages/OBCKit/Tests/OBCMockTests/MockLaunchOptionsTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCMockTests/MockLaunchOptionsTests.swift index c4b23bf28..9d4e2b176 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCMockTests/MockLaunchOptionsTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCMockTests/MockLaunchOptionsTests.swift @@ -141,7 +141,7 @@ final class MockLaunchOptionsTests: XCTestCase { func testSampleRouteFileServesEveryKind() { let names: [SampleRouteFile.Kind: [String]] = [ .gpx: ["sample-import.gpx"], .tcx: ["sample-import.tcx"], .bad: ["packing-list.pdf"], - .grimsel: ["website-import.gpx"], .trip: ["sample-import.gpx", "website-import.gpx"], + .grimsel: ["grimsel-pass.gpx"], .trip: ["sample-import.gpx", "grimsel-pass.gpx"], ] for (kind, expected) in names { XCTAssertEqual(SampleRouteFile.files(kind).map(\.fileName), expected, "\(kind) sample must load") diff --git a/companion-ios/Packages/OBCKit/Tests/OBCMockTests/OnboardingFixturesTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCMockTests/OnboardingFixturesTests.swift new file mode 100644 index 000000000..c723a90e2 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Tests/OBCMockTests/OnboardingFixturesTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +import OBCDomain +import OBCTransport +import OBCMock + +struct OnboardingFixturesTests { + @Test func nearbyScenarioSelectsTheNamedPeer() async throws { + let control = MockControl(scenario: .onboardingNearby) + control.latency = .zero + let transport = MockTransport(control: control) + let candidates = try await transport.scanForPairing() + #expect(candidates.map(\.name) == ["OBC-7A2F", "OBC-9C41"]) + #expect(Set(candidates.map(\.id)).count == 2) + try await transport.discover(try #require(candidates.last)) + try await transport.authenticate() + #expect(try await transport.deviceInfo().name == "OBC-9C41") + control.apply(.onboarding) + #expect(control.pairingDevices == nil) + #expect(try await transport.scanForPairing().map(\.name) == ["OBC-7A2F"]) + } + + @Test(arguments: [Scenario.onboardingUpdate, .onboardingUpdateNeeded]) + func localManifestAndContainerExerciseTheNormalUpdater(_ scenario: Scenario) async throws { + let control = MockControl(scenario: scenario) + let expectedProtocol = scenario == .onboardingUpdateNeeded ? OBCProtocol.version - 1 : OBCProtocol.version + #expect(control.deviceInfo.protocolVersion == expectedProtocol) + let checker = try #require(OnboardingFixtures.updateChecker(for: scenario)) + let cached = try #require(checker.cachedCheck()) + let release = try #require(cached.release) + #expect(FirmwareVersion.updateStatus(running: control.deviceInfo.firmwareVersion, latest: release.version) == .available) + #expect(try await checker.check().release == release) + let downloaded = try await checker.download(release) + let staged = try StagedFirmware.validate(downloaded) + #expect(staged.version == "0.5.0") + } +} diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/LaunchFlowModelTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/LaunchFlowModelTests.swift index 91008109c..a03a210f7 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/LaunchFlowModelTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/LaunchFlowModelTests.swift @@ -102,9 +102,9 @@ struct LaunchFlowModelTests { #expect(model.phase == .scanning(devices: [first, second])) model.confirmPairing(second) model.confirmPairing(first) - try await wait { model.phase == .paired(deviceName: "Second") } - #expect(control.deviceInfo.name == "Second") - #expect(control.bondedName == "Second") + try await wait { model.phase == .paired(deviceName: "OBC-Second") } + #expect(control.deviceInfo.name == "OBC-Second") + #expect(control.bondedName == "OBC-Second") } @Test func namingWritesExistingConfigAndPersistsOnlySuccess() async throws { From 9175102a9f374daa80e749d31dc629fe20f8bb78 Mon Sep 17 00:00:00 2001 From: timohueser Date: Tue, 29 Sep 2026 00:31:09 +0200 Subject: [PATCH 06/17] feat(ios): finish optional onboarding through the Library --- .../OBCCompanion/OBCCompanion.entitlements | 10 + .../OBCCompanion/OBCCompanionApp.swift | 16 + companion-ios/OBCCompanion/RootView.swift | 161 +- companion-ios/OBCCompanion/ScreenHosts.swift | 47 +- .../OnboardingBranchesTests.swift | 4 + .../OnboardingFlowTests.swift | 92 + .../PairingFlowTests.swift | 60 +- companion-ios/Packages/OBCKit/Package.swift | 2 +- .../Sources/OBCFormats/BundledDemoRoute.swift | 12 + .../OBCKit/Sources/OBCMock/Fixtures.swift | 12 +- .../OBCMock/Fixtures/website-import.gpx | 3008 ----------------- .../OBCKit/Sources/OBCMock/Scenario.swift | 6 + .../OBCUI/Firmware/FirmwareUpdateModel.swift | 31 +- .../OBCUI/Firmware/UpdateSurfaceModel.swift | 31 +- .../Sources/OBCUI/Main/MainScreenView.swift | 21 +- .../OBCUI/Onboarding/OnboardingFlowView.swift | 260 ++ .../OBCUI/Onboarding/OnboardingProgress.swift | 56 + .../Sources/OBCUI/Settings/SettingsView.swift | 14 + .../OBCUITests/FirmwareUpdateCheckTests.swift | 40 +- .../OBCUITests/OnboardingProgressTests.swift | 33 + .../OBCUITests/UpdateSurfaceModelTests.swift | 106 + companion-ios/project.yml | 5 + .../scripts/generate-website-fixture.py | 2 +- 23 files changed, 938 insertions(+), 3091 deletions(-) create mode 100644 companion-ios/OBCCompanion/OBCCompanion.entitlements create mode 100644 companion-ios/OBCCompanionUITests/OnboardingFlowTests.swift create mode 100644 companion-ios/Packages/OBCKit/Sources/OBCFormats/BundledDemoRoute.swift delete mode 100644 companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/website-import.gpx create mode 100644 companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingFlowView.swift create mode 100644 companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingProgress.swift create mode 100644 companion-ios/Packages/OBCKit/Tests/OBCUITests/OnboardingProgressTests.swift create mode 100644 companion-ios/Packages/OBCKit/Tests/OBCUITests/UpdateSurfaceModelTests.swift diff --git a/companion-ios/OBCCompanion/OBCCompanion.entitlements b/companion-ios/OBCCompanion/OBCCompanion.entitlements new file mode 100644 index 000000000..bb7269865 --- /dev/null +++ b/companion-ios/OBCCompanion/OBCCompanion.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.associated-domains + + applinks:openbikecomputer.com + + + diff --git a/companion-ios/OBCCompanion/OBCCompanionApp.swift b/companion-ios/OBCCompanion/OBCCompanionApp.swift index 2ef927ded..b064cf8ea 100644 --- a/companion-ios/OBCCompanion/OBCCompanionApp.swift +++ b/companion-ios/OBCCompanion/OBCCompanionApp.swift @@ -60,6 +60,8 @@ struct OBCCompanionApp: App { RootView( transport: Self.makeTransport(), bondStore: Self.makeBondStore(), + onboarding: Self.makeOnboardingProgress(), + updateChecker: Self.makeSetupUpdateChecker(), library: Self.makeLibraryStore(), photoLibrary: Self.makePhotoLibrary(), lastBikeType: Self.makeLastBikeTypeStore(), @@ -248,6 +250,20 @@ struct OBCCompanionApp: App { return PathMonitorReachability() } + static func makeOnboardingProgress() -> OnboardingProgress { + #if DEBUG + if mockControl != nil { return OnboardingProgress() } + #endif + return OnboardingProgress(defaults: .standard) + } + + static func makeSetupUpdateChecker() -> UpdateChecker? { + #if DEBUG + if let mockControl { return OnboardingFixtures.updateChecker(for: mockControl.scenario) } + #endif + return UpdateChecker() + } + /// The bond record behind the launch branch. Mock runs read it from the scenario, so the dev /// panel can replay first-run pairing; the real path persists it in `UserDefaults`. static func makeBondStore() -> any BondStore { diff --git a/companion-ios/OBCCompanion/RootView.swift b/companion-ios/OBCCompanion/RootView.swift index 8fe0dc0fc..2a50267f6 100644 --- a/companion-ios/OBCCompanion/RootView.swift +++ b/companion-ios/OBCCompanion/RootView.swift @@ -11,6 +11,11 @@ import OBCUI struct RootView: View { @State private var launchModel: LaunchFlowModel @State private var mainModel: MainScreenModel + @State private var onboarding: OnboardingProgress + @State private var onboardingFirmware: FirmwareUpdateModel + @State private var onboardingUpload: UploadRequest? + @State private var pendingSetupRoute: RouteID? + @State private var setupRouteError: String? @State private var importModel: ImportFlowModel /// The foreground-only link policy: a real background transition suspends the link, after /// draining any in-flight transfer; foreground re-raises it over the bonded reconnect path. @@ -49,6 +54,8 @@ struct RootView: View { init( transport: any DeviceTransport, bondStore: any BondStore, + onboarding: OnboardingProgress = OnboardingProgress(), + updateChecker: UpdateChecker? = nil, library: any LibraryStore = InMemoryLibraryStore(), photoLibrary: any PhotoLibrary = PhotoKitLibrary(), lastBikeType: LastBikeTypeStore = LastBikeTypeStore(), @@ -78,7 +85,15 @@ struct RootView: View { self.importer = importer let transferActivity = TransferActivity() self.transferActivity = transferActivity - _launchModel = State(initialValue: LaunchFlowModel(transport: transport, bondStore: bondStore)) + _onboarding = State(initialValue: onboarding) + _onboardingFirmware = State(initialValue: FirmwareUpdateModel( + transport: transport, deviceName: bondStore.load()?.deviceName ?? "your OBC", + activity: transferActivity, updateChecker: updateChecker + )) + _launchModel = State(initialValue: LaunchFlowModel( + transport: transport, bondStore: bondStore, + onboardingPending: { onboarding.isPending } + )) _lifecycleModel = State(initialValue: LinkLifecycleModel( transport: transport, activity: transferActivity, backgroundTasks: backgroundTasks )) @@ -110,13 +125,24 @@ struct RootView: View { _updateSurfaceModel = State(initialValue: UpdateSurfaceModel( transport: transport, bondStore: bondStore, - runner: UpdateSurfaceRunner(store: updateSurface), + runner: updateChecker.map { UpdateSurfaceRunner(checker: $0, store: updateSurface) }, notifier: updateNotifier )) } var body: some View { - LaunchFlowView(model: launchModel) { + LaunchFlowView(model: launchModel, setup: { + OnboardingFlowView( + progress: onboarding, firmware: onboardingFirmware, library: mainModel, + onDemoRoute: sendDemoRoute, + onImport: { urls in Task { await importModel.openFiles(at: urls) } }, + onUpdateAnswered: recordSetupUpdateAnswer, + onFinish: { + mainModel.tab = mainModel.rides.contains(where: \.isDemo) ? .tracked : .planned + launchModel.finishSetup() + } + ) + }) { NavigationStack(path: $path) { MainScreenView( model: mainModel, @@ -138,7 +164,9 @@ struct RootView: View { }, onOpenTrash: { path.append(.trash) - } + }, + showsReadyNote: onboarding.showsReadyNote, + onDismissReadyNote: { onboarding.dismissReadyNote() } ) // The main screen draws its own chrome, but the title still names the pop target. .navigationTitle("Library") @@ -149,7 +177,12 @@ struct RootView: View { } // Everything below hangs outside the launch gate: a share can arrive before pairing, so // the import cover and its alert must present over the pairing flow too. - .fullScreenCover(item: $importModel.pendingImport) { pending in + .fullScreenCover(item: $importModel.pendingImport, onDismiss: { + if let id = pendingSetupRoute { + pendingSetupRoute = nil + sendSetupRoute(id) + } + }) { pending in importLanding(for: pending) } .fullScreenCover(item: $importModel.pendingJoin) { join in @@ -192,12 +225,12 @@ struct RootView: View { onSave: importModel.confirmNewName ) .task { + updateSurfaceModel.setEnabled(launchModel.phase == .main) lifecycleModel.start() reachability.start() // Remember the device's version while the link can be read, and run the launch check // once for this cold start; the `.active` edge below covers every return. - updateSurfaceModel.start() - updateSurfaceModel.appBecameActive() + if launchModel.phase == .main { startLibraryServices() } // A notice tapped from a cold launch: iOS delivers the response during startup, so // the flag may already be set by the time the first `.task` runs. if UpdateRouteRequest.shared.consume() { pushFirmwareUpdate() } @@ -218,7 +251,7 @@ struct RootView: View { // the way out. `.inactive` is deliberately neither. switch newPhase { case .active: - updateSurfaceModel.appBecameActive() + if launchModel.phase == .main { updateSurfaceModel.appBecameActive() } case .background: BackgroundUpdateRefresh.schedule() default: break @@ -226,9 +259,28 @@ struct RootView: View { } // Share-sheet delivery: iOS hands route files here, the same path as a Files pick. .onOpenURL { url in - // A share of several files arrives one URL at a time; the model batches them. - importModel.receive(url) + receive(url) } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + if let url = activity.webpageURL { receive(url) } + } + .onChange(of: launchModel.phase) { _, phase in + updateSurfaceModel.setEnabled(phase == .main) + switch phase { + case .pairing: onboarding.begin() + case .setup: mainModel.start() + case .main: startLibraryServices() + default: break + } + } + .sheet(item: $onboardingUpload) { request in + UploadSheetView(model: request.model) + } + .alert("Could not send the route", isPresented: Binding( + get: { setupRouteError != nil }, set: { if !$0 { setupRouteError = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { Text(setupRouteError ?? "") } // One shared online and offline signal for every basemap preview. .environment(\.obcIsOnline, reachability.isOnline) // Hold the screen awake while any transfer is in flight: the idle-timer touch reads the @@ -254,12 +306,86 @@ struct RootView: View { } } + private func receive(_ url: URL) { + if LaunchFlowModel.acceptsPairingLink(url) { + launchModel.openPairingLink() + } else if url.isFileURL { + importModel.receive(url) + } + } + + private func startLibraryServices() { + updateSurfaceModel.setEnabled(true) + mainModel.start() + updateSurfaceModel.start() + updateSurfaceModel.appBecameActive() + } + + private func recordSetupUpdateAnswer(_ version: String) { + guard let serial = onboardingFirmware.deviceSerial, + let runningVersion = onboardingFirmware.runningVersion else { return } + let device = LastSeenDevice(serial: serial, firmwareVersion: runningVersion, seenAt: Date()) + let runner = UpdateSurfaceRunner(store: updateSurface) + runner.remember(device) + runner.recordAnswered(version: version, device: device) + } + + private func sendDemoRoute() { + do { + let data = try BundledDemoRoute.data() + let route = try importer.importRoute(from: data, fileExtension: "gpx") + let existing = library.plannedRoutes().first { + $0.sourceFileName == BundledDemoRoute.fileName && $0.sourceFileData == data && $0.route == route + } + if let existing { + sendSetupRoute(existing.id) + return + } + let detail = RouteDetailModel( + transport: transport, dressing: .imported(route, fileName: BundledDemoRoute.fileName) + ).makeDetail() + mainModel.addImportedRoute(PlannedRouteRecord( + summary: detail.summary, route: route, + sourceFileName: BundledDemoRoute.fileName, sourceFileData: data + )) + sendSetupRoute(detail.summary.id) + } catch { + setupRouteError = "The demo route could not be opened. You can import a GPX or TCX route, or skip this step." + } + } + + private func sendSetupRoute(_ id: RouteID) { + guard mainModel.connection == .connected, mainModel.connectedScope != nil, + mainModel.protocolMismatch == nil, + let summary = mainModel.routes.first(where: { $0.id == id }) else { + setupRouteError = "Keep your OBC on and nearby, then try again. Routes need compatible app and OBC versions." + return + } + let detail = RouteDetailModel( + transport: transport, dressing: .planned(summary), + bikeType: mainModel.plannedBikeType(for: id), + preloadedDetail: mainModel.importedDetail(for: id), + plannedGeometry: mainModel.plannedGeometry(for: id), + sourceFileName: mainModel.plannedSourceFileName(for: id), + deviceObjectID: mainModel.plannedDeviceObjectID(for: id), + provenCommittedCRC: mainModel.plannedProvenCommittedCRC(for: id) + ) + onboardingUpload = UploadRequest(model: UploadSheetModel( + transport: transport, blob: detail.makeUploadBlob(), deviceName: mainModel.deviceName, + timing: OBCCompanionApp.launchUploadTiming(), activity: transferActivity, + onCompleted: { objectID, crc in + if let objectID { mainModel.markRouteUploaded(id, objectID: objectID, crc32: crc) } + onboarding.move(to: .ride) + } + )) + } + // MARK: The proactive update surface /// Presentation binding for the launch sheet. Dismissal answers: a swipe-away says "not now". private var pendingUpdate: Binding { Binding( - get: { updateSurfaceModel.pending }, + get: { launchModel.phase == .main ? updateSurfaceModel.pending : nil }, set: { if $0 == nil { updateSurfaceModel.dismiss() } } ) } @@ -289,9 +415,15 @@ struct RootView: View { noDevicePaired: pending.noDevicePaired, // A trip is app-local, so the trip rows work with no device paired just the same. trips: mainModel.tripPickerItems, + isOnboarding: launchModel.phase == .setup && onboarding.stage == .route, replacing: pending.replacing, onSave: { detail, tripSelection in mainModel.addImportedRoute(pending.record(for: detail)) + if launchModel.phase == .setup, onboarding.stage == .route { + pendingSetupRoute = detail.summary.id + importModel.closeImport() + return + } // A trip choice moves the route into the trip and opens the trip page. // A new trip from one file opens in the day editor's split mode. if let tripID = mainModel.fileRoute(detail.summary.id, into: tripSelection) { @@ -511,12 +643,17 @@ struct RootView: View { // Bond cleared and link dropped by the model; pop the stack and show the pairing prompt. onForget: { path.removeAll() + onboarding.reset() launchModel.forgetDevice() }, // Its own destination, so the host owns a stable model and an in-flight transfer // survives Settings body passes. onOpenFirmwareUpdate: { path.append(.firmwareUpdate) }, - + onReplaySetup: { + path.removeAll() + onboarding.replay() + launchModel.replaySetup() + }, onOpenDevPanel: devPanelOpener ) case .firmwareUpdate: diff --git a/companion-ios/OBCCompanion/ScreenHosts.swift b/companion-ios/OBCCompanion/ScreenHosts.swift index 2cb14c5e1..5e41ecd66 100644 --- a/companion-ios/OBCCompanion/ScreenHosts.swift +++ b/companion-ios/OBCCompanion/ScreenHosts.swift @@ -12,6 +12,7 @@ import OBCUI struct SettingsScreen: View { @State private var model: SettingsModel private let onOpenFirmwareUpdate: () -> Void + private let onReplaySetup: () -> Void private let onOpenDevPanel: (() -> Void)? @@ -22,6 +23,7 @@ struct SettingsScreen: View { onDeviceRenamed: @escaping (String) -> Void, onForget: @escaping () -> Void, onOpenFirmwareUpdate: @escaping () -> Void, + onReplaySetup: @escaping () -> Void, onOpenDevPanel: (() -> Void)? ) { @@ -33,6 +35,7 @@ struct SettingsScreen: View { onForget: onForget )) self.onOpenFirmwareUpdate = onOpenFirmwareUpdate + self.onReplaySetup = onReplaySetup self.onOpenDevPanel = onOpenDevPanel } @@ -41,6 +44,7 @@ struct SettingsScreen: View { SettingsView( model: model, onOpenFirmwareUpdate: onOpenFirmwareUpdate, + onReplaySetup: onReplaySetup, onOpenDevPanel: onOpenDevPanel ) @@ -283,6 +287,7 @@ struct ImportLandingHost: View { private let noDevicePaired: Bool /// The trips the route can join, most recently edited first. private let trips: [TripPickerItem] + private let isOnboarding: Bool private let onSave: (RouteDetail, TripSelection) -> Void private let onPair: (RouteDetail) -> Void private let onCancel: () -> Void @@ -296,6 +301,7 @@ struct ImportLandingHost: View { deviceName: String, noDevicePaired: Bool, trips: [TripPickerItem] = [], + isOnboarding: Bool = false, // When this import replaces an existing route, the landing reuses its id, so New route // updates that route in place instead of adding a duplicate. replacing: PlannedRouteRecord? = nil, @@ -312,6 +318,7 @@ struct ImportLandingHost: View { self.deviceName = deviceName self.noDevicePaired = noDevicePaired self.trips = trips + self.isOnboarding = isOnboarding self.onSave = onSave self.onPair = onPair self.onCancel = onCancel @@ -334,30 +341,32 @@ struct ImportLandingHost: View { /// The one save, then the two ways into a trip. Each choice says where the route lands. private var rows: some View { VStack(alignment: .leading, spacing: 20) { - Button("Save to Library") { save(.none) } + Button(isOnboarding ? "Save and send to OBC" : "Save to Library") { save(.none) } .buttonStyle(.obcPrimary) .accessibilityIdentifier("import.newRoute") - OBCGroupedSection("Or put it in a trip") { - if trips.count == 1, let trip = trips.first { - OBCListRow(label: "Add to \(trip.name)", detail: "It becomes day \(trip.dayCount + 1).", showsChevron: true) { - save(.existing(trip.id)) + if !isOnboarding { + OBCGroupedSection("Or put it in a trip") { + if trips.count == 1, let trip = trips.first { + OBCListRow(label: "Add to \(trip.name)", detail: "It becomes day \(trip.dayCount + 1).", showsChevron: true) { + save(.existing(trip.id)) + } + .accessibilityIdentifier("import.addToTrip") + } else if trips.count > 1 { + OBCListRow(label: "Add to a trip", detail: "It becomes the last day.", showsChevron: true) { + tripPickerShown = true + } + .accessibilityIdentifier("import.addToTrip") } - .accessibilityIdentifier("import.addToTrip") - } else if trips.count > 1 { - OBCListRow(label: "Add to a trip", detail: "It becomes the last day.", showsChevron: true) { - tripPickerShown = true + OBCListRow( + label: "Start a new trip", + detail: "Then split the route into days.", + showsChevron: true, + showsDivider: false + ) { + save(.new(model.name)) } - .accessibilityIdentifier("import.addToTrip") + .accessibilityIdentifier("import.startTrip") } - OBCListRow( - label: "Start a new trip", - detail: "Then split the route into days.", - showsChevron: true, - showsDivider: false - ) { - save(.new(model.name)) - } - .accessibilityIdentifier("import.startTrip") } } } diff --git a/companion-ios/OBCCompanionUITests/OnboardingBranchesTests.swift b/companion-ios/OBCCompanionUITests/OnboardingBranchesTests.swift index 18b8b258e..ec6ea6245 100644 --- a/companion-ios/OBCCompanionUITests/OnboardingBranchesTests.swift +++ b/companion-ios/OBCCompanionUITests/OnboardingBranchesTests.swift @@ -85,6 +85,10 @@ final class OnboardingBranchesTests: XCTestCase { capture(app, "P09-update-or-later") tap(app, "onboarding.updateLater") XCTAssertTrue(app.staticTexts["onboarding.routeTitle"].waitForExistence(timeout: 10)) + tap(app, "onboarding.routeSkip") + tap(app, "onboarding.finish") + XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.buttons["firmware.updateSheet.view"].exists) } @MainActor diff --git a/companion-ios/OBCCompanionUITests/OnboardingFlowTests.swift b/companion-ios/OBCCompanionUITests/OnboardingFlowTests.swift new file mode 100644 index 000000000..ccff68b10 --- /dev/null +++ b/companion-ios/OBCCompanionUITests/OnboardingFlowTests.swift @@ -0,0 +1,92 @@ +import XCTest + +final class OnboardingFlowTests: XCTestCase { + override func setUp() { + continueAfterFailure = false + } + + @MainActor + private func launch() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = [ + "-OBCScenario", "onboarding", "-OBCNetwork", "offline", + "-OBCHideMockHUD", "-OBCDisableAnimations", "-OBCHoldConfirmations", + "-AppleLanguages", "(en)", "-AppleLocale", "en_US" + ] + app.launchEnvironment["TZ"] = "UTC" + app.launch() + return app + } + + @MainActor + private func capture(_ app: XCUIApplication, _ name: String) { + var previous = app.screenshot() + for _ in 0..<40 { + let next = app.screenshot() + if previous.pngRepresentation == next.pngRepresentation { + let attachment = XCTAttachment(screenshot: next) + attachment.name = "onboarding-\(name)" + attachment.lifetime = .keepAlways + add(attachment) + return + } + previous = next + } + XCTFail("\(name) did not settle") + } + + @MainActor + private func tap(_ app: XCUIApplication, _ identifier: String) { + let button = app.buttons[identifier] + XCTAssertTrue(button.waitForExistence(timeout: 15), identifier) + let enabled = expectation(for: NSPredicate(format: "enabled == true"), evaluatedWith: button) + wait(for: [enabled], timeout: 15) + button.tap() + } + + @MainActor + func testDemoRouteAndRideReachLibrary() { + let app = launch() + tap(app, "onboarding.getStarted") + tap(app, "pair.start") + tap(app, "onboarding.allowBluetooth") + XCTAssertTrue(app.staticTexts["pair.pairedTitle"].waitForExistence(timeout: 15)) + XCTAssertEqual(app.textFields["pairing.name"].value as? String, "OBC-7A2F") + capture(app, "P06-factory-name") + tap(app, "pairing.keepName") + tap(app, "onboarding.sensorsContinue") + tap(app, "onboarding.demoRoute") + XCTAssertTrue(app.buttons["upload.done"].waitForExistence(timeout: 30)) + XCTAssertTrue(app.staticTexts["Grimsel Pass is on OBC-7A2F"].exists) + capture(app, "P10-route-sent") + tap(app, "upload.done") + XCTAssertTrue(app.staticTexts["onboarding.rideTitle"].waitForExistence(timeout: 10)) + tap(app, "onboarding.syncRide") + let title = app.staticTexts["onboarding.rideTitle"] + let synced = expectation( + for: NSPredicate(format: "label == 'Your demo ride is here'"), evaluatedWith: title) + wait(for: [synced], timeout: 30) + capture(app, "P11-ride-synced") + tap(app, "onboarding.finish") + XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["Grimsel Pass"].firstMatch.waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts.containing( + NSPredicate(format: "label CONTAINS %@", "Demo ride, excluded from totals") + ).firstMatch.exists) + capture(app, "P12-demo-library") + app.buttons["Got it"].tap() + XCTAssertFalse(app.descendants(matching: .any)["onboarding.readyNote"].firstMatch.exists) + } + + @MainActor + func testBrowseThenReplaySetupFromSettings() { + let app = launch() + tap(app, "onboarding.browse") + XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 10)) + tap(app, "topbar.settings") + let replay = app.buttons["settings.replaySetup"] + if !replay.isHittable { app.swipeUp() } + tap(app, "settings.replaySetup") + XCTAssertTrue(app.staticTexts["onboarding.welcomeTitle"].waitForExistence(timeout: 10)) + } +} diff --git a/companion-ios/OBCCompanionUITests/PairingFlowTests.swift b/companion-ios/OBCCompanionUITests/PairingFlowTests.swift index 3719623db..e2bca780a 100644 --- a/companion-ios/OBCCompanionUITests/PairingFlowTests.swift +++ b/companion-ios/OBCCompanionUITests/PairingFlowTests.swift @@ -12,7 +12,7 @@ final class PairingFlowTests: XCTestCase { @MainActor private func launch(scenario: String) -> XCUIApplication { let app = XCUIApplication() - app.launchArguments += ["-OBCScenario", scenario] + app.launchArguments += ["-OBCScenario", scenario, "-OBCHideMockHUD", "-OBCDisableAnimations"] app.launch() return app } @@ -26,25 +26,41 @@ final class PairingFlowTests: XCTestCase { add(attachment) } + @MainActor + private func enterPairing(_ app: XCUIApplication, capture: Bool = false) { + XCTAssertTrue(app.staticTexts["onboarding.welcomeTitle"].waitForExistence(timeout: 10)) + if capture { snap(app, "onboarding-P01-welcome") } + app.buttons["onboarding.getStarted"].tap() + XCTAssertTrue(app.staticTexts["onboarding.switchOnTitle"].waitForExistence(timeout: 10)) + if capture { snap(app, "onboarding-P02-switch-on") } + app.buttons["pair.start"].tap() + XCTAssertTrue(app.staticTexts["onboarding.bluetoothTitle"].waitForExistence(timeout: 10)) + if capture { snap(app, "onboarding-P03-bluetooth") } + app.buttons["onboarding.allowBluetooth"].tap() + } + /// The intro, the scan with the row sliding in, the pairing beat, then the main screen. @MainActor func testFirstRunPairingHappyPath() { let app = launch(scenario: "noDevice") - XCTAssertTrue(app.staticTexts["pair.introTitle"].waitForExistence(timeout: 10), "D1 missing") - snap(app, "D1-pairing-prompt") - app.buttons["pair.start"].tap() - - let row = app.buttons["pair.deviceRow"] - XCTAssertTrue(row.waitForExistence(timeout: 10), "D2 discovered row missing") - snap(app, "D2-scanning-found") - row.tap() + enterPairing(app, capture: true) XCTAssertTrue(app.staticTexts["pair.pairedTitle"].waitForExistence(timeout: 10), "D4 missing") - snap(app, "D4-paired") - app.buttons["pair.goToRoutes"].tap() - - XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 10), "main missing after pairing") + snap(app, "onboarding-P06-paired") + app.buttons["pairing.keepName"].tap() + XCTAssertTrue(app.staticTexts["onboarding.sensorsTitle"].waitForExistence(timeout: 10)) + snap(app, "onboarding-P08-sensors") + app.buttons["onboarding.sensorsContinue"].tap() + XCTAssertTrue(app.staticTexts["onboarding.routeTitle"].waitForExistence(timeout: 10)) + snap(app, "onboarding-P10-route") + app.buttons["onboarding.routeSkip"].tap() + XCTAssertTrue(app.staticTexts["onboarding.rideTitle"].waitForExistence(timeout: 10)) + snap(app, "onboarding-P11-ride") + app.buttons["onboarding.finish"].tap() + XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 10), "main missing after setup") + XCTAssertTrue(app.descendants(matching: .any)["onboarding.readyNote"].firstMatch.exists) + snap(app, "onboarding-P12-library") } /// A scan timeout resolves to the failure screen; Try again loops back through scanning. @@ -52,8 +68,7 @@ final class PairingFlowTests: XCTestCase { func testPairingTimeoutShowsD5AndRetryLoops() { let app = launch(scenario: "pairingTimeout") - XCTAssertTrue(app.staticTexts["pair.introTitle"].waitForExistence(timeout: 10)) - app.buttons["pair.start"].tap() + enterPairing(app) let failed = app.staticTexts["pair.failedTitle"] XCTAssertTrue(failed.waitForExistence(timeout: 10), "D5 missing") @@ -68,14 +83,7 @@ final class PairingFlowTests: XCTestCase { func testPairingRejectedShowsD5RejectedCopy() { let app = launch(scenario: "pairingRejected") - XCTAssertTrue(app.staticTexts["pair.introTitle"].waitForExistence(timeout: 10)) - app.buttons["pair.start"].tap() - - // The row appears first, from un-gated discovery. The passkey is gated and only fires on - // the row tap, so a rejection surfaces after confirming, not before. - let row = app.buttons["pair.deviceRow"] - XCTAssertTrue(row.waitForExistence(timeout: 10), "D2 discovered row missing") - row.tap() + enterPairing(app) let failed = app.staticTexts["pair.failedTitle"] XCTAssertTrue(failed.waitForExistence(timeout: 10), "D5 missing") @@ -87,8 +95,7 @@ final class PairingFlowTests: XCTestCase { func testBluetoothOffShowsH8AndLibraryStaysReachable() { let app = launch(scenario: "bluetoothOff") - XCTAssertTrue(app.staticTexts["pair.introTitle"].waitForExistence(timeout: 10)) - app.buttons["pair.start"].tap() + enterPairing(app) let title = app.staticTexts["radio.title"] XCTAssertTrue(title.waitForExistence(timeout: 10), "H8 missing") @@ -104,8 +111,7 @@ final class PairingFlowTests: XCTestCase { func testPermissionDeniedShowsH7State() { let app = launch(scenario: "permissionDenied") - XCTAssertTrue(app.staticTexts["pair.introTitle"].waitForExistence(timeout: 10)) - app.buttons["pair.start"].tap() + enterPairing(app) let title = app.staticTexts["radio.title"] XCTAssertTrue(title.waitForExistence(timeout: 10), "H7 state missing") diff --git a/companion-ios/Packages/OBCKit/Package.swift b/companion-ios/Packages/OBCKit/Package.swift index 8d721cc7a..d86fc76f3 100644 --- a/companion-ios/Packages/OBCKit/Package.swift +++ b/companion-ios/Packages/OBCKit/Package.swift @@ -52,7 +52,7 @@ let package = Package( ), .target( name: "OBCMock", - dependencies: ["OBCTransport", "OBCDomain"], + dependencies: ["OBCTransport", "OBCDomain", "OBCFormats"], // Editable JSON fixture sets (routes/rides/config/diagnostics) the mock // serves. The Swift that loads them is `#if DEBUG`; these are inert data. resources: [.process("Fixtures")], diff --git a/companion-ios/Packages/OBCKit/Sources/OBCFormats/BundledDemoRoute.swift b/companion-ios/Packages/OBCKit/Sources/OBCFormats/BundledDemoRoute.swift new file mode 100644 index 000000000..e0461408e --- /dev/null +++ b/companion-ios/Packages/OBCKit/Sources/OBCFormats/BundledDemoRoute.swift @@ -0,0 +1,12 @@ +import Foundation + +public enum BundledDemoRoute { + public static let fileName = "grimsel-pass.gpx" + + public static func data() throws -> Data { + guard let url = Bundle.module.url(forResource: "grimsel-pass", withExtension: "gpx") else { + throw CocoaError(.fileNoSuchFile) + } + return try Data(contentsOf: url) + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures.swift b/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures.swift index 7b72616fd..0d9672109 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures.swift @@ -2,6 +2,7 @@ import Foundation import OBCDomain import OBCTransport +import OBCFormats /// A loaded fixture set: the domain objects the mock serves. A value type, so the live /// `MockControl` can copy-mutate it under its lock. Built by decoding editable JSON in @@ -187,6 +188,15 @@ extension FixtureSet { /// without the planned route, so an import can share that launch without a collision. Missing /// or unreadable resources fall back to `builtIn`: the mock never traps. public static func load(_ named: String) -> FixtureSet { + if named == "onboarding" { + var fixtures = load("website-rides") + fixtures.deviceInfo = fixtures.deviceInfo.renamed("OBC-7A2F") + fixtures.config.name = "OBC-7A2F" + for index in fixtures.rides.indices { + fixtures.rides[index].summary.isDemo = true + } + return fixtures + } if named == "website-rides" { var fixtures = load("website") fixtures.routes = [] @@ -238,7 +248,7 @@ public enum SampleRouteFile { case .gpx, .tcx: bundled("sample-import", kind.rawValue).map { [($0, "sample-import.\(kind.rawValue)")] } ?? [] case .grimsel: - bundled("website-import", "gpx").map { [($0, "website-import.gpx")] } ?? [] + (try? BundledDemoRoute.data()).map { [($0, BundledDemoRoute.fileName)] } ?? [] case .bad: [(Data("socks · stove · sleeping bag — definitely not a route\n".utf8), "packing-list.pdf")] case .trip: diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/website-import.gpx b/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/website-import.gpx deleted file mode 100644 index e59461696..000000000 --- a/companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/website-import.gpx +++ /dev/null @@ -1,3008 +0,0 @@ - - - Grimsel Pass - 1063.1 - 1063.2 - 1063.7 - 1063.8 - 1064.1 - 1064.1 - 1064.5 - 1064.8 - 1065.0 - 1065.1 - 1065.2 - 1065.4 - 1065.8 - 1066.1 - 1067.4 - 1067.8 - 1068.0 - 1068.6 - 1069.6 - 1069.7 - 1070.0 - 1070.6 - 1070.9 - 1071.2 - 1071.9 - 1072.1 - 1072.9 - 1073.3 - 1073.6 - 1074.0 - 1074.4 - 1074.7 - 1075.1 - 1075.2 - 1075.3 - 1075.8 - 1076.0 - 1076.3 - 1076.8 - 1077.6 - 1078.2 - 1078.5 - 1078.8 - 1079.2 - 1079.4 - 1079.5 - 1079.8 - 1080.0 - 1080.4 - 1080.8 - 1081.2 - 1081.5 - 1082.1 - 1082.4 - 1082.8 - 1083.1 - 1083.4 - 1083.6 - 1084.0 - 1084.5 - 1085.1 - 1085.5 - 1086.2 - 1086.8 - 1087.8 - 1087.9 - 1088.8 - 1089.6 - 1089.6 - 1089.7 - 1089.7 - 1089.7 - 1089.8 - 1089.8 - 1089.8 - 1089.8 - 1089.9 - 1089.9 - 1089.9 - 1089.9 - 1090.0 - 1090.0 - 1090.0 - 1090.0 - 1090.1 - 1090.2 - 1090.3 - 1090.4 - 1090.5 - 1090.5 - 1090.6 - 1090.7 - 1090.8 - 1090.9 - 1091.0 - 1091.1 - 1091.1 - 1091.4 - 1092.3 - 1092.5 - 1093.1 - 1093.4 - 1093.5 - 1094.3 - 1094.4 - 1095.2 - 1096.1 - 1097.2 - 1097.3 - 1098.1 - 1098.3 - 1098.5 - 1098.7 - 1099.4 - 1100.2 - 1100.5 - 1100.9 - 1101.6 - 1102.3 - 1102.6 - 1102.9 - 1103.4 - 1103.5 - 1103.6 - 1104.2 - 1104.6 - 1104.8 - 1105.6 - 1105.7 - 1106.3 - 1106.9 - 1107.0 - 1107.7 - 1108.1 - 1108.5 - 1108.9 - 1109.3 - 1109.4 - 1109.5 - 1109.7 - 1109.8 - 1109.9 - 1110.3 - 1110.8 - 1111.0 - 1111.7 - 1111.9 - 1112.2 - 1112.4 - 1113.0 - 1113.4 - 1114.2 - 1114.6 - 1115.2 - 1115.6 - 1116.2 - 1116.3 - 1117.2 - 1117.3 - 1118.0 - 1118.2 - 1118.6 - 1118.9 - 1119.1 - 1119.6 - 1120.2 - 1120.4 - 1121.0 - 1121.1 - 1121.2 - 1121.8 - 1122.1 - 1122.3 - 1122.8 - 1123.3 - 1123.6 - 1124.3 - 1125.3 - 1125.6 - 1126.0 - 1126.1 - 1126.7 - 1127.0 - 1127.8 - 1128.5 - 1128.9 - 1129.0 - 1129.4 - 1129.7 - 1130.1 - 1130.3 - 1131.5 - 1131.9 - 1132.2 - 1133.0 - 1133.2 - 1133.7 - 1134.3 - 1134.9 - 1135.3 - 1135.7 - 1136.1 - 1136.4 - 1136.8 - 1137.4 - 1138.2 - 1138.8 - 1139.5 - 1139.7 - 1140.1 - 1140.7 - 1140.8 - 1141.3 - 1141.7 - 1141.9 - 1142.5 - 1142.9 - 1143.5 - 1143.6 - 1143.9 - 1143.9 - 1144.3 - 1144.7 - 1145.1 - 1145.3 - 1145.5 - 1146.1 - 1146.2 - 1146.8 - 1147.0 - 1147.6 - 1148.1 - 1148.4 - 1149.2 - 1149.4 - 1149.8 - 1150.4 - 1151.1 - 1151.6 - 1151.7 - 1152.1 - 1152.7 - 1152.8 - 1153.2 - 1153.6 - 1153.9 - 1154.7 - 1154.8 - 1155.4 - 1155.8 - 1155.9 - 1156.1 - 1156.8 - 1157.0 - 1157.7 - 1158.2 - 1158.6 - 1159.1 - 1159.3 - 1160.2 - 1160.3 - 1161.2 - 1161.4 - 1162.2 - 1162.6 - 1163.1 - 1163.6 - 1163.8 - 1164.3 - 1164.6 - 1164.9 - 1165.5 - 1165.7 - 1166.3 - 1166.6 - 1166.9 - 1167.5 - 1167.6 - 1168.4 - 1168.5 - 1168.7 - 1169.3 - 1169.5 - 1170.1 - 1170.5 - 1170.7 - 1171.5 - 1171.6 - 1172.3 - 1172.4 - 1173.0 - 1173.1 - 1173.2 - 1173.4 - 1174.1 - 1174.5 - 1174.7 - 1175.3 - 1175.4 - 1175.8 - 1176.0 - 1176.5 - 1177.1 - 1177.2 - 1177.9 - 1178.2 - 1178.5 - 1179.3 - 1179.4 - 1180.3 - 1180.7 - 1181.2 - 1181.6 - 1181.9 - 1182.4 - 1183.0 - 1183.1 - 1183.8 - 1184.1 - 1184.9 - 1185.0 - 1185.4 - 1185.7 - 1185.8 - 1186.1 - 1186.5 - 1187.2 - 1187.4 - 1187.8 - 1188.1 - 1188.4 - 1188.4 - 1188.6 - 1188.7 - 1189.1 - 1189.2 - 1189.9 - 1190.5 - 1190.9 - 1191.8 - 1192.3 - 1192.9 - 1193.4 - 1193.8 - 1194.4 - 1194.8 - 1195.5 - 1195.8 - 1196.7 - 1196.9 - 1197.6 - 1197.7 - 1198.2 - 1198.7 - 1198.9 - 1199.7 - 1199.8 - 1199.9 - 1200.7 - 1200.8 - 1201.7 - 1201.9 - 1202.5 - 1202.7 - 1203.2 - 1203.7 - 1203.9 - 1204.7 - 1205.1 - 1205.3 - 1205.5 - 1205.8 - 1205.9 - 1206.3 - 1206.4 - 1207.1 - 1207.2 - 1207.6 - 1208.1 - 1208.3 - 1209.1 - 1209.5 - 1209.7 - 1209.9 - 1210.3 - 1210.6 - 1210.9 - 1211.4 - 1212.3 - 1213.0 - 1213.4 - 1213.8 - 1214.3 - 1214.5 - 1215.0 - 1215.1 - 1215.7 - 1216.0 - 1216.3 - 1216.7 - 1217.1 - 1217.1 - 1217.3 - 1217.4 - 1217.5 - 1217.6 - 1217.8 - 1218.6 - 1219.1 - 1219.4 - 1219.6 - 1220.0 - 1220.4 - 1220.5 - 1220.8 - 1220.9 - 1221.4 - 1221.5 - 1221.6 - 1221.8 - 1222.1 - 1222.6 - 1222.7 - 1222.7 - 1222.8 - 1222.8 - 1222.8 - 1223.1 - 1223.5 - 1223.9 - 1224.2 - 1224.7 - 1224.8 - 1225.1 - 1225.8 - 1226.0 - 1226.6 - 1226.7 - 1227.1 - 1227.9 - 1228.0 - 1228.6 - 1229.1 - 1229.2 - 1229.8 - 1230.1 - 1230.9 - 1231.1 - 1231.7 - 1232.2 - 1232.4 - 1233.0 - 1233.1 - 1233.4 - 1233.6 - 1233.8 - 1234.2 - 1234.3 - 1234.3 - 1234.6 - 1235.5 - 1235.7 - 1236.4 - 1236.5 - 1236.7 - 1237.5 - 1237.7 - 1238.6 - 1239.5 - 1239.6 - 1240.3 - 1240.7 - 1240.9 - 1241.1 - 1241.2 - 1241.6 - 1242.0 - 1242.5 - 1242.7 - 1242.7 - 1242.9 - 1243.0 - 1243.1 - 1244.3 - 1245.4 - 1245.6 - 1247.3 - 1248.1 - 1249.8 - 1251.4 - 1251.7 - 1252.7 - 1253.0 - 1255.1 - 1255.3 - 1256.3 - 1257.2 - 1257.7 - 1258.1 - 1258.7 - 1260.2 - 1260.5 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1261.7 - 1280.9 - 1281.3 - 1281.4 - 1281.7 - 1281.8 - 1282.3 - 1282.4 - 1282.7 - 1282.9 - 1283.1 - 1283.4 - 1283.5 - 1283.7 - 1284.0 - 1284.5 - 1284.7 - 1285.0 - 1285.4 - 1285.5 - 1285.8 - 1285.9 - 1286.3 - 1286.5 - 1286.6 - 1287.0 - 1287.3 - 1287.5 - 1287.7 - 1288.3 - 1288.3 - 1288.9 - 1289.2 - 1289.4 - 1290.0 - 1290.1 - 1290.2 - 1290.6 - 1291.2 - 1291.3 - 1291.8 - 1292.1 - 1292.3 - 1292.8 - 1293.0 - 1293.1 - 1293.8 - 1294.3 - 1294.5 - 1295.0 - 1295.8 - 1296.5 - 1297.2 - 1297.4 - 1297.9 - 1298.5 - 1298.9 - 1299.3 - 1299.8 - 1299.9 - 1300.6 - 1300.8 - 1301.2 - 1301.5 - 1301.9 - 1302.2 - 1302.7 - 1303.4 - 1303.5 - 1304.3 - 1305.0 - 1305.3 - 1305.6 - 1306.2 - 1306.3 - 1307.0 - 1307.2 - 1307.3 - 1307.6 - 1307.9 - 1308.5 - 1309.3 - 1309.6 - 1309.9 - 1310.4 - 1311.1 - 1311.7 - 1312.1 - 1312.3 - 1313.2 - 1313.8 - 1314.1 - 1314.6 - 1314.8 - 1315.3 - 1315.7 - 1316.0 - 1316.8 - 1316.9 - 1317.5 - 1317.9 - 1318.0 - 1318.1 - 1318.4 - 1318.7 - 1318.8 - 1319.4 - 1319.5 - 1320.2 - 1320.4 - 1320.7 - 1321.5 - 1321.6 - 1322.3 - 1322.4 - 1323.3 - 1324.1 - 1324.4 - 1324.7 - 1325.1 - 1325.8 - 1326.2 - 1326.5 - 1327.1 - 1327.5 - 1328.0 - 1328.3 - 1328.7 - 1329.5 - 1330.4 - 1330.6 - 1331.1 - 1331.4 - 1331.8 - 1332.5 - 1333.4 - 1334.0 - 1334.1 - 1334.5 - 1334.8 - 1334.9 - 1335.1 - 1335.4 - 1336.0 - 1336.2 - 1336.3 - 1336.5 - 1336.6 - 1337.1 - 1337.3 - 1337.5 - 1337.9 - 1338.2 - 1338.6 - 1339.3 - 1339.4 - 1339.9 - 1340.4 - 1340.7 - 1341.3 - 1341.7 - 1342.1 - 1342.5 - 1342.6 - 1343.3 - 1343.6 - 1344.1 - 1344.9 - 1345.2 - 1345.6 - 1346.2 - 1347.2 - 1348.6 - 1348.8 - 1349.6 - 1350.0 - 1350.5 - 1351.2 - 1351.4 - 1352.0 - 1352.4 - 1353.1 - 1353.7 - 1354.4 - 1354.7 - 1355.5 - 1356.7 - 1357.2 - 1357.8 - 1358.1 - 1358.5 - 1358.6 - 1359.4 - 1360.2 - 1360.7 - 1361.2 - 1361.9 - 1362.2 - 1362.5 - 1362.5 - 1362.5 - 1362.5 - 1362.5 - 1362.5 - 1362.7 - 1362.7 - 1362.9 - 1363.3 - 1363.5 - 1363.7 - 1364.0 - 1364.2 - 1364.6 - 1364.7 - 1365.0 - 1365.1 - 1365.2 - 1365.4 - 1365.4 - 1365.4 - 1365.4 - 1365.4 - 1365.4 - 1365.4 - 1365.4 - 1365.4 - 1365.4 - 1365.4 - 1365.4 - 1365.4 - 1365.4 - 1376.2 - 1376.7 - 1376.8 - 1377.3 - 1377.4 - 1377.6 - 1377.9 - 1378.1 - 1378.9 - 1379.3 - 1379.6 - 1380.3 - 1380.5 - 1381.0 - 1381.4 - 1382.0 - 1382.1 - 1382.7 - 1382.8 - 1382.9 - 1383.1 - 1383.3 - 1383.6 - 1384.1 - 1384.2 - 1384.8 - 1385.2 - 1385.6 - 1386.0 - 1386.3 - 1386.9 - 1387.1 - 1387.2 - 1387.5 - 1388.0 - 1388.1 - 1389.0 - 1389.1 - 1389.7 - 1390.0 - 1390.4 - 1390.8 - 1391.0 - 1391.7 - 1391.8 - 1392.3 - 1392.4 - 1392.9 - 1393.2 - 1393.6 - 1393.9 - 1394.0 - 1394.5 - 1394.6 - 1394.7 - 1395.3 - 1395.5 - 1395.7 - 1395.9 - 1395.9 - 1396.7 - 1397.2 - 1397.6 - 1397.9 - 1398.4 - 1399.1 - 1399.2 - 1400.2 - 1400.3 - 1400.8 - 1401.6 - 1401.7 - 1401.8 - 1401.9 - 1402.4 - 1402.7 - 1403.0 - 1403.6 - 1403.7 - 1404.0 - 1404.2 - 1404.3 - 1404.7 - 1404.7 - 1405.0 - 1405.3 - 1405.4 - 1405.5 - 1405.7 - 1406.1 - 1406.1 - 1406.4 - 1406.5 - 1406.6 - 1406.8 - 1407.3 - 1407.4 - 1407.8 - 1408.1 - 1408.4 - 1408.9 - 1409.3 - 1409.7 - 1410.0 - 1410.1 - 1410.2 - 1410.2 - 1410.4 - 1410.6 - 1410.7 - 1410.9 - 1411.2 - 1411.4 - 1411.6 - 1411.7 - 1412.0 - 1412.2 - 1412.5 - 1412.9 - 1413.3 - 1413.7 - 1414.0 - 1414.0 - 1414.3 - 1414.5 - 1414.7 - 1415.1 - 1415.1 - 1415.4 - 1415.7 - 1416.4 - 1417.6 - 1417.9 - 1418.8 - 1419.5 - 1420.0 - 1420.1 - 1420.4 - 1421.1 - 1421.2 - 1421.8 - 1421.8 - 1422.4 - 1422.6 - 1422.9 - 1423.3 - 1423.4 - 1423.8 - 1423.9 - 1424.4 - 1424.7 - 1425.5 - 1426.5 - 1427.3 - 1427.5 - 1428.1 - 1428.7 - 1428.9 - 1429.7 - 1429.9 - 1430.5 - 1430.6 - 1431.3 - 1431.9 - 1432.2 - 1432.8 - 1433.1 - 1433.8 - 1434.0 - 1434.9 - 1435.8 - 1435.9 - 1436.5 - 1437.3 - 1437.5 - 1438.1 - 1438.4 - 1438.9 - 1439.3 - 1439.6 - 1440.2 - 1440.8 - 1441.0 - 1441.3 - 1441.7 - 1442.4 - 1443.1 - 1443.4 - 1444.1 - 1444.5 - 1444.9 - 1445.6 - 1445.9 - 1446.5 - 1447.1 - 1447.4 - 1448.0 - 1448.2 - 1448.9 - 1449.4 - 1449.6 - 1450.2 - 1450.4 - 1451.0 - 1451.2 - 1451.7 - 1451.9 - 1452.4 - 1452.8 - 1453.1 - 1453.7 - 1453.8 - 1454.4 - 1454.7 - 1455.2 - 1455.4 - 1455.7 - 1456.2 - 1456.6 - 1456.9 - 1457.1 - 1457.7 - 1458.3 - 1458.5 - 1459.2 - 1459.9 - 1460.0 - 1460.2 - 1460.9 - 1461.2 - 1461.7 - 1462.3 - 1462.4 - 1463.2 - 1463.5 - 1463.9 - 1464.5 - 1464.9 - 1465.5 - 1465.6 - 1466.1 - 1466.4 - 1466.7 - 1467.2 - 1467.7 - 1468.4 - 1468.8 - 1469.6 - 1469.8 - 1470.4 - 1470.7 - 1471.1 - 1471.3 - 1471.7 - 1472.2 - 1472.8 - 1473.2 - 1473.6 - 1473.9 - 1474.3 - 1474.7 - 1474.8 - 1475.5 - 1476.4 - 1477.2 - 1477.8 - 1478.3 - 1478.7 - 1479.3 - 1480.0 - 1480.3 - 1480.5 - 1480.7 - 1480.9 - 1481.2 - 1481.5 - 1481.7 - 1482.2 - 1482.8 - 1483.4 - 1483.7 - 1483.9 - 1484.1 - 1484.7 - 1484.8 - 1484.9 - 1485.1 - 1485.1 - 1485.1 - 1485.1 - 1485.1 - 1485.1 - 1485.1 - 1485.1 - 1485.1 - 1485.1 - 1491.3 - 1491.4 - 1491.4 - 1491.8 - 1492.0 - 1492.2 - 1492.7 - 1493.2 - 1493.5 - 1493.6 - 1493.7 - 1494.1 - 1494.6 - 1494.9 - 1495.6 - 1495.9 - 1496.2 - 1496.8 - 1497.4 - 1497.7 - 1498.1 - 1498.6 - 1498.9 - 1499.5 - 1499.6 - 1500.0 - 1500.3 - 1500.4 - 1500.5 - 1500.5 - 1500.6 - 1500.6 - 1500.7 - 1500.7 - 1500.8 - 1500.9 - 1500.9 - 1500.9 - 1501.0 - 1501.0 - 1501.0 - 1501.1 - 1501.3 - 1501.4 - 1501.5 - 1501.5 - 1501.5 - 1501.5 - 1501.7 - 1501.7 - 1501.8 - 1502.0 - 1502.0 - 1502.1 - 1502.1 - 1502.2 - 1502.2 - 1502.3 - 1502.4 - 1502.4 - 1502.6 - 1502.7 - 1502.7 - 1502.7 - 1502.7 - 1503.0 - 1503.4 - 1503.9 - 1504.5 - 1504.8 - 1504.9 - 1505.7 - 1506.0 - 1506.6 - 1507.0 - 1507.2 - 1507.3 - 1507.6 - 1508.0 - 1508.2 - 1508.5 - 1509.2 - 1510.0 - 1510.3 - 1510.6 - 1511.1 - 1511.4 - 1511.5 - 1511.7 - 1512.3 - 1512.3 - 1513.1 - 1513.2 - 1513.6 - 1513.9 - 1514.0 - 1514.2 - 1514.5 - 1514.7 - 1514.8 - 1515.4 - 1515.7 - 1516.1 - 1516.7 - 1516.8 - 1516.8 - 1516.8 - 1517.1 - 1517.3 - 1517.5 - 1517.9 - 1518.3 - 1518.9 - 1519.2 - 1519.4 - 1519.5 - 1519.7 - 1520.0 - 1520.4 - 1520.5 - 1521.0 - 1521.5 - 1522.0 - 1522.3 - 1522.9 - 1523.0 - 1523.8 - 1523.9 - 1524.0 - 1524.7 - 1525.0 - 1525.5 - 1525.9 - 1526.0 - 1526.8 - 1526.9 - 1527.5 - 1528.0 - 1528.2 - 1528.3 - 1529.0 - 1529.3 - 1529.7 - 1530.2 - 1530.5 - 1530.6 - 1531.2 - 1531.4 - 1531.6 - 1531.7 - 1532.5 - 1533.0 - 1533.5 - 1533.7 - 1534.4 - 1534.5 - 1535.2 - 1535.3 - 1536.0 - 1536.4 - 1537.3 - 1537.7 - 1537.9 - 1538.3 - 1538.6 - 1538.8 - 1539.2 - 1539.3 - 1539.5 - 1539.9 - 1540.1 - 1540.2 - 1540.3 - 1540.5 - 1540.8 - 1540.9 - 1541.0 - 1541.5 - 1541.8 - 1542.3 - 1543.0 - 1543.1 - 1543.9 - 1544.2 - 1544.8 - 1544.9 - 1545.5 - 1545.7 - 1546.3 - 1546.5 - 1546.7 - 1547.1 - 1547.7 - 1547.9 - 1548.4 - 1548.5 - 1549.1 - 1549.3 - 1549.9 - 1550.7 - 1550.9 - 1551.0 - 1551.0 - 1551.0 - 1551.4 - 1551.8 - 1551.9 - 1552.1 - 1553.0 - 1553.5 - 1553.6 - 1554.0 - 1554.4 - 1554.5 - 1555.2 - 1555.5 - 1555.9 - 1556.2 - 1556.4 - 1557.2 - 1557.5 - 1557.8 - 1557.8 - 1557.8 - 1557.8 - 1557.8 - 1557.8 - 1557.8 - 1557.8 - 1557.8 - 1562.2 - 1562.3 - 1562.8 - 1563.2 - 1564.0 - 1564.5 - 1564.7 - 1565.0 - 1565.3 - 1565.4 - 1565.7 - 1565.8 - 1566.0 - 1566.4 - 1567.0 - 1567.3 - 1567.4 - 1567.5 - 1568.2 - 1568.3 - 1568.4 - 1568.9 - 1569.3 - 1569.9 - 1570.5 - 1570.9 - 1571.4 - 1572.2 - 1572.5 - 1572.7 - 1573.5 - 1574.1 - 1574.6 - 1575.4 - 1575.5 - 1575.6 - 1575.9 - 1576.0 - 1576.4 - 1577.1 - 1577.2 - 1577.5 - 1577.8 - 1577.9 - 1578.4 - 1579.0 - 1579.0 - 1579.4 - 1579.7 - 1579.7 - 1580.0 - 1580.2 - 1580.3 - 1581.1 - 1581.2 - 1582.0 - 1582.2 - 1582.9 - 1583.1 - 1583.9 - 1584.4 - 1584.6 - 1585.2 - 1585.8 - 1586.3 - 1587.0 - 1587.5 - 1587.7 - 1588.2 - 1588.8 - 1589.6 - 1590.0 - 1590.6 - 1590.9 - 1591.5 - 1591.9 - 1592.9 - 1593.9 - 1594.7 - 1595.0 - 1595.3 - 1596.1 - 1596.8 - 1597.5 - 1598.1 - 1598.4 - 1598.9 - 1599.1 - 1599.2 - 1599.6 - 1599.7 - 1600.1 - 1600.6 - 1600.7 - 1601.1 - 1601.3 - 1601.6 - 1602.1 - 1602.3 - 1602.7 - 1603.2 - 1603.6 - 1604.2 - 1604.6 - 1604.7 - 1604.9 - 1605.3 - 1605.7 - 1606.2 - 1606.5 - 1606.6 - 1607.1 - 1607.6 - 1607.8 - 1608.0 - 1608.3 - 1608.8 - 1609.5 - 1609.7 - 1610.4 - 1610.6 - 1611.3 - 1611.8 - 1612.0 - 1612.6 - 1612.8 - 1613.7 - 1614.0 - 1614.8 - 1615.0 - 1615.6 - 1615.9 - 1616.2 - 1617.0 - 1617.2 - 1617.9 - 1618.0 - 1618.8 - 1619.1 - 1619.9 - 1620.0 - 1620.7 - 1621.3 - 1621.6 - 1622.5 - 1622.6 - 1623.3 - 1624.0 - 1624.1 - 1624.7 - 1625.4 - 1625.6 - 1626.0 - 1626.5 - 1627.0 - 1627.2 - 1627.5 - 1628.2 - 1628.8 - 1629.2 - 1630.0 - 1630.2 - 1630.3 - 1631.0 - 1631.5 - 1631.8 - 1632.4 - 1633.3 - 1633.8 - 1634.2 - 1634.9 - 1635.1 - 1635.8 - 1636.1 - 1636.8 - 1637.1 - 1637.7 - 1637.9 - 1638.4 - 1638.9 - 1639.2 - 1639.9 - 1640.0 - 1640.5 - 1641.2 - 1641.3 - 1641.5 - 1641.6 - 1641.7 - 1642.1 - 1642.4 - 1642.6 - 1642.7 - 1643.7 - 1643.8 - 1644.8 - 1644.9 - 1645.6 - 1645.7 - 1646.4 - 1646.8 - 1647.2 - 1648.2 - 1648.4 - 1648.9 - 1649.3 - 1649.4 - 1650.1 - 1650.2 - 1650.6 - 1651.2 - 1651.8 - 1652.2 - 1652.7 - 1652.8 - 1653.1 - 1653.3 - 1653.7 - 1653.8 - 1654.3 - 1654.4 - 1655.1 - 1655.3 - 1655.9 - 1656.5 - 1656.7 - 1657.4 - 1657.5 - 1658.3 - 1659.2 - 1659.3 - 1660.2 - 1660.6 - 1661.1 - 1661.4 - 1662.0 - 1662.3 - 1662.6 - 1663.1 - 1663.3 - 1664.0 - 1664.0 - 1665.0 - 1665.1 - 1665.9 - 1666.1 - 1666.7 - 1666.9 - 1667.6 - 1668.1 - 1668.3 - 1668.8 - 1668.9 - 1669.1 - 1669.5 - 1670.3 - 1671.0 - 1671.3 - 1671.7 - 1672.1 - 1672.5 - 1673.1 - 1673.4 - 1673.7 - 1674.2 - 1674.8 - 1675.0 - 1675.7 - 1676.2 - 1676.5 - 1676.7 - 1677.1 - 1677.8 - 1678.0 - 1678.3 - 1678.8 - 1679.5 - 1679.7 - 1680.1 - 1680.4 - 1680.8 - 1681.6 - 1682.2 - 1682.4 - 1683.1 - 1683.7 - 1684.2 - 1684.3 - 1684.4 - 1684.9 - 1685.1 - 1685.6 - 1686.1 - 1686.4 - 1686.7 - 1687.6 - 1687.8 - 1688.3 - 1688.4 - 1689.2 - 1689.7 - 1689.8 - 1690.2 - 1690.6 - 1690.9 - 1691.2 - 1691.5 - 1691.9 - 1692.7 - 1693.3 - 1693.4 - 1694.0 - 1694.1 - 1694.2 - 1694.5 - 1694.7 - 1695.3 - 1695.5 - 1696.3 - 1696.4 - 1696.9 - 1697.4 - 1697.6 - 1697.7 - 1698.2 - 1698.4 - 1698.6 - 1699.4 - 1700.2 - 1700.6 - 1701.1 - 1701.6 - 1701.8 - 1702.7 - 1702.8 - 1703.3 - 1703.6 - 1703.9 - 1704.6 - 1705.1 - 1705.7 - 1706.0 - 1706.5 - 1707.1 - 1707.4 - 1708.0 - 1708.4 - 1708.8 - 1709.5 - 1709.8 - 1710.6 - 1711.0 - 1711.5 - 1712.1 - 1712.4 - 1712.7 - 1713.3 - 1713.5 - 1714.0 - 1714.1 - 1714.7 - 1714.9 - 1715.0 - 1715.6 - 1715.8 - 1716.0 - 1716.2 - 1716.6 - 1716.9 - 1717.0 - 1717.3 - 1717.5 - 1717.6 - 1717.7 - 1717.8 - 1717.9 - 1718.1 - 1718.7 - 1718.8 - 1719.8 - 1719.9 - 1720.8 - 1721.0 - 1721.7 - 1722.1 - 1722.6 - 1723.1 - 1723.2 - 1723.8 - 1724.1 - 1724.6 - 1725.2 - 1725.8 - 1726.1 - 1726.8 - 1727.8 - 1728.3 - 1728.7 - 1729.2 - 1729.3 - 1729.3 - 1729.3 - 1729.3 - 1729.4 - 1729.4 - 1729.4 - 1729.4 - 1729.4 - 1729.4 - 1729.7 - 1729.7 - 1730.0 - 1730.3 - 1731.2 - 1732.1 - 1732.6 - 1733.0 - 1733.8 - 1734.1 - 1735.1 - 1736.1 - 1736.4 - 1736.8 - 1737.4 - 1737.5 - 1737.8 - 1738.2 - 1738.8 - 1738.9 - 1739.7 - 1739.8 - 1740.3 - 1740.9 - 1741.0 - 1741.7 - 1741.8 - 1742.6 - 1742.9 - 1743.3 - 1743.9 - 1744.1 - 1744.9 - 1745.0 - 1745.6 - 1745.9 - 1746.1 - 1746.2 - 1746.8 - 1747.1 - 1747.6 - 1748.3 - 1748.6 - 1749.2 - 1749.5 - 1750.0 - 1750.1 - 1750.2 - 1751.0 - 1751.5 - 1751.6 - 1751.8 - 1752.3 - 1752.8 - 1753.0 - 1753.4 - 1753.6 - 1753.6 - 1753.8 - 1754.1 - 1754.2 - 1754.4 - 1754.6 - 1754.7 - 1754.9 - 1755.0 - 1755.1 - 1755.6 - 1755.7 - 1755.9 - 1756.0 - 1756.2 - 1756.4 - 1756.8 - 1756.9 - 1757.6 - 1757.7 - 1758.1 - 1758.7 - 1758.8 - 1759.3 - 1759.4 - 1759.6 - 1759.8 - 1760.2 - 1760.3 - 1760.7 - 1760.9 - 1761.0 - 1761.4 - 1761.6 - 1762.1 - 1762.6 - 1763.1 - 1763.2 - 1763.3 - 1763.3 - 1763.4 - 1763.4 - 1763.4 - 1763.4 - 1763.4 - 1763.4 - 1763.5 - 1763.5 - 1763.5 - 1763.5 - 1763.5 - 1763.5 - 1763.5 - 1763.6 - 1763.6 - 1763.6 - 1763.6 - 1763.7 - 1763.7 - 1763.7 - 1763.7 - 1763.7 - 1763.8 - 1763.8 - 1763.7 - 1763.6 - 1763.5 - 1763.5 - 1763.5 - 1763.4 - 1763.3 - 1763.3 - 1763.2 - 1763.2 - 1763.1 - 1763.0 - 1762.9 - 1762.8 - 1762.7 - 1762.7 - 1762.5 - 1762.4 - 1762.4 - 1762.4 - 1762.4 - 1762.4 - 1762.4 - 1762.6 - 1762.7 - 1762.7 - 1762.7 - 1762.8 - 1762.8 - 1762.8 - 1762.9 - 1762.9 - 1763.1 - 1763.1 - 1763.1 - 1763.1 - 1763.1 - 1763.1 - 1763.1 - 1763.1 - 1763.1 - 1763.1 - 1763.2 - 1763.2 - 1763.3 - 1763.3 - 1763.4 - 1763.4 - 1763.7 - 1764.1 - 1764.3 - 1764.6 - 1764.6 - 1764.6 - 1764.6 - 1765.0 - 1765.2 - 1765.3 - 1765.4 - 1765.4 - 1765.6 - 1765.8 - 1765.8 - 1765.9 - 1766.6 - 1767.2 - 1768.7 - 1768.9 - 1769.4 - 1769.7 - 1770.4 - 1770.7 - 1771.1 - 1771.5 - 1771.9 - 1772.2 - 1772.5 - 1772.8 - 1773.2 - 1773.7 - 1773.9 - 1774.5 - 1774.6 - 1775.1 - 1775.5 - 1775.7 - 1776.3 - 1777.1 - 1777.2 - 1777.5 - 1777.8 - 1778.1 - 1778.7 - 1778.9 - 1779.4 - 1780.0 - 1780.3 - 1781.0 - 1781.2 - 1781.7 - 1782.2 - 1782.5 - 1783.3 - 1784.0 - 1784.6 - 1785.3 - 1785.7 - 1785.8 - 1786.1 - 1786.5 - 1787.1 - 1787.4 - 1788.1 - 1788.3 - 1789.0 - 1789.1 - 1789.4 - 1789.6 - 1789.8 - 1790.5 - 1791.2 - 1791.3 - 1791.9 - 1792.5 - 1792.7 - 1793.4 - 1793.7 - 1794.0 - 1794.3 - 1794.5 - 1795.2 - 1795.4 - 1795.9 - 1796.3 - 1796.5 - 1797.1 - 1797.3 - 1797.8 - 1797.9 - 1798.5 - 1798.7 - 1799.2 - 1800.0 - 1800.8 - 1801.0 - 1801.6 - 1801.9 - 1802.4 - 1802.5 - 1802.9 - 1803.0 - 1803.6 - 1803.9 - 1804.3 - 1805.0 - 1805.7 - 1805.9 - 1806.5 - 1806.6 - 1807.1 - 1807.2 - 1807.9 - 1808.6 - 1809.1 - 1809.1 - 1809.3 - 1809.6 - 1809.7 - 1809.9 - 1810.1 - 1810.2 - 1810.5 - 1811.0 - 1811.6 - 1811.7 - 1812.4 - 1812.5 - 1813.0 - 1813.4 - 1814.1 - 1814.2 - 1814.7 - 1815.2 - 1815.4 - 1815.6 - 1815.6 - 1815.6 - 1815.8 - 1815.9 - 1816.1 - 1816.3 - 1817.0 - 1817.2 - 1817.6 - 1818.3 - 1819.0 - 1819.3 - 1819.6 - 1820.4 - 1820.4 - 1821.4 - 1822.2 - 1822.5 - 1823.0 - 1823.7 - 1824.3 - 1824.7 - 1825.0 - 1825.6 - 1825.8 - 1826.6 - 1826.7 - 1827.4 - 1827.6 - 1828.3 - 1828.9 - 1829.2 - 1829.8 - 1830.1 - 1830.8 - 1830.9 - 1831.4 - 1831.6 - 1831.8 - 1832.6 - 1833.4 - 1834.1 - 1834.4 - 1834.6 - 1835.0 - 1835.2 - 1835.8 - 1836.0 - 1836.3 - 1836.7 - 1836.9 - 1837.5 - 1837.9 - 1838.0 - 1838.1 - 1838.2 - 1838.3 - 1839.0 - 1839.1 - 1839.9 - 1840.6 - 1841.2 - 1841.4 - 1841.5 - 1841.7 - 1842.1 - 1842.8 - 1843.2 - 1843.6 - 1844.6 - 1844.9 - 1845.1 - 1845.2 - 1845.3 - 1846.1 - 1846.4 - 1846.7 - 1847.2 - 1847.4 - 1847.9 - 1848.3 - 1848.4 - 1849.0 - 1849.2 - 1849.7 - 1850.0 - 1850.6 - 1850.8 - 1851.1 - 1851.7 - 1851.9 - 1852.3 - 1852.7 - 1853.3 - 1853.5 - 1854.2 - 1854.5 - 1854.7 - 1855.4 - 1855.5 - 1856.4 - 1856.5 - 1857.4 - 1857.5 - 1858.1 - 1858.3 - 1859.1 - 1859.5 - 1859.8 - 1860.6 - 1861.3 - 1861.5 - 1861.9 - 1862.4 - 1862.7 - 1863.0 - 1863.6 - 1863.7 - 1864.2 - 1864.4 - 1864.8 - 1864.9 - 1865.0 - 1865.0 - 1865.0 - 1865.0 - 1866.2 - 1866.3 - 1866.6 - 1866.9 - 1867.4 - 1868.1 - 1868.3 - 1868.9 - 1869.2 - 1869.6 - 1870.3 - 1870.5 - 1871.1 - 1871.3 - 1871.8 - 1872.0 - 1872.5 - 1872.9 - 1873.2 - 1873.7 - 1873.8 - 1874.7 - 1875.5 - 1875.7 - 1876.0 - 1876.6 - 1876.8 - 1877.0 - 1877.5 - 1877.8 - 1878.0 - 1878.1 - 1878.3 - 1878.6 - 1879.3 - 1880.0 - 1880.1 - 1880.3 - 1880.5 - 1880.7 - 1880.8 - 1881.1 - 1881.3 - 1881.9 - 1882.7 - 1882.8 - 1883.6 - 1883.8 - 1884.3 - 1884.9 - 1885.1 - 1885.6 - 1885.9 - 1886.2 - 1886.7 - 1887.0 - 1887.6 - 1888.1 - 1888.1 - 1888.2 - 1888.7 - 1888.8 - 1889.3 - 1889.7 - 1889.8 - 1890.4 - 1890.5 - 1890.9 - 1891.1 - 1891.4 - 1891.9 - 1892.1 - 1892.3 - 1892.7 - 1893.1 - 1893.4 - 1894.0 - 1894.8 - 1895.3 - 1895.4 - 1895.4 - 1895.4 - 1895.5 - 1895.6 - 1895.6 - 1895.6 - 1895.7 - 1895.8 - 1895.8 - 1895.9 - 1896.0 - 1896.1 - 1896.2 - 1897.0 - 1897.4 - 1897.7 - 1898.5 - 1898.6 - 1899.2 - 1899.7 - 1899.8 - 1899.8 - 1899.9 - 1900.0 - 1900.0 - 1900.0 - 1900.0 - 1900.1 - 1900.1 - 1900.1 - 1900.2 - 1900.3 - 1900.3 - 1900.3 - 1900.3 - 1900.3 - 1900.4 - 1900.4 - 1900.4 - 1900.4 - 1900.4 - 1900.4 - 1900.4 - 1900.4 - 1900.4 - 1900.4 - 1900.4 - 1900.4 - 1900.4 - 1900.5 - 1900.5 - 1900.5 - 1900.5 - 1900.5 - 1900.5 - 1900.5 - 1900.5 - 1900.5 - 1900.5 - 1900.5 - 1900.5 - 1901.1 - 1901.1 - 1901.3 - 1901.3 - 1901.2 - 1901.2 - 1901.2 - 1901.1 - 1901.1 - 1901.1 - 1900.9 - 1900.9 - 1900.8 - 1900.8 - 1900.7 - 1900.7 - 1900.7 - 1900.8 - 1900.9 - 1901.0 - 1901.0 - 1901.0 - 1901.1 - 1901.4 - 1901.6 - 1901.6 - 1901.9 - 1902.4 - 1903.3 - 1903.8 - 1905.1 - 1905.3 - 1906.2 - 1906.5 - 1906.7 - 1906.8 - 1906.9 - 1907.0 - 1907.8 - 1907.9 - 1908.4 - 1908.7 - 1909.4 - 1909.5 - 1910.4 - 1910.6 - 1911.2 - 1911.5 - 1912.1 - 1912.3 - 1912.8 - 1913.1 - 1913.2 - 1913.6 - 1914.1 - 1914.4 - 1914.7 - 1915.3 - 1915.8 - 1916.4 - 1916.5 - 1917.5 - 1917.6 - 1918.5 - 1919.5 - 1919.6 - 1920.5 - 1920.7 - 1921.4 - 1921.8 - 1922.1 - 1922.9 - 1923.0 - 1923.6 - 1924.2 - 1924.6 - 1925.4 - 1925.6 - 1926.3 - 1926.5 - 1926.6 - 1926.7 - 1927.5 - 1927.7 - 1928.5 - 1928.6 - 1929.1 - 1929.2 - 1929.7 - 1929.9 - 1930.8 - 1931.4 - 1931.5 - 1932.4 - 1932.7 - 1933.4 - 1933.6 - 1933.9 - 1934.2 - 1934.3 - 1934.8 - 1935.3 - 1935.9 - 1936.0 - 1936.8 - 1936.9 - 1937.6 - 1938.3 - 1938.6 - 1939.4 - 1940.3 - 1940.4 - 1941.3 - 1941.6 - 1942.4 - 1942.5 - 1943.3 - 1943.4 - 1944.3 - 1944.5 - 1945.5 - 1945.6 - 1946.4 - 1946.7 - 1947.4 - 1947.9 - 1948.0 - 1948.6 - 1948.8 - 1949.4 - 1949.5 - 1950.3 - 1950.4 - 1950.9 - 1951.2 - 1952.0 - 1952.2 - 1952.3 - 1952.9 - 1953.1 - 1953.7 - 1954.5 - 1954.8 - 1955.2 - 1955.5 - 1955.8 - 1956.3 - 1956.8 - 1957.1 - 1957.7 - 1958.2 - 1958.4 - 1959.2 - 1959.9 - 1960.2 - 1960.7 - 1961.4 - 1961.5 - 1962.1 - 1962.9 - 1963.2 - 1963.6 - 1963.9 - 1964.1 - 1965.1 - 1966.0 - 1966.5 - 1966.8 - 1967.2 - 1967.4 - 1968.2 - 1969.0 - 1969.2 - 1969.7 - 1970.1 - 1970.7 - 1970.9 - 1971.3 - 1971.9 - 1972.2 - 1973.0 - 1973.2 - 1973.4 - 1973.6 - 1974.1 - 1974.4 - 1974.6 - 1975.2 - 1975.3 - 1975.8 - 1976.1 - 1976.4 - 1977.1 - 1977.2 - 1977.6 - 1977.8 - 1978.4 - 1978.7 - 1979.4 - 1979.6 - 1980.5 - 1980.6 - 1981.3 - 1981.4 - 1981.7 - 1982.0 - 1982.4 - 1982.5 - 1983.4 - 1983.7 - 1984.4 - 1984.5 - 1985.0 - 1985.6 - 1985.9 - 1986.3 - 1986.6 - 1987.1 - 1987.3 - 1987.5 - 1987.9 - 1988.7 - 1988.9 - 1989.6 - 1990.4 - 1990.6 - 1991.4 - 1991.5 - 1992.1 - 1992.5 - 1992.8 - 1993.4 - 1993.9 - 1994.6 - 1994.9 - 1995.4 - 1995.8 - 1996.0 - 1996.2 - 1996.8 - 1997.6 - 1998.1 - 1998.5 - 1999.0 - 1999.2 - 1999.8 - 2000.3 - 2001.1 - 2001.6 - 2001.9 - 2002.3 - 2002.5 - 2002.7 - 2003.0 - 2003.4 - 2003.8 - 2004.4 - 2004.8 - 2005.1 - 2005.4 - 2005.9 - 2006.0 - 2006.7 - 2007.1 - 2007.4 - 2007.7 - 2007.8 - 2007.9 - 2008.8 - 2008.9 - 2009.2 - 2009.5 - 2009.7 - 2010.1 - 2010.5 - 2010.9 - 2011.0 - 2011.2 - 2011.6 - 2011.7 - 2012.3 - 2012.3 - 2012.7 - 2013.2 - 2013.2 - 2013.6 - 2013.6 - 2013.9 - 2014.4 - 2014.4 - 2014.6 - 2014.8 - 2015.0 - 2015.1 - 2015.1 - 2015.5 - 2015.7 - 2015.8 - 2015.8 - 2015.8 - 2015.8 - 2015.8 - 2015.8 - 2015.9 - 2016.1 - 2016.2 - 2016.5 - 2016.7 - 2016.7 - 2016.8 - 2016.9 - 2017.0 - 2017.0 - 2017.2 - 2017.2 - 2017.3 - 2017.4 - 2017.5 - 2017.5 - 2017.6 - 2017.8 - 2017.8 - 2017.9 - 2018.0 - 2018.1 - 2018.3 - 2018.3 - 2018.4 - 2018.4 - 2018.5 - 2018.5 - 2018.6 - 2018.8 - 2018.9 - 2018.9 - 2019.1 - 2019.1 - 2019.2 - 2019.4 - 2019.5 - 2019.6 - 2019.6 - 2019.9 - 2020.1 - 2020.1 - 2020.2 - 2020.3 - 2020.3 - 2020.4 - 2020.5 - 2020.6 - 2020.6 - 2020.6 - 2020.6 - 2020.6 - 2020.6 - 2020.6 - 2020.7 - 2020.8 - 2020.8 - 2020.9 - 2020.9 - 2021.0 - 2021.0 - 2021.0 - 2021.1 - 2021.1 - 2021.9 - 2022.1 - 2022.7 - 2022.9 - 2023.2 - 2023.5 - 2023.6 - 2024.0 - 2024.1 - 2024.5 - 2024.8 - 2025.0 - 2025.1 - 2025.4 - 2025.6 - 2026.0 - 2026.1 - 2026.6 - 2026.6 - 2027.1 - 2027.5 - 2027.8 - 2028.6 - 2028.7 - 2028.8 - 2029.5 - 2029.7 - 2030.3 - 2030.8 - 2030.9 - 2031.2 - 2031.7 - 2032.1 - 2032.5 - 2033.2 - 2033.6 - 2034.3 - 2034.6 - 2035.3 - 2035.6 - 2036.4 - 2036.6 - 2037.5 - 2037.6 - 2038.7 - 2039.7 - 2040.9 - 2042.1 - 2043.2 - 2043.5 - 2044.2 - 2044.6 - 2045.1 - 2045.8 - 2046.0 - 2046.5 - 2046.7 - 2047.0 - 2047.3 - 2047.3 - 2048.0 - 2048.3 - 2048.6 - 2048.8 - 2049.0 - 2049.4 - 2050.0 - 2050.2 - 2050.9 - 2051.0 - 2051.7 - 2051.9 - 2052.7 - 2053.1 - 2053.7 - 2054.0 - 2054.5 - 2054.8 - 2055.0 - 2055.4 - 2055.7 - 2055.9 - 2056.4 - 2057.0 - 2057.1 - 2058.1 - 2059.0 - 2059.4 - 2059.9 - 2060.7 - 2060.8 - 2061.8 - 2062.3 - 2062.8 - 2062.9 - 2063.3 - 2064.3 - 2064.7 - 2065.1 - 2065.7 - 2065.9 - 2066.6 - 2066.7 - 2067.1 - 2067.2 - 2067.9 - 2068.0 - 2068.6 - 2068.8 - 2069.1 - 2069.7 - 2069.8 - 2070.4 - 2070.9 - 2071.2 - 2071.7 - 2071.9 - 2072.0 - 2072.5 - 2072.6 - 2073.0 - 2073.6 - 2074.2 - 2074.5 - 2074.8 - 2075.0 - 2075.4 - 2075.7 - 2076.3 - 2076.4 - 2076.7 - 2076.9 - 2077.1 - 2077.2 - 2077.3 - 2077.8 - 2078.2 - 2078.4 - 2079.1 - 2079.2 - 2079.9 - 2080.5 - 2080.9 - 2081.2 - 2081.3 - 2081.6 - 2081.6 - 2081.6 - 2081.8 - 2082.0 - 2082.1 - 2082.4 - 2082.6 - 2083.1 - 2083.8 - 2084.1 - 2084.4 - 2084.9 - 2085.0 - 2085.5 - 2085.9 - 2086.3 - 2087.0 - 2087.6 - 2088.2 - 2088.8 - 2089.0 - 2089.5 - 2089.9 - 2090.2 - 2090.6 - 2090.8 - 2091.7 - 2091.8 - 2092.4 - 2093.1 - 2093.3 - 2094.1 - 2094.2 - 2094.8 - 2095.0 - 2095.3 - 2095.9 - 2096.1 - 2096.7 - 2097.0 - 2097.4 - 2097.9 - 2098.3 - 2098.9 - 2099.1 - 2099.7 - 2100.2 - 2100.4 - 2101.0 - 2101.1 - 2101.4 - 2101.6 - 2101.8 - 2101.9 - 2102.4 - 2103.2 - 2103.8 - 2104.2 - 2104.5 - 2104.6 - 2105.1 - 2105.7 - 2106.4 - 2106.9 - 2107.3 - 2107.7 - 2107.9 - 2108.3 - 2108.8 - 2108.9 - 2109.6 - 2109.8 - 2110.4 - 2110.8 - 2111.0 - 2111.7 - 2111.8 - 2111.9 - 2112.2 - 2112.7 - 2112.8 - 2112.9 - 2113.5 - 2113.8 - 2114.4 - 2114.5 - 2114.7 - 2114.8 - 2115.0 - 2115.4 - 2116.0 - 2116.4 - 2116.9 - 2117.2 - 2117.7 - 2118.0 - 2118.7 - 2119.2 - 2119.6 - 2120.2 - 2120.3 - 2120.5 - 2120.6 - 2121.2 - 2121.3 - 2122.2 - 2122.4 - 2122.5 - 2122.7 - 2122.9 - 2123.2 - 2123.6 - 2123.8 - 2124.4 - 2124.6 - 2125.4 - 2125.5 - 2126.2 - 2126.6 - 2127.2 - 2127.6 - 2128.2 - 2128.8 - 2128.9 - 2129.8 - 2129.9 - 2130.0 - 2130.5 - 2130.7 - 2131.2 - 2131.9 - 2132.0 - 2132.8 - 2133.1 - 2133.4 - 2133.9 - 2134.2 - 2134.9 - 2135.0 - 2135.8 - 2136.0 - 2136.5 - 2136.6 - 2137.1 - 2137.5 - 2137.9 - 2138.5 - 2138.8 - 2139.1 - 2139.3 - 2139.9 - 2140.1 - 2140.7 - 2140.9 - 2141.5 - 2141.7 - 2142.2 - 2142.3 - 2142.5 - 2142.5 - 2142.5 - 2142.5 - 2142.5 - 2142.5 - 2143.0 - 2143.5 - 2143.5 - 2143.5 - 2143.9 - 2144.3 - 2144.4 - 2144.6 - 2145.4 - 2146.1 - 2146.3 - 2146.7 - 2147.0 - 2148.1 - 2148.3 - 2148.5 - 2148.5 - 2148.8 - 2149.1 - 2149.3 - 2149.3 - 2149.3 - 2149.4 - 2149.4 - 2149.4 - 2149.4 - 2149.5 - 2149.5 - 2149.6 - 2149.7 - 2149.7 - 2149.7 - 2149.7 - 2149.7 - 2149.7 - 2149.7 - 2149.7 - 2149.7 - 2149.7 - 2149.7 - 2149.7 - 2149.7 - 2149.8 - 2149.9 - 2149.9 - 2150.1 - 2150.1 - 2150.2 - 2150.2 - 2150.4 - 2150.5 - 2150.6 - 2150.6 - 2150.7 - 2150.8 - 2151.0 - - \ No newline at end of file diff --git a/companion-ios/Packages/OBCKit/Sources/OBCMock/Scenario.swift b/companion-ios/Packages/OBCKit/Sources/OBCMock/Scenario.swift index 00bdacd68..fa40164e8 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCMock/Scenario.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCMock/Scenario.swift @@ -16,6 +16,10 @@ public enum Scenario: String, CaseIterable, Sendable { case outOfRange case deviceUnreachable case noDevice + case onboarding + case onboardingNearby + case onboardingUpdate + case onboardingUpdateNeeded case pairingTimeout case pairingRejected case bluetoothOff @@ -90,6 +94,8 @@ extension Scenario { // Bonded but the device never answers: connect() parks on the huge latency the // way a real scan parks on an absent peripheral. Launch must time out, not hang. return ScenarioPreset(connection: .disconnected, latency: .seconds(3_600)) + case .onboarding, .onboardingNearby, .onboardingUpdate, .onboardingUpdateNeeded: + return ScenarioPreset(fixtures: "onboarding", connection: .disconnected, bonded: false) case .noDevice: return ScenarioPreset(connection: .disconnected, bonded: false) case .pairingTimeout: diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Firmware/FirmwareUpdateModel.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Firmware/FirmwareUpdateModel.swift index 10ccac480..9eeb4f7c1 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Firmware/FirmwareUpdateModel.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Firmware/FirmwareUpdateModel.swift @@ -46,8 +46,9 @@ public final class FirmwareUpdateModel { } } public private(set) var progress = TransferProgress(bytesDone: 0, total: 0) - /// The device's running firmware version, nil until it lands or while the link is down. + /// The last version read from the device. Kept offline so a known update can be downloaded. public private(set) var runningVersion: String? + public private(set) var deviceSerial: String? public private(set) var staged: StagedFirmware? public private(set) var connection: ConnectionState = .connecting /// A picked file that is not a usable update, surfaced as an alert and never a phase. @@ -97,6 +98,7 @@ public final class FirmwareUpdateModel { @ObservationIgnored private let updateChecker: UpdateChecker? @ObservationIgnored private var handle: TransferHandle? @ObservationIgnored private var stateTask: Task? + @ObservationIgnored private var versionTask: Task? @ObservationIgnored private var checkTask: Task? @ObservationIgnored private var downloadTask: Task? @ObservationIgnored private var transferWatchers: [Task] = [] @@ -238,20 +240,17 @@ public final class FirmwareUpdateModel { } stateTask = Task { [weak self, transport] in for await state in transport.state { - guard let self else { return } + guard let self, !Task.isCancelled else { return } handleState(state) } } - Task { [weak self, transport] in - guard let info = try? await transport.deviceInfo() else { return } - guard let self else { return } - runningVersion = info.firmwareVersion - } checkForUpdate() } private func handleState(_ state: ConnectionState) { connection = state + versionTask?.cancel() + versionTask = nil let dropped = !linkUp switch phase { case .transferring where dropped: @@ -259,20 +258,21 @@ public final class FirmwareUpdateModel { if let handle, handle.currentOutcome == nil { phase = .interrupted } case .awaitingConfirm: if dropped { sawDropSinceInstall = true } - if state == .connected, sawDropSinceInstall { checkInstalledVersion() } default: break } + if state == .connected { refreshRunningVersion() } } - /// A reconnect after the install reboot: re-read the device info. The staged version now - /// running means the update landed. - private func checkInstalledVersion() { - Task { [weak self, transport] in + /// Every connection gets a fresh version. After an install reboot it also confirms success. + private func refreshRunningVersion() { + versionTask = Task { [weak self, transport] in guard let info = try? await transport.deviceInfo() else { return } - guard let self else { return } + guard let self, !Task.isCancelled, connection == .connected else { return } runningVersion = info.firmwareVersion - if let staged, info.firmwareVersion == staged.version, phase == .awaitingConfirm { + deviceSerial = info.serial + if let staged, info.firmwareVersion == staged.version, + phase == .awaitingConfirm, sawDropSinceInstall { phase = .done } } @@ -480,6 +480,8 @@ public final class FirmwareUpdateModel { started = false stateTask?.cancel() stateTask = nil + versionTask?.cancel() + versionTask = nil // The check and the download are screen-scoped too: a popped screen has nobody to tell, // and `start()` re-runs the check from the cache anyway. A killed download stages nothing. checkTask?.cancel() @@ -504,6 +506,7 @@ public final class FirmwareUpdateModel { deinit { stateTask?.cancel() + versionTask?.cancel() checkTask?.cancel() downloadTask?.cancel() transferWatchers.forEach { $0.cancel() } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Firmware/UpdateSurfaceModel.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Firmware/UpdateSurfaceModel.swift index 7d2d596fd..26a869adc 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Firmware/UpdateSurfaceModel.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Firmware/UpdateSurfaceModel.swift @@ -28,7 +28,9 @@ public final class UpdateSurfaceModel { @ObservationIgnored private let notifier: (any UpdateNotifying)? @ObservationIgnored private var stateTask: Task? @ObservationIgnored private var checkTask: Task? + @ObservationIgnored private var notificationTask: Task? @ObservationIgnored private var started = false + @ObservationIgnored private var enabled = true /// The device the offer is about, so a dismiss writes the ledger under the right key. @ObservationIgnored private var pendingDevice: LastSeenDevice? @@ -51,7 +53,7 @@ public final class UpdateSurfaceModel { stateTask = Task { [weak self, transport] in for await state in transport.state { guard let self else { return } - guard state == .connected else { continue } + guard enabled, state == .connected else { continue } await rememberDevice() // The link arrives after the app does, so the check on `.active` had // only the persisted version. Re-decide with the live one; the cache @@ -61,20 +63,33 @@ public final class UpdateSurfaceModel { } } + /// Setup owns the update offer while it is open, including when the rider replays it. + public func setEnabled(_ enabled: Bool) { + self.enabled = enabled + guard !enabled else { return } + checkTask?.cancel() + checkTask = nil + notificationTask?.cancel() + notificationTask = nil + pending = nil + pendingDevice = nil + } + /// The app came to the front. Runs the policy; presents a sheet only if it says to. public func appBecameActive() { - guard let runner, pending == nil, checkTask == nil else { return } + guard enabled, let runner, pending == nil, checkTask == nil else { return } checkTask = Task { [weak self, runner] in // A live read beats the remembered one and refreshes it. `?? nil` flattens // the optional chain: no model and no link both land on the persisted record. let live = await self?.rememberDevice() ?? nil + guard !Task.isCancelled, self?.enabled == true else { return } // Capture the fallback once: another device can connect while the request // is in flight, and the offer must stay with the evaluated device. let target = runner.device(live) let release = await runner.run(device: target) - guard let self else { return } + guard let self, !Task.isCancelled, enabled else { return } checkTask = nil - guard !Task.isCancelled, pending == nil, let release else { return } + guard pending == nil, let release else { return } present(release, device: target) } } @@ -101,8 +116,11 @@ public final class UpdateSurfaceModel { private func askForNotificationPermissionOnce() { guard let runner, let notifier, !runner.didAskNotificationPermission else { return } - runner.markAskedNotificationPermission() - Task { await notifier.requestAuthorization() } + notificationTask = Task { [weak self] in + guard let self, !Task.isCancelled, enabled else { return } + runner.markAskedNotificationPermission() + await notifier.requestAuthorization() + } } /// "View": acting on the offer is also an answer. Navigation belongs to the host. @@ -126,5 +144,6 @@ public final class UpdateSurfaceModel { deinit { stateTask?.cancel() checkTask?.cancel() + notificationTask?.cancel() } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenView.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenView.swift index 5453cf613..1d5b6a090 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenView.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenView.swift @@ -17,6 +17,8 @@ public struct MainScreenView: View { private let onSelectRide: (RideSummary) -> Void private let onSettings: () -> Void private let onOpenTrash: () -> Void + private let showsReadyNote: Bool + private let onDismissReadyNote: () -> Void @State private var emptyStatePickerShown = false @State private var libraryMapShown = false @@ -35,7 +37,9 @@ public struct MainScreenView: View { onSelectTrip: @escaping (Trip) -> Void = { _ in }, onSelectRide: @escaping (RideSummary) -> Void = { _ in }, onSettings: @escaping () -> Void = {}, - onOpenTrash: @escaping () -> Void = {} + onOpenTrash: @escaping () -> Void = {}, + showsReadyNote: Bool = false, + onDismissReadyNote: @escaping () -> Void = {} ) { self.model = model self.importFileExtensions = importFileExtensions @@ -45,6 +49,8 @@ public struct MainScreenView: View { self.onSelectRide = onSelectRide self.onSettings = onSettings self.onOpenTrash = onOpenTrash + self.showsReadyNote = showsReadyNote + self.onDismissReadyNote = onDismissReadyNote } public var body: some View { @@ -61,6 +67,19 @@ public struct MainScreenView: View { onSettings: onSettings ) + if showsReadyNote { + OBCInlineBanner( + systemImage: "checkmark", + title: "Ready to ride", + message: "Hold BACK on your OBC to open the menu. You can change your setup in Settings.", + actionTitle: "Got it", + action: onDismissReadyNote + ) + .accessibilityIdentifier("onboarding.readyNote") + .padding(.horizontal, 16) + .padding(.top, 10) + } + // One banner at a time. A protocol mismatch outranks the rest: the link is up but // unusable for data, so it is neither a transfer nor an out-of-range story. if let mismatch = model.protocolMismatch { diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingFlowView.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingFlowView.swift new file mode 100644 index 000000000..55b141f54 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingFlowView.swift @@ -0,0 +1,260 @@ +import SwiftUI +import UniformTypeIdentifiers +import OBCDomain +import OBCTransport + +/// Optional phone steps use the same firmware, import and sync operations as the Library. +public struct OnboardingFlowView: View { + private let progress: OnboardingProgress + private let firmware: FirmwareUpdateModel + private let library: MainScreenModel + private let onDemoRoute: () -> Void + private let onImport: ([URL]) -> Void + private let onUpdateAnswered: (String) -> Void + private let onFinish: () -> Void + @State private var showsFirmware = false + @State private var showsImporter = false + + public init( + progress: OnboardingProgress, + firmware: FirmwareUpdateModel, + library: MainScreenModel, + onDemoRoute: @escaping () -> Void, + onImport: @escaping ([URL]) -> Void, + onUpdateAnswered: @escaping (String) -> Void = { _ in }, + onFinish: @escaping () -> Void + ) { + self.progress = progress + self.firmware = firmware + self.library = library + self.onDemoRoute = onDemoRoute + self.onImport = onImport + self.onUpdateAnswered = onUpdateAnswered + self.onFinish = onFinish + } + + public var body: some View { + Group { + switch progress.stage { + case .inactive, .sensors: sensors + case .update: update + case .route: route + case .ride: ride + case .done: Color.clear + } + } + .task { + progress.begin() + library.start() + firmware.start() + skipCurrentFirmware() + } + .onChange(of: firmware.checkState) { _, _ in skipCurrentFirmware() } + .onChange(of: firmware.runningVersion) { _, _ in skipCurrentFirmware() } + .onChange(of: library.connectedScope) { _, _ in skipCurrentFirmware() } + .onChange(of: firmware.phase) { _, phase in + if phase == .done { + showsFirmware = false + if progress.stage == .update { progress.move(to: .route) } + } + } + .sheet(isPresented: $showsFirmware, onDismiss: { firmware.start() }) { + NavigationStack { + FirmwareUpdateView(model: firmware) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Back to setup") { showsFirmware = false } + .disabled(firmware.phase == .transferring) + } + } + } + .interactiveDismissDisabled(firmware.phase == .transferring) + } + .fileImporter( + isPresented: $showsImporter, + allowedContentTypes: [UTType(filenameExtension: "gpx") ?? .xml, + UTType(filenameExtension: "tcx") ?? .xml], + allowsMultipleSelection: false + ) { result in + if case .success(let urls) = result { onImport(urls) } + } + } + + private var sensors: some View { + LaunchScreenScaffold { + VStack(spacing: 24) { + DeviceGlyphView(variant: .home(name: library.deviceName)) + .accessibilityHidden(true) + LaunchTitle("Look at your OBC") + .accessibilityIdentifier("onboarding.sensorsTitle") + LaunchMessage("Set up sensors and effort zones on the OBC. You can skip either step and add them later in Settings.") + VStack(alignment: .leading, spacing: 18) { + tip("heart", "Heart rate", "Wear the strap to wake it.") + tip("bolt", "Power and cadence", "Turn the cranks to wake them.") + } + LaunchMessage("Sensors connect to the OBC itself. Continue here when you are ready.") + } + } actions: { + Button("Continue") { + progress.move(to: .update) + skipCurrentFirmware() + } + .buttonStyle(.obcPrimary) + .accessibilityIdentifier("onboarding.sensorsContinue") + } + } + + private var update: some View { + LaunchScreenScaffold { + VStack(spacing: 24) { + Image(systemName: "arrow.down.circle") + .font(.system(.largeTitle)).foregroundStyle(OBCTheme.tint) + .accessibilityHidden(true) + LaunchTitle(updateTitle) + .accessibilityIdentifier("onboarding.updateTitle") + LaunchMessage(updateMessage) + if firmware.checkState == .checking { + ProgressView("Checking for an update") + } else if firmware.updateStatus == .available { + Text("\(firmware.runningVersionLine) → \(firmware.latestVersionLine)") + .font(.headline.monospacedDigit()).foregroundStyle(OBCTheme.ink) + } + } + } actions: { + if firmware.updateStatus == .available || library.protocolMismatch.map({ $0.found < $0.expected }) == true { + Button("View update") { + answerVisibleUpdate() + showsFirmware = true + } + .buttonStyle(.obcPrimary) + .accessibilityIdentifier("onboarding.updateNow") + } + Button(library.protocolMismatch == nil ? "Later" : "Finish setup") { + answerVisibleUpdate() + if library.protocolMismatch == nil { progress.move(to: .route) } + else { finish() } + } + .buttonStyle(.obcGhost) + .accessibilityIdentifier("onboarding.updateLater") + } + } + + private var updateTitle: String { + if let mismatch = library.protocolMismatch { + return mismatch.found > mismatch.expected ? "Update the app" : "An update is needed" + } + return firmware.checkState == .checking ? "Check your firmware" : "Update your OBC" + } + + private var updateMessage: String { + if let mismatch = library.protocolMismatch { + return mismatch.found > mismatch.expected + ? "This OBC needs a newer app for routes and ride sync. You can still ride and finish setup." + : "Routes and ride sync need newer OBC firmware. Riding still works, and you can finish setup now." + } + return "Install the latest improvements, or do this later in Settings. Confirm the installation on your OBC." + } + + private var route: some View { + LaunchScreenScaffold { + VStack(spacing: 24) { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + .font(.system(.largeTitle)).foregroundStyle(OBCTheme.tint) + .accessibilityHidden(true) + LaunchTitle("Send your first route") + .accessibilityIdentifier("onboarding.routeTitle") + LaunchMessage("Try the Grimsel Pass demo route, or import a GPX or TCX file. Your OBC shows a confirmation when the route arrives.") + if !canTransfer { connectionMessage } + } + } actions: { + Button("Send demo route", action: onDemoRoute) + .buttonStyle(.obcPrimary).disabled(!canTransfer) + .accessibilityIdentifier("onboarding.demoRoute") + Button("Import a route") { showsImporter = true } + .buttonStyle(.obcGhost).disabled(!canTransfer) + .accessibilityIdentifier("onboarding.importRoute") + Button("Skip for now") { progress.move(to: .ride) } + .buttonStyle(.obcGhost) + .accessibilityIdentifier("onboarding.routeSkip") + } + } + + private var ride: some View { + LaunchScreenScaffold { + VStack(spacing: 24) { + Image(systemName: "arrow.down.to.line") + .font(.system(.largeTitle)).foregroundStyle(OBCTheme.tint) + .accessibilityHidden(true) + LaunchTitle(hasDemoRide ? "Your demo ride is here" : "Bring a ride home") + .accessibilityIdentifier("onboarding.rideTitle") + LaunchMessage(hasDemoRide + ? "Find the Grimsel Pass ride in your Library. It is marked Demo and stays out of your totals." + : "Sync the demo ride from your OBC to see how rides reach your phone. It stays out of your totals.") + if library.sync.syncState == .syncing { + ProgressView("Syncing rides…") + } else if let interruption = library.sync.syncInterruption { + LaunchMessage(interruption.message) + } else if library.sync.lastSyncCount != nil && !hasDemoRide { + LaunchMessage("No demo ride was found. You can sync your own rides from the Library after your first ride.") + } + if !canTransfer { connectionMessage } + } + } actions: { + if !hasDemoRide { + Button(library.sync.syncInterruption?.actionTitle ?? "Sync demo ride") { + if library.sync.syncInterruption != nil { library.sync.resumeSync() } + else { library.sync.sync() } + } + .buttonStyle(.obcPrimary) + .disabled(!canTransfer || library.sync.syncState == .syncing) + .accessibilityIdentifier("onboarding.syncRide") + } + Button(hasDemoRide ? "Open Library" : "Skip for now", action: finish) + .buttonStyle(.obcGhost) + .disabled(library.sync.syncState == .syncing) + .accessibilityIdentifier("onboarding.finish") + } + } + + private var canTransfer: Bool { + library.connection == .connected && library.connectedScope != nil && library.protocolMismatch == nil + } + + private var hasDemoRide: Bool { library.rides.contains(where: \.isDemo) } + + private var connectionMessage: some View { + LaunchMessage(library.protocolMismatch != nil + ? "The app and OBC need compatible versions before routes or rides can be sent. You can skip this step." + : "Keep your OBC on and nearby. You can skip this step and connect later.") + } + + private func skipCurrentFirmware() { + guard progress.stage == .update, library.connectedScope != nil, + library.protocolMismatch == nil, firmware.hasUpdateAnswer, + firmware.checkState != .checking, firmware.updateStatus != .available else { return } + progress.move(to: .route) + } + + private func finish() { + firmware.stop() + progress.finish() + onFinish() + } + + private func answerVisibleUpdate() { + guard firmware.checkState != .checking, firmware.updateStatus == .available, + let version = firmware.latestRelease?.version else { return } + onUpdateAnswered(version) + } + + private func tip(_ icon: String, _ title: String, _ message: String) -> some View { + HStack(alignment: .top, spacing: 14) { + Image(systemName: icon).foregroundStyle(OBCTheme.tint).frame(width: 24) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.headline).foregroundStyle(OBCTheme.ink) + Text(message).font(.subheadline).foregroundStyle(OBCTheme.secondary) + } + } + .accessibilityElement(children: .combine) + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingProgress.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingProgress.swift new file mode 100644 index 000000000..ae775765a --- /dev/null +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingProgress.swift @@ -0,0 +1,56 @@ +import Foundation +import Observation + +@MainActor @Observable +public final class OnboardingProgress { + public enum Stage: String, Sendable { + case inactive, sensors, update, route, ride, done + } + + public private(set) var stage: Stage + public private(set) var showsReadyNote: Bool + private let defaults: UserDefaults? + private static let stageKey = "obc.onboarding.stage" + private static let readyKey = "obc.onboarding.ready" + + /// A nil store keeps previews and simulator scenarios independent of previous launches. + public init(defaults: UserDefaults? = nil) { + self.defaults = defaults + stage = defaults?.string(forKey: Self.stageKey).flatMap(Stage.init(rawValue:)) ?? .inactive + showsReadyNote = defaults?.bool(forKey: Self.readyKey) ?? false + } + + public var isPending: Bool { stage != .inactive && stage != .done } + + public func begin() { + guard !isPending else { return } + move(to: .sensors) + dismissReadyNote() + } + + public func move(to stage: Stage) { + self.stage = stage + defaults?.set(stage.rawValue, forKey: Self.stageKey) + } + + public func finish() { + move(to: .done) + showsReadyNote = true + defaults?.set(true, forKey: Self.readyKey) + } + + public func dismissReadyNote() { + showsReadyNote = false + defaults?.set(false, forKey: Self.readyKey) + } + + public func replay() { + move(to: .sensors) + dismissReadyNote() + } + + public func reset() { + move(to: .inactive) + dismissReadyNote() + } +} diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Settings/SettingsView.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Settings/SettingsView.swift index df08f0818..4f9339a8e 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Settings/SettingsView.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Settings/SettingsView.swift @@ -9,6 +9,7 @@ public struct SettingsView: View { /// Push the firmware-update screen. `nil` leaves the Update firmware row out (previews, and /// any wiring that does not host the update screen). private let onOpenFirmwareUpdate: (() -> Void)? + private let onReplaySetup: (() -> Void)? /// Debug-only: five taps on the App version row open the mock dev panel. `nil` in /// Release wiring, where the gesture goes nowhere. @@ -25,10 +26,12 @@ public struct SettingsView: View { public init( model: SettingsModel, onOpenFirmwareUpdate: (() -> Void)? = nil, + onReplaySetup: (() -> Void)? = nil, onOpenDevPanel: (() -> Void)? = nil ) { self.model = model self.onOpenFirmwareUpdate = onOpenFirmwareUpdate + self.onReplaySetup = onReplaySetup self.onOpenDevPanel = onOpenDevPanel } @@ -171,6 +174,17 @@ public struct SettingsView: View { private var appGroup: some View { OBCGroupedSection("App", footer: "Automatic follows the setting on your iPhone.") { OBCAppearanceRow() + if let onReplaySetup { + OBCListRow( + icon: "arrow.counterclockwise", + iconColor: OBCTheme.tint, + label: "Run setup again", + showsChevron: true, + showsDivider: false, + action: onReplaySetup + ) + .accessibilityIdentifier("settings.replaySetup") + } } } diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/FirmwareUpdateCheckTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/FirmwareUpdateCheckTests.swift index 726d7b55a..9e7b22102 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/FirmwareUpdateCheckTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/FirmwareUpdateCheckTests.swift @@ -113,6 +113,40 @@ struct FirmwareUpdateCheckTests { #expect(store.loadCheck()?.release?.version == "1.4.0", "the refreshed answer is cached") } + @Test func reconnectRefreshesTheVersionAfterAnInitialReadFailure() async throws { + let cached = UpdateCheckRecord( + release: FirmwareRelease( + version: "1.4.0", bytes: 10, sha256: String(repeating: "a", count: 64), + url: Self.containerURL + ), checkedAt: Date() + ) + let (model, transport, fetcher, _) = makeModel(cached: cached) + transport.deviceInfoError = .notConnected + model.start() + defer { model.stop() } + try await waitFor("initial version read") { transport.deviceInfoReads == 1 } + #expect(!model.hasUpdateAnswer) + + transport.push(.outOfRange) + try await waitFor("disconnected") { model.connection == .outOfRange } + transport.deviceInfoError = nil + transport.push(.connected) + try await waitFor("version after reconnect") { model.hasUpdateAnswer } + #expect(model.updateStatus == .available) + #expect(model.deviceSerial == "OBC-001") + + transport.push(.outOfRange) + try await waitFor("disconnected again") { model.connection == .outOfRange } + #expect(model.runningVersion == "1.3.0") + #expect(model.deviceSerial == "OBC-001") + #expect(model.canDownloadUpdate) + transport.fwVersion = "1.4.0" + transport.push(.connected) + try await waitFor("new running version") { model.runningVersion == "1.4.0" } + #expect(model.updateStatus == .current) + #expect(fetcher.requested.isEmpty) + } + @Test func aManualCheckReAsksEvenWithAFreshCache() async throws { let payload = container(version: "1.5.0") let cached = UpdateCheckRecord( @@ -385,6 +419,8 @@ private final class StubTransport: DeviceLink, DeviceUpdates, @unchecked Sendabl private var stateConts: [AsyncStream.Continuation] = [] private var lastState: ConnectionState = .connected var fwVersion = "1.3.0" + var deviceInfoError: DeviceError? + var deviceInfoReads = 0 var installResult: FirmwareInstallResult = .accepted private var uploadProgress: AsyncStream.Continuation? @@ -408,7 +444,9 @@ private final class StubTransport: DeviceLink, DeviceUpdates, @unchecked Sendabl } func deviceInfo() async throws -> DeviceInfo { - DeviceInfo(name: "Trailhead", firmwareVersion: fwVersion) + deviceInfoReads += 1 + if let deviceInfoError { throw deviceInfoError } + return DeviceInfo(name: "Trailhead", firmwareVersion: fwVersion, serial: "OBC-001") } func uploadFirmware(_ container: Data) -> TransferHandle { diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/OnboardingProgressTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/OnboardingProgressTests.swift new file mode 100644 index 000000000..2d8f6e373 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/OnboardingProgressTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing +@testable import OBCUI + +@Suite @MainActor struct OnboardingProgressTests { + @Test func interruptedSetupResumesAndCompletionDoesNotReplay() throws { + let name = "OnboardingProgressTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let first = OnboardingProgress(defaults: defaults) + #expect(!first.isPending) + first.begin() + first.move(to: .route) + + let resumed = OnboardingProgress(defaults: defaults) + #expect(resumed.isPending) + #expect(resumed.stage == .route) + resumed.begin() + #expect(resumed.stage == .route) + resumed.finish() + + let finished = OnboardingProgress(defaults: defaults) + #expect(!finished.isPending) + #expect(finished.showsReadyNote) + finished.dismissReadyNote() + #expect(!OnboardingProgress(defaults: defaults).showsReadyNote) + finished.replay() + #expect(finished.stage == .sensors) + #expect(finished.isPending) + finished.reset() + #expect(!OnboardingProgress(defaults: defaults).isPending) + } +} diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/UpdateSurfaceModelTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/UpdateSurfaceModelTests.swift new file mode 100644 index 000000000..ece2641f2 --- /dev/null +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/UpdateSurfaceModelTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +import OBCDomain +import OBCTransport +@testable import OBCUI + +@MainActor +struct UpdateSurfaceModelTests { + private let release = FirmwareRelease( + version: "1.4.0", bytes: 10, sha256: String(repeating: "a", count: 64), + url: URL(string: "https://updates.openbikecomputer.com/fw/UPDATE.BIN")! + ) + + @Test func setupSuppressesOffersWithoutAnsweringThem() async throws { + let store = InMemoryUpdateSurfaceStore() + let notifier = SurfaceNotifier() + let model = UpdateSurfaceModel( + transport: SurfaceLink(), bondStore: SurfaceBondStore(), + runner: UpdateSurfaceRunner( + checker: UpdateChecker(store: InMemoryUpdateCheckStore( + record: UpdateCheckRecord(release: release, checkedAt: Date()) + )), store: store + ), notifier: notifier + ) + model.setEnabled(false) + model.start() + model.appBecameActive() + #expect(await neverHolds({ model.pending != nil }, for: .milliseconds(50))) + #expect(notifier.requests == 0) + #expect(!store.loadDidAskNotificationPermission()) + + model.setEnabled(true) + model.appBecameActive() + try await waitFor("offer after setup") { model.pending != nil } + model.setEnabled(false) + #expect(model.pending == nil) + #expect(store.loadAnsweredVersion(device: "OBC-001") == nil) + + model.setEnabled(true) + model.appBecameActive() + try await waitFor("offer remains unanswered") { model.pending != nil } + model.dismiss() + #expect(store.loadAnsweredVersion(device: "OBC-001") == "1.4.0") + } + + @Test func aCheckThatFinishesDuringSetupCannotOfferOrRequestPermission() async throws { + let fetcher = HeldSurfaceFetcher() + let store = InMemoryUpdateSurfaceStore() + let cache = InMemoryUpdateCheckStore() + let notifier = SurfaceNotifier() + let model = UpdateSurfaceModel( + transport: SurfaceLink(), bondStore: SurfaceBondStore(), + runner: UpdateSurfaceRunner( + checker: UpdateChecker(fetcher: fetcher, store: cache), store: store + ), notifier: notifier + ) + model.appBecameActive() + try await waitFor("manifest request") { fetcher.started.current == true } + model.setEnabled(false) + fetcher.response.fulfill((200, try JSONEncoder().encode(release))) + try await waitFor("late check completion") { cache.loadCheck() != nil } + #expect(await neverHolds({ model.pending != nil }, for: .milliseconds(50))) + #expect(notifier.requests == 0) + #expect(!store.loadDidAskNotificationPermission()) + + model.setEnabled(true) + model.appBecameActive() + try await waitFor("new check after setup") { model.pending != nil } + try await waitFor("permission request") { notifier.requests == 1 } + #expect(model.pending?.release == release) + } +} + +private struct SurfaceLink: DeviceLink { + var state: AsyncStream { + AsyncStream { $0.yield(.connected); $0.finish() } + } + func connect() async throws {} + func disconnect() async {} + func deviceInfo() async throws -> DeviceInfo { + DeviceInfo(name: "Trailhead", firmwareVersion: "1.3.0", serial: "OBC-001") + } +} + +private struct SurfaceBondStore: BondStore { + func load() -> BondRecord? { BondRecord(deviceName: "Trailhead") } + func save(_ record: BondRecord) {} + func clear() {} +} + +private final class SurfaceNotifier: UpdateNotifying, @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + var requests: Int { lock.withLock { count } } + func requestAuthorization() async { lock.withLock { count += 1 } } + func notifyUpdateAvailable(version: String, deviceName: String) async -> Bool { false } +} + +private struct HeldSurfaceFetcher: ManifestFetching { + let started = AsyncPromise() + let response = AsyncPromise<(Int, Data)>() + func get(_ url: URL) async throws -> (status: Int, body: Data) { + started.fulfill(true) + return await response.value + } +} diff --git a/companion-ios/project.yml b/companion-ios/project.yml index d56b01fd7..dc4216eca 100644 --- a/companion-ios/project.yml +++ b/companion-ios/project.yml @@ -69,6 +69,11 @@ targets: # is real (grep a Release build: the marker string is absent). - package: OBCKit product: OBCMock + entitlements: + path: OBCCompanion/OBCCompanion.entitlements + properties: + com.apple.developer.associated-domains: + - applinks:openbikecomputer.com info: path: OBCCompanion/Info.plist properties: diff --git a/companion-ios/scripts/generate-website-fixture.py b/companion-ios/scripts/generate-website-fixture.py index 10c1a8c2b..cabc8df97 100755 --- a/companion-ios/scripts/generate-website-fixture.py +++ b/companion-ios/scripts/generate-website-fixture.py @@ -27,7 +27,7 @@ DISPLAY_NAME = "Grimsel Pass" FIXTURE_DIR = REPO_DIR / "companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures" FIXTURE_JSON = FIXTURE_DIR / "website.json" -IMPORT_GPX = FIXTURE_DIR / "website-import.gpx" +IMPORT_GPX = REPO_DIR / "companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx" GPX_NS = "http://www.topografix.com/GPX/1/1" From 14176d727d47c724de0abe550986cc1845dbb12c Mon Sep 17 00:00:00 2001 From: timohueser Date: Tue, 29 Sep 2026 00:35:56 +0200 Subject: [PATCH 07/17] fix(ios): clarify onboarding completion actions --- .../OBCCompanionUITests/PairingFlowTests.swift | 2 +- .../Sources/OBCUI/Launch/WelcomeScreens.swift | 2 +- .../OBCUI/Onboarding/OnboardingFlowView.swift | 15 +++++++++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/companion-ios/OBCCompanionUITests/PairingFlowTests.swift b/companion-ios/OBCCompanionUITests/PairingFlowTests.swift index e2bca780a..11cc6bf3a 100644 --- a/companion-ios/OBCCompanionUITests/PairingFlowTests.swift +++ b/companion-ios/OBCCompanionUITests/PairingFlowTests.swift @@ -42,7 +42,7 @@ final class PairingFlowTests: XCTestCase { /// The intro, the scan with the row sliding in, the pairing beat, then the main screen. @MainActor func testFirstRunPairingHappyPath() { - let app = launch(scenario: "noDevice") + let app = launch(scenario: "onboarding") enterPairing(app, capture: true) diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/WelcomeScreens.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/WelcomeScreens.swift index 9e6b38cb6..8de1b4596 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/WelcomeScreens.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/WelcomeScreens.swift @@ -7,7 +7,7 @@ struct WelcomeView: View { var body: some View { LaunchScreenScaffold { VStack(spacing: 0) { - DeviceGlyphView(variant: .home(name: "OpenBikeComputer")) + DeviceGlyphView(variant: .home(name: "OBC")) .padding(.bottom, 32) LaunchTitle("Your next ride starts here") .accessibilityIdentifier("onboarding.welcomeTitle") diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingFlowView.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingFlowView.swift index 55b141f54..387618e6b 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingFlowView.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Onboarding/OnboardingFlowView.swift @@ -209,10 +209,17 @@ public struct OnboardingFlowView: View { .disabled(!canTransfer || library.sync.syncState == .syncing) .accessibilityIdentifier("onboarding.syncRide") } - Button(hasDemoRide ? "Open Library" : "Skip for now", action: finish) - .buttonStyle(.obcGhost) - .disabled(library.sync.syncState == .syncing) - .accessibilityIdentifier("onboarding.finish") + if hasDemoRide { + Button("Open Library", action: finish) + .buttonStyle(.obcPrimary) + .disabled(library.sync.syncState == .syncing) + .accessibilityIdentifier("onboarding.finish") + } else { + Button("Skip for now", action: finish) + .buttonStyle(.obcGhost) + .disabled(library.sync.syncState == .syncing) + .accessibilityIdentifier("onboarding.finish") + } } } From 1797ff4e29e85203773a411f7dc738fdcb6e0a80 Mon Sep 17 00:00:00 2001 From: timohueser Date: Tue, 29 Sep 2026 00:43:26 +0200 Subject: [PATCH 08/17] test(ios): exercise onboarding at accessibility text sizes --- .../OnboardingFlowTests.swift | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/companion-ios/OBCCompanionUITests/OnboardingFlowTests.swift b/companion-ios/OBCCompanionUITests/OnboardingFlowTests.swift index ccff68b10..b5356e82b 100644 --- a/companion-ios/OBCCompanionUITests/OnboardingFlowTests.swift +++ b/companion-ios/OBCCompanionUITests/OnboardingFlowTests.swift @@ -47,20 +47,28 @@ final class OnboardingFlowTests: XCTestCase { @MainActor func testDemoRouteAndRideReachLibrary() { let app = launch() + XCTAssertTrue(app.staticTexts["onboarding.welcomeTitle"].waitForExistence(timeout: 10)) + capture(app, "P01-welcome") tap(app, "onboarding.getStarted") + capture(app, "P02-switch-on") tap(app, "pair.start") + capture(app, "P03-bluetooth") tap(app, "onboarding.allowBluetooth") XCTAssertTrue(app.staticTexts["pair.pairedTitle"].waitForExistence(timeout: 15)) XCTAssertEqual(app.textFields["pairing.name"].value as? String, "OBC-7A2F") capture(app, "P06-factory-name") tap(app, "pairing.keepName") + capture(app, "P08-sensors") tap(app, "onboarding.sensorsContinue") + XCTAssertTrue(app.staticTexts["onboarding.routeTitle"].waitForExistence(timeout: 15)) + capture(app, "P10-route") tap(app, "onboarding.demoRoute") XCTAssertTrue(app.buttons["upload.done"].waitForExistence(timeout: 30)) XCTAssertTrue(app.staticTexts["Grimsel Pass is on OBC-7A2F"].exists) capture(app, "P10-route-sent") tap(app, "upload.done") XCTAssertTrue(app.staticTexts["onboarding.rideTitle"].waitForExistence(timeout: 10)) + capture(app, "P11-ride") tap(app, "onboarding.syncRide") let title = app.staticTexts["onboarding.rideTitle"] let synced = expectation( @@ -69,13 +77,16 @@ final class OnboardingFlowTests: XCTestCase { capture(app, "P11-ride-synced") tap(app, "onboarding.finish") XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 10)) - XCTAssertTrue(app.staticTexts["Grimsel Pass"].firstMatch.waitForExistence(timeout: 10)) + capture(app, "P12-ready-note") + tap(app, "Got it") + XCTAssertFalse(app.descendants(matching: .any)["onboarding.readyNote"].firstMatch.exists) + let demo = app.staticTexts["Grimsel Pass"].firstMatch + for _ in 0..<3 where !demo.exists { app.swipeUp() } + XCTAssertTrue(demo.waitForExistence(timeout: 10)) XCTAssertTrue(app.staticTexts.containing( NSPredicate(format: "label CONTAINS %@", "Demo ride, excluded from totals") ).firstMatch.exists) capture(app, "P12-demo-library") - app.buttons["Got it"].tap() - XCTAssertFalse(app.descendants(matching: .any)["onboarding.readyNote"].firstMatch.exists) } @MainActor From e8e32c4d6a254ba9e23586596a43f62a9b2f86d3 Mon Sep 17 00:00:00 2001 From: timohueser Date: Tue, 29 Sep 2026 00:50:29 +0200 Subject: [PATCH 09/17] fix: follow the bundled demo route in the fixture guard --- tools/check_fixture_policy.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/check_fixture_policy.py b/tools/check_fixture_policy.py index 43efa85b3..99303282b 100644 --- a/tools/check_fixture_policy.py +++ b/tools/check_fixture_policy.py @@ -18,11 +18,12 @@ ALLOWED = { # Shipped wasm demo payload, not a developer fixture. "apps/obc-sim/assets/grimsel-demo.obcm", + # Bundled onboarding route and factory demo ride source. + "companion-ios/Packages/OBCKit/Sources/OBCFormats/Resources/grimsel-pass.gpx", # Skin-preview product input/golden, owned and rendered by obc-bake. "host/obc-bake/assets/teningen-preview.obcm", # Authored source rides whose textual diffs remain reviewable. "fixtures/sources/sim-grimsel/tracks/grimsel-climb.gpx", - "companion-ios/Packages/OBCKit/Sources/OBCMock/Fixtures/website-import.gpx", } FIXTURE_SUFFIXES = ( ".obcm", From 5e3306f1e1098ba033d4d1c029a34433b7d120e7 Mon Sep 17 00:00:00 2001 From: timohueser Date: Tue, 29 Sep 2026 00:53:10 +0200 Subject: [PATCH 10/17] fix(web): preserve the demo flag in ride objects --- builder/app/dev-harness/ride-library.ts | 1 + builder/app/dev-harness/simulated-device.svelte.ts | 1 + builder/app/src/lib/device/library.test.ts | 1 + builder/app/src/lib/device/rides.test.ts | 14 ++++++++++++++ builder/app/src/lib/usb/objects.ts | 7 ++++++- 5 files changed, 23 insertions(+), 1 deletion(-) diff --git a/builder/app/dev-harness/ride-library.ts b/builder/app/dev-harness/ride-library.ts index b5cbb13ce..fcfdb99e6 100644 --- a/builder/app/dev-harness/ride-library.ts +++ b/builder/app/dev-harness/ride-library.ts @@ -62,6 +62,7 @@ function loopRide( const distanceM = Math.round(radiusDeg * 111_000 * 2 * Math.PI * 1.2); return { version: 6, + isDemo: false, effortLimits: { maxHrBpm: null, ftpW: null }, name, startTime, diff --git a/builder/app/dev-harness/simulated-device.svelte.ts b/builder/app/dev-harness/simulated-device.svelte.ts index de36bdfbe..b3a451988 100644 --- a/builder/app/dev-harness/simulated-device.svelte.ts +++ b/builder/app/dev-harness/simulated-device.svelte.ts @@ -284,6 +284,7 @@ function syntheticRide(name: string, startTime: number, points: number, sensors: } return { version: 6, + isDemo: false, effortLimits: { maxHrBpm: null, ftpW: null }, name, startTime, diff --git a/builder/app/src/lib/device/library.test.ts b/builder/app/src/lib/device/library.test.ts index 418844ddc..87dc91383 100644 --- a/builder/app/src/lib/device/library.test.ts +++ b/builder/app/src/lib/device/library.test.ts @@ -84,6 +84,7 @@ function rideObject(name: string, startTime: number, points = 24): RideObject { } return { version: 6, + isDemo: false, effortLimits: { maxHrBpm: null, ftpW: null }, name, startTime, diff --git a/builder/app/src/lib/device/rides.test.ts b/builder/app/src/lib/device/rides.test.ts index cc2fe2612..df1379a71 100644 --- a/builder/app/src/lib/device/rides.test.ts +++ b/builder/app/src/lib/device/rides.test.ts @@ -95,6 +95,7 @@ function rideFromTrackLog(log: Uint8Array, name: string, startTime: number): Rid } return { version: 6, + isDemo: false, effortLimits: { maxHrBpm: null, ftpW: null }, name, startTime, @@ -132,6 +133,7 @@ function longRide(points: number): RideObject { } return { version: 6, + isDemo: false, effortLimits: { maxHrBpm: null, ftpW: null }, name: "Long Way Round", startTime: 1_783_598_400, @@ -231,6 +233,7 @@ describe("the ride object", () => { const ride = decodeRideObject(bytes); expect(ride).toMatchObject({ version: 6, + isDemo: true, name: "Sensor Ride", startTime: 1_751_460_000, distanceM: 12_345, @@ -262,6 +265,17 @@ describe("the ride object", () => { expect(encodeRideObject(ride)).toEqual(bytes); }); + it("keeps ordinary rides unmarked and rejects reserved footer flags", () => { + const bytes = encodeRideObject(longRide(1)); + const flags = bytes.length - 154 + 33; + expect(bytes[flags]).toBe(0); + expect(decodeRideObject(bytes).isDemo).toBe(false); + for (const reserved of [2, 0x80, 0xff]) { + bytes[flags] = reserved; + expect(() => decodeRideObject(bytes)).toThrow("non-canonical summary footer"); + } + }); + it("keeps a zero elevation as the device's 'no barometer yet' value", () => { const ride: RideObject = { ...longRide(1), diff --git a/builder/app/src/lib/usb/objects.ts b/builder/app/src/lib/usb/objects.ts index 714d072dc..8d1d2344e 100644 --- a/builder/app/src/lib/usb/objects.ts +++ b/builder/app/src/lib/usb/objects.ts @@ -75,6 +75,8 @@ export interface RideEffortLimits { /** A downloaded v6 ride: the recorded sample bytes followed by one fixed summary footer. */ export interface RideObject { version: 6; + /** Synthetic onboarding ride; preserved with the recorded payload. */ + isDemo: boolean; name: string; startTime: number; distanceM: number; @@ -110,7 +112,8 @@ export function decodeRideObject(data: Uint8Array): RideObject { const version = data[footer + 4]; if (version !== 6) throw new ObjectDecodeError(`ride object version ${version}; this client decodes 6.`); const limits = footer + RIDE_LIMITS_AT; - if (view.getUint16(footer + 6, true) !== RIDE_FOOTER_LEN || data[footer + 33] !== 0 || data[limits + 1] !== 0) { + const flags = data[footer + 33]; + if (view.getUint16(footer + 6, true) !== RIDE_FOOTER_LEN || (flags & ~1) !== 0 || data[limits + 1] !== 0) { throw new ObjectDecodeError("ride object has a non-canonical summary footer."); } const name = footerName(data, footer + RIDE_NAME_AT, data[footer + 5]); @@ -149,6 +152,7 @@ export function decodeRideObject(data: Uint8Array): RideObject { return { version: 6, + isDemo: (flags & 1) !== 0, points, name, startTime: view.getUint32(footer + 8, true), @@ -210,6 +214,7 @@ export function encodeRideObject(r: RideObject): Uint8Array { out[footer + 30] = r.avgHr ?? NO_U8; out[footer + 31] = r.maxHr ?? NO_U8; out[footer + 32] = r.avgCadence ?? NO_U8; + out[footer + 33] = r.isDemo ? 1 : 0; view.setUint16(footer + 34, r.avgPower ?? NO_U16, true); view.setUint16(footer + 36, r.maxPower ?? NO_U16, true); view.setUint32(footer + 38, r.energyKj ?? NO_U32, true); From 66f2aa229c69a024862e5e51c4449c9ff2ad414a Mon Sep 17 00:00:00 2001 From: timohueser Date: Tue, 29 Sep 2026 01:15:52 +0200 Subject: [PATCH 11/17] docs: refresh the bundled route import capture --- docs/assets/companion/route-imported.webp | Bin 18486 -> 18208 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/assets/companion/route-imported.webp b/docs/assets/companion/route-imported.webp index e258276d98eb0d1d65030a2c48d86a2769fd9a79..ab4a189dd31ae8f9385aed350fae795c7a1e0233 100644 GIT binary patch literal 18208 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 literal 18486 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 From 1aa33e12666b757689d38420bd4b20256a1ac372 Mon Sep 17 00:00:00 2001 From: timohueser Date: Tue, 29 Sep 2026 09:55:52 +0200 Subject: [PATCH 12/17] fix(ios): use the signpost in device illustrations --- .../OBCUI/Components/DeviceGlyphView.swift | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/DeviceGlyphView.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/DeviceGlyphView.swift index 028eef88c..6ce597e5d 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/DeviceGlyphView.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Components/DeviceGlyphView.swift @@ -8,7 +8,7 @@ import SwiftUI struct DeviceGlyphView: View { enum Variant { case welcome - /// The title bar with the device's name and the amber track squiggle. + /// The title bar with the device's name and the signpost mark. case home(name: String) /// The device's pairing card. The app never knows the code, so the digits are blanks. case passkey @@ -129,7 +129,7 @@ struct DeviceGlyphView: View { } } -/// The name in the title bar over the amber track squiggle. +/// The name in the title bar over the signpost mark. private struct DeviceHomeScreen: View { let name: String @@ -137,17 +137,10 @@ private struct DeviceHomeScreen: View { Canvas { context, size in let title = name.count <= 15 ? name : String(name.prefix(13)).trimmingCharacters(in: .whitespaces) + ".." context.deviceFrame(size: size, title: title) - // The source path is "M12 60 C 20 40 34 44 40 52 C 48 62 60 40 68 20" in an 80×74 box. - let box = CGRect(x: 24, y: 70, width: 192, height: 200) - func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint { - CGPoint(x: box.minX + x / 80 * box.width, y: box.minY + y / 74 * box.height) - } - var path = Path() - path.move(to: point(12, 60)) - path.addCurve(to: point(40, 52), control1: point(20, 40), control2: point(34, 44)) - path.addCurve(to: point(68, 20), control1: point(48, 62), control2: point(60, 40)) - context.stroke(path, with: .color(OBCTheme.deviceTrack), style: StrokeStyle(lineWidth: 6, lineCap: .round)) + context.draw(Image("Signpost", bundle: .module), + in: CGRect(x: 40, y: 96, width: 160, height: 160)) } + .environment(\.colorScheme, .light) } } From a8f8d69bdd163bd8b234c9c74efc2803cc3f6f50 Mon Sep 17 00:00:00 2001 From: timohueser Date: Tue, 29 Sep 2026 09:55:52 +0200 Subject: [PATCH 13/17] fix(ios): open the Library while reconnecting --- companion-ios/OBCCompanion/RootView.swift | 4 +- .../PairingFlowTests.swift | 35 +++---- .../OBCUI/Gallery/OBCComponentGallery.swift | 2 - .../OBCUI/Launch/LaunchFlowModel.swift | 67 ++----------- .../Sources/OBCUI/Launch/LaunchFlowView.swift | 7 -- .../OBCUI/Launch/LaunchStateScreens.swift | 81 ---------------- .../Sources/OBCUI/Main/MainScreenModel.swift | 72 ++++++++------ .../OBCUITests/LaunchFlowModelTests.swift | 51 ++++++++-- .../OBCUITests/MainScreenModelTests.swift | 93 +++++++++++++++++++ .../Support/ObservedMockTransport.swift | 14 ++- .../OBCUITests/TripReconcileModelTests.swift | 35 +++++++ 11 files changed, 248 insertions(+), 213 deletions(-) diff --git a/companion-ios/OBCCompanion/RootView.swift b/companion-ios/OBCCompanion/RootView.swift index 2a50267f6..77535d5ab 100644 --- a/companion-ios/OBCCompanion/RootView.swift +++ b/companion-ios/OBCCompanion/RootView.swift @@ -4,8 +4,8 @@ import OBCTransport import OBCFormats import OBCUI -/// The app's root: the launch gate, a bond check and quiet reconnect or the pairing flow, in -/// front of the main screen, which pushes the detail screens. Holds only the seams the +/// The app's root: first-use setup or the Library, with a quiet reconnect for a saved bond. +/// The main screen pushes the detail screens. Holds only the seams the /// composition root chose, plus the file-format edge that turns a picked file into an import. /// The import flow itself is `ImportFlowModel`; this view only binds its presentation state. struct RootView: View { diff --git a/companion-ios/OBCCompanionUITests/PairingFlowTests.swift b/companion-ios/OBCCompanionUITests/PairingFlowTests.swift index 11cc6bf3a..19c2b1845 100644 --- a/companion-ios/OBCCompanionUITests/PairingFlowTests.swift +++ b/companion-ios/OBCCompanionUITests/PairingFlowTests.swift @@ -137,8 +137,7 @@ final class PairingFlowTests: XCTestCase { snap(app, "S4-main-out-of-range") } - /// Bonded but with the link down at launch: the connecting state resolves to the main screen - /// within the grace window. + /// A saved pairing opens the Library while the link connects in the background. @MainActor func testBondedColdLaunchResolvesToMain() { let app = XCUIApplication() @@ -147,26 +146,22 @@ final class PairingFlowTests: XCTestCase { XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 15)) } - /// Bonded but the device never answers: the grace window expires onto the connect-failed - /// screen, never a forever-spinner, and the secondary action still reaches the library. + /// An absent device leaves the Library available, with connection status in its header. @MainActor - func testDeviceUnreachableTimesOutToConnectFailedAndRoutesStayReachable() { + func testDeviceUnreachableOpensLibraryWhileReconnecting() { let app = launch(scenario: "deviceUnreachable") - - XCTAssertTrue(app.staticTexts["launch.connectingTitle"].waitForExistence(timeout: 10), "A state missing") - // The default connect grace must expire onto the timeout screen. - let title = app.staticTexts["launch.connectFailedTitle"] - XCTAssertTrue(title.waitForExistence(timeout: 15), "connect-failed screen missing") - XCTAssertEqual(title.label, "Can't reach Trailhead") - XCTAssertTrue(app.buttons["launch.tryAgain"].exists) - snap(app, "A-timeout-connect-failed") - - // Try again re-enters the connecting state, and the still-silent device times out again. - app.buttons["launch.tryAgain"].tap() - XCTAssertTrue(app.staticTexts["launch.connectingTitle"].waitForExistence(timeout: 10), "retry must re-enter A") - XCTAssertTrue(title.waitForExistence(timeout: 15), "second timeout missing") - - app.buttons["launch.goToRoutes"].tap() XCTAssertTrue(app.otherElements["main.screen"].waitForExistence(timeout: 10), "library must stay reachable") + let header = app.descendants(matching: .any)["topbar.device"].firstMatch + let reconnecting = expectation( + for: NSPredicate(format: "label CONTAINS[c] 'connecting'"), evaluatedWith: header) + wait(for: [reconnecting], timeout: 10) + XCTAssertFalse(app.buttons["topbar.sync"].isEnabled) + XCTAssertTrue(app.buttons["topbar.settings"].isEnabled) + XCTAssertFalse(app.staticTexts["launch.connectingTitle"].exists) + XCTAssertFalse(app.staticTexts["launch.connectFailedTitle"].exists) + app.segmentedControls.buttons["Rides"].tap() + XCTAssertTrue(app.staticTexts["No rides yet"].waitForExistence(timeout: 10)) + XCTAssertFalse(app.descendants(matching: .any)["main.readError"].firstMatch.exists) + snap(app, "D5-library-offline") } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Gallery/OBCComponentGallery.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Gallery/OBCComponentGallery.swift index 73cd41523..608836e73 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Gallery/OBCComponentGallery.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Gallery/OBCComponentGallery.swift @@ -229,8 +229,6 @@ public struct OBCComponentGallery: View { #endif section("Launch & Pairing") { - launchScreen { LaunchConnectingView(deviceName: "Trailhead") } - launchScreen { LaunchConnectFailedView(deviceName: "Trailhead", onRetry: {}, onGoToRoutes: {}) } launchScreen { WelcomeView(onStart: {}, onBrowse: {}) } launchScreen { SwitchOnView(onFind: {}, onBack: {}) } launchScreen { BluetoothPermissionView(onAllow: {}, onBack: {}, onBrowse: {}) } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowModel.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowModel.swift index cd96a4e3a..9fdbc6fd7 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowModel.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowModel.swift @@ -31,8 +31,6 @@ public final class LaunchFlowModel { public enum Phase: Equatable, Sendable { case idle - case connecting(deviceName: String) - case connectFailed(deviceName: String) case welcome case pairIntro case bluetoothPermission @@ -46,15 +44,13 @@ public final class LaunchFlowModel { } public struct Timing: Sendable { - public var connectGrace: Duration public var scanTimeout: Duration public var pairingBeat: Duration public init( - connectGrace: Duration = .seconds(8), scanTimeout: Duration = .seconds(30), + scanTimeout: Duration = .seconds(30), pairingBeat: Duration = .milliseconds(700) ) { - self.connectGrace = connectGrace self.scanTimeout = scanTimeout self.pairingBeat = pairingBeat } @@ -93,65 +89,18 @@ public final class LaunchFlowModel { public func start() { guard phase == .idle else { return } - if let bond = bondStore.load() { beginBondedConnect(bond) } else { phase = .welcome } - } - - private var connectedDestination: Phase { onboardingPending() ? .setup : .main } - - private func beginBondedConnect(_ bond: BondRecord) { - phase = .connecting(deviceName: bond.deviceName) - flowTask = Task { [transport, timing] in - var current: ConnectionState? - for await state in transport.state { current = state; break } - guard !Task.isCancelled else { return } - if current == .connected || current == .outOfRange { - phase = connectedDestination + guard bondStore.load() != nil else { phase = .welcome; return } + phase = onboardingPending() ? .setup : .main + // The Library observes link status. Reconnection never owns navigation. + connectAttempt = Task { [transport] in + for await state in transport.state { + guard !Task.isCancelled, state == .disconnected else { return } + try? await transport.connect() return } - startConnectAttemptIfNeeded() - let connected = await Self.linkCameUp(transport.state, within: timing.connectGrace) - guard !Task.isCancelled else { return } - phase = connected ? connectedDestination : .connectFailed(deviceName: bond.deviceName) - } - } - - private func startConnectAttemptIfNeeded() { - guard connectAttempt == nil else { return } - connectAttempt = Task { [transport, weak self] in - try? await transport.connect() - guard let self, !Task.isCancelled else { return } - connectAttempt = nil - switch phase { - case .connecting, .connectFailed: - flowTask?.cancel() - phase = connectedDestination - default: break - } } } - private static func linkCameUp(_ states: AsyncStream, within grace: Duration) async -> Bool { - await withTaskGroup(of: Bool.self) { group in - group.addTask { - for await state in states where state == .connected { return true } - return false - } - group.addTask { - try? await Task.sleep(for: grace) - return false - } - let first = await group.next() ?? false - group.cancelAll() - return first - } - } - - public func retryConnect() { - guard let bond = bondStore.load() else { phase = .welcome; return } - flowTask?.cancel() - beginBondedConnect(bond) - } - public static func acceptsPairingLink(_ url: URL) -> Bool { guard let link = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } return link.scheme?.lowercased() == "https" && link.host?.lowercased() == "openbikecomputer.com" diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowView.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowView.swift index 8803ddd9e..a8ab20732 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowView.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchFlowView.swift @@ -27,11 +27,6 @@ public struct LaunchFlowView: View { switch model.phase { case .idle: OBCTheme.page.ignoresSafeArea() - case .connecting(let deviceName): - LaunchConnectingView(deviceName: deviceName) - case .connectFailed(let deviceName): - LaunchConnectFailedView( - deviceName: deviceName, onRetry: model.retryConnect, onGoToRoutes: model.browseLibrary) case .welcome: WelcomeView(onStart: model.showSwitchOn, onBrowse: model.browseLibrary) case .pairIntro: @@ -61,8 +56,6 @@ public struct LaunchFlowView: View { private var phaseKey: String { switch model.phase { case .idle: "idle" - case .connecting: "connecting" - case .connectFailed: "connectFailed" case .welcome: "welcome" case .pairIntro: "pairIntro" case .bluetoothPermission: "bluetoothPermission" diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchStateScreens.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchStateScreens.swift index a90f9f9df..3426d4108 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchStateScreens.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Launch/LaunchStateScreens.swift @@ -1,78 +1,5 @@ import SwiftUI -// The launch-side states around the pairing flow: bonded and quietly reconnecting, -// the bonded device unreachable, and the radio blocked. Dumb views. - -/// Bonded launch, connecting. Brief and non-blocking by contract: the flow model caps -/// it with `Timing.connectGrace` and always resolves to main. -struct LaunchConnectingView: View { - let deviceName: String - - var body: some View { - LaunchScreenScaffold { - VStack(spacing: 0) { - DeviceGlyphView(variant: .home(name: deviceName)) - .padding(.bottom, 34) - - HStack(spacing: 10) { - OBCSpinner() - .accessibilityHidden(true) - Text("Connecting to \(deviceName)") - .font(.system(.headline)) - .foregroundStyle(OBCTheme.ink) - .accessibilityIdentifier("launch.connectingTitle") - } - .padding(.bottom, 8) - - LaunchMessage("This can take a moment when the OBC wakes from sleep.") - } - } actions: { - HStack(spacing: 8) { - OBCLogo().frame(width: 24, height: 24) - PixelText("OpenBikeComputer", size: .label, color: OBCTheme.secondary) - } - .accessibilityHidden(true) - } - } -} - -/// The bonded device did not answer within the connect grace window: asleep, out of -/// range, or powered off. Never a trap: retry re-enters connecting, or head to the -/// routes, and the background connect keeps listening either way. -struct LaunchConnectFailedView: View { - let deviceName: String - let onRetry: () -> Void - let onGoToRoutes: () -> Void - - var body: some View { - LaunchScreenScaffold { - VStack(spacing: 0) { - // The device drawn dark and grey: it is there, but not answering. - DeviceGlyphView(variant: .home(name: deviceName)) - .grayscale(1) - .opacity(0.45) - .accessibilityHidden(true) - .padding(.bottom, 34) - - LaunchTitle("Can't reach \(deviceName)") - .accessibilityIdentifier("launch.connectFailedTitle") - .padding(.bottom, 8) - - LaunchMessage( - "It is asleep or out of range. The app connects on its own when \(deviceName) is nearby." - ) - } - } actions: { - Button("Try again", action: onRetry) - .buttonStyle(.obcPrimary) - .accessibilityIdentifier("launch.tryAgain") - Button("Open Library", action: onGoToRoutes) - .buttonStyle(.obcGhost) - .accessibilityIdentifier("launch.goToRoutes") - } - } -} - /// Radio off, or the state after the rider denied Bluetooth. Say which switch fixes it, and /// never trap the rider: the library stays reachable. struct RadioBlockedView: View { @@ -139,14 +66,6 @@ struct RadioBlockedView: View { } } -#Preview("Connecting") { - LaunchConnectingView(deviceName: "Trailhead") -} - -#Preview("Can't reach") { - LaunchConnectFailedView(deviceName: "Trailhead", onRetry: {}, onGoToRoutes: {}) -} - #Preview("Bluetooth off") { RadioBlockedView(block: .off, onRetry: {}, onBrowseLibrary: {}) } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenModel.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenModel.swift index cc9d144d5..76de5d38d 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenModel.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenModel.swift @@ -98,6 +98,7 @@ public final class MainScreenModel { /// `ride(_:)`. @ObservationIgnored private var rideSummaries: [RideID: RideSummary] = [:] @ObservationIgnored private var started = false + @ObservationIgnored private var connectionRevision = 0 @ObservationIgnored private var streamTasks: [Task] = [] @ObservationIgnored private var loadTask: Task? /// Set when a reload is requested while `loadTask` is already reading. Bursts coalesce @@ -126,8 +127,7 @@ public final class MainScreenModel { private let legRouter: (any LegRouter)? /// The in-flight transfer ledger. Nil in tests and previews. @ObservationIgnored private let transferActivity: TransferActivity? - /// The in-flight identity read for the current connection. A reconnect replaces it and - /// never cancels it: a superseded read settles the same session-stable verdict. + /// Reconnect waits for the previous identity read to drain before starting another. @ObservationIgnored private var identityTask: Task? public static let trashRetentionDays = 30 @@ -207,19 +207,35 @@ public final class MainScreenModel { for await state in transport.state { guard let self else { return } connection = state - // A regained link, never the stream's replayed first value: re-read the lists - // and run the desired-name reconcile for a config write that never landed. - if state == .connected, let was = previous, was != .connected { + guard state != previous else { continue } + previous = state + connectionRevision += 1 + if state == .connected { + identityChecked = false + connectedScope = nil + lastRouteCatalog = nil + lastTripCatalog = nil + deviceRouteCRCs = [:] + deviceTripCRCs = [:] + refreshOnDeviceStates() + // Initial connection and reconnect share the same catalog/identity order. reload() - // Re-read identity because firmware or the mounted store can change. + let catalogs = loadTask + let priorIdentity = identityTask + let revision = connectionRevision identityTask = Task { [weak self] in - await self?.runIdentityCheck() - } - if let nameReconciler { - Task { await nameReconciler.reconcile() } + await priorIdentity?.value + await catalogs?.value + guard let self, connection == .connected, + connectionRevision == revision, !Task.isCancelled else { return } + await runIdentityCheck() + guard connection == .connected, connectionRevision == revision else { return } + await nameReconciler?.reconcile() } + } else { + reloadRequested = false + loadState = .loaded } - previous = state } }) streamTasks.append(Task { [weak self, transport] in @@ -246,22 +262,6 @@ public final class MainScreenModel { if connection == .connected { reload() } } }) - reload() - // Identity after the first library read, so a fault armed for the first read hits the - // lists and not this fetch. The same read carries the protocol-version check. - let firstLoad = loadTask - identityTask = Task { [weak self] in - await firstLoad?.value - await self?.runIdentityCheck() - } - // The reconnect edge never fires for the stream's replayed first value, so the launch - // connection reconciles here. Launched disconnected, the pass skips silently. - if let nameReconciler { - streamTasks.append(Task { - await firstLoad?.value - await nameReconciler.reconcile() - }) - } } deinit { @@ -276,7 +276,7 @@ public final class MainScreenModel { public func reload() { // Never decode an incompatible device's objects. The library-first content stays up // and the banner explains. - guard protocolMismatch == nil else { + guard connection == .connected, protocolMismatch == nil else { reloadRequested = false loadState = .loaded return @@ -294,13 +294,15 @@ public final class MainScreenModel { /// Drain coalesced catalog requests serially. Main-actor isolation stops the dirty bit and /// the `loadTask` retirement from racing a store-change callback. private func runReloadLoop() async { - while reloadRequested, !Task.isCancelled { + while reloadRequested, connection == .connected, !Task.isCancelled { reloadRequested = false + let revision = connectionRevision do { // Only the route catalog: Tracked is library-first, so its rows come from the // local library and device rides are pulled by Sync alone. let deviceRoutes = try await transport.listRoutes() guard !Task.isCancelled else { break } + guard connectionRevision == revision else { continue } lastRouteCatalog = deviceRoutes reconcileOnDevice(with: deviceRoutes) // Fail closed: a failed trip read skips the reconcile, because treating it as @@ -308,15 +310,18 @@ public final class MainScreenModel { // duplicate device trip instead of replacing in place. if let deviceTrips = try? await transport.listTrips() { guard !Task.isCancelled else { break } + guard connectionRevision == revision else { continue } lastTripCatalog = deviceTrips reconcileTripsOnDevice(with: deviceTrips) } + guard connectionRevision == revision else { continue } routes = plannedList() reloadTrips() rides = trackedList() loadState = .loaded } catch { guard !Task.isCancelled else { break } + guard connectionRevision == revision else { continue } loadState = .failed } } @@ -327,11 +332,17 @@ public final class MainScreenModel { /// A missing scope or failed read leaves device writes disabled until the /// next successful connection. Local library browsing remains available. private func runIdentityCheck() async { + guard connection == .connected else { return } + let revision = connectionRevision + let wasIncompatible = protocolMismatch != nil // Unknown until proven, every connection: the device may have been // reinitialized (new StoreId) or swapped since the last read. connectedScope = nil await stampDeviceClock() - if let info = try? await transport.deviceInfo() { + guard connectionRevision == revision, !Task.isCancelled else { return } + let info = try? await transport.deviceInfo() + guard connectionRevision == revision, !Task.isCancelled else { return } + if let info { deviceName = info.name if case let .protocolMismatch(expected, found)? = OBCProtocol.versionMismatch(reportedBy: info.protocolVersion) { @@ -353,6 +364,7 @@ public final class MainScreenModel { reconcileTripsOnDevice(with: tripCatalog) } reloadTrips() + if wasIncompatible { reload() } } } diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/LaunchFlowModelTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/LaunchFlowModelTests.swift index a03a210f7..4993c57a7 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/LaunchFlowModelTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/LaunchFlowModelTests.swift @@ -8,7 +8,7 @@ import OBCTransport @MainActor struct LaunchFlowModelTests { private static let timing = LaunchFlowModel.Timing( - connectGrace: .milliseconds(50), scanTimeout: .seconds(2), pairingBeat: .zero) + scanTimeout: .seconds(2), pairingBeat: .zero) private func make( _ scenario: Scenario = .noDevice, @@ -218,27 +218,58 @@ struct LaunchFlowModelTests { #expect(model.phase == .setup) } - @Test func bondedLaunchResumesOnlyPendingSetup() async throws { + @Test func bondedLaunchResumesOnlyPendingSetup() { let (normal, _) = make(.happyPath) normal.start() - try await wait { normal.phase == .main } - let (pending, _) = make(.happyPath, pending: { true }) + #expect(normal.phase == .main) + let (pending, control) = make(.deviceUnreachable, pending: { true }) + control.latency = .seconds(3_600) pending.start() - try await wait { pending.phase == .setup } + #expect(pending.phase == .setup) pending.finishSetup() pending.replaySetup() #expect(pending.phase == .setup) } - @Test func bondedUnreachableDeviceCanUseLibraryAndLaterReconnect() async throws { + @Test(arguments: [ConnectionState.disconnected, .connecting, .connected, .outOfRange]) + func bondedLaunchShowsLibraryBeforeAnyLinkWork(_ connection: ConnectionState) { + let (model, control) = make(.happyPath) + control.connection = connection + control.latency = .seconds(3_600) + model.start() + #expect(model.phase == .main) + #expect(control.connection == connection) + #expect(control.bonded) + } + + @Test func backgroundReconnectNeverChangesLibraryNavigation() async throws { let (model, control) = make(.happyPath) control.connection = .disconnected - control.latency = .milliseconds(100) + let states = MockTransport(control: control).state + var observed: [ConnectionState] = [] + let watch = Task { for await state in states { observed.append(state) } } + defer { watch.cancel() } model.start() - try await wait { model.phase == .connectFailed(deviceName: "Trailhead") } - model.browseLibrary() - try await wait { control.connection == .connected } #expect(model.phase == .main) + try await wait { observed.count == 3 } + #expect(observed == [.disconnected, .connecting, .connected]) + #expect(model.phase == .main) + } + + @Test func failedBackgroundReconnectKeepsLibraryAndBond() async throws { + let (model, control) = make(.happyPath) + control.connection = .disconnected + control.failNextOp(.readFailed) + let states = MockTransport(control: control).state + var observed: [ConnectionState] = [] + let watch = Task { for await state in states { observed.append(state) } } + defer { watch.cancel() } + model.start() + #expect(model.phase == .main) + try await wait { observed.count == 3 } + #expect(observed == [.disconnected, .connecting, .disconnected]) + #expect(model.phase == .main) + #expect(control.bonded) } @Test func forgetReturnsToWelcome() async throws { diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/MainScreenModelTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/MainScreenModelTests.swift index c955e98d5..2c3414c3b 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/MainScreenModelTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/MainScreenModelTests.swift @@ -162,6 +162,99 @@ final class MainScreenModelTests: XCTestCase { XCTAssertEqual(model.routes.count, 5) } + func testOfflineLibraryDefersDeviceReadsUntilConnected() async throws { + for connection in [ConnectionState.disconnected, .connecting, .outOfRange] { + for savedContent in [false, true] { + let control = MockControl(scenario: .happyPath) + control.latency = .zero + control.connection = connection + let library = InMemoryLibraryStore() + if savedContent { + library.savePlannedRoute(importedRecord()) + library.saveRide(Ride( + summary: RideSummary(id: RideID("saved"), name: "Saved ride", date: Date(), distanceMeters: 1_000), + points: [])) + } + let transport = ObservedMockTransport(control: control) + let model = MainScreenModel(transport: transport, library: library) + model.start() + try await waitFor("offline Library") { + model.connection == connection && model.loadState == .loaded && transport.catalogChangesObserved + } + XCTAssertEqual(model.routes.count, savedContent ? 1 : 0) + XCTAssertEqual(model.rides.count, savedContent ? 1 : 0) + model.reload() + control.deviceDeletesRoute(DeviceObjectID(7)) + let stayedLocal = await neverHolds({ + transport.routeCatalogStartedCount != 0 || transport.tripCatalogStartedCount != 0 + || transport.deviceInfoStartedCount != 0 || model.loadState != .loaded + }, for: .milliseconds(50)) + XCTAssertTrue(stayedLocal, "offline retries and catalog events must not read the device") + XCTAssertTrue(control.setClockSamples.isEmpty) + + control.connection = .connected + try await waitFor("connected catalogs and identity") { + model.connectedScope != nil && model.loadState == .loaded + } + XCTAssertEqual(transport.routeCatalogStartedCount, 1) + XCTAssertEqual(transport.tripCatalogStartedCount, 1) + XCTAssertEqual(transport.deviceInfoStartedCount, 1) + } + } + } + + func testDisconnectDrainsAnOpenCatalogWithoutAReadError() async throws { + let control = MockControl(scenario: .happyPath) + control.latency = .zero + let transport = ObservedMockTransport(control: control, gateFirstRouteCatalog: true) + let model = MainScreenModel(transport: transport) + model.start() + defer { transport.releaseFirstRouteCatalog() } + try await waitFor("open catalog") { transport.routeCatalogStartedCount == 1 } + control.connection = .disconnected + try await waitFor("offline Library") { model.connection == .disconnected && model.loadState == .loaded } + model.reload() + transport.releaseFirstRouteCatalog() + try await waitFor("catalog drained") { transport.routeCatalogCompletedCount == 1 } + let stayedLocal = await neverHolds({ + model.loadState != .loaded || transport.tripCatalogStartedCount != 0 + || transport.deviceInfoStartedCount != 0 + }, for: .milliseconds(50)) + XCTAssertTrue(stayedLocal) + XCTAssertEqual(control.cancelledRouteCatalogReadCount, 0) + + control.connection = .connected + try await waitFor("reconnected Library") { model.connectedScope != nil && model.loadState == .loaded } + XCTAssertEqual(transport.routeCatalogStartedCount, 2) + XCTAssertEqual(transport.tripCatalogStartedCount, 1) + XCTAssertEqual(transport.deviceInfoStartedCount, 1) + } + + func testFailedReconnectCatalogCannotDisproveAnotherDevicesSavedLink() async throws { + let library = InMemoryLibraryStore() + var record = importedRecord() + record.deviceLink = DeviceRouteLink( + serial: "OBC-B", storeID: FixtureSet.defaultStoreID, objectID: DeviceObjectID(900)) + record.uploadedCRC32 = 0x1234_5678 + library.savePlannedRoute(record) + let (model, control) = makeModel(.happyPath, library: library, seedLibrary: false) + try await startLoaded(model) + try await waitFor("first identity") { model.connectedScope != nil } + + control.connection = .disconnected + try await waitFor("link down") { model.connection == .disconnected } + control.deviceInfo = DeviceInfo( + name: "Second OBC", firmwareVersion: "1.0.0", serial: "OBC-B", storeID: FixtureSet.defaultStoreID) + control.failNextOp(.readFailed) + control.connection = .connected + try await waitFor("new identity after failed catalog") { + model.connectedScope?.serial == "OBC-B" && model.loadState == .failed + } + XCTAssertEqual(library.plannedRoutes().first?.deviceLink, record.deviceLink) + XCTAssertEqual(library.plannedRoutes().first?.uploadedCRC32, record.uploadedCRC32) + XCTAssertFalse(model.isUploaded(record.id)) + } + // MARK: Search func testSearchFiltersBothTabsCaseInsensitively() async throws { diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/Support/ObservedMockTransport.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/Support/ObservedMockTransport.swift index dc72538bf..25b7b7950 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/Support/ObservedMockTransport.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/Support/ObservedMockTransport.swift @@ -14,6 +14,8 @@ final class ObservedMockTransport: DeviceLink, DeviceBattery, DeviceObjects, Dev private let lock = NSLock() private var routeCatalogStarted = 0 private var routeCatalogCompleted = 0 + private var tripCatalogStarted = 0 + private var deviceInfoStarted = 0 private var observedCatalogChanges = false init(control: MockControl, gateFirstRouteCatalog: Bool = false) { @@ -23,6 +25,8 @@ final class ObservedMockTransport: DeviceLink, DeviceBattery, DeviceObjects, Dev var routeCatalogStartedCount: Int { lock.withLock { routeCatalogStarted } } var routeCatalogCompletedCount: Int { lock.withLock { routeCatalogCompleted } } + var tripCatalogStartedCount: Int { lock.withLock { tripCatalogStarted } } + var deviceInfoStartedCount: Int { lock.withLock { deviceInfoStarted } } var catalogChangesObserved: Bool { lock.withLock { observedCatalogChanges } } func releaseFirstRouteCatalog() { @@ -38,7 +42,10 @@ final class ObservedMockTransport: DeviceLink, DeviceBattery, DeviceObjects, Dev func connect() async throws { try await base.connect() } func disconnect() async { await base.disconnect() } - func deviceInfo() async throws -> DeviceInfo { try await base.deviceInfo() } + func deviceInfo() async throws -> DeviceInfo { + lock.withLock { deviceInfoStarted += 1 } + return try await base.deviceInfo() + } func setClock(_ sample: WallClockSample) async throws -> ClockSyncOutcome { try await base.setClock(sample) } @@ -61,7 +68,10 @@ final class ObservedMockTransport: DeviceLink, DeviceBattery, DeviceObjects, Dev func uploadRoute(_ route: RouteBlob) -> TransferHandle { base.uploadRoute(route) } func deleteRoute(_ id: DeviceObjectID) async throws { try await base.deleteRoute(id) } - func listTrips() async throws -> [TripCatalogEntry] { try await base.listTrips() } + func listTrips() async throws -> [TripCatalogEntry] { + lock.withLock { tripCatalogStarted += 1 } + return try await base.listTrips() + } func downloadTrip(_ id: DeviceObjectID) async throws -> TripObjectCodec.Trip { try await base.downloadTrip(id) } diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripReconcileModelTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripReconcileModelTests.swift index a290e8609..fb82ae20e 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripReconcileModelTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripReconcileModelTests.swift @@ -178,6 +178,41 @@ struct TripReconcileModelTests { // MARK: Reconcile transitions + @Test + func aReplacementDevicesCatalogKeepsThePreviousDevicesRouteTripAndDayLinks() async throws { + let (model, control, library) = try await makeMainWithLibrary() + try await uploadTrip(model) + let kettle = RouteID("kettle-moraine-loop") + let savedRoute = try #require(library.plannedRoutes().first { $0.id == kettle }) + let savedTrip = try #require(library.trips().first { $0.id == tripID }) + #expect(savedRoute.deviceLink != nil) + #expect(savedTrip.deviceLink != nil) + #expect(savedTrip.dayCopies.compactMap { $0 }.count == savedTrip.dayCount) + + control.connection = .disconnected + try await waitFor("link down") { model.connection == .disconnected } + for id in control.deviceTripObjectIDs { control.deviceDeletesTrip(id) } + control.fixtures.routes = [] + control.deviceInfo = DeviceInfo( + name: "Second OBC", firmwareVersion: "1.0.0", serial: "OBC-B", + storeID: FixtureSet.defaultStoreID) + control.connection = .connected + try await waitFor("replacement identity and catalogs") { + model.connectedScope?.serial == "OBC-B" && model.loadState == .loaded + } + + let keptRoute = try #require(library.plannedRoutes().first { $0.id == kettle }) + let keptTrip = try #require(library.trips().first { $0.id == tripID }) + #expect(keptRoute.deviceLink == savedRoute.deviceLink) + #expect(keptRoute.uploadedCRC32 == savedRoute.uploadedCRC32) + #expect(keptTrip.deviceLink == savedTrip.deviceLink) + #expect(keptTrip.uploadedCRC32 == savedTrip.uploadedCRC32) + #expect(keptTrip.dayCopies == savedTrip.dayCopies) + #expect(model.plannedDeviceObjectID(for: kettle) == nil) + #expect(model.tripOnDeviceState(tripID) == .notOnDevice) + #expect(model.planTripUpload(tripID)?.days.allSatisfy { $0.action == .fresh } == true) + } + @Test func aDeviceSideTripDeleteClearsTheLink() async throws { let (model, control) = try await makeMain() From 82738c1326fe844aab3777785d1f863ef954134c Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 30 Sep 2026 10:07:47 +0200 Subject: [PATCH 14/17] fix(ios): preserve ride archives and harden transfer recovery --- .../OBCProtocolV4/TransferClient.swift | 28 ++++++++++-------- .../OBCTransport/BLE/BLETransport.swift | 18 +++++++----- .../Library/FileLibraryStore.swift | 4 +-- .../Sources/OBCUI/Main/MainScreenModel.swift | 10 ++++++- .../OBCUI/Main/RideSyncCoordinator.swift | 21 ++++++++++---- .../Sources/OBCUI/Trip/TripUploadModel.swift | 29 +++++++++++++++---- .../ArchiveReceiptTests.swift | 15 ++++++++-- .../OBCTransportTests/RideArchiveTests.swift | 25 ++++++++++++++++ .../OBCUITests/RideSyncCoordinatorTests.swift | 8 +++-- .../OBCUITests/TripReconcileModelTests.swift | 5 ++++ .../OBCUITests/TripUploadModelTests.swift | 18 ++++++++++++ 11 files changed, 141 insertions(+), 40 deletions(-) diff --git a/companion-ios/Packages/OBCKit/Sources/OBCProtocolV4/TransferClient.swift b/companion-ios/Packages/OBCKit/Sources/OBCProtocolV4/TransferClient.swift index 71b62e61e..fe3bd0870 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCProtocolV4/TransferClient.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCProtocolV4/TransferClient.swift @@ -58,7 +58,7 @@ public actor TransferClient { } public func list(kind: ObjectKind? = nil) async throws -> [CatalogEntry] { - await acquire() + try await acquire() defer { release() } do { return try await listAll(kind: kind).entries } catch is TransferLinkLost { @@ -68,7 +68,7 @@ public actor TransferClient { } public func catalog(kind: ObjectKind? = nil) async throws -> (storeID: StoreID, entries: [CatalogEntry]) { - await acquire() + try await acquire() defer { release() } do { return try await listAll(kind: kind) } catch is TransferLinkLost { @@ -81,7 +81,7 @@ public actor TransferClient { /// A BLE page carries only two entries at the preferred MTU, so `catalog()` would cost /// hundreds of needless control writes for one StoreId. public func storeID() async throws -> StoreID { - await acquire() + try await acquire() defer { release() } do { return try await identifyStore() } catch is TransferLinkLost { @@ -92,7 +92,7 @@ public actor TransferClient { } public func status(objectID: ObjectID, revision: Revision) async throws -> StatusResult { - await acquire() + try await acquire() defer { release() } try await ensureIntroduced() do { return try await statusOnLiveLink(objectID: objectID, revision: revision) } @@ -106,7 +106,7 @@ public actor TransferClient { objectID: ObjectID, revision: Revision? = nil, expectedStoreID: StoreID? = nil, progress: @escaping @Sendable (_ bytesDone: Int, _ total: Int) -> Void = { _, _ in } ) async throws -> (result: GetResult, payload: Data) { - await acquire() + try await acquire() defer { release() } try await ensureIntroduced() if let expectedStoreID { try await checkStore(expectedStoreID) } @@ -137,7 +137,7 @@ public actor TransferClient { kind: ObjectKind, displayName: String, progress: @escaping @Sendable (_ bytesDone: Int, _ total: Int) -> Void = { _, _ in } ) async throws -> PutResult { - await acquire() + try await acquire() defer { release() } try await ensureIntroduced() let crc = CRC32.checksum(payload) @@ -193,7 +193,7 @@ public actor TransferClient { } public func remove(objectID: ObjectID, expectedRevision: Revision) async throws -> RemoveResult { - await acquire() + try await acquire() defer { release() } try await ensureIntroduced() do { @@ -212,7 +212,7 @@ public actor TransferClient { } public func cancel(transfer: RequestID) async throws -> CancelResult { - await acquire() + try await acquire() defer { release() } try await ensureIntroduced() let response = try await request(.cancel(transfer: transfer), opcode: .cancel) @@ -221,7 +221,7 @@ public actor TransferClient { } public func arm(packageObjectID: ObjectID, expectedRevision: Revision) async throws -> ArmResult { - await acquire() + try await acquire() defer { release() } try await ensureIntroduced() let response = try await request( @@ -232,7 +232,7 @@ public actor TransferClient { /// Destructively initialize the card as a new empty flat store. No iOS surface calls it today. public func format(expectedStoreID: StoreID, replacementStoreID: StoreID) async throws -> FormatResult { - await acquire() + try await acquire() defer { release() } try await ensureIntroduced() let response = try await request( @@ -247,7 +247,7 @@ public actor TransferClient { storeID: StoreID, objectID: ObjectID, revision: Revision, payloadLength: UInt64, payloadCRC32: UInt32 ) async throws -> ArchiveRideResult { - await acquire() + try await acquire() defer { release() } try Task.checkCancellation() let receipt = ControlRequest.archiveRide( @@ -413,6 +413,7 @@ public actor TransferClient { } private func request(_ request: ControlRequest, opcode: Opcode) async throws -> ControlResponse { + try Task.checkCancellation() let requestID = try makeRequestID() try await link.sendControlRecord(try request.frame(requestID: requestID).encode()) return try ControlResponse( @@ -495,9 +496,12 @@ public actor TransferClient { return id } - private func acquire() async { + private func acquire() async throws { + try Task.checkCancellation() if !busy { busy = true; return } await withCheckedContinuation { operationWaiters.append($0) } + do { try Task.checkCancellation() } + catch { release(); throw error } } private func release() { diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/BLE/BLETransport.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/BLE/BLETransport.swift index 4feb0f712..31a202c36 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCTransport/BLE/BLETransport.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/BLE/BLETransport.swift @@ -368,19 +368,18 @@ public final class BLETransport: NSObject, DeviceTransport, @unchecked Sendable catch { throw deviceError(for: error) } let scope = LibraryScope( serial: try await readString(GATT.serialNumber), storeID: catalog.storeID.description) - var rides: [RideSummary] = [] - for entry in catalog.entries where !entry.flags.contains(.retained) - && !entry.flags.contains(.reserved) && !entry.flags.contains(.recording) { + let rides = catalog.entries.filter { + !$0.flags.contains(.retained) && !$0.flags.contains(.reserved) && !$0.flags.contains(.recording) + }.map { entry in let id = RideID( deviceObjectID: DeviceObjectID(entry.objectID.rawValue), scope: scope) - // The ride footer is not frozen yet, so the fielded decoder stays behind the GET path. + // LIST establishes content identity. Display fields come from the one verified GET + // when sync downloads a missing ride, never from a catalog preflight. let source = RideSource(storeID: catalog.storeID.description, objectID: entry.objectID.rawValue, revision: entry.revision.rawValue, payloadLength: entry.payloadLength, payloadCRC32: entry.payloadCRC32) - let downloaded = try await downloadRide(id: id, source: source) - var summary = try RideObjectCodec.decode(downloaded.payload, id: id).summary - summary.source = downloaded.source - rides.append(summary) + return RideSummary(id: id, name: entry.displayName, date: .distantPast, + distanceMeters: 0, source: source) } return RideCatalog(rides: rides, hiddenRideCount: 0) } @@ -1323,11 +1322,14 @@ extension BLETransport: TransferLink { do { _ = try ControlFrame(decoding: record, direction: .request) _ = try await readyChannel() + try Task.checkCancellation() queue.async { [self] in objectControlReceiveCancelled = false byteChannel?.expectControlResponse(true) } // a request re-arms the lane try await write(record, to: GATT.objectControl) + } catch is CancellationError { + throw CancellationError() } catch is WireError { throw DeviceError.writeFailed } catch { diff --git a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift index 410012902..e41542260 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCTransport/Library/FileLibraryStore.swift @@ -943,7 +943,7 @@ private struct RidePointDTO: Codable { } private struct RideSummaryDTO: Codable { - var isDemo: Bool + var isDemo: Bool? var source: RideSource? var id: String var name: String @@ -1000,7 +1000,7 @@ private struct RideSummaryDTO: Codable { avgHeartRate: avgHeartRate, maxHeartRate: maxHeartRate, avgCadence: avgCadence, avgPower: avgPower, maxPower: maxPower, energyKJ: energyKJ, bikeType: BikeType(rawValue: bikeType) ?? .road, trip: trip?.domain, - zoneLimits: zoneLimits ?? .notSet, source: source, isDemo: isDemo + zoneLimits: zoneLimits ?? .notSet, source: source, isDemo: isDemo ?? false ) } } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenModel.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenModel.swift index 76de5d38d..b9d2c62ea 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenModel.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/MainScreenModel.swift @@ -771,6 +771,7 @@ public final class MainScreenModel { _ id: TripID, timing: TripUploadModel.Timing = TripUploadModel.Timing() ) -> TripUploadModel? { guard let trip = trip(id), let plan = planTripUpload(id) else { return nil } + let scope = connectedScope let days = dayRoutes(of: trip) var steps: [TripUploadModel.QueueStep] = [] // A reversed trip has a new key. The device must not keep the old trip object, with the @@ -830,7 +831,14 @@ public final class MainScreenModel { transport: transport, card: DeviceTripCard(name: trip.name, days: days.map { $0.summary(tripID: id) }), deviceName: deviceName, precheck: plan.precheck, steps: steps, - timing: timing, activity: transferActivity + timing: timing, activity: transferActivity, + verifyConnection: { [weak self] in + guard let self else { throw DeviceError.readFailed } + await identityTask?.value + try Task.checkCancellation() + guard connection == .connected, connectedScope != nil, + scope == nil || connectedScope == scope else { throw DeviceError.readFailed } + } ) } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/RideSyncCoordinator.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/RideSyncCoordinator.swift index 95a81c0eb..78a2d7b0c 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/RideSyncCoordinator.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Main/RideSyncCoordinator.swift @@ -21,17 +21,20 @@ public final class RideSyncCoordinator { public var reason: Reason = .download public enum Reason: Equatable, Sendable { - case download, confirmationPending, unsupported, sourceUnavailable, refused + case download, archive, confirmationPending, unsupported, sourceUnavailable, refused } - /// Every reason but `download` is about the mark that tells the OBC a ride is saved on the - /// phone: the rides themselves are saved. public var title: String { - reason == .download ? "Sync interrupted." : "Rides saved on this phone." + switch reason { + case .download: "Sync interrupted." + case .archive: "Ride could not be saved." + default: "Rides saved on this phone." + } } public var message: String { switch reason { case .download: "Got \(landed) of \(total) rides." + case .archive: "Saved \(landed) of \(total) rides. The app could not save a ride in this phone's library. Your rides remain on the OBC." case .confirmationPending: "The OBC did not answer when the app marked them as synced, so the OBC does not show them as synced yet." case .unsupported: "This OBC cannot show rides as synced. Update its firmware." @@ -271,6 +274,7 @@ public final class RideSyncCoordinator { defer { dropWatch.cancel() } var landed = 0 var batchFailed = false + var failureReason: SyncInterruption.Reason = .download do { for try await downloaded in download.rides { try Task.checkCancellation() @@ -297,7 +301,12 @@ public final class RideSyncCoordinator { ride.summary.maxPower = decoded.maxPower ride.summary.energyKJ = decoded.energyKJ } - let receipt = try library.archiveRide(ride) + let receipt: RideArchiveReceipt? + do { receipt = try library.archiveRide(ride) } + catch { + failureReason = .archive + throw error + } syncedRideIDs.insert(downloaded.id) onRideLanded(ride) landed += 1 @@ -322,7 +331,7 @@ public final class RideSyncCoordinator { syncState = .idle syncInterruption = SyncInterruption( landed: landed, total: fresh.count, - reason: batchFailed || outcome != .completed ? .download : confirmationFailure!) + reason: batchFailed || outcome != .completed ? failureReason : confirmationFailure!) return } diff --git a/companion-ios/Packages/OBCKit/Sources/OBCUI/Trip/TripUploadModel.swift b/companion-ios/Packages/OBCKit/Sources/OBCUI/Trip/TripUploadModel.swift index 4736bfcf4..25a323807 100644 --- a/companion-ios/Packages/OBCKit/Sources/OBCUI/Trip/TripUploadModel.swift +++ b/companion-ios/Packages/OBCKit/Sources/OBCUI/Trip/TripUploadModel.swift @@ -42,7 +42,7 @@ public final class TripUploadModel: Identifiable { /// One queue step: a skipped day with no bytes, or a transfer of a day route or the trip object. /// `makeTransfer` is evaluated at execution time, so the trip-object step reads the day route ids - /// the just-committed days landed under. It returns nil to degenerate to a skip. + /// the just-committed days landed under. An unavailable required transfer fails the queue. public struct QueueStep: Sendable { let title: String let skip: Bool @@ -102,6 +102,7 @@ public final class TripUploadModel: Identifiable { private let steps: [QueueStep] private let precheck: TripUploadPrecheck private let timing: Timing + private let verifyConnection: @MainActor @Sendable () async throws -> Void @ObservationIgnored private let activity: TransferActivity? @ObservationIgnored private var activityToken: TransferActivity.Token? @ObservationIgnored private var currentHandle: TransferHandle? @@ -118,7 +119,8 @@ public final class TripUploadModel: Identifiable { precheck: TripUploadPrecheck, steps: [QueueStep], timing: Timing = Timing(), - activity: TransferActivity? = nil + activity: TransferActivity? = nil, + verifyConnection: @escaping @MainActor @Sendable () async throws -> Void = {} ) { self.transport = transport self.card = card @@ -128,6 +130,7 @@ public final class TripUploadModel: Identifiable { self.stepCount = steps.count self.timing = timing self.activity = activity + self.verifyConnection = verifyConnection self.phase = .uploading } @@ -252,6 +255,11 @@ public final class TripUploadModel: Identifiable { stepIndex += 1 continue } + do { try await verifyConnection() } + catch { + fail(error) + return + } if let run = step.run { do { try await run() @@ -265,10 +273,8 @@ public final class TripUploadModel: Identifiable { continue } guard let (handle, committedCRC) = step.makeTransfer?() else { - // Nothing resolvable to send, such as a trip with no on-device days, so treat it - // as a skip and move on. - stepIndex += 1 - continue + fail(DeviceError.readFailed) + return } currentHandle = handle phase = .uploading @@ -282,6 +288,11 @@ public final class TripUploadModel: Identifiable { switch outcome { case .completed: let objectID = await handle.assignedObjectID + do { try await verifyConnection() } + catch { + fail(error) + return + } step.commit?(objectID, committedCRC) committedCount += 1 stepIndex += 1 @@ -303,6 +314,12 @@ public final class TripUploadModel: Identifiable { shouldDismiss = true } + private func fail(_ error: Error) { + failure = .device(error as? DeviceError ?? .writeFailed) + phase = .failed + setActive(false) + } + private func watchProgress(_ handle: TransferHandle) { progressWatcher?.cancel() progressWatcher = Task { [weak self] in diff --git a/companion-ios/Packages/OBCKit/Tests/OBCProtocolV4Tests/ArchiveReceiptTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCProtocolV4Tests/ArchiveReceiptTests.swift index 1e879c3f0..56bb3a308 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCProtocolV4Tests/ArchiveReceiptTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCProtocolV4Tests/ArchiveReceiptTests.swift @@ -55,18 +55,29 @@ struct ArchiveReceiptTests { #expect(await link.restores == 0) } - @Test func cancelledReceiveAndQueuedReceiptDoNotWriteAgain() async throws { + @Test(arguments: 0..<6) + func cancelledReceiveAndQueuedOperationDoNotWriteAgain(operation: Int) async throws { let link = ReceiptLink(steps: [.park, .reply(0)]) let client = TransferClient(link: link) let first = Task { try await send(client, store: link.store) } await link.waitUntilParked() - let queued = Task { try await send(client, store: link.store) } + let queued = Task { + switch operation { + case 0: _ = try await send(client, store: link.store) + case 1: _ = try await client.get(objectID: ObjectID(rawValue: 1)) + case 2: _ = try await client.put(Data([1]), kind: .route, displayName: "Route") + case 3: _ = try await client.list() + case 4: _ = try await client.remove(objectID: ObjectID(rawValue: 1), expectedRevision: Revision(rawValue: 1)) + default: _ = try await client.status(objectID: ObjectID(rawValue: 1), revision: Revision(rawValue: 1)) + } + } await Task.yield() queued.cancel() first.cancel() await #expect(throws: CancellationError.self) { try await first.value } await #expect(throws: CancellationError.self) { try await queued.value } #expect(await link.frames.filter { $0.opcode == .archiveRide }.count == 1) + #expect(await link.frames.count == 2) #expect(try await send(client, store: link.store).timestamp == 0) } diff --git a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideArchiveTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideArchiveTests.swift index 9e859d70d..e7b2b99e5 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideArchiveTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCTransportTests/RideArchiveTests.swift @@ -74,6 +74,31 @@ struct RideArchiveTests { #expect(reopened.archivedRideSource(next.id) == next.summary.source) } + @Test func absentDemoFlagKeepsArchiveProofAndPermitsReplacement() throws { + let dir = try directory() + defer { try? FileManager.default.removeItem(at: dir) } + let store = FileLibraryStore(directory: dir) + let original = ride() + _ = try store.archiveRide(original) + let rideDir = try #require(FileManager.default.contentsOfDirectory( + at: dir.appendingPathComponent("rides"), includingPropertiesForKeys: nil).first) + let manifestURL = rideDir.appendingPathComponent("summary.json") + var manifest = try #require(JSONSerialization.jsonObject( + with: Data(contentsOf: manifestURL)) as? [String: Any]) + var summary = try #require(manifest["summary"] as? [String: Any]) + summary.removeValue(forKey: "isDemo") + manifest["summary"] = summary + try JSONSerialization.data(withJSONObject: manifest).write(to: manifestURL) + + let reopened = FileLibraryStore(directory: dir) + #expect(reopened.rideSummaries() == [original.summary]) + #expect(reopened.archivedRideSource(original.id) == original.summary.source) + #expect(reopened.ridePoints(original.id) == original.points) + let next = ride(revision: 2) + _ = try reopened.archiveRide(next) + #expect(reopened.archivedRideSource(next.id) == next.summary.source) + } + @Test func historyAndMissingPointsAreNotArchiveProof() throws { let dir = try directory() defer { try? FileManager.default.removeItem(at: dir) } diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/RideSyncCoordinatorTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/RideSyncCoordinatorTests.swift index 4298b5a09..b45a94473 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/RideSyncCoordinatorTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/RideSyncCoordinatorTests.swift @@ -339,6 +339,7 @@ final class RideSyncCoordinatorTests: XCTestCase { XCTAssertFalse(try XCTUnwrap(library.ridePoints(first.summary.id)).isEmpty) XCTAssertNil(coordinator.lastSyncCount, "transport completion cannot override a local failure") XCTAssertEqual(coordinator.syncInterruption?.landed, 1) + XCTAssertEqual(coordinator.syncInterruption?.reason, malformedPayload ? .download : .archive) XCTAssertFalse(coordinator.upToDateToastVisible) if !malformedPayload { try FileManager.default.removeItem(at: blocker) } @@ -368,9 +369,8 @@ final class RideSyncCoordinatorTests: XCTestCase { for revision: UInt64 in [1, 2] { let source = RideSource(storeID: scope.storeID, objectID: 42, revision: revision, payloadLength: UInt64(payload.count), payloadCRC32: CRC32.checksum(payload)) - var catalogSummary = original.summary - catalogSummary.name = "Stale catalog display name" - catalogSummary.source = source + let catalogSummary = RideSummary(id: id, name: "Stale catalog display name", + date: .distantPast, distanceMeters: 0, source: source) library.markRideSynced(id) let transport = ScriptedDownloadTransport( base: MockTransport(control: control), @@ -382,6 +382,8 @@ final class RideSyncCoordinatorTests: XCTestCase { try await waitFor("exact revision archived") { coordinator.syncState == .done } XCTAssertEqual(library.archivedRideSource(id), source) XCTAssertEqual(library.rideSummaries().first?.name, original.summary.name) + XCTAssertEqual(library.rideSummaries().first?.date, original.summary.date) + XCTAssertEqual(library.rideSummaries().first?.distanceMeters, original.summary.distanceMeters) let next = RideSyncCoordinator(transport: transport, library: library, timing: Self.stickyTiming) try await startConnected(next) next.sync() diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripReconcileModelTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripReconcileModelTests.swift index fb82ae20e..b5999be47 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripReconcileModelTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripReconcileModelTests.swift @@ -147,6 +147,11 @@ struct TripReconcileModelTests { #expect(control.deviceTripCount == 0, "no trip object while the reversed days land") upload.cancel() + control.connection = .connected + try await waitFor("reconnected before retry", timeout: .seconds(20), interval: .milliseconds(5)) { + model.connection == .connected && model.connectedScope != nil + } + let retry = await model.prepareTripUpload(tripID, timing: Self.fastTiming)! retry.start() try await waitFor("retry landed", timeout: .seconds(20), interval: .milliseconds(5)) { retry.phase == .done } diff --git a/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripUploadModelTests.swift b/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripUploadModelTests.swift index 13189ddfd..c6e82ec4e 100644 --- a/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripUploadModelTests.swift +++ b/companion-ios/Packages/OBCKit/Tests/OBCUITests/TripUploadModelTests.swift @@ -61,6 +61,7 @@ struct TripUploadModelTests { startAndConfirm(upload) try await waitFor("interrupted", timeout: .seconds(20), interval: .milliseconds(5)) { upload.phase == .interrupted } + control.latency = .milliseconds(100) upload.resume() try await waitFor("done after resume", timeout: .seconds(20), interval: .milliseconds(5)) { upload.phase == .done } #expect(upload.committedCount == 3) @@ -69,6 +70,23 @@ struct TripUploadModelTests { // MARK: Flat catalog vs. resident menu capacity + @Test + func anUnavailableRequiredTransferFailsInsteadOfReportingDone() async throws { + let (model, control) = try await makeMain() + let plan = try #require(model.planTripUpload(tripID)) + let upload = TripUploadModel( + transport: MockTransport(control: control), + card: DeviceTripCard(name: "Trip", days: []), deviceName: "OBC", + precheck: plan.precheck, + steps: [.transfer(title: "Trip details", makeTransfer: { nil }, commit: { _, _ in + Issue.record("an unavailable transfer cannot commit") + })], timing: Self.fastTiming) + upload.start() + try await waitFor("required transfer fails", timeout: .seconds(20)) { upload.phase == .failed } + #expect(upload.committedCount == 0) + #expect(control.deviceTripCount == 0) + } + @Test func aFullResidentRouteMenuDoesNotPretendTheFlatStoreIsFull() async throws { let (model, control) = try await makeMain(routesNearlyFull: true) From 8b35956d79d607c6c721ce45cbeba13989cc4936 Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 30 Sep 2026 10:07:55 +0200 Subject: [PATCH 15/17] fix(firmware): clear personal card data on factory reset --- firmware/obc-app/i18n/de.toml | 6 +- firmware/obc-app/i18n/en.toml | 6 +- firmware/obc-app/i18n/es.toml | 6 +- firmware/obc-app/i18n/fr.toml | 6 +- firmware/obc-app/src/app.rs | 43 +++++++-- firmware/obc-app/src/catalog_state.rs | 42 ++++++++- firmware/obc-app/src/device_core/pass.rs | 83 +++++++++++++++++- firmware/obc-app/src/navigator/review.rs | 6 ++ firmware/obc-app/src/screen/settings/reset.rs | 79 ++++++++++++++--- firmware/obc-fw-nrf54l/src/flat_store.rs | 17 ++++ firmware/obc-fw-nrf54l/src/ride.rs | 20 +++++ firmware/obc-storage/src/flat/mod.rs | 1 + .../obc-storage/src/flat/personal_data.rs | 87 +++++++++++++++++++ firmware/ui-snapshots.sha256 | 6 +- host/obc-host-core/src/dispatch.rs | 4 + host/obc-host-core/src/flat_routes.rs | 13 +++ host/obc-host-core/src/repo.rs | 4 + .../tests/device_core_conformance.rs | 9 +- 18 files changed, 399 insertions(+), 39 deletions(-) create mode 100644 firmware/obc-storage/src/flat/personal_data.rs diff --git a/firmware/obc-app/i18n/de.toml b/firmware/obc-app/i18n/de.toml index d0445bb42..57a17dbc4 100644 --- a/firmware/obc-app/i18n/de.toml +++ b/firmware/obc-app/i18n/de.toml @@ -332,13 +332,15 @@ all_added_sub = "Erst eins entfernen" # --- Factory-reset settings screen --- [reset] +erasing = "Persönliche Daten löschen..." +data_failed = "Daten nicht gelöscht. Speicherkarte prüfen und erneut versuchen." retry = "Wiederholen" power_cycle = "Ausschalten und den OBC wieder einschalten." phone_failed = "Telefon nicht entfernt" title = "ZURÜCKSETZEN" factory = "Werksreset" -erases = "Löscht alle Werte" -saved_time = "& die Uhrzeit." +erases = "Löscht Einstellungen, Routen, Touren und Fahrten." +saved_time = "Karten bleiben erhalten." confirm = "Bestätigen" keep_holding = "Weiter halten" hold_to_erase = "Halten: löschen" diff --git a/firmware/obc-app/i18n/en.toml b/firmware/obc-app/i18n/en.toml index a59f8ece2..373f7bf2b 100644 --- a/firmware/obc-app/i18n/en.toml +++ b/firmware/obc-app/i18n/en.toml @@ -360,10 +360,12 @@ all_added_sub = "Remove one to swap" # --- Factory-reset settings screen --- [reset] +erasing = "Erasing personal data..." +data_failed = "Data not erased. Check the memory card and retry." title = "RESET" factory = "Factory reset" -erases = "Erases all settings" -saved_time = "& saved time." +erases = "Erases settings, routes, trips, and rides." +saved_time = "Keeps maps." confirm = "Confirm" keep_holding = "Keep holding" hold_to_erase = "Hold to erase" diff --git a/firmware/obc-app/i18n/es.toml b/firmware/obc-app/i18n/es.toml index 847024b52..8ad75affd 100644 --- a/firmware/obc-app/i18n/es.toml +++ b/firmware/obc-app/i18n/es.toml @@ -331,13 +331,15 @@ all_added_sub = "Quita uno antes" # --- Factory-reset settings screen --- [reset] +erasing = "Borrando datos personales..." +data_failed = "Datos no borrados. Comprueba la tarjeta y reintenta." retry = "Reintentar" power_cycle = "Apaga y vuelve a encender el OBC." phone_failed = "Teléfono no eliminado" title = "RESTABLECER" factory = "Restablecer" -erases = "Borra los ajustes" -saved_time = "y la hora." +erases = "Borra ajustes, rutas, viajes y recorridos." +saved_time = "Conserva los mapas." confirm = "Confirmar" keep_holding = "Sigue pulsando" hold_to_erase = "Mantén: borrar" diff --git a/firmware/obc-app/i18n/fr.toml b/firmware/obc-app/i18n/fr.toml index a2744166e..af1954eea 100644 --- a/firmware/obc-app/i18n/fr.toml +++ b/firmware/obc-app/i18n/fr.toml @@ -331,13 +331,15 @@ all_added_sub = "Retirez-en un" # --- Factory-reset settings screen --- [reset] +erasing = "Effacement des données..." +data_failed = "Données non effacées. Vérifiez la carte mémoire et réessayez." retry = "Réessayer" power_cycle = "Éteignez puis rallumez l'OBC." phone_failed = "Téléphone non supprimé" title = "RÉINITIALISER" factory = "Réinit. usine" -erases = "Efface les réglages" -saved_time = "& l'heure." +erases = "Efface réglages, itinéraires, voyages et sorties." +saved_time = "Conserve les cartes." confirm = "Confirmer" keep_holding = "Maintenez" hold_to_erase = "Tenez: effacer" diff --git a/firmware/obc-app/src/app.rs b/firmware/obc-app/src/app.rs index 4030f540e..de4c60a38 100644 --- a/firmware/obc-app/src/app.rs +++ b/firmware/obc-app/src/app.rs @@ -1905,26 +1905,46 @@ impl App { self.ui.map_dirty = true; } - /// Commit a confirmed factory reset only after the bond store acknowledges removal. The + /// Commit a confirmed factory reset after bond removal and personal-data deletion. The /// board's unconfirmed controller clearance keeps the restart instruction on screen. pub(crate) fn finish_factory_reset(&mut self) { use crate::ble::BondStatus; - if !matches!(self.state.bond_status, BondStatus::Removed | BondStatus::RestartRequired) - || !self.factory_reset_pending() - { + if !self.factory_reset_pending() { + return; + } + let bond_cleared = !self.state.bonding + || (!self.state.device.ble_paired + && !matches!(self.state.bond_status, BondStatus::Pending | BondStatus::Failed(_))) + || matches!(self.state.bond_status, BondStatus::Removed | BondStatus::RestartRequired); + if !bond_cleared { + return; + } + let mut data_cleared = false; + for screen in self.ui.stack.iter_mut() { + if let Screen::Reset(reset) = screen { + reset.request_data_clear(); + data_cleared = reset.data_cleared(); + } + } + if !data_cleared { return; } + for screen in self.ui.stack.iter_mut() { + if let Screen::Reset(reset) = screen { + reset.committed(); + } + } self.settings = Settings::FACTORY; self.wall_clock.set(self.settings.local_clock(), self.ui.now_ms); self.sync_find_preferences(); self.settings_ops.note_edited(); - if self.state.bond_status == BondStatus::Removed { + if self.state.bond_status != BondStatus::RestartRequired { screen::apply(&mut self.ui.stack, screen::setup::go_to(&self.settings)); self.ui.cancel_holds(); } } - fn factory_reset_pending(&self) -> bool { + pub(crate) fn factory_reset_pending(&self) -> bool { self.ui.stack.iter().any(|s| matches!(s, Screen::Reset(r) if r.removing())) } @@ -4622,9 +4642,8 @@ mod tests { /// cannot loop forever. const MAX_DEPTH_BACKOUT: usize = crate::screen::MAX_DEPTH; - /// An unpaired device needs no bond receipt: reset saves at once and opens setup. #[test] - fn an_unpaired_factory_reset_saves_at_once_and_opens_setup() { + fn an_unpaired_factory_reset_waits_for_data_deletion_then_opens_setup() { use crate::screen::{ResetScreen, SettingsPage}; let mut app = App::new_idle(AppState::new(0, 0, 1.0)); app.set_settings(Settings::default()); @@ -4632,6 +4651,14 @@ mod tests { let _ = app.ui.stack.push(Screen::Reset(ResetScreen::new())); app.apply_gesture(Gesture::Press); app.apply_gesture(Gesture::Hold); + app.finish_factory_reset(); + assert_eq!(*app.settings(), Settings::default()); + let Some(Screen::Reset(reset)) = app.ui.stack.last_mut() else { + panic!("reset stays visible"); + }; + assert!(reset.data_clear_requested()); + reset.data_clear_finished(true); + app.finish_factory_reset(); assert!(matches!(app.ui.stack.as_slice(), [Screen::Home(_), Screen::Hello(_)])); assert_eq!(*app.settings(), Settings::FACTORY); assert!(!app.state.ble_forget_requested, "there is no phone to forget"); diff --git a/firmware/obc-app/src/catalog_state.rs b/firmware/obc-app/src/catalog_state.rs index ad133a156..d601523e2 100644 --- a/firmware/obc-app/src/catalog_state.rs +++ b/firmware/obc-app/src/catalog_state.rs @@ -548,6 +548,9 @@ use crate::device_core::{CatalogTag, OperationToken, StoreRevision}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CatalogIntent { + ClearPersonalData { + store: crate::device_core::StoreIdentity, + }, /// Remove unused temporary directions and abandoned candidates, one commit at a time. RemoveOrphanRoutes, CleanupRoutes { @@ -579,6 +582,10 @@ pub enum CatalogObjectKind { /// One bounded physical catalog operation, carrying the [`OperationToken`] the domain issued. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CatalogEffect { + ClearPersonalData { + token: OperationToken, + store: crate::device_core::StoreIdentity, + }, RemoveOrphanRoutes { token: OperationToken, }, @@ -601,7 +608,8 @@ pub enum CatalogEffect { impl CatalogEffect { pub fn token(&self) -> OperationToken { match self { - CatalogEffect::CleanupRoute { token, .. } + CatalogEffect::ClearPersonalData { token, .. } + | CatalogEffect::CleanupRoute { token, .. } | CatalogEffect::ReadCatalog { token } | CatalogEffect::RemoveObject { token, .. } | CatalogEffect::RemoveOrphanRoutes { token } => *token, @@ -623,6 +631,10 @@ pub enum CatalogError { /// The result of one [`CatalogEffect`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CatalogOutcome { + PersonalDataCleared { + token: OperationToken, + done: bool, + }, OrphanRoutesRemoved { token: OperationToken, }, @@ -655,7 +667,8 @@ pub enum CatalogOutcome { impl CatalogOutcome { pub fn token(&self) -> OperationToken { match self { - CatalogOutcome::CleanupFinished { token } + CatalogOutcome::PersonalDataCleared { token, .. } + | CatalogOutcome::CleanupFinished { token } | CatalogOutcome::CatalogRead { token, .. } | CatalogOutcome::ObjectRemoved { token, .. } | CatalogOutcome::OrphanRoutesRemoved { token } @@ -722,6 +735,10 @@ impl CatalogState { self.cleanup_running } + pub(crate) fn reset_running(&self) -> bool { + matches!(self.pending, Some(CatalogIntent::ClearPersonalData { .. })) + } + pub(crate) fn accepts(&self, outcome: CatalogOutcome) -> bool { self.ops.is_current(outcome.token()) } @@ -752,6 +769,11 @@ impl CatalogState { return Some(CatalogEffect::ReadCatalog { token: self.ops.issue() }); }; let effect = match intent { + CatalogIntent::ClearPersonalData { store } => { + self.cleanup_running = true; + self.pending = Some(intent); + CatalogEffect::ClearPersonalData { token: self.ops.issue(), store } + } CatalogIntent::RemoveOrphanRoutes => CatalogEffect::RemoveOrphanRoutes { token: self.ops.issue() }, CatalogIntent::CleanupRoutes { before_utc, store } => { self.cleanup_running = true; @@ -821,7 +843,12 @@ impl CatalogState { } self.ops.invalidate(); // terminal: a duplicate of this answer is no longer current self.in_flight = false; - if core::mem::take(&mut self.cleanup_running) && !matches!(outcome, CatalogOutcome::ObjectRemoved { .. }) { + if core::mem::take(&mut self.cleanup_running) + && !matches!( + outcome, + CatalogOutcome::ObjectRemoved { .. } | CatalogOutcome::PersonalDataCleared { done: false, .. } + ) + { self.pending = None; } if matches!(outcome, CatalogOutcome::Failed { .. }) @@ -832,6 +859,11 @@ impl CatalogState { self.cascade = None; } match outcome { + CatalogOutcome::PersonalDataCleared { .. } => { + self.loaded_scope = None; + self.refresh_owed = true; + None + } CatalogOutcome::CleanupFinished { .. } => { self.refresh_owed = true; None @@ -1172,7 +1204,9 @@ mod tests { for _ in 0..=steps.capacity() { let Some(effect) = catalogs.next_effect() else { break }; match effect { - CatalogEffect::CleanupRoute { .. } | CatalogEffect::RemoveOrphanRoutes { .. } => { + CatalogEffect::ClearPersonalData { .. } + | CatalogEffect::CleanupRoute { .. } + | CatalogEffect::RemoveOrphanRoutes { .. } => { panic!("unexpected cleanup") } CatalogEffect::RemoveObject { token, object, .. } => { diff --git a/firmware/obc-app/src/device_core/pass.rs b/firmware/obc-app/src/device_core/pass.rs index cf3368ba7..27beab851 100644 --- a/firmware/obc-app/src/device_core/pass.rs +++ b/firmware/obc-app/src/device_core/pass.rs @@ -260,6 +260,23 @@ impl App { self.pass.record(PassStage::Outcomes); if let Some(outcome) = outcomes.catalog.take() { if self.catalogs.accepts(outcome) { + if self.catalogs.reset_running() { + use crate::catalog_state::CatalogOutcome; + let finished = match outcome { + CatalogOutcome::PersonalDataCleared { done: true, .. } => Some(true), + CatalogOutcome::Failed { .. } | CatalogOutcome::Cancelled { .. } => Some(false), + _ => None, + }; + if let Some(success) = finished { + for screen in self.ui.stack.iter_mut() { + if let crate::Screen::Reset(reset) = screen { + reset.data_clear_finished(success); + } + } + self.finish_factory_reset(); + self.ui.map_dirty = true; + } + } if self.catalogs.cleanup_running() { for screen in self.ui.stack.iter_mut() { if let crate::screen::Screen::RouteCleanup(screen) = screen { @@ -368,6 +385,11 @@ impl App { if let crate::screen::Screen::RouteCleanup(screen) = screen { screen.cancel(); } + if let crate::Screen::Reset(reset) = screen { + if reset.removing() { + reset.data_clear_finished(false); + } + } } } self.metadata.reset_store(); @@ -464,9 +486,39 @@ impl App { } fn stage_catalog(&mut self, effects: &mut EffectSlots) { self.pass.record(PassStage::Catalog); + self.finish_factory_reset(); + if self.catalogs.can_admit_intent() { + for screen in self.ui.stack.iter_mut() { + if let crate::Screen::Reset(reset) = screen { + if reset.data_clear_requested() { + if let Some(scope) = self.pass.store { + self.catalogs + .admit_intent(CatalogIntent::ClearPersonalData { store: scope.store }) + .unwrap(); + reset.data_clear_started(); + self.navigator.forget_personal_data(); + self.metadata = crate::metadata::MetadataMachine::new(); + } else { + reset.data_clear_finished(false); + } + self.ui.map_dirty = true; + } + } + } + } if self.pass.connections.ride_finalized.take().is_some() { self.catalogs.note_ride_finalized(); } + if self.catalogs.remount_required { + for screen in self.ui.stack.iter_mut() { + if let crate::Screen::Reset(reset) = screen { + if reset.data_clear_requested() { + reset.data_clear_finished(false); + self.ui.map_dirty = true; + } + } + } + } // A refused intent goes back into the slot it came from: that slot is its producer's // pending state until the catalog has room, so a busy pass costs a delay, not a delete. if let Some(intent) = self.pass.connections.ui_catalog.take() { @@ -501,6 +553,9 @@ impl App { } fn stage_metadata(&mut self, effects: &mut EffectSlots) { + if self.factory_reset_pending() { + return; + } let Some(scope) = self.catalogs.loaded_scope.filter(|_| effects.metadata.is_empty()) else { return; }; @@ -793,6 +848,7 @@ mod tests { assert!(app.ui.stack.push(Screen::Reset(ResetScreen::new())).is_ok()); let mut outcomes = OutcomeSlots::new(); let mut facts = ExternalFacts::NONE; + app.pass.store = Some(StoreRevision { store: StoreIdentity::new(1), revision: Revision::new(1) }); let mut plan = pass_with(&mut app, 10, &[Gesture::Press, Gesture::Hold], &mut outcomes, &mut facts); let removal = plan.effects.bond.take().expect("the confirmed reset removes the phone first"); assert!(plan.effects.settings.is_empty()); @@ -817,13 +873,38 @@ mod tests { outcomes.bond.try_put(BondOutcome::KeysRemoved { token: retry.token(), controller }).unwrap(); app.set_ble_status(BleStatus::DISCONNECTED); let mut plan = pass_with(&mut app, 60_030, &[], &mut outcomes, &mut facts); + assert_eq!(*app.settings(), original, "bond removal is not card deletion"); + let deletion = plan.effects.catalog.take().expect("personal card data is next"); + assert!(matches!(deletion, CatalogEffect::ClearPersonalData { .. })); + outcomes + .catalog + .try_put(CatalogOutcome::Failed { token: deletion.token(), error: CatalogError::RemoveFailed }) + .unwrap(); + let plan = pass_with(&mut app, 60_040, &[], &mut outcomes, &mut facts); + assert_eq!(*app.settings(), original, "failed card deletion preserves settings"); + assert!(plan.effects.settings.is_empty()); + assert!(plan.effects.catalog.is_empty(), "failed deletion needs explicit retry"); + let mut plan = pass_with(&mut app, 60_050, &[Gesture::Press], &mut outcomes, &mut facts); + let deletion = plan.effects.catalog.take().expect("Select retries card deletion"); + outcomes + .catalog + .try_put(CatalogOutcome::PersonalDataCleared { token: deletion.token(), done: false }) + .unwrap(); + let mut plan = pass_with(&mut app, 60_060, &[], &mut outcomes, &mut facts); + assert_eq!(*app.settings(), original, "one batch does not finish reset"); + let deletion = plan.effects.catalog.take().expect("deletion continues in bounded batches"); + outcomes + .catalog + .try_put(CatalogOutcome::PersonalDataCleared { token: deletion.token(), done: true }) + .unwrap(); + let mut plan = pass_with(&mut app, 60_070, &[], &mut outcomes, &mut facts); assert_eq!(*app.settings(), Settings::FACTORY); assert!(plan.effects.settings.take().is_some(), "the successful reset saves at once"); match controller { ControllerClearance::Confirmed => assert!(matches!(app.top_screen(), Screen::Hello(_))), ControllerClearance::Unconfirmed => { assert!(matches!(app.top_screen(), Screen::Reset(_)), "restart is required before setup"); - pass_with(&mut app, 60_040, &[Gesture::Press], &mut outcomes, &mut facts); + pass_with(&mut app, 60_080, &[Gesture::Press], &mut outcomes, &mut facts); assert!(matches!(app.top_screen(), Screen::QuickDrawer(d) if d.selection_is_guarded())); } } diff --git a/firmware/obc-app/src/navigator/review.rs b/firmware/obc-app/src/navigator/review.rs index 6429b8934..e9558b12c 100644 --- a/firmware/obc-app/src/navigator/review.rs +++ b/firmware/obc-app/src/navigator/review.rs @@ -487,6 +487,12 @@ impl NavigatorMachine { } } + pub(crate) fn forget_personal_data(&mut self) { + self.reset_detour(); + self.set_active_route(None); + self.review = ReviewState::new(); + } + /// Called only after a complete read and CRC verification on the current card. pub(crate) fn offer_checkpoint(&mut self, store: StoreIdentity, checkpoint: Option) { if self.review.recovery_seen || self.review.change.is_some() { diff --git a/firmware/obc-app/src/screen/settings/reset.rs b/firmware/obc-app/src/screen/settings/reset.rs index 94b8e210a..4a74f8912 100644 --- a/firmware/obc-app/src/screen/settings/reset.rs +++ b/firmware/obc-app/src/screen/settings/reset.rs @@ -2,7 +2,7 @@ //! safe alone, so a reset takes two steps: a press to arm, then a hold to erase. A hold on an //! un-armed screen does nothing. A paired device waits for bond removal before it clears the //! settings. Unconfirmed controller clearance asks for a restart before first-use setup. The -//! reset keeps the files on the card. +//! reset keeps maps and deletes personal card data before it clears settings. use embedded_graphics::prelude::Point; use obc_render::{ @@ -15,19 +15,30 @@ use crate::ble::BondStatus; use crate::input::Gesture; use crate::screen::vocab::chrome::{card_triangle, copy_w, title_frame, wrapped, TITLE_BAR_H}; use crate::screen::{palette, Ctx, QuickDrawerScreen, Render, Screen, Transition}; -use crate::settings::Settings; use crate::Msg; +#[derive(Debug, Default, PartialEq, Eq)] +enum DataReset { + #[default] + Waiting, + Requested, + Running, + Failed, + Done, + Committed, +} + /// `armed` is set by the first press, and only an armed screen can erase. #[derive(Debug, Default)] pub struct ResetScreen { armed: bool, removing: bool, + data: DataReset, } impl ResetScreen { pub fn new() -> Self { - ResetScreen { armed: false, removing: false } + Self::default() } /// True while the hold-to-erase bar is on screen and fills with the live hold progress. @@ -39,8 +50,41 @@ impl ResetScreen { self.removing } + pub(crate) fn request_data_clear(&mut self) { + if self.removing && self.data == DataReset::Waiting { + self.data = DataReset::Requested; + } + } + + pub(crate) fn data_clear_requested(&self) -> bool { + self.removing && self.data == DataReset::Requested + } + + pub(crate) fn data_clear_started(&mut self) { + self.data = DataReset::Running; + } + + pub(crate) fn data_clear_finished(&mut self, success: bool) { + self.data = if success { DataReset::Done } else { DataReset::Failed }; + } + + pub(crate) fn data_cleared(&self) -> bool { + self.data == DataReset::Done + } + + pub(crate) fn committed(&mut self) { + self.data = DataReset::Committed; + } + pub fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { if self.removing { + if self.data == DataReset::Failed && g == Gesture::Press { + self.data = DataReset::Requested; + return Transition::None; + } + if matches!(self.data, DataReset::Requested | DataReset::Running | DataReset::Failed) { + return Transition::None; + } return match (g, cx.state.bond_status) { (Gesture::Press, BondStatus::Failed(_)) => { cx.state.ble_forget_requested = true; @@ -60,7 +104,7 @@ impl ResetScreen { } Gesture::Hold if self.armed && cx.state.bond_status == BondStatus::RestartRequired => { self.removing = true; - *cx.settings = Settings::FACTORY; + self.data = DataReset::Requested; Transition::None } // Keep the settings until bond removal succeeds. A failed clear must not boot setup @@ -76,8 +120,9 @@ impl ResetScreen { Transition::None } Gesture::Hold if self.armed => { - *cx.settings = Settings::FACTORY; - crate::screen::setup::go_to(cx.settings) + self.removing = true; + self.data = DataReset::Requested; + Transition::None } Gesture::Back => Transition::Pop, _ => Transition::None, @@ -91,10 +136,16 @@ impl ResetScreen { card_triangle(cv, Point::new(w / 2, TITLE_BAR_H + 50), 24); if self.removing { - let (message, action) = match rx.state.bond_status { - BondStatus::Failed(_) => (Msg::ResetPhoneFailed, Some(Msg::ResetRetry)), - BondStatus::RestartRequired => (Msg::BluetoothRestart, Some(Msg::QuickPower)), - _ => (Msg::BluetoothRemoving, None), + let (message, action) = if self.data == DataReset::Failed { + (Msg::ResetDataFailed, Some(Msg::ResetRetry)) + } else if matches!(self.data, DataReset::Requested | DataReset::Running) { + (Msg::ResetErasing, None) + } else { + match rx.state.bond_status { + BondStatus::Failed(_) => (Msg::ResetPhoneFailed, Some(Msg::ResetRetry)), + BondStatus::RestartRequired => (Msg::BluetoothRestart, Some(Msg::QuickPower)), + _ => (Msg::BluetoothRemoving, None), + } }; let y = wrapped(cv, rx.t(message), w / 2, TITLE_BAR_H + 96, copy_w(w), Font::Body, INK); if rx.state.bond_status == BondStatus::RestartRequired { @@ -140,6 +191,7 @@ mod tests { use super::*; use crate::activity::Activity; use crate::screen::test_ctx; + use crate::settings::Settings; use crate::{AppState, Mode, Units}; fn run(scr: &mut ResetScreen, s: &mut Settings, g: Gesture) -> Transition { @@ -153,7 +205,7 @@ mod tests { } #[test] - fn arm_then_hold_resets_an_unpaired_device_and_starts_setup() { + fn arm_then_hold_waits_for_personal_data_clear_on_an_unpaired_device() { let mut s = Settings { units: Units::Imperial, power_saver: true, fix_interval_s: 30, ..Settings::default() }; let before = s; let mut st = AppState::new(0, 0, 1.0); @@ -166,8 +218,9 @@ mod tests { run_in(&mut scr, &mut s, &mut st, Gesture::Press); assert!(scr.armed); let t = run_in(&mut scr, &mut s, &mut st, Gesture::Hold); - assert!(matches!(t, Transition::Root(crate::Screen::Hello(_))), "setup opens at once"); - assert_eq!(s, Settings::FACTORY, "settings were cleared to factory defaults"); + assert!(matches!(t, Transition::None)); + assert_eq!(s, before, "settings wait for confirmed deletion"); + assert!(scr.data_clear_requested()); assert!(!st.ble_forget_requested, "there is no phone to forget"); } diff --git a/firmware/obc-fw-nrf54l/src/flat_store.rs b/firmware/obc-fw-nrf54l/src/flat_store.rs index 32db0f8a4..f339cced6 100644 --- a/firmware/obc-fw-nrf54l/src/flat_store.rs +++ b/firmware/obc-fw-nrf54l/src/flat_store.rs @@ -381,6 +381,9 @@ pub(crate) const REQUEST_QUEUE_BYTES: usize = /// up atomicity across a batch — are both worse than the roughly 1 KB this costs. #[allow(dead_code, clippy::large_enum_variant)] pub(crate) enum Request { + ClearPersonalData { + store: obc_app::device_core::StoreIdentity, + }, WriteCheckpoint { scope: obc_app::device_core::StoreRevision, change: obc_app::navigator::CheckpointChange, @@ -533,6 +536,9 @@ pub(crate) enum Request { /// What one [`Request`] produced. #[allow(dead_code)] pub(crate) enum Outcome { + PersonalDataCleared { + done: bool, + }, CleanedRoute(Option), Metadata(Result<(), obc_app::metadata::MetadataError>), Allocated(Allocation), @@ -1083,6 +1089,17 @@ fn serve( check_route_change(store, id)?; store.commit(&[Mutation::Remove { id, revision }]).map(|_| Outcome::Done) } + Request::ClearPersonalData { store: identity } => { + if catalog_scope(store).store != identity { + return Err(StoreError::Invalid); + } + let batch = obc_storage::flat::personal_data::next(store)?; + let done = batch.is_empty(); + if !done { + store.commit(&batch)?; + } + Ok(Outcome::PersonalDataCleared { done }) + } Request::CleanupRoute { before_utc, store: identity, active } => { if catalog_scope(store).store != identity { return Err(StoreError::Invalid); diff --git a/firmware/obc-fw-nrf54l/src/ride.rs b/firmware/obc-fw-nrf54l/src/ride.rs index 5d06b374c..c0b856018 100644 --- a/firmware/obc-fw-nrf54l/src/ride.rs +++ b/firmware/obc-fw-nrf54l/src/ride.rs @@ -1112,6 +1112,19 @@ pub(crate) async fn run_app( if let Some(effect) = exec.effects.catalog.take() { use obc_app::catalog_state::{CatalogEffect, CatalogError, CatalogOutcome}; match effect { + CatalogEffect::ClearPersonalData { token, store } => { + crate::flat_store::reconcile_route(flat, None); + match crate::flat_store::writer().ok_or(()).and_then(|w| { + w.try_call(crate::flat_store::Request::ClearPersonalData { store }, &CATALOG_STORE_REPLY) + }) { + Ok(ticket) => exec.catalog = Some(CatalogRemoval { ticket, token, object: 0 }), + Err(()) => RideExec::deliver( + &mut exec.outcomes.catalog, + CatalogOutcome::Failed { token, error: CatalogError::RemoveFailed }, + "catalog", + ), + } + } CatalogEffect::RemoveOrphanRoutes { token } => { let heads = crate::flat_store::route_heads(flat, app.orphan_routes()); let result = if heads.is_empty() { @@ -1216,6 +1229,13 @@ pub(crate) async fn run_app( crate::flat_store::writer().and_then(|w| w.try_result(removal.ticket, &CATALOG_STORE_REPLY)); match answer { None => exec.catalog = Some(removal), + Some(Ok(crate::flat_store::Outcome::PersonalDataCleared { done })) => { + RideExec::deliver( + &mut exec.outcomes.catalog, + CatalogOutcome::PersonalDataCleared { token: removal.token, done }, + "catalog", + ); + } Some(Ok(crate::flat_store::Outcome::CleanedRoute(object))) => { let outcome = match object { Some(object) => { diff --git a/firmware/obc-storage/src/flat/mod.rs b/firmware/obc-storage/src/flat/mod.rs index c8397422d..5405dc00b 100644 --- a/firmware/obc-storage/src/flat/mod.rs +++ b/firmware/obc-storage/src/flat/mod.rs @@ -8,6 +8,7 @@ pub mod error; pub(crate) mod journal; pub(crate) mod layout; pub mod metadata; +pub mod personal_data; pub(crate) mod raw; pub mod seam; pub mod source; diff --git a/firmware/obc-storage/src/flat/personal_data.rs b/firmware/obc-storage/src/flat/personal_data.rs new file mode 100644 index 000000000..399579aa4 --- /dev/null +++ b/firmware/obc-storage/src/flat/personal_data.rs @@ -0,0 +1,87 @@ +//! Bounded factory-reset deletion. Maps and firmware packages are system data. +use super::{store::MAX_BATCH, BlockDevice, FlatStore, Mutation, ObjectKind, Store, StoreError}; + +/// Remove metadata first so no checkpoint or archive proof outlives its personal objects. +pub fn next(store: &FlatStore) -> Result, StoreError> { + let mut batch = heapless::Vec::new(); + for metadata in [true, false] { + for entry in store.entries().filter(|entry| { + if metadata { + entry.kind == ObjectKind::Metadata + } else { + matches!(entry.kind, ObjectKind::Route | ObjectKind::Trip | ObjectKind::Ride) + } + }) { + batch.push(Mutation::Remove { id: entry.id, revision: entry.revision }).map_err(|_| StoreError::Invalid)?; + if batch.is_full() { + break; + } + } + if !store.entries_ok() { + return Err(StoreError::Media); + } + if !batch.is_empty() { + break; + } + } + Ok(batch) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::flat::{sim::SparseDisk, EntryFlags, EntryMeta, PutSource, Revision, Store, StoreId}; + + fn put(store: &FlatStore<&SparseDisk>, kind: ObjectKind, bytes: &[u8]) -> EntryMeta { + let mut allocation = store.allocate(bytes.len() as u64).unwrap(); + store.write(&mut allocation, bytes).unwrap(); + let meta = EntryMeta { + id: store.next_object_id(), + revision: Revision(1), + kind, + flags: EntryFlags::NONE, + payload_len: bytes.len() as u64, + payload_crc: obc_crc::crc32(bytes), + name: Default::default(), + added_at_utc: 0, + }; + store.commit(&[Mutation::Put { meta, source: PutSource::Fresh(allocation) }]).unwrap(); + meta + } + + #[test] + fn reset_removes_metadata_first_and_all_personal_objects_across_reboots() { + let disk = SparseDisk::blank(2_000_000, 5); + let identity = StoreId([7; 16]); + let mut store = FlatStore::initialize(&disk, identity).unwrap(); + let system = + [ObjectKind::MapShard, ObjectKind::MapSetManifest, ObjectKind::UpdatePackage, ObjectKind::RollbackReserve] + .map(|kind| put(&store, kind, b"system data")); + for i in 0..75 { + put(&store, [ObjectKind::Route, ObjectKind::Trip, ObjectKind::Ride][i % 3], b"personal data"); + } + let metadata = put(&store, ObjectKind::Metadata, b"personal metadata"); + let first = next(&store).unwrap(); + assert_eq!(first.len(), 1); + assert!(matches!(first[0], Mutation::Remove { id, .. } if id == metadata.id)); + store.commit(&first).unwrap(); + loop { + store = FlatStore::mount(&disk); + let batch = next(&store).unwrap(); + if batch.is_empty() { + break; + } + assert!(batch.len() <= MAX_BATCH); + store.commit(&batch).unwrap(); + } + assert_eq!(store.store_id(), identity); + assert_eq!(store.entries().collect::>(), system); + for meta in system { + let handle = store.open(meta.id, Some(meta.revision)).unwrap(); + let mut bytes = [0; 11]; + assert_eq!(store.read(&handle, 0, &mut bytes).unwrap(), bytes.len()); + assert_eq!(&bytes, b"system data"); + store.close(handle); + } + } +} diff --git a/firmware/ui-snapshots.sha256 b/firmware/ui-snapshots.sha256 index e47f11524..2dd6ab9ee 100644 --- a/firmware/ui-snapshots.sha256 +++ b/firmware/ui-snapshots.sha256 @@ -227,9 +227,9 @@ ca550709d320135ff7f1cf030674ad5e08afd2c789dd06aef6c992b2b7cc778b quick-power-ho 3ad379a04dd18384defbba00d2d36053cea73cbff7f4d4c4fc81cb9d96445034 quick-root-fr.png 326671ddbb2b6505e8235eb7bd5525166bbaf51b729189d3e51f041065b5aa6e quick-root-no-backlight.png 39e4c27dd6ce6a963cf7f9134b9020c1d1efc61e61a64ae47f70b02fc2b405ce quick-root.png -88dc4b0a16ecdc8cb8c67fc414f13b8c37a3a8985466be44574af99e9c39aa5a reset-confirm-de.png -083471cd7f91717c0ab8c73c799ef0d798dd76b511c090b8e8053438351ce43b reset-confirm-es.png -ffb316c1bf0f8c11f533342b9b451ff40db1a4a4ccb76b6cc8de84cb017550b9 reset-confirm-fr.png +f36e8fa15795f0505e8847a03b253e1992d9036e4202f500998fd9697738b8ac reset-confirm-de.png +e63ff30033c113641fe5edef983c859169194e2eed6aa2b28c9ee0dcde8928ec reset-confirm-es.png +056181875ec762edc4ac9cad9b8f33c484976dc7547edec0db051d7c7077f02a reset-confirm-fr.png 49da9fb814667eee32b85a7fee6c00fb8aec8a2a451a6334f54a828bd25a839b reset-de.png 72a4212e578aba76210f7a02c8ae2ab7aa926700e574b44bf30b70e8527719ed reset-es.png fca2f95f0ac16aa6b95c261e522ef225f8deabbb21de39df77158ce14264395b reset-fr.png diff --git a/host/obc-host-core/src/dispatch.rs b/host/obc-host-core/src/dispatch.rs index a712a38b5..d00f59720 100644 --- a/host/obc-host-core/src/dispatch.rs +++ b/host/obc-host-core/src/dispatch.rs @@ -611,6 +611,10 @@ impl HostLoop { trips: &mut dyn TripCatalog, ) -> CatalogOutcome { match effect { + CatalogEffect::ClearPersonalData { token, store } => match routes.clear_personal_data(store) { + Ok(done) => CatalogOutcome::PersonalDataCleared { token, done }, + Err(error) => CatalogOutcome::Failed { token, error }, + }, CatalogEffect::RemoveOrphanRoutes { token } => { let orphans: Vec<_> = app.orphan_routes().collect(); match routes.retract_generated_routes(&orphans) { diff --git a/host/obc-host-core/src/flat_routes.rs b/host/obc-host-core/src/flat_routes.rs index 965f109f5..d5439c002 100644 --- a/host/obc-host-core/src/flat_routes.rs +++ b/host/obc-host-core/src/flat_routes.rs @@ -125,6 +125,19 @@ impl FlatRouteStore { } impl RouteRepository for FlatRouteStore { + fn clear_personal_data(&mut self, identity: obc_app::device_core::StoreIdentity) -> Result { + let mut owner = self.owner.0.lock().map_err(|_| CatalogError::Unreadable)?; + let store = owner.ready().map_err(|_| CatalogError::RemountRequired)?; + if scope(store).store != identity { + return Err(CatalogError::Stale); + } + let batch = obc_storage::flat::personal_data::next(store).map_err(|_| CatalogError::Unreadable)?; + if batch.is_empty() { + return Ok(true); + } + owner.commit(&batch).map_err(|_| CatalogError::RemoveFailed)?; + Ok(false) + } fn set_route_clock(&mut self, utc: Option) { if let Ok(owner) = self.owner.0.lock() { if let Ok(store) = owner.ready() { diff --git a/host/obc-host-core/src/repo.rs b/host/obc-host-core/src/repo.rs index 6917a8580..f6525c88d 100644 --- a/host/obc-host-core/src/repo.rs +++ b/host/obc-host-core/src/repo.rs @@ -62,6 +62,10 @@ impl ByteSource for RouteLease { /// Route projections, retained readers and physical writes used by the shared host executor. pub trait RouteRepository { + /// Delete one batch of personal objects from the shared card; true means it is empty. + fn clear_personal_data(&mut self, _store: obc_app::device_core::StoreIdentity) -> Result { + Err(CatalogError::Unsupported) + } fn set_route_clock(&mut self, _utc: Option) {} fn cleanup_route( &mut self, diff --git a/host/obc-host-core/tests/device_core_conformance.rs b/host/obc-host-core/tests/device_core_conformance.rs index 507bbc720..735295784 100644 --- a/host/obc-host-core/tests/device_core_conformance.rs +++ b/host/obc-host-core/tests/device_core_conformance.rs @@ -392,11 +392,14 @@ impl CoreHarness { let kind = match effect { CatalogEffect::RemoveObject { kind, .. } => Some(kind), CatalogEffect::ReadCatalog { .. } + | CatalogEffect::ClearPersonalData { .. } | CatalogEffect::CleanupRoute { .. } | CatalogEffect::RemoveOrphanRoutes { .. } => None, }; match effect { - CatalogEffect::CleanupRoute { .. } | CatalogEffect::RemoveOrphanRoutes { .. } => { + CatalogEffect::ClearPersonalData { .. } + | CatalogEffect::CleanupRoute { .. } + | CatalogEffect::RemoveOrphanRoutes { .. } => { panic!("unexpected cleanup") } CatalogEffect::ReadCatalog { token } => { @@ -890,7 +893,9 @@ impl CoreHarness { for _ in 0..4 { let effect = self.next_catalog_effect(); match effect { - CatalogEffect::CleanupRoute { .. } | CatalogEffect::RemoveOrphanRoutes { .. } => { + CatalogEffect::ClearPersonalData { .. } + | CatalogEffect::CleanupRoute { .. } + | CatalogEffect::RemoveOrphanRoutes { .. } => { panic!("unexpected cleanup") } CatalogEffect::RemoveObject { .. } => return effect, From 666f8ab6d043eb0b262e58e0ddeb311fc25c628c Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 30 Sep 2026 10:08:02 +0200 Subject: [PATCH 16/17] fix(firmware): keep factory demo provisioning alive after warm reset --- firmware/obc-fw-nrf54l/src/main.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/firmware/obc-fw-nrf54l/src/main.rs b/firmware/obc-fw-nrf54l/src/main.rs index adc281c6f..7d7567dc6 100644 --- a/firmware/obc-fw-nrf54l/src/main.rs +++ b/firmware/obc-fw-nrf54l/src/main.rs @@ -871,7 +871,14 @@ async fn main(_spawner: Spawner) { Err(error) => defmt::error!("factory demo ride: refused: {:?}", defmt::Debug2Format(&error)), } if cfg!(feature = "factory-demo-ride") { - idle_blink(&mut led).await + let mut watchdog = board::watchdog!(p.WDT0, ride::WDT_TIMEOUT_TICKS).ok().map(|(_, [handle])| handle); + loop { + if let Some(handle) = &mut watchdog { + handle.pet(); + } + led.toggle(); + Timer::after_millis(500).await; + } } } let flat_catalog = flat_store::report(flat, flat_started.elapsed().as_micros()); @@ -1019,6 +1026,7 @@ async fn main(_spawner: Spawner) { // between app entry and this line must complete well inside one WDT period (24 s). If it // does not, the dog resets a healthy trial image and the bootloader rolls it back. Never move // a blocking or open-ended retry loop above this point. + #[cfg(not(feature = "factory-demo-ride"))] let wdt_handle = match board::watchdog!(p.WDT0, ride::WDT_TIMEOUT_TICKS) { Ok((_wdt, [handle])) => Some(handle), Err(_) => { @@ -1026,6 +1034,8 @@ async fn main(_spawner: Spawner) { None } }; + #[cfg(feature = "factory-demo-ride")] + let wdt_handle: Option = None; // The settings store moves behind one async mutex, so the ride loop and both link planes can // lock it per operation. The flat store owns the card through its separate command seam. From 7178c2fcfa500500ba3316892ff8a60ef9c37a58 Mon Sep 17 00:00:00 2001 From: timohueser Date: Wed, 30 Sep 2026 10:08:07 +0200 Subject: [PATCH 17/17] docs: define reset deletion scope and demo archive fields --- docs/content/software/companion-link.md | 3 +++ specs/Ride_Archive_Contract.md | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/content/software/companion-link.md b/docs/content/software/companion-link.md index 58165ddf1..609950df6 100644 --- a/docs/content/software/companion-link.md +++ b/docs/content/software/companion-link.md @@ -314,6 +314,9 @@ then removes the host keys, because the order decides what a failure leaves behi cleanup has no receipt, so the screen asks for a restart to finish. A disconnect proves nothing about stored keys. A factory reset runs the same removal, so the setup that follows can pair a phone again. +It also deletes personal card data. Maps stay installed, so setup does not require another map +download. Reset waits for card deletion to finish. A card failure keeps the reset screen open for +Retry. Device information, the battery level, and the protocol version are readable before pairing. Everything else needs an authenticated, encrypted link. The clock is set by the phone or by a GPS diff --git a/specs/Ride_Archive_Contract.md b/specs/Ride_Archive_Contract.md index 446b99769..ab42fb7fc 100644 --- a/specs/Ride_Archive_Contract.md +++ b/specs/Ride_Archive_Contract.md @@ -35,11 +35,12 @@ summary, all canonical samples, source record and local downloaded flag as one g Samples preserve timestamps, coordinates, optional elevation and sensor values, and segment starts. Wire bytes are not the library schema. -Each `rides//summary.json` is a version 3 manifest. It contains the canonical summary, +Each `rides//summary.json` is a version 4 manifest. It contains the canonical summary, its source, the downloaded flag, and the name, length and CRC of one immutable -`points-.json` file. That points file contains version 3 canonical point records. The manifest +`points-.json` file. That points file contains version 4 canonical point records. The manifest is the only publication point. A list reads manifests without reading track samples. A local summary edit keeps the source and points reference; it does not rewrite samples. +The summary's optional `isDemo` flag is false when absent. It does not change archive proof. The production persistence anchor is the existing `Library` directory inside the app container. The archive can create `Application Support/OBCLibrary` and its descendants below that anchor.