19 Commits

Author SHA1 Message Date
e0c88983f1 feat: Pilot tab — HUD compass tape from saltybot/imu heading
- CompassTapeHUD: fighter-jet style horizontal scrolling tape,
  smooth sub-pixel phase correction, cardinal labels (N/E/S/W) in
  yellow, intercardinals (NE/SE/SW/NW), numeric ticks every 30°;
  fixed yellow ▼ centre indicator; degree readout (e.g. 327°)
- Toggle button (top-left, waveform icon) shows/hides HUD with
  fade+slide animation; defaults to on; yellow when active
- "NO IMU" placeholder when saltybot/imu data not yet received
- SensorManager: subscribe saltybot/imu in startStreaming() and
  ensureMQTTConnected(); handleBotIMU() parses heading / true_heading
  (degrees) or yaw (auto-detects radians vs degrees); normalises to
  [0, 360); exposes as @Published var botHeadingDeg: Double?

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 08:32:10 -04:00
f954b844d4 feat: Add Pilot tab — MJPEG camera feed + virtual joystick RC control
- 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>
2026-04-06 20:47:30 -04:00
615dc405d0 fix: auto-connect to UWB_TAG on launch — no user action needed
Root cause: autoReconnect was false on init, so the
centralManagerDidUpdateState(.poweredOn) callback was a no-op.
User had to tap 'Scan' to set autoReconnect=true and start scanning.

Fixes:
- Set autoReconnect=true before creating CBCentralManager so the
  .poweredOn callback immediately starts scanning on every app launch
- Scan timeout (15s): on expiry, call reconnectAfterDelay() instead
  of staying idle — retries every 2s until TAG is found (handles
  TAG still booting after firmware flash)

Behaviour after this change:
  Launch → auto-scan within ~1s of Bluetooth ready
  TAG not found after 15s → retry after 2s, repeat indefinitely
  TAG disconnects → rescan after 2s
  TAG reboots/reflashes → found within one 15s scan window
  User taps Disconnect → autoReconnect=false, stops all retries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:01:47 -04:00
cd90d6dbee 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>
2026-04-06 18:58:33 -04:00
6fa2a1b03f fix: throttle BLE writes to TAG — GPS 1Hz, IMU 2Hz
Tag was crashing ~5s after BLE connect due to GPS_RX at 5Hz +
IMU_RX at 10Hz flooding NimBLE while DW1000 UWB SPI also runs.
Combined 15 writes/sec exceeded ESP32 processing capacity.

GPS: 0.2s interval → 1.0s (5Hz → 1Hz)
IMU: 0.1s interval → 0.5s (10Hz → 2Hz)
Total BLE write load: 15/s → 3/s

Updated BLEStatusView captions to match new rates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:31:30 -04:00
b2bda0f467 feat: prioritise UWB ranging over GPS for distance display
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>
2026-04-06 17:24:39 -04:00
313e84a516 fix: migrate stale UserDefaults WebSocket URL on launch
UserDefaults persists across app reinstalls. Any device that
previously stored ws://100.64.0.2:9090 would ignore the new
defaultOrinURL constant. On init, if the saved URL contains
'100.64.0.2' it is cleared so the new default wss://www.saultee.bot/ws
is used on next launch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:44:43 -04:00
efeff9e6c0 config: change default WebSocket URL to wss://www.saultee.bot/ws
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:35:11 -04:00
9197371522 fix: support v3.4 12-byte ranging packet with best_rssi float
HAL v3.4 extends the range notify from 8→12 bytes:
  [0-3]  int32 LE  front_mm
  [4-7]  int32 LE  back_mm
  [8-11] float32 LE best_rssi dBm  (NEW)

Parser now handles both lengths (8=v3.3, 12=v3.4) by detecting
packet size. RSSI is extracted as Double and assigned to both
Front and Back anchors (shared signal quality); nil for v3.3.
UI already conditionally renders rssiString (guard on Optional).

Added Data.readFloat32LE(at:) helper using IEEE 754 bit-pattern
reinterpretation (little-endian UInt32 → Float(bitPattern:)).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:29:38 -04:00
12338f491e fix: parse HAL two-anchor ranging format (8-byte int32×2)
The app's old parser expected a multi-anchor protocol:
  [count][anchorID+rangeMM+RSSI+age]×N (min 10 bytes)
HAL's firmware sends a fixed 8-byte packet:
  [int32 front_mm LE][int32 back_mm LE]

With the old parser data[0] was interpreted as anchor count
(e.g. 0xF8 = 248 for a 4.6m reading), the loop guard failed
immediately, and every notify returned [] — hence "waiting for
ranging data" despite the tag showing live ranges on OLED.

Changes:
- BLEPackets: detect 8-byte HAL format by length; decode as
  anchor id=0 (Front) and id=1 (Back); legacy multi-anchor path
  retained for forward compatibility
- AnchorInfo: rssiDBm is now Optional (nil when not reported);
  label maps id 0→"F", 1→"B" for the two-anchor HAL format
- BLEStatusView: guard on optional rssiString before rendering

Auto-reconnect confirmed correct (2s delay, bluetooth-central
background mode declared in Info.plist).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:25:35 -04:00
7f9f159016 fix: resolve all Swift compiler errors and BLE write type mismatch
- BLEManager: fall back to withResponse write when characteristic lacks
  PROPERTY_WRITE_NR (0x4); NimBLE defaults to PROPERTY_WRITE (0x8) only,
  causing iOS to silently drop every withoutResponse write at 5/10 Hz
- BLEManager: scan withServices:nil so NimBLE scan-response UUIDs are found;
  filter by UWB_TAG name prefix in didDiscover
- BLEPackets: remove custom clamping extensions (Int16/Int32/UInt16/UInt8)
  that shadowed Swift.max() with Int16.max inside the extension scope;
  stdlib BinaryInteger.init(clamping:) covers all cases
- BLEStatusView: use explicit Binding(get:set:) for gpsStreamEnabled /
  imuStreamEnabled — SwiftUI cannot synthesize $binding through a let
  computed property backed by a class reference
- SensorManager: fix isRelativeAltitudeAvailable() — it is a class method,
  not an instance method; also fixed inverted double-negative logic

Note for HAL: add NimBLE PROPERTY_WRITE_NR to GPS (abcdef3) and IMU
(abcdef4) characteristics for no-ACK streaming at 5/10 Hz.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 15:48:58 -04:00
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