feat: Merge SaltyTag BLE — GPS/IMU streaming to UWB tag, anchor display, UWB position authority #5
Loading…
x
Reference in New Issue
Block a user
No description provided.
Delete Branch "sl-ios/saltytag-merge"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Full native Swift port of the SaltyTag Flutter app, integrated into SAUL-T-MOTE, plus phone position source hierarchy with UWB as the authoritative source when in range.
BLE Protocol (from SaltyTag firmware, exact wire format)
…def3…def4…def5GPS packet (20 bytes)
lat×1e7 (Int32) | lon×1e7 (Int32) | alt×10 (Int16) | speed×100 (UInt16) | hdg×100 (UInt16) | acc×10 (UInt8) | fix_type (UInt8) | ts_ms_low32 (UInt32)IMU packet (22 bytes)
accel XYZ milli-g (Int16×3) | gyro XYZ centi-deg/s (Int16×3) | mag XYZ μT (Int16×3) | ts_ms_low32 (UInt32)Ranging notification
count (UInt8) | [anchor_idx (UInt8) | range_mm (Int32) | rssi×10 (Int16) | age_ms (UInt16)]×NNew files
BLEManager.swiftBLEPackets.swiftAnchorInfo.swiftBLEStatusView.swiftPhone position source hierarchy
Key insight: when UWB is in range, the Orin has a more accurate phone position than the phone itself (robot RTK GPS + UWB tag offset = cm-level).
phonePositionSource: .uwb(accuracyM) | .gps(accuracyM)saltybot/ios/gpscontinues regardless (Orin fallback)BLE streaming
lastKnownLocationlastKnownMotion(100 Hz CMDeviceMotion buffer)Info.plist additions
NSBluetoothAlwaysUsageDescriptionUIBackgroundModes+=bluetooth-centralTest plan
nRF Connector logic analyzer: verify 20-byte GPS packets at 5 Hz on GPS charsaltybot/uwb/tag/position→ map badge shows "Position: UWB 2cm"🤖 Generated with Claude Code
- SulTee SwiftUI app targeting iOS 17+, iPhone 15 Pro - CoreLocation: dual-frequency GPS (L1+L5) continuous updates, background mode enabled - CoreMotion: 100 Hz IMU (accel + gyro + attitude + gravity), magnetometer via device motion - CMAltimeter: barometer relative altitude + pressure streaming - CLLocationManager heading updates for magnetometer heading - URLSessionWebSocketTask client connecting to ws://192.168.86.158:9090 - JSON protocol: {type, timestamp, data} for gps/imu/heading/baro messages - Auto-reconnect on disconnect (2s backoff) - Haptic feedback on incoming "haptic" messages from bot - Background streaming: UIBackgroundModes location + external-accessory in Info.plist - SwiftUI status UI: connection banner, sensor rate counters (Hz), start/stop follow-me button - Dev team Z37N597UWY (vayrette@gmail.com), bundle ID com.saltylab.sultee Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Adds minimal MQTT 3.1.1 client (MQTTClient.swift) using Network.framework — no external dependency. Implements CONNECT + PUBLISH (QoS 0) + PINGREQ keepalive. - Broker: 192.168.87.29:1883 (user: mqtt_seb) - Topic: saltybot/ios/gps - Rate: 1 Hz Timer, decoupled from GPS update rate - Payload matches sensor_dashboard.py format: {ts, lat, lon, alt_m, accuracy_m, speed_ms, bearing_deg, provider: "gps"} - lastKnownLocation cached from CLLocationManagerDelegate, published on timer - MQTT connect/disconnect tied to startStreaming()/stopStreaming() - ATS NSExceptionDomains extended to include 192.168.87.29 (MQTT broker LAN IP) - MQTTClient.swift registered in project.pbxproj Sources build phase Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>UWBModels.swift (new): - FollowMode enum: .gps / .uwb — publishes {"mode":"gps|uwb"} to saltybot/follow/mode - FollowPreset enum: .close(1.5m) / .medium(3m) / .far(5m) — publishes {"range_m":N,"preset":"..."} to saltybot/follow/range - UWBPosition struct: x/y/z + timestamp - UWBRange struct: anchorID + rangeMetres + timestamp SensorManager: - Subscribes to saltybot/uwb/range + saltybot/uwb/position on startStreaming - handleUWBRange: updates uwbRanges[anchorID] (keyed dict) - handleUWBPosition: updates uwbPosition + sets uwbActive=true - UWB staleness watchdog (1Hz timer): clears uwbActive and prunes stale ranges >3s - setFollowMode(_:) / setFollowPreset(_:): update state + publish to MQTT immediately - Publishes current follow mode+range on connect MapContentView: - UWB status badge (top-left): green/gray dot, "UWB Active|Out of Range", per-anchor range readouts (e.g. A1 2.34m) - Follow mode segmented control: GPS | UWB - Follow range segmented control: Close | Medium | Far (shows metres) - MapCircle follow-range ring around robot: green inside, orange outside range - Stats bar: distance turns green + checkmark when user is inside follow range; UWB (x,y) coord shown when UWB mode active and position known Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>BLE Protocol (from SaltyTag Flutter app, exact binary format): Service: 12345678-1234-5678-1234-56789abcdef0 GPS char: …def3 IMU char: …def4 Ranging char: …def5 (notify) BLEManager.swift — CoreBluetooth central: - Scans for peripherals advertising the service UUID; name prefix "UWB_TAG" - 15 s scan timeout, auto-reconnect with 2 s backoff on disconnect - Exposes sendGPS(Data) + sendIMU(Data); gpsStreamEnabled / imuStreamEnabled toggles - Subscribes to ranging notifications → parses → publishes anchors[] BLEPackets.swift — exact binary encoders matching SaltyTag firmware expectations: - gpsPacket(CLLocation) → 20 bytes LE: lat×1e7, lon×1e7, alt×10(Int16), speed×100(UInt16), heading×100(UInt16), accuracy×10(UInt8), fix_type, ts_ms_low32 - imuPacket(CMDeviceMotion) → 22 bytes LE: accel XYZ milli-g (already in g from CoreMotion), gyro XYZ centi-deg/s (rad/s × 5729.578), mag XYZ μT, ts_ms_low32 - parseRanging(Data) → [AnchorInfo]: count byte + 9 bytes/anchor (index, Int32-mm range, Int16×10 RSSI, UInt16 age_ms) AnchorInfo.swift — anchor model with 3 s staleness check BLEStatusView.swift — "BLE Tag" tab (3rd tab in ContentView): - Connection card: state dot, peripheral name, Scan/Stop/Disconnect button - GPS→Tag and IMU→Tag streaming toggles (5 Hz / 10 Hz rates shown) - Anchor list matching SaltyTag UI: freshness dot, A{id} label, range, RSSI, STALE badge Green label if <5 m, orange if ≥5 m, gray if stale SensorManager: - Owns BLEManager; observes connectionState via Combine → starts/stops BLE timers - BLE GPS timer: 200 ms (5 Hz), sends lastKnownLocation via BLEPackets.gpsPacket - BLE IMU timer: 100 ms (10 Hz), sends lastKnownMotion via BLEPackets.imuPacket - lastKnownMotion updated from 100 Hz CMDeviceMotion callback - ensureSensorsRunning() called on BLE connect (sensors start even without Follow-Me) - Subscribes to saltybot/uwb/tag/position — Orin-fused phone absolute position (robot RTK GPS + UWB tag offset = cm-accurate phone position) Phone position source hierarchy (updateBestPhonePosition): 1. saltybot/uwb/tag/position fresh < 3 s → UWB authority (more accurate than phone GPS) 2. CoreLocation GPS fallback - phonePositionSource: PhonePositionSource (.uwb(accuracyM) | .gps(accuracyM)) - userLocation always set to best source; MQTT publish to saltybot/ios/gps unchanged MapContentView: - positionSourceBadge (top-left, below UWB badge): "Position: UWB 2cm" or "Position: GPS 5m" with waveform icon (UWB) or location icon (GPS) Info.plist: - NSBluetoothAlwaysUsageDescription added - NSMotionUsageDescription updated (SAUL-T-MOTE branding) - UIBackgroundModes: added bluetooth-central Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Distance source priority: 1. BLE Range notify from wearable TAG (cm-accurate, live) 2. MQTT saltybot/uwb/range from Orin anchors 3. GPS coordinate diff — fallback only Changes: - SensorManager: add DistanceSource enum (blueUWB/mqttUWB/gps); replace updateDistance() with updateDistanceToRobot() that checks fresh BLE anchors first, then MQTT UWB ranges, then GPS; subscribe to ble.$anchors so every Range notify triggers re-evaluation; also trigger on MQTT UWB range arrival and BLE disconnect - ContentView: distanceRow now shows source label and icon: green + "UWB (BLE tag)" when BLE anchors are fresh mint + "UWB (Orin anchors)" when MQTT UWB ranges are fresh orange + "GPS estimate" fallback; prefixed with ~ to signal UWB shows cm precision (e.g. "3.84 m"), GPS shows integer metres Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>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>- New PilotView.swift: full-screen MJPEG stream via WKWebView, virtual joystick overlay (bottom-right, semi-transparent), camera switcher pill (top-right, hidden when single source) - Dead-man switch: finger lift snaps joystick to zero; next 10 Hz tick publishes {linear_x:0, angular_z:0} within 100 ms - cmd_vel published to saltybot/cmd_vel at 10 Hz max via MQTT - SensorManager: add ensureMQTTConnected(), releaseMQTTIfIdle(), publishCmdVel(linearX:angularZ:) so Pilot tab can use MQTT independently of Follow-Me streaming - ContentView: add Pilot tab (camera.fill icon, last tab) - xcodeproj: register PilotView.swift in Sources build phase Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Checkout
From your project repository, check out a new branch and test the changes.