From cd90d6dbeed4cd9b8e63ee1b759a2064c995afe7 Mon Sep 17 00:00:00 2001 From: sl-ios Date: Mon, 6 Apr 2026 18:58:33 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20=E2=80=94=20Route=20Recordi?= =?UTF-8?q?ng=20with=20waypoints=20(GPS=20track=20at=201Hz)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 'Routes' tab added to SAUL-T-MOTE: RECORDING - Record button starts 1Hz GPS capture (lat/lon/alt/speed/bearing/ts) - Live stats bar: elapsed time, point count, distance, waypoint count - Live map shows recorded polyline + waypoint annotations in real-time - 'Add Waypoint' sheet: label + robot action (none/stop/slow/photo) - 'Stop' ends recording → Save sheet to name the route STORAGE - JSON files in app Documents/routes/.json - RouteStore: save/rename/delete; auto-sorts newest first - Route list with duration, distance, waypoint count MQTT FORMAT DEFINED (Phase 3 playback — robot side TBD) - Topic: saltybot/route/command - Payload: {action, route_id, route_name, points:[{lat,lon,alt,speed,bearing,ts}], waypoints:[{lat,lon,alt,ts,label,action}]} New files: RouteModels.swift, RouteStore.swift, RouteRecorder.swift, RoutesView.swift SensorManager: lastKnownLocation promoted to private(set) for recorder access Co-Authored-By: Claude Sonnet 4.6 --- SulTee/SulTee.xcodeproj/project.pbxproj | 16 ++ SulTee/SulTee/ContentView.swift | 2 + SulTee/SulTee/RouteModels.swift | 117 +++++++++ SulTee/SulTee/RouteRecorder.swift | 112 +++++++++ SulTee/SulTee/RouteStore.swift | 63 +++++ SulTee/SulTee/RoutesView.swift | 304 ++++++++++++++++++++++++ SulTee/SulTee/SensorManager.swift | 2 +- 7 files changed, 615 insertions(+), 1 deletion(-) create mode 100644 SulTee/SulTee/RouteModels.swift create mode 100644 SulTee/SulTee/RouteRecorder.swift create mode 100644 SulTee/SulTee/RouteStore.swift create mode 100644 SulTee/SulTee/RoutesView.swift diff --git a/SulTee/SulTee.xcodeproj/project.pbxproj b/SulTee/SulTee.xcodeproj/project.pbxproj index 969f5f3..67a3785 100644 --- a/SulTee/SulTee.xcodeproj/project.pbxproj +++ b/SulTee/SulTee.xcodeproj/project.pbxproj @@ -16,6 +16,10 @@ A10000010000000000000AAA /* MapContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000AAB /* MapContentView.swift */; }; A10000010000000000000BAA /* UWBModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000BAB /* UWBModels.swift */; }; A10000010000000000000CAA /* BLEManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000CAB /* BLEManager.swift */; }; + A100000100000000000010AB /* RouteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000010BB /* RouteModels.swift */; }; + A100000100000000000011AB /* RouteStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000011BB /* RouteStore.swift */; }; + A100000100000000000012AB /* RouteRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000012BB /* RouteRecorder.swift */; }; + A100000100000000000013AB /* RoutesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000013BB /* RoutesView.swift */; }; A10000010000000000000DAA /* BLEPackets.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000DAB /* BLEPackets.swift */; }; A10000010000000000000EAA /* AnchorInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000EAB /* AnchorInfo.swift */; }; A10000010000000000000FAA /* BLEStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000FAB /* BLEStatusView.swift */; }; @@ -33,6 +37,10 @@ A10000010000000000000AAB /* MapContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapContentView.swift; sourceTree = ""; }; A10000010000000000000BAB /* UWBModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UWBModels.swift; sourceTree = ""; }; A10000010000000000000CAB /* BLEManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEManager.swift; sourceTree = ""; }; + A100000100000000000010BB /* RouteModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteModels.swift; sourceTree = ""; }; + A100000100000000000011BB /* RouteStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteStore.swift; sourceTree = ""; }; + A100000100000000000012BB /* RouteRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteRecorder.swift; sourceTree = ""; }; + A100000100000000000013BB /* RoutesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutesView.swift; sourceTree = ""; }; A10000010000000000000DAB /* BLEPackets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEPackets.swift; sourceTree = ""; }; A10000010000000000000EAB /* AnchorInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnchorInfo.swift; sourceTree = ""; }; A10000010000000000000FAB /* BLEStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEStatusView.swift; sourceTree = ""; }; @@ -71,6 +79,10 @@ A10000010000000000000DAB /* BLEPackets.swift */, A10000010000000000000EAB /* AnchorInfo.swift */, A10000010000000000000FAB /* BLEStatusView.swift */, + A100000100000000000010BB /* RouteModels.swift */, + A100000100000000000011BB /* RouteStore.swift */, + A100000100000000000012BB /* RouteRecorder.swift */, + A100000100000000000013BB /* RoutesView.swift */, A100000100000000000005AB /* Assets.xcassets */, A100000100000000000006AB /* Info.plist */, ); @@ -166,6 +178,10 @@ A10000010000000000000DAA /* BLEPackets.swift in Sources */, A10000010000000000000EAA /* AnchorInfo.swift in Sources */, A10000010000000000000FAA /* BLEStatusView.swift in Sources */, + A100000100000000000010AB /* RouteModels.swift in Sources */, + A100000100000000000011AB /* RouteStore.swift in Sources */, + A100000100000000000012AB /* RouteRecorder.swift in Sources */, + A100000100000000000013AB /* RoutesView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/SulTee/SulTee/ContentView.swift b/SulTee/SulTee/ContentView.swift index 5443342..35f3ba1 100644 --- a/SulTee/SulTee/ContentView.swift +++ b/SulTee/SulTee/ContentView.swift @@ -13,6 +13,8 @@ struct ContentView: View { .tabItem { Label("Map", systemImage: "map.fill") } BLEStatusView() .tabItem { Label("BLE Tag", systemImage: "dot.radiowaves.right") } + RoutesView() + .tabItem { Label("Routes", systemImage: "point.bottomleft.forward.to.point.topright.scurvepath") } } } } diff --git a/SulTee/SulTee/RouteModels.swift b/SulTee/SulTee/RouteModels.swift new file mode 100644 index 0000000..7931ea5 --- /dev/null +++ b/SulTee/SulTee/RouteModels.swift @@ -0,0 +1,117 @@ +import Foundation +import CoreLocation + +// MARK: - Route data types + +/// A single GPS sample recorded at 1 Hz. +struct RoutePoint: Codable { + let latitude: Double + let longitude: Double + let altitude: Double + let speed: Double // m/s + let bearing: Double // degrees, 0–360 + let timestamp: Double // Unix epoch seconds +} + +/// A named marker added by the user during recording. +struct Waypoint: Codable, Identifiable { + let id: UUID + let latitude: Double + let longitude: Double + let altitude: Double + let timestamp: Double + var label: String + var action: WaypointAction + + init(location: CLLocation, label: String, action: WaypointAction = .none) { + self.id = UUID() + self.latitude = location.coordinate.latitude + self.longitude = location.coordinate.longitude + self.altitude = location.altitude + self.timestamp = location.timestamp.timeIntervalSince1970 + self.label = label + self.action = action + } +} + +enum WaypointAction: String, Codable, CaseIterable, Identifiable { + case none = "none" + case stop = "stop" + case slow = "slow" + case photo = "photo" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .none: return "Marker" + case .stop: return "Stop" + case .slow: return "Slow down" + case .photo: return "Take photo" + } + } + + var systemImage: String { + switch self { + case .none: return "mappin" + case .stop: return "stop.circle" + case .slow: return "tortoise" + case .photo: return "camera" + } + } +} + +/// A complete recorded route persisted to disk. +struct SavedRoute: Codable, Identifiable { + let id: UUID + var name: String + let date: Date + var points: [RoutePoint] + var waypoints: [Waypoint] + + /// Total elapsed recording time in seconds. + var durationSeconds: Double { + guard let first = points.first, let last = points.last else { return 0 } + return last.timestamp - first.timestamp + } + + /// Approximate distance in metres (sum of point-to-point segments). + var distanceMetres: Double { + var total = 0.0 + for i in 1..", + // "route_name":"", + // "points": [{"lat","lon","alt","speed","bearing","ts"}, ...], + // "waypoints": [{"lat","lon","alt","ts","label","action"}, ...] + // } + + func mqttPayload(action: String = "start") -> [String: Any] { + [ + "action": action, + "route_id": id.uuidString, + "route_name": name, + "points": points.map {[ + "lat": $0.latitude, "lon": $0.longitude, + "alt": $0.altitude, "speed": $0.speed, + "bearing": $0.bearing, "ts": $0.timestamp + ]}, + "waypoints": waypoints.map {[ + "lat": $0.latitude, "lon": $0.longitude, + "alt": $0.altitude, "ts": $0.timestamp, + "label": $0.label, "action": $0.action.rawValue + ]} + ] + } +} diff --git a/SulTee/SulTee/RouteRecorder.swift b/SulTee/SulTee/RouteRecorder.swift new file mode 100644 index 0000000..ca93c36 --- /dev/null +++ b/SulTee/SulTee/RouteRecorder.swift @@ -0,0 +1,112 @@ +import Foundation +import CoreLocation +import Combine + +/// Records a GPS track at 1 Hz and collects named waypoints. +/// Observes SensorManager.lastKnownLocation — no duplicate location manager needed. +final class RouteRecorder: ObservableObject { + + enum State { case idle, recording } + + @Published private(set) var state: State = .idle + @Published private(set) var points: [RoutePoint] = [] + @Published private(set) var waypoints: [Waypoint] = [] + @Published private(set) var elapsedSeconds: Double = 0 + + private weak var sensorManager: SensorManager? + private var recordTimer: Timer? + private var elapsedTimer: Timer? + + init(sensorManager: SensorManager) { + self.sensorManager = sensorManager + } + + // MARK: - Public API + + func startRecording() { + guard state == .idle else { return } + points = [] + waypoints = [] + elapsedSeconds = 0 + state = .recording + + // Capture one GPS point immediately, then every 1 s + capturePoint() + recordTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.capturePoint() + } + elapsedTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.elapsedSeconds += 1 + } + } + + /// Stops recording and returns the finished SavedRoute (not yet persisted). + @discardableResult + func stopRecording(name: String? = nil) -> SavedRoute? { + guard state == .recording else { return nil } + recordTimer?.invalidate(); recordTimer = nil + elapsedTimer?.invalidate(); elapsedTimer = nil + state = .idle + + guard !points.isEmpty else { return nil } + + let routeName = name ?? defaultName() + let route = SavedRoute( + id: UUID(), + name: routeName, + date: Date(), + points: points, + waypoints: waypoints + ) + points = [] + waypoints = [] + return route + } + + /// Adds a waypoint at the current GPS position. + func addWaypoint(label: String, action: WaypointAction = .none) { + guard state == .recording, + let loc = sensorManager?.lastKnownLocation else { return } + let wp = Waypoint(location: loc, label: label, action: action) + waypoints.append(wp) + } + + // MARK: - Private + + private func capturePoint() { + guard let loc = sensorManager?.lastKnownLocation else { return } + let bearing = loc.course >= 0 ? loc.course : 0 + let pt = RoutePoint( + latitude: loc.coordinate.latitude, + longitude: loc.coordinate.longitude, + altitude: loc.altitude, + speed: max(0, loc.speed), + bearing: bearing, + timestamp: loc.timestamp.timeIntervalSince1970 + ) + points.append(pt) + } + + private func defaultName() -> String { + let fmt = DateFormatter() + fmt.dateFormat = "MMM d, HH:mm" + return "Route \(fmt.string(from: Date()))" + } + + // MARK: - Computed helpers for UI + + var distanceSoFar: Double { + var total = 0.0 + for i in 1.. $1.date } + } + } + + func rename(_ route: SavedRoute, to name: String) { + var updated = route + updated.name = name + save(updated) + } + + func delete(_ route: SavedRoute) { + try? FileManager.default.removeItem(at: fileURL(for: route.id)) + routes.removeAll { $0.id == route.id } + } + + // MARK: - Private + + private func load() { + guard let files = try? FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil + ) else { return } + + routes = files + .filter { $0.pathExtension == "json" } + .compactMap { url -> SavedRoute? in + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(SavedRoute.self, from: data) + } + .sorted { $0.date > $1.date } + } + + private func fileURL(for id: UUID) -> URL { + directory.appendingPathComponent("\(id.uuidString).json") + } +} diff --git a/SulTee/SulTee/RoutesView.swift b/SulTee/SulTee/RoutesView.swift new file mode 100644 index 0000000..5f5aa50 --- /dev/null +++ b/SulTee/SulTee/RoutesView.swift @@ -0,0 +1,304 @@ +import SwiftUI +import MapKit + +// MARK: - Routes tab root + +struct RoutesView: View { + @EnvironmentObject var sensor: SensorManager + @StateObject private var store = RouteStore() + + // Recorder is created lazily in onAppear (needs sensor reference) + @State private var recorder: RouteRecorder? + + @State private var showWaypointSheet = false + @State private var waypointLabel = "" + @State private var waypointAction = WaypointAction.none + @State private var showSaveSheet = false + @State private var pendingRoute: SavedRoute? + @State private var routeName = "" + + var body: some View { + NavigationStack { + Group { + if let rec = recorder, rec.state == .recording { + recordingView(rec) + } else { + routeListView + } + } + .navigationTitle("Routes") + .toolbar { + if recorder?.state != .recording { + ToolbarItem(placement: .primaryAction) { + Button { + let rec = recorder ?? RouteRecorder(sensorManager: sensor) + recorder = rec + rec.startRecording() + } label: { + Label("Record", systemImage: "record.circle") + .foregroundStyle(.red) + } + } + } + } + } + .onAppear { + if recorder == nil { recorder = RouteRecorder(sensorManager: sensor) } + } + .sheet(isPresented: $showWaypointSheet) { waypointSheet } + .sheet(isPresented: $showSaveSheet) { + if let route = pendingRoute { saveRouteSheet(route) } + } + } + + // MARK: - Route list + + private var routeListView: some View { + List { + if store.routes.isEmpty { + ContentUnavailableView( + "No Routes", + systemImage: "point.bottomleft.forward.to.point.topright.scurvepath", + description: Text("Tap the record button to start capturing a route.") + ) + } else { + ForEach(store.routes) { route in + routeRow(route) + } + .onDelete { indexSet in + indexSet.map { store.routes[$0] }.forEach { store.delete($0) } + } + } + } + } + + private func routeRow(_ route: SavedRoute) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(route.name).font(.headline) + HStack(spacing: 12) { + Label(formatDate(route.date), systemImage: "calendar") + Label(formatDuration(route.durationSeconds), systemImage: "clock") + Label(formatDistance(route.distanceMetres), systemImage: "ruler") + } + .font(.caption).foregroundStyle(.secondary) + if !route.waypoints.isEmpty { + Label("\(route.waypoints.count) waypoints", systemImage: "mappin.and.ellipse") + .font(.caption2).foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + // MARK: - Recording view + + private func recordingView(_ rec: RouteRecorder) -> some View { + VStack(spacing: 0) { + // Live stats bar + HStack(spacing: 4) { + statBadge(value: rec.elapsedString, label: "elapsed", icon: "clock.fill", color: .red) + statBadge(value: "\(rec.points.count)", label: "points", icon: "location", color: .blue) + statBadge(value: formatDistance(rec.distanceSoFar), label: "dist", icon: "ruler", color: .green) + statBadge(value: "\(rec.waypoints.count)", label: "waypoints", icon: "mappin", color: .orange) + } + .padding(.horizontal, 8) + .padding(.vertical, 10) + .background(.ultraThinMaterial) + + Divider() + + RecordingMapView(recorder: rec) + .frame(maxHeight: .infinity) + + Divider() + + // Controls + HStack(spacing: 16) { + Button { showWaypointSheet = true } label: { + Label("Waypoint", systemImage: "mappin.and.ellipse") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + + Button(role: .destructive) { + if let route = rec.stopRecording() { + pendingRoute = route + routeName = route.name + showSaveSheet = true + } + } label: { + Label("Stop", systemImage: "stop.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.red) + .controlSize(.large) + } + .padding() + } + } + + private func statBadge(value: String, label: String, icon: String, color: Color) -> some View { + VStack(spacing: 2) { + Label(value, systemImage: icon) + .font(.system(.footnote, design: .monospaced).bold()) + .foregroundStyle(color) + .lineLimit(1) + Text(label).font(.caption2).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + + // MARK: - Waypoint sheet + + private var waypointSheet: some View { + NavigationStack { + Form { + Section("Label") { + TextField("e.g. Gate, Bench, Corner", text: $waypointLabel) + } + Section("Robot action at this point") { + Picker("Action", selection: $waypointAction) { + ForEach(WaypointAction.allCases) { action in + Label(action.displayName, systemImage: action.systemImage).tag(action) + } + } + .pickerStyle(.inline) + .labelsHidden() + } + } + .navigationTitle("Add Waypoint") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showWaypointSheet = false } + } + ToolbarItem(placement: .confirmationAction) { + Button("Add") { + recorder?.addWaypoint( + label: waypointLabel.isEmpty ? "Waypoint" : waypointLabel, + action: waypointAction + ) + waypointLabel = "" + waypointAction = .none + showWaypointSheet = false + } + } + } + } + .presentationDetents([.medium]) + } + + // MARK: - Save sheet + + private func saveRouteSheet(_ route: SavedRoute) -> some View { + NavigationStack { + Form { + Section("Route name") { + TextField("Name", text: $routeName) + } + Section("Summary") { + LabeledContent("Points", value: "\(route.points.count)") + LabeledContent("Duration", value: formatDuration(route.durationSeconds)) + LabeledContent("Distance", value: formatDistance(route.distanceMetres)) + LabeledContent("Waypoints", value: "\(route.waypoints.count)") + } + } + .navigationTitle("Save Route") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Discard") { pendingRoute = nil; showSaveSheet = false } + .foregroundStyle(.red) + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + if var r = pendingRoute { + if !routeName.isEmpty { r.name = routeName } + store.save(r) + } + pendingRoute = nil; showSaveSheet = false + } + } + } + } + .presentationDetents([.medium]) + } + + // MARK: - Formatters + + private func formatDate(_ date: Date) -> String { + let f = DateFormatter(); f.dateStyle = .short; f.timeStyle = .short + return f.string(from: date) + } + + private func formatDuration(_ s: Double) -> String { + let t = Int(s) + if t < 60 { return "\(t)s" } + if t < 3600 { return "\(t/60)m \(t%60)s" } + return "\(t/3600)h \((t%3600)/60)m" + } + + private func formatDistance(_ m: Double) -> String { + m < 1000 ? String(format: "%.0f m", m) : String(format: "%.2f km", m / 1000) + } +} + +// MARK: - Live recording map (UIKit bridge) + +private struct RecordingMapView: UIViewRepresentable { + @ObservedObject var recorder: RouteRecorder + + func makeUIView(context: Context) -> MKMapView { + let map = MKMapView() + map.showsUserLocation = true + map.userTrackingMode = .follow + map.delegate = context.coordinator + return map + } + + func updateUIView(_ map: MKMapView, context: Context) { + map.removeOverlays(map.overlays) + map.removeAnnotations(map.annotations.filter { !($0 is MKUserLocation) }) + + let coords = recorder.points.map { + CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) + } + if coords.count > 1 { + map.addOverlay(MKPolyline(coordinates: coords, count: coords.count)) + } + + for wp in recorder.waypoints { + map.addAnnotation(WaypointAnnotation(waypoint: wp)) + } + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + final class Coordinator: NSObject, MKMapViewDelegate { + func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { + guard let line = overlay as? MKPolyline else { + return MKOverlayRenderer(overlay: overlay) + } + let r = MKPolylineRenderer(polyline: line) + r.strokeColor = .systemRed; r.lineWidth = 3 + return r + } + } +} + +private final class WaypointAnnotation: NSObject, MKAnnotation { + let coordinate: CLLocationCoordinate2D + let title: String? + let subtitle: String? + init(waypoint: Waypoint) { + coordinate = CLLocationCoordinate2D(latitude: waypoint.latitude, + longitude: waypoint.longitude) + title = waypoint.label + subtitle = waypoint.action == .none ? nil : waypoint.action.displayName + } +} + +#Preview { + RoutesView().environmentObject(SensorManager()) +} diff --git a/SulTee/SulTee/SensorManager.swift b/SulTee/SulTee/SensorManager.swift index 32dfc51..47e3d63 100644 --- a/SulTee/SulTee/SensorManager.swift +++ b/SulTee/SulTee/SensorManager.swift @@ -107,7 +107,7 @@ final class SensorManager: NSObject, ObservableObject { // MARK: - Internal sensor state - private var lastKnownLocation: CLLocation? + private(set) var lastKnownLocation: CLLocation? private var lastKnownMotion: CMDeviceMotion? /// Orin-fused phone absolute position (RTK GPS + UWB offset).