feat: publish iOS GPS to MQTT topic saltybot/ios/gps at 1 Hz (Issue #681)

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>
This commit is contained in:
sl-ios 2026-04-04 11:11:11 -04:00
parent 19c05516df
commit 541a27b07b
4 changed files with 246 additions and 2 deletions

View File

@ -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 = "<group>"; };
A100000100000000000006AB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
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 = "<group>"; };
/* 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;
};

View File

@ -62,6 +62,11 @@
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
<key>192.168.87.29</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>UIApplicationSceneManifest</key>

View File

@ -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)])
}
}

View File

@ -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",