- 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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
- 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>