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 */; };
|
A100000100000000000003AA /* SensorManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000003AB /* SensorManager.swift */; };
|
||||||
A100000100000000000004AA /* WebSocketClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000004AB /* WebSocketClient.swift */; };
|
A100000100000000000004AA /* WebSocketClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000004AB /* WebSocketClient.swift */; };
|
||||||
A100000100000000000005AA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A100000100000000000005AB /* Assets.xcassets */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@ -22,6 +23,7 @@
|
|||||||
A100000100000000000005AB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
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>"; };
|
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; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@ -50,6 +52,7 @@
|
|||||||
A100000100000000000002AB /* ContentView.swift */,
|
A100000100000000000002AB /* ContentView.swift */,
|
||||||
A100000100000000000003AB /* SensorManager.swift */,
|
A100000100000000000003AB /* SensorManager.swift */,
|
||||||
A100000100000000000004AB /* WebSocketClient.swift */,
|
A100000100000000000004AB /* WebSocketClient.swift */,
|
||||||
|
A100000100000000000009AB /* MQTTClient.swift */,
|
||||||
A100000100000000000005AB /* Assets.xcassets */,
|
A100000100000000000005AB /* Assets.xcassets */,
|
||||||
A100000100000000000006AB /* Info.plist */,
|
A100000100000000000006AB /* Info.plist */,
|
||||||
);
|
);
|
||||||
@ -138,6 +141,7 @@
|
|||||||
A100000100000000000002AA /* ContentView.swift in Sources */,
|
A100000100000000000002AA /* ContentView.swift in Sources */,
|
||||||
A100000100000000000003AA /* SensorManager.swift in Sources */,
|
A100000100000000000003AA /* SensorManager.swift in Sources */,
|
||||||
A100000100000000000004AA /* WebSocketClient.swift in Sources */,
|
A100000100000000000004AA /* WebSocketClient.swift in Sources */,
|
||||||
|
A100000100000000000009AA /* MQTTClient.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -62,6 +62,11 @@
|
|||||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>192.168.87.29</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<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 CoreMotion
|
||||||
import Combine
|
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 {
|
final class SensorManager: NSObject, ObservableObject {
|
||||||
|
|
||||||
// MARK: - Published state for UI
|
// MARK: - Published state for UI
|
||||||
@ -16,7 +17,7 @@ final class SensorManager: NSObject, ObservableObject {
|
|||||||
@Published var baroRate: Double = 0
|
@Published var baroRate: Double = 0
|
||||||
@Published var botDistanceMeters: Double? = nil
|
@Published var botDistanceMeters: Double? = nil
|
||||||
|
|
||||||
// MARK: - Dependencies
|
// MARK: - WebSocket
|
||||||
|
|
||||||
static let defaultOrinURL = "ws://100.64.0.2:9090"
|
static let defaultOrinURL = "ws://100.64.0.2:9090"
|
||||||
private static let orinURLKey = "orinURL"
|
private static let orinURLKey = "orinURL"
|
||||||
@ -28,6 +29,22 @@ final class SensorManager: NSObject, ObservableObject {
|
|||||||
get { UserDefaults.standard.string(forKey: Self.orinURLKey) ?? Self.defaultOrinURL }
|
get { UserDefaults.standard.string(forKey: Self.orinURLKey) ?? Self.defaultOrinURL }
|
||||||
set { UserDefaults.standard.set(newValue, forKey: Self.orinURLKey) }
|
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 locationManager = CLLocationManager()
|
||||||
private let motionManager = CMMotionManager()
|
private let motionManager = CMMotionManager()
|
||||||
private let altimeter = CMAltimeter()
|
private let altimeter = CMAltimeter()
|
||||||
@ -67,17 +84,22 @@ final class SensorManager: NSObject, ObservableObject {
|
|||||||
guard !isStreaming else { return }
|
guard !isStreaming else { return }
|
||||||
isStreaming = true
|
isStreaming = true
|
||||||
ws.connect()
|
ws.connect()
|
||||||
|
mqtt.connect()
|
||||||
requestPermissionsAndStartSensors()
|
requestPermissionsAndStartSensors()
|
||||||
startRateTimer()
|
startRateTimer()
|
||||||
|
startMQTTPublishTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopStreaming() {
|
func stopStreaming() {
|
||||||
guard isStreaming else { return }
|
guard isStreaming else { return }
|
||||||
isStreaming = false
|
isStreaming = false
|
||||||
ws.disconnect()
|
ws.disconnect()
|
||||||
|
mqtt.disconnect()
|
||||||
stopSensors()
|
stopSensors()
|
||||||
rateTimer?.invalidate()
|
rateTimer?.invalidate()
|
||||||
rateTimer = nil
|
rateTimer = nil
|
||||||
|
mqttPublishTimer?.invalidate()
|
||||||
|
mqttPublishTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Call when the user edits the Orin URL. Persists the value and updates
|
/// 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
|
// MARK: - Sensor start / stop
|
||||||
|
|
||||||
private func requestPermissionsAndStartSensors() {
|
private func requestPermissionsAndStartSensors() {
|
||||||
@ -199,6 +246,7 @@ extension SensorManager: CLLocationManagerDelegate {
|
|||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
guard let loc = locations.last else { return }
|
guard let loc = locations.last else { return }
|
||||||
|
lastKnownLocation = loc
|
||||||
recordEvent(in: &gpsCounts)
|
recordEvent(in: &gpsCounts)
|
||||||
ws.send([
|
ws.send([
|
||||||
"type": "gps",
|
"type": "gps",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user