- 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>
115 lines
3.4 KiB
Swift
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()
|
|
}
|
|
}
|