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:
parent
a7a4ed262a
commit
f39b9d432d
@ -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;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
187
SulTee/SulTee/MQTTClient.swift
Normal file
187
SulTee/SulTee/MQTTClient.swift
Normal 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)])
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user