saltylab-ios/SulTee/SulTee/WebSocketClient.swift
sl-ios 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

115 lines
3.4 KiB
Swift

import Foundation
import UIKit
/// Thin WebSocket wrapper around URLSessionWebSocketTask.
/// Reconnects automatically on disconnect.
final class WebSocketClient: NSObject, ObservableObject {
enum ConnectionState {
case disconnected, connecting, connected
}
@Published var state: ConnectionState = .disconnected
private let url: URL
private var session: URLSession!
private var task: URLSessionWebSocketTask?
private var shouldRun = false
init(url: URL) {
self.url = url
super.init()
self.session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
}
func connect() {
shouldRun = true
guard state == .disconnected else { return }
openConnection()
}
func disconnect() {
shouldRun = false
task?.cancel(with: .normalClosure, reason: nil)
task = nil
DispatchQueue.main.async { self.state = .disconnected }
}
func send(_ message: [String: Any]) {
guard state == .connected,
let data = try? JSONSerialization.data(withJSONObject: message),
let json = String(data: data, encoding: .utf8) else { return }
task?.send(.string(json)) { error in
if let error {
print("[WebSocket] send error: \(error)")
}
}
}
// MARK: - Private
private func openConnection() {
DispatchQueue.main.async { self.state = .connecting }
task = session.webSocketTask(with: url)
task?.resume()
scheduleReceive()
}
private func scheduleReceive() {
task?.receive { [weak self] result in
guard let self else { return }
switch result {
case .success(let message):
self.handle(message)
self.scheduleReceive()
case .failure(let error):
print("[WebSocket] receive error: \(error)")
self.reconnectIfNeeded()
}
}
}
private func handle(_ message: URLSessionWebSocketTask.Message) {
switch message {
case .string(let text):
guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String else { return }
if type == "haptic" {
DispatchQueue.main.async {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
}
}
default:
break
}
}
private func reconnectIfNeeded() {
DispatchQueue.main.async { self.state = .disconnected }
guard shouldRun else { return }
DispatchQueue.global().asyncAfter(deadline: .now() + 2) { [weak self] in
self?.openConnection()
}
}
}
// MARK: - URLSessionWebSocketDelegate
extension WebSocketClient: URLSessionWebSocketDelegate {
func urlSession(_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didOpenWithProtocol protocol: String?) {
DispatchQueue.main.async { self.state = .connected }
}
func urlSession(_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
reason: Data?) {
reconnectIfNeeded()
}
}