From 541a27b07b9b5888b26bf4dbc4a7140e297f0372 Mon Sep 17 00:00:00 2001 From: sl-ios Date: Sat, 4 Apr 2026 11:11:11 -0400 Subject: [PATCH] feat: publish iOS GPS to MQTT topic saltybot/ios/gps at 1 Hz (Issue #681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- SulTee/SulTee.xcodeproj/project.pbxproj | 4 + SulTee/SulTee/Info.plist | 5 + SulTee/SulTee/MQTTClient.swift | 187 ++++++++++++++++++++++++ SulTee/SulTee/SensorManager.swift | 52 ++++++- 4 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 SulTee/SulTee/MQTTClient.swift diff --git a/SulTee/SulTee.xcodeproj/project.pbxproj b/SulTee/SulTee.xcodeproj/project.pbxproj index c0193ec..c3a2c72 100644 --- a/SulTee/SulTee.xcodeproj/project.pbxproj +++ b/SulTee/SulTee.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ A100000100000000000003AA /* SensorManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000003AB /* SensorManager.swift */; }; A100000100000000000004AA /* WebSocketClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000004AB /* WebSocketClient.swift */; }; A100000100000000000005AA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A100000100000000000005AB /* Assets.xcassets */; }; + A100000100000000000009AA /* MQTTClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000009AB /* MQTTClient.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -22,6 +23,7 @@ A100000100000000000005AB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A100000100000000000006AB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A100000100000000000007AB /* SulTee.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SulTee.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A100000100000000000009AB /* MQTTClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTClient.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -50,6 +52,7 @@ A100000100000000000002AB /* ContentView.swift */, A100000100000000000003AB /* SensorManager.swift */, A100000100000000000004AB /* WebSocketClient.swift */, + A100000100000000000009AB /* MQTTClient.swift */, A100000100000000000005AB /* Assets.xcassets */, A100000100000000000006AB /* Info.plist */, ); @@ -138,6 +141,7 @@ A100000100000000000002AA /* ContentView.swift in Sources */, A100000100000000000003AA /* SensorManager.swift in Sources */, A100000100000000000004AA /* WebSocketClient.swift in Sources */, + A100000100000000000009AA /* MQTTClient.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/SulTee/SulTee/Info.plist b/SulTee/SulTee/Info.plist index 40874c4..8e3361b 100644 --- a/SulTee/SulTee/Info.plist +++ b/SulTee/SulTee/Info.plist @@ -62,6 +62,11 @@ NSExceptionAllowsInsecureHTTPLoads + 192.168.87.29 + + NSExceptionAllowsInsecureHTTPLoads + + UIApplicationSceneManifest diff --git a/SulTee/SulTee/MQTTClient.swift b/SulTee/SulTee/MQTTClient.swift new file mode 100644 index 0000000..88df9bc --- /dev/null +++ b/SulTee/SulTee/MQTTClient.swift @@ -0,0 +1,187 @@ +import Foundation +import Network + +/// Minimal MQTT 3.1.1 client — CONNECT + PUBLISH (QoS 0) + PINGREQ only. +/// Sufficient for 1 Hz telemetry publishing; no subscription support needed. +final class MQTTClient { + + struct Config { + var host: String + var port: UInt16 + var username: String + var password: String + var clientID: String + var keepAlive: UInt16 = 60 + } + + enum State { case disconnected, connecting, connected } + + private(set) var state: State = .disconnected + private var config: Config + private var connection: NWConnection? + private var pingTimer: DispatchSourceTimer? + private var shouldRun = false + private let queue = DispatchQueue(label: "mqtt.client", qos: .utility) + + init(config: Config) { + self.config = config + } + + // MARK: - Public + + func connect() { + shouldRun = true + guard state == .disconnected else { return } + openConnection() + } + + func disconnect() { + shouldRun = false + pingTimer?.cancel() + pingTimer = nil + connection?.cancel() + connection = nil + state = .disconnected + } + + /// Publish a UTF-8 string payload to `topic` at QoS 0. + func publish(topic: String, payload: String) { + guard state == .connected else { return } + let packet = buildPublish(topic: topic, payload: payload) + connection?.send(content: packet, completion: .idempotent) + } + + // MARK: - Connection lifecycle + + private func openConnection() { + state = .connecting + let host = NWEndpoint.Host(config.host) + let port = NWEndpoint.Port(rawValue: config.port)! + connection = NWConnection(host: host, port: port, using: .tcp) + connection?.stateUpdateHandler = { [weak self] newState in + self?.handleStateChange(newState) + } + connection?.start(queue: queue) + scheduleRead() + } + + private func handleStateChange(_ newState: NWConnection.State) { + switch newState { + case .ready: + sendConnect() + schedulePing() + case .failed, .cancelled: + state = .disconnected + pingTimer?.cancel() + pingTimer = nil + reconnectIfNeeded() + default: + break + } + } + + private func reconnectIfNeeded() { + guard shouldRun else { return } + queue.asyncAfter(deadline: .now() + 3) { [weak self] in + self?.openConnection() + } + } + + // MARK: - Read loop (to receive CONNACK / PINGRESP) + + private func scheduleRead() { + connection?.receive(minimumIncompleteLength: 2, maximumLength: 256) { [weak self] data, _, _, error in + guard let self else { return } + if let data, !data.isEmpty { + self.handleIncoming(data) + } + if error == nil { self.scheduleRead() } + } + } + + private func handleIncoming(_ data: Data) { + guard let first = data.first else { return } + switch first { + case 0x20: // CONNACK + state = .connected + case 0xD0: // PINGRESP — no action needed + break + default: + break + } + } + + // MARK: - Keep-alive ping + + private func schedulePing() { + let t = DispatchSource.makeTimerSource(queue: queue) + t.schedule(deadline: .now() + Double(config.keepAlive / 2), + repeating: Double(config.keepAlive / 2)) + t.setEventHandler { [weak self] in + self?.sendPing() + } + t.resume() + pingTimer = t + } + + private func sendPing() { + let packet = Data([0xC0, 0x00]) + connection?.send(content: packet, completion: .idempotent) + } + + // MARK: - MQTT packet builders + + private func sendConnect() { + let packet = buildConnect() + connection?.send(content: packet, completion: .idempotent) + } + + private func buildConnect() -> Data { + var payload = Data() + payload += mqttString("MQTT") // protocol name + payload.append(0x04) // protocol level (3.1.1) + payload.append(0xC2) // flags: username + password + clean session + payload += uint16BE(config.keepAlive) // keep-alive + payload += mqttString(config.clientID) // client ID + payload += mqttString(config.username) // username + payload += mqttString(config.password) // password + return mqttPacket(type: 0x10, payload: payload) + } + + private func buildPublish(topic: String, payload: String) -> Data { + var body = Data() + body += mqttString(topic) + body += payload.data(using: .utf8) ?? Data() + return mqttPacket(type: 0x30, payload: body) + } + + // MARK: - Encoding helpers + + private func mqttPacket(type: UInt8, payload: Data) -> Data { + var packet = Data([type]) + packet += remainingLength(payload.count) + packet += payload + return packet + } + + private func remainingLength(_ value: Int) -> Data { + var data = Data() + var n = value + repeat { + var byte = UInt8(n & 0x7F) + n >>= 7 + if n > 0 { byte |= 0x80 } + data.append(byte) + } while n > 0 + return data + } + + private func mqttString(_ s: String) -> Data { + let bytes = s.data(using: .utf8) ?? Data() + return uint16BE(UInt16(bytes.count)) + bytes + } + + private func uint16BE(_ v: UInt16) -> Data { + Data([UInt8(v >> 8), UInt8(v & 0xFF)]) + } +} diff --git a/SulTee/SulTee/SensorManager.swift b/SulTee/SulTee/SensorManager.swift index 70531e2..9604f0a 100644 --- a/SulTee/SulTee/SensorManager.swift +++ b/SulTee/SulTee/SensorManager.swift @@ -3,7 +3,8 @@ import CoreLocation import CoreMotion import Combine -/// Manages all iPhone sensors and forwards data to the WebSocket client. +/// Manages all iPhone sensors and forwards data to the WebSocket client +/// and MQTT broker (topic: saltybot/ios/gps, 1 Hz). final class SensorManager: NSObject, ObservableObject { // MARK: - Published state for UI @@ -16,7 +17,7 @@ final class SensorManager: NSObject, ObservableObject { @Published var baroRate: Double = 0 @Published var botDistanceMeters: Double? = nil - // MARK: - Dependencies + // MARK: - WebSocket static let defaultOrinURL = "ws://100.64.0.2:9090" private static let orinURLKey = "orinURL" @@ -28,6 +29,22 @@ final class SensorManager: NSObject, ObservableObject { get { UserDefaults.standard.string(forKey: Self.orinURLKey) ?? Self.defaultOrinURL } set { UserDefaults.standard.set(newValue, forKey: Self.orinURLKey) } } + + // MARK: - MQTT + + private let mqtt = MQTTClient(config: .init( + host: "192.168.87.29", + port: 1883, + username: "mqtt_seb", + password: "mqtt_pass", + clientID: "sultee-ios-\(UUID().uuidString.prefix(8))" + )) + private static let mqttGPSTopic = "saltybot/ios/gps" + private var lastKnownLocation: CLLocation? + private var mqttPublishTimer: Timer? + + // MARK: - Sensors + private let locationManager = CLLocationManager() private let motionManager = CMMotionManager() private let altimeter = CMAltimeter() @@ -67,17 +84,22 @@ final class SensorManager: NSObject, ObservableObject { guard !isStreaming else { return } isStreaming = true ws.connect() + mqtt.connect() requestPermissionsAndStartSensors() startRateTimer() + startMQTTPublishTimer() } func stopStreaming() { guard isStreaming else { return } isStreaming = false ws.disconnect() + mqtt.disconnect() stopSensors() rateTimer?.invalidate() rateTimer = nil + mqttPublishTimer?.invalidate() + mqttPublishTimer = nil } /// Call when the user edits the Orin URL. Persists the value and updates @@ -90,6 +112,31 @@ final class SensorManager: NSObject, ObservableObject { } } + // MARK: - MQTT GPS publish (1 Hz) + + private func startMQTTPublishTimer() { + mqttPublishTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.publishGPSToMQTT() + } + } + + private func publishGPSToMQTT() { + guard let loc = lastKnownLocation else { return } + let payload: [String: Any] = [ + "ts": loc.timestamp.timeIntervalSince1970, + "lat": loc.coordinate.latitude, + "lon": loc.coordinate.longitude, + "alt_m": loc.altitude, + "accuracy_m": max(0, loc.horizontalAccuracy), + "speed_ms": max(0, loc.speed), + "bearing_deg": loc.course >= 0 ? loc.course : 0.0, + "provider": "gps" + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { return } + mqtt.publish(topic: Self.mqttGPSTopic, payload: json) + } + // MARK: - Sensor start / stop private func requestPermissionsAndStartSensors() { @@ -199,6 +246,7 @@ extension SensorManager: CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let loc = locations.last else { return } + lastKnownLocation = loc recordEvent(in: &gpsCounts) ws.send([ "type": "gps",