8 Commits

Author SHA1 Message Date
7b911d3591 fix: scan all peripherals so NimBLE service UUID in scan response is not missed
iOS CoreBluetooth only fires didDiscover when the service UUID is in the
primary ADV_IND packet. NimBLE (ESP32) puts service UUIDs in the scan
response by default, so scanForPeripherals(withServices:[uuid]) never
returned the UWB_TAG device.

Fix: scan with withServices:nil and filter by device name prefix "UWB_TAG"
in didDiscover. Also fix the broken OR guard (|| name.isEmpty == false
passed any named peripheral) to a clean hasPrefix check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 15:20:30 -04:00
c472668d7a feat: Merge SaltyTag BLE — GPS/IMU streaming to UWB tag, anchor display, UWB position authority
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>
2026-04-04 12:22:17 -04:00
1d5f196e68 feat: Add UWB integration — follow mode, range presets, UWB status
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>
2026-04-04 12:17:22 -04:00
0ad2b2f5c0 feat: Rename to SAUL-T-MOTE, add map with user + robot positions and follow path
Rename:
- CFBundleDisplayName = "SAUL-T-MOTE" in Info.plist
- navigationTitle updated to "SAUL-T-MOTE" in StatusView
- MQTT clientID prefix changed to "saul-t-mote-"

Map view (MapContentView.swift, MapKit):
- Blue marker + fading breadcrumb trail for user (iPhone GPS)
- Orange car marker + fading breadcrumb trail for robot (Pixel 5)
- Dashed yellow line from robot → user (follow path)
- Bottom overlay: distance between user and robot, robot speed
- Auto-follow camera tracks user; manual drag disables it; re-centre button restores
- MapPolyline for trails, per-point Annotation for fading breadcrumb dots

Robot GPS subscription (saltybot/phone/gps):
- MQTTClient extended with SUBSCRIBE (QoS 0) + incoming PUBLISH parser
  (handles variable-length remaining-length, multi-packet frames)
- Subscriptions persisted and re-sent on reconnect (CONNACK handler)
- SensorManager.handleRobotGPS() updates robotLocation, robotSpeed,
  robotBreadcrumbs, distanceToRobot

iOS GPS publish unchanged (saltybot/ios/gps, 1 Hz) — PR #2 intact.

ContentView restructured as TabView:
- Tab 1: Status (sensor rates, WS URL, follow-me button)
- Tab 2: Map

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:17:22 -04:00
f39b9d432d feat: publish iOS GPS to MQTT topic saltybot/ios/gps at 1 Hz (Issue #681)
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>
2026-04-04 12:17:22 -04:00
a7a4ed262a fix: add ATS exception for Tailscale WebSocket (error -1022) (Issue #709)
NSAllowsLocalNetworking covers CGNAT 100.64.0.0/10 range used by Tailscale,
fixing ATS blocking plain ws:// connections. Also adds NSExceptionDomains
entry for 100.64.0.2 as explicit fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:17:22 -04:00
52c9e823ac fix: switch WebSocket to Tailscale IP, add configurable Orin URL (Issue #709)
- Default URL updated from ws://192.168.86.158:9090 (LAN) to ws://100.64.0.2:9090 (Tailscale)
- URL persisted in UserDefaults under key "orinURL" — survives app restarts
- WebSocketClient.url is now mutable so it can be updated without recreation
- SensorManager.updateURL(_:) applies a new URL when not streaming
- ContentView: editable text field for Orin address with Apply button, disabled while streaming
- Connection banner shows the active URL instead of hardcoded string

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:17:22 -04:00
baab4eaeb2 feat: iOS companion app - sensor streaming over WebSocket (Issue #709)
- 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>
2026-04-04 12:17:22 -04:00