feat: Merge SaltyTag BLE — GPS/IMU streaming to UWB tag, anchor display, UWB position authority #5

Open
sl-ios wants to merge 19 commits from sl-ios/saltytag-merge into main
Collaborator

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)

Characteristic UUID suffix Direction Format
GPS …def3 Write (no resp) 20 bytes LE, 5 Hz
IMU …def4 Write (no resp) 22 bytes LE, 10 Hz
Ranging …def5 Notify 1 + 9×N bytes

GPS 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)]×N

New files

File Purpose
BLEManager.swift CoreBluetooth central — scan, connect, auto-reconnect, send GPS/IMU, receive ranging
BLEPackets.swift Exact binary packet builders (GPS/IMU) + ranging parser
AnchorInfo.swift Anchor model with 3 s staleness
BLEStatusView.swift "BLE Tag" tab: connection control, streaming toggles, anchor list

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

MQTT saltybot/uwb/tag/position fresh (<3s) → use as blue marker  [UWB badge]
                     stale (>3s)           → fallback to CoreLocation GPS  [GPS badge]
  • phonePositionSource: .uwb(accuracyM) | .gps(accuracyM)
  • Map shows "Position: UWB 2cm" or "Position: GPS 5m" badge
  • MQTT publish to saltybot/ios/gps continues regardless (Orin fallback)

BLE streaming

  • BLE GPS timer: 200 ms (5 Hz) using lastKnownLocation
  • BLE IMU timer: 100 ms (10 Hz) using lastKnownMotion (100 Hz CMDeviceMotion buffer)
  • Sensors start when BLE connects (independent of Follow-Me mode)
  • Per-toggle: GPS→Tag and IMU→Tag can be disabled independently

Info.plist additions

  • NSBluetoothAlwaysUsageDescription
  • UIBackgroundModes += bluetooth-central

Test plan

  • Deploy to iPhone 15 Pro
  • Tap BLE Tag tab → tap Scan → verify "UWB_TAG…" connects
  • nRF Connect or logic analyzer: verify 20-byte GPS packets at 5 Hz on GPS char
  • Verify 22-byte IMU packets at 10 Hz on IMU char
  • Move phone → anchor distances update in list
  • Disable GPS toggle → GPS packets stop; IMU continues
  • Disconnect tag → app auto-reconnects after 2 s
  • Publish to saltybot/uwb/tag/position → map badge shows "Position: UWB 2cm"
  • Wait 3 s → badge switches to "Position: GPS Xm"

🤖 Generated with Claude Code

## 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) | Characteristic | UUID suffix | Direction | Format | |---|---|---|---| | GPS | `…def3` | Write (no resp) | 20 bytes LE, 5 Hz | | IMU | `…def4` | Write (no resp) | 22 bytes LE, 10 Hz | | Ranging | `…def5` | Notify | 1 + 9×N bytes | ### GPS 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)]×N` ## New files | File | Purpose | |---|---| | `BLEManager.swift` | CoreBluetooth central — scan, connect, auto-reconnect, send GPS/IMU, receive ranging | | `BLEPackets.swift` | Exact binary packet builders (GPS/IMU) + ranging parser | | `AnchorInfo.swift` | Anchor model with 3 s staleness | | `BLEStatusView.swift` | "BLE Tag" tab: connection control, streaming toggles, anchor list | ## Phone 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). ``` MQTT saltybot/uwb/tag/position fresh (<3s) → use as blue marker [UWB badge] stale (>3s) → fallback to CoreLocation GPS [GPS badge] ``` - `phonePositionSource: .uwb(accuracyM) | .gps(accuracyM)` - Map shows **"Position: UWB 2cm"** or **"Position: GPS 5m"** badge - MQTT publish to `saltybot/ios/gps` continues regardless (Orin fallback) ## BLE streaming - BLE GPS timer: 200 ms (5 Hz) using `lastKnownLocation` - BLE IMU timer: 100 ms (10 Hz) using `lastKnownMotion` (100 Hz CMDeviceMotion buffer) - Sensors start when BLE connects (independent of Follow-Me mode) - Per-toggle: GPS→Tag and IMU→Tag can be disabled independently ## Info.plist additions - `NSBluetoothAlwaysUsageDescription` - `UIBackgroundModes` += `bluetooth-central` ## Test plan - [ ] Deploy to iPhone 15 Pro - [ ] Tap **BLE Tag** tab → tap **Scan** → verify "UWB_TAG…" connects - [ ] `nRF Connect` or logic analyzer: verify 20-byte GPS packets at 5 Hz on GPS char - [ ] Verify 22-byte IMU packets at 10 Hz on IMU char - [ ] Move phone → anchor distances update in list - [ ] Disable GPS toggle → GPS packets stop; IMU continues - [ ] Disconnect tag → app auto-reconnects after 2 s - [ ] Publish to `saltybot/uwb/tag/position` → map badge shows "Position: UWB 2cm" - [ ] Wait 3 s → badge switches to "Position: GPS Xm" 🤖 Generated with [Claude Code](https://claude.com/claude-code)
sl-ios added 7 commits 2026-04-04 12:22:57 -04:00
- 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>
- 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>
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>
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>
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>
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>
sl-ios added 1 commit 2026-04-06 15:20:42 -04:00
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>
sl-ios added 1 commit 2026-04-06 15:49:03 -04:00
- 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>
sl-ios added 1 commit 2026-04-06 16:25:41 -04:00
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>
sl-ios added 1 commit 2026-04-06 16:29:42 -04:00
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>
sl-ios added 1 commit 2026-04-06 16:35:14 -04:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sl-ios added 1 commit 2026-04-06 16:46:09 -04:00
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>
sl-ios added 1 commit 2026-04-06 17:24:41 -04:00
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>
sl-ios added 1 commit 2026-04-06 17:31:32 -04:00
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>
sl-ios added 1 commit 2026-04-06 18:58:35 -04:00
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>
sl-ios added 1 commit 2026-04-06 20:01:49 -04:00
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>
sl-ios added 1 commit 2026-04-06 20:47:38 -04:00
- 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>
sl-ios added 1 commit 2026-04-07 08:32:17 -04:00
- 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>
This pull request can be merged automatically.
You are not authorized to merge this pull request.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin sl-ios/saltytag-merge:sl-ios/saltytag-merge
git checkout sl-ios/saltytag-merge
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: seb/saltylab-ios#5
No description provided.