feat: Phase 1 — Route Recording with waypoints (GPS track at 1Hz)

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/<uuid>.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 <noreply@anthropic.com>
This commit is contained in:
sl-ios 2026-04-06 18:58:33 -04:00
parent 6fa2a1b03f
commit cd90d6dbee
7 changed files with 615 additions and 1 deletions

View File

@ -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 = "<group>"; };
A10000010000000000000BAB /* UWBModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UWBModels.swift; sourceTree = "<group>"; };
A10000010000000000000CAB /* BLEManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEManager.swift; sourceTree = "<group>"; };
A100000100000000000010BB /* RouteModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteModels.swift; sourceTree = "<group>"; };
A100000100000000000011BB /* RouteStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteStore.swift; sourceTree = "<group>"; };
A100000100000000000012BB /* RouteRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteRecorder.swift; sourceTree = "<group>"; };
A100000100000000000013BB /* RoutesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutesView.swift; sourceTree = "<group>"; };
A10000010000000000000DAB /* BLEPackets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEPackets.swift; sourceTree = "<group>"; };
A10000010000000000000EAB /* AnchorInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnchorInfo.swift; sourceTree = "<group>"; };
A10000010000000000000FAB /* BLEStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEStatusView.swift; sourceTree = "<group>"; };
@ -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;
};

View File

@ -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") }
}
}
}

View File

@ -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, 0360
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..<points.count {
let a = CLLocation(latitude: points[i-1].latitude, longitude: points[i-1].longitude)
let b = CLLocation(latitude: points[i].latitude, longitude: points[i].longitude)
total += a.distance(from: b)
}
return total
}
// MARK: - MQTT payload for robot route-following (Phase 3)
//
// Topic: saltybot/route/command
// {
// "action": "start",
// "route_id": "<uuid>",
// "route_name":"<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
]}
]
}
}

View File

@ -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..<points.count {
let a = CLLocation(latitude: points[i-1].latitude, longitude: points[i-1].longitude)
let b = CLLocation(latitude: points[i].latitude, longitude: points[i].longitude)
total += a.distance(from: b)
}
return total
}
var elapsedString: String {
let s = Int(elapsedSeconds)
return String(format: "%02d:%02d", s / 60, s % 60)
}
}

View File

@ -0,0 +1,63 @@
import Foundation
/// Persists SavedRoute objects as individual JSON files in the app's Documents directory.
final class RouteStore: ObservableObject {
@Published private(set) var routes: [SavedRoute] = []
private let directory: URL
init() {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
directory = docs.appendingPathComponent("routes", isDirectory: true)
try? FileManager.default.createDirectory(at: directory,
withIntermediateDirectories: true)
load()
}
// MARK: - Public API
func save(_ route: SavedRoute) {
let url = fileURL(for: route.id)
if let data = try? JSONEncoder().encode(route) {
try? data.write(to: url, options: .atomic)
}
if let idx = routes.firstIndex(where: { $0.id == route.id }) {
routes[idx] = route
} else {
routes.append(route)
routes.sort { $0.date > $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")
}
}

View File

@ -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())
}

View File

@ -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).