From f2c8a08034ae0498c4b7166f06fb334ae800124c Mon Sep 17 00:00:00 2001 From: manonstreet Date: Sat, 14 Mar 2026 12:34:28 -0400 Subject: [PATCH] feat: add MQTT transport with HA auto-discovery and rich attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MQTT as an alternative transport alongside REST. Users pick one mode in Access Settings — MQTT publishes via HA auto-discovery with json_attributes_topic carrying altitude, speed, motion state, and location labels from secureLocations bplist blobs. - RichLocationAttributes model + DevicePoint enrichment from LocalStorage - TransportMode enum (.rest/.mqtt) with migration for existing REST users - MQTTClient with CocoaMQTT 2.1.9, connection lifecycle, exponential backoff - Transport-aware SyncEngine post phase and preflight - Settings UI: transport picker, MQTT broker card, generic connection test - HomeView/DeviceManager conditional transport rows - DMG packaging in build.sh release workflow - README updated for dual transport Co-Authored-By: Claude Opus 4.6 --- FindMySyncPlus.xcodeproj/project.pbxproj | 17 ++ FindMySyncPlus/HAClient.swift | 2 +- FindMySyncPlus/Helpers/Keychain.swift | 1 + FindMySyncPlus/LocalStorageDecryptor.swift | 21 +- FindMySyncPlus/MQTTClient.swift | 277 ++++++++++++++++++ FindMySyncPlus/Models/AppModel.swift | 12 + FindMySyncPlus/Models/DevicePoint.swift | 5 +- .../Models/RichLocationAttributes.swift | 23 ++ FindMySyncPlus/Models/SettingsStore.swift | 41 ++- FindMySyncPlus/SyncEngine.swift | 83 ++++-- FindMySyncPlus/Views/AccessSettingsView.swift | 199 +++++++++++-- FindMySyncPlus/Views/DeviceManagerView.swift | 22 +- FindMySyncPlus/Views/HomeView.swift | 52 +++- README.md | 23 +- 14 files changed, 683 insertions(+), 95 deletions(-) create mode 100644 FindMySyncPlus/MQTTClient.swift create mode 100644 FindMySyncPlus/Models/RichLocationAttributes.swift diff --git a/FindMySyncPlus.xcodeproj/project.pbxproj b/FindMySyncPlus.xcodeproj/project.pbxproj index ef31440..262bf86 100644 --- a/FindMySyncPlus.xcodeproj/project.pbxproj +++ b/FindMySyncPlus.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ AABB0002AABB0002AABB0002 /* CacheDecryptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABB0001AABB0001AABB0001 /* CacheDecryptorTests.swift */; }; CC110002CC110002CC110002 /* TextSanitizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC110001CC110001CC110001 /* TextSanitizationTests.swift */; }; CC110004CC110004CC110004 /* LocalStorageDecryptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC110003CC110003CC110003 /* LocalStorageDecryptorTests.swift */; }; + MQTT0001MQTT0001MQTT0001 /* CocoaMQTT in Frameworks */ = {isa = PBXBuildFile; productRef = MQTT0002MQTT0002MQTT0002 /* CocoaMQTT */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -66,6 +67,7 @@ buildActionMask = 2147483647; files = ( 5DAE2C4B2E73A82E00EF354B /* Ink in Frameworks */, + MQTT0001MQTT0001MQTT0001 /* CocoaMQTT in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -136,6 +138,7 @@ name = FindMySyncPlus; packageProductDependencies = ( 5DAE2C4A2E73A82E00EF354B /* Ink */, + MQTT0002MQTT0002MQTT0002 /* CocoaMQTT */, ); productName = FindMySequoia; productReference = 5DA3BFED2E5B3B8800B73B01 /* FindMySyncPlus.app */; @@ -215,6 +218,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 5DAE2C492E73A82E00EF354B /* XCRemoteSwiftPackageReference "Ink" */, + MQTT0003MQTT0003MQTT0003 /* XCRemoteSwiftPackageReference "CocoaMQTT" */, ); preferredProjectObjectVersion = 77; productRefGroup = 5DA3BFEE2E5B3B8800B73B01 /* Products */; @@ -627,6 +631,14 @@ minimumVersion = 0.6.0; }; }; + MQTT0003MQTT0003MQTT0003 /* XCRemoteSwiftPackageReference "CocoaMQTT" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/emqx/CocoaMQTT.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.1.9; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -635,6 +647,11 @@ package = 5DAE2C492E73A82E00EF354B /* XCRemoteSwiftPackageReference "Ink" */; productName = Ink; }; + MQTT0002MQTT0002MQTT0002 /* CocoaMQTT */ = { + isa = XCSwiftPackageProductDependency; + package = MQTT0003MQTT0003MQTT0003 /* XCRemoteSwiftPackageReference "CocoaMQTT" */; + productName = CocoaMQTT; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5DA3BFE52E5B3B8800B73B01 /* Project object */; diff --git a/FindMySyncPlus/HAClient.swift b/FindMySyncPlus/HAClient.swift index 75d7f21..9cd7222 100644 --- a/FindMySyncPlus/HAClient.swift +++ b/FindMySyncPlus/HAClient.swift @@ -57,7 +57,7 @@ enum AuthError: LocalizedError { } } -private enum PostResult: Sendable { +enum PostResult: Sendable { case success(id: String, status: Int) case authRejected(id: String, status: Int) // 401/403 case transient(id: String, reason: String) // network/other HTTP diff --git a/FindMySyncPlus/Helpers/Keychain.swift b/FindMySyncPlus/Helpers/Keychain.swift index 085a575..5d1f4b6 100644 --- a/FindMySyncPlus/Helpers/Keychain.swift +++ b/FindMySyncPlus/Helpers/Keychain.swift @@ -6,6 +6,7 @@ enum KeychainKey: String { case fmipSymmetricKey = "fmipSymmetricKey" // raw bytes case fmfKey = "fmfKey" // raw bytes (FMF symmetric key) case localStorageKey = "localStorageKey" // raw 32 bytes (AES-256) + case mqttPassword = "mqttPassword" // String (MQTT broker password) } enum Keychain { diff --git a/FindMySyncPlus/LocalStorageDecryptor.swift b/FindMySyncPlus/LocalStorageDecryptor.swift index 202deb9..0efa953 100644 --- a/FindMySyncPlus/LocalStorageDecryptor.swift +++ b/FindMySyncPlus/LocalStorageDecryptor.swift @@ -289,6 +289,24 @@ actor LocalStorageDecryptor { let acc = (locDict["horizontalAccuracy"] as? Double) ?? 0 + // Extract rich location attributes from the bplist blob + let richTimestamp: Date? = { + if let ts = locDict["timestamp"] as? Double { + // NSDate reference date (2001-01-01) encoded as TimeInterval + return Date(timeIntervalSinceReferenceDate: ts) + } + return nil + }() + let rich = RichLocationAttributes( + verticalAccuracy: locDict["verticalAccuracy"] as? Double, + altitude: locDict["altitude"] as? Double, + speed: locDict["speed"] as? Double, + course: locDict["course"] as? Double, + timestamp: richTimestamp, + motionActivityState: locDict["motionActivityState"] as? Int, + locationLabel: locDict["locationLabel"] as? String + ) + let name = prettyName ?? contactId ?? handleId ?? "(unknown)" // Log types field at debug level for investigation @@ -302,7 +320,8 @@ actor LocalStorageDecryptor { latitude: lat, longitude: lon, accuracy: acc, - battery: nil + battery: nil, + richAttributes: rich )) } diff --git a/FindMySyncPlus/MQTTClient.swift b/FindMySyncPlus/MQTTClient.swift new file mode 100644 index 0000000..8883653 --- /dev/null +++ b/FindMySyncPlus/MQTTClient.swift @@ -0,0 +1,277 @@ +import Foundation +import CocoaMQTT + +enum MQTTConnectionState: Sendable { + case disconnected + case connecting + case connected +} + +@MainActor +final class MQTTClient: NSObject, ObservableObject { + @Published private(set) var connectionState: MQTTConnectionState = .disconnected + + private var client: CocoaMQTT? + private var reconnectTask: Task? + private var reconnectAttempts = 0 + private var publishedDiscoveryIds: Set = [] + + private weak var logger: LogStore? + + func bind(logger: LogStore) { + self.logger = logger + } + + // MARK: - Connection lifecycle + + func connect(settings: SettingsStore) { + disconnect() + guard !settings.mqttHost.isEmpty else { + logger?.warn("MQTT: host not configured") + return + } + + let clientId = "FindMySyncPlus-\(UUID().uuidString.prefix(8))" + let mqtt = CocoaMQTT( + clientID: clientId, + host: settings.mqttHost, + port: UInt16(settings.mqttPort) + ) + if !settings.mqttUsername.isEmpty { + mqtt.username = settings.mqttUsername + if !settings.mqttPassword.isEmpty { + mqtt.password = settings.mqttPassword + } + } + mqtt.keepAlive = 60 + mqtt.autoReconnect = false + if settings.mqttUseTLS { + mqtt.enableSSL = true + mqtt.allowUntrustCACertificate = true + } + mqtt.delegate = self + client = mqtt + + connectionState = .connecting + logger?.info("MQTT connecting to \(settings.mqttHost):\(settings.mqttPort)") + _ = mqtt.connect() + } + + func disconnect() { + reconnectTask?.cancel() + reconnectTask = nil + reconnectAttempts = 0 + client?.disconnect() + client = nil + connectionState = .disconnected + publishedDiscoveryIds.removeAll() + } + + func ensureConnected(settings: SettingsStore) async -> Bool { + if connectionState == .connected { return true } + connect(settings: settings) + // Wait up to 5 seconds for connection + for _ in 0..<50 { + try? await Task.sleep(for: .milliseconds(100)) + if connectionState == .connected { return true } + } + return connectionState == .connected + } + + // MARK: - Connection test + + func testConnection(settings: SettingsStore) async -> (Bool, String) { + let wasConnected = connectionState == .connected + if !wasConnected { + connect(settings: settings) + } + // Wait up to 5 seconds + for _ in 0..<50 { + try? await Task.sleep(for: .milliseconds(100)) + if connectionState == .connected { + if !wasConnected { + disconnect() + } + return (true, "Connected successfully to \(settings.mqttHost):\(settings.mqttPort)") + } + } + let msg = "Connection failed to \(settings.mqttHost):\(settings.mqttPort)" + if !wasConnected { + disconnect() + } + return (false, msg) + } + + // MARK: - Publishing + + func post(_ devices: [DevicePoint], + aliasByUUID: [String: String], + settings: SettingsStore, + logger: LogStore, + dryRun: Bool = false) async -> PostSummary { + + if dryRun { + for d in devices { + let uuid = d.id.normalized() + if let alias = aliasByUUID[uuid] { + let devId = DeviceAlias.entityID(for: alias) + logger.info("[DRY] Would publish MQTT for dev_id=\(devId)") + } else { + logger.warn("[DRY] Skipping \(uuid): no alias mapping found") + } + } + return PostSummary(successCount: 0, authRejectedCount: 0, transientCount: 0) + } + + guard connectionState == .connected, let client else { + logger.warn("MQTT: not connected, skipping publish") + return PostSummary(successCount: 0, authRejectedCount: 0, + transientCount: devices.count) + } + + var successCount = 0 + var transientCount = 0 + let prefix = settings.mqttTopicPrefix + let iso = ISO8601DateFormatter() + + for d in devices { + let uuid = d.id.normalized() + guard let alias = aliasByUUID[uuid] else { + transientCount += 1 + logger.warn("MQTT: no alias for UUID \(uuid)") + continue + } + + let devId = DeviceAlias.entityID(for: alias) + + // Publish HA auto-discovery config (once per session) + if !publishedDiscoveryIds.contains(devId) { + let configTopic = "homeassistant/device_tracker/\(devId)/config" + let configPayload: [String: Any] = [ + "name": d.name.isEmpty ? alias : d.name, + "unique_id": devId, + "object_id": devId, + "state_topic": "\(prefix)\(devId)/state", + "json_attributes_topic": "\(prefix)\(devId)/attributes", + "source_type": "gps" + ] + publishJSON(client: client, topic: configTopic, payload: configPayload, retain: true) + publishedDiscoveryIds.insert(devId) + logger.info("MQTT discovery published for \(devId)") + } + + // Publish state + let stateMsg = CocoaMQTTMessage( + topic: "\(prefix)\(devId)/state", + string: "not_home", + qos: .qos1, + retained: false + ) + client.publish(stateMsg) + + // Build and publish attributes + let attrs = buildAttributes(for: d, iso: iso) + publishJSON(client: client, topic: "\(prefix)\(devId)/attributes", payload: attrs, retain: true) + successCount += 1 + logger.info("[\(devId)] MQTT published") + } + + return PostSummary(successCount: successCount, + authRejectedCount: 0, + transientCount: transientCount) + } + + // MARK: - Attribute building + + private func buildAttributes(for device: DevicePoint, iso: ISO8601DateFormatter) -> [String: Any] { + var attrs: [String: Any] = [ + "latitude": device.latitude, + "longitude": device.longitude, + "gps_accuracy": device.accuracy, + "last_update": iso.string(from: Date()) + ] + if let b = device.battery { + attrs["battery"] = Int((b * 100).rounded()) + } + if let rich = device.richAttributes { + if let alt = rich.altitude { attrs["altitude"] = alt } + if let speed = rich.speed { attrs["speed"] = speed } + if let course = rich.course { attrs["course"] = course } + if let vAcc = rich.verticalAccuracy { attrs["vertical_accuracy"] = vAcc } + if let ts = rich.timestamp { + attrs["location_timestamp"] = iso.string(from: ts) + } + if rich.motionActivityState != nil { + attrs["motion_state"] = rich.motionStateDescription.lowercased() + } + if let label = rich.locationLabel { + attrs["location_label"] = label + } + } + return attrs + } + + // MARK: - Helpers + + private func publishJSON(client: CocoaMQTT, topic: String, payload: [String: Any], retain: Bool) { + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { return } + let msg = CocoaMQTTMessage(topic: topic, string: json, qos: .qos1, retained: retain) + client.publish(msg) + } + + private func scheduleReconnect(settings: SettingsStore) { + reconnectTask?.cancel() + reconnectAttempts += 1 + guard reconnectAttempts <= 10 else { + logger?.warn("MQTT: max reconnect attempts reached") + return + } + let delay = min(Double(reconnectAttempts) * 5.0, 60.0) + connectionState = .connecting + logger?.warn("MQTT reconnecting (attempt \(reconnectAttempts), \(Int(delay))s)") + let settingsRef = settings + reconnectTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled else { return } + self?.connect(settings: settingsRef) + } + } +} + +// MARK: - CocoaMQTTDelegate + +extension MQTTClient: CocoaMQTTDelegate { + nonisolated func mqtt(_ mqtt: CocoaMQTT, didConnectAck ack: CocoaMQTTConnAck) { + let accepted = (ack == .accept) + let ackDesc = "\(ack)" + Task { @MainActor in + if accepted { + self.connectionState = .connected + self.reconnectAttempts = 0 + self.reconnectTask?.cancel() + self.logger?.info("MQTT connected") + } else { + self.logger?.error("MQTT connection rejected: \(ackDesc)") + self.connectionState = .disconnected + } + } + } + + nonisolated func mqttDidDisconnect(_ mqtt: CocoaMQTT, withError err: (any Error)?) { + Task { @MainActor in + self.connectionState = .disconnected + if let err { + self.logger?.warn("MQTT disconnected: \(err.localizedDescription)") + } + } + } + + nonisolated func mqtt(_ mqtt: CocoaMQTT, didPublishMessage message: CocoaMQTTMessage, id: UInt16) {} + nonisolated func mqtt(_ mqtt: CocoaMQTT, didPublishAck id: UInt16) {} + nonisolated func mqtt(_ mqtt: CocoaMQTT, didReceiveMessage message: CocoaMQTTMessage, id: UInt16) {} + nonisolated func mqtt(_ mqtt: CocoaMQTT, didSubscribeTopics success: NSDictionary, failed: [String]) {} + nonisolated func mqtt(_ mqtt: CocoaMQTT, didUnsubscribeTopics topics: [String]) {} + nonisolated func mqttDidPing(_ mqtt: CocoaMQTT) {} + nonisolated func mqttDidReceivePong(_ mqtt: CocoaMQTT) {} +} diff --git a/FindMySyncPlus/Models/AppModel.swift b/FindMySyncPlus/Models/AppModel.swift index 48e71cc..7fe2d1f 100644 --- a/FindMySyncPlus/Models/AppModel.swift +++ b/FindMySyncPlus/Models/AppModel.swift @@ -132,6 +132,14 @@ final class AppModel: NSObject, ObservableObject { } } + // MARK: - MQTT test + + func triggerManualMQTTTestAsync() async -> (Bool, String) { + guard !isPerformingRun else { return (false, "Busy") } + guard let settings else { return (false, "Unavailable") } + return await syncEngine.mqtt.testConnection(settings: settings) + } + // MARK: - Scheduler func start() { @@ -141,6 +149,9 @@ final class AppModel: NSObject, ObservableObject { totalRunsCount = 0 runWarningsCount = 0 postedUpdatesCount = 0 + if let settings, settings.transportMode == .mqtt { + syncEngine.mqtt.connect(settings: settings) + } runOnce() scheduleTimer() } @@ -152,6 +163,7 @@ final class AppModel: NSObject, ObservableObject { timerTask = nil nextRun = nil lastRunHadWarnings = false + syncEngine.mqtt.disconnect() } @discardableResult diff --git a/FindMySyncPlus/Models/DevicePoint.swift b/FindMySyncPlus/Models/DevicePoint.swift index f189d4e..36608a6 100644 --- a/FindMySyncPlus/Models/DevicePoint.swift +++ b/FindMySyncPlus/Models/DevicePoint.swift @@ -8,8 +8,10 @@ struct DevicePoint: Sendable { let accuracy: Double let battery: Double? let prsId: String? // person ID (base64 DSID); "owner" for self, DSID for family devices + let richAttributes: RichLocationAttributes? - init(id: String, name: String, latitude: Double, longitude: Double, accuracy: Double, battery: Double?, prsId: String? = nil) { + // swiftlint:disable:next line_length + init(id: String, name: String, latitude: Double, longitude: Double, accuracy: Double, battery: Double?, prsId: String? = nil, richAttributes: RichLocationAttributes? = nil) { self.id = id self.name = name self.latitude = latitude @@ -17,5 +19,6 @@ struct DevicePoint: Sendable { self.accuracy = accuracy self.battery = battery self.prsId = prsId + self.richAttributes = richAttributes } } diff --git a/FindMySyncPlus/Models/RichLocationAttributes.swift b/FindMySyncPlus/Models/RichLocationAttributes.swift new file mode 100644 index 0000000..f5510d3 --- /dev/null +++ b/FindMySyncPlus/Models/RichLocationAttributes.swift @@ -0,0 +1,23 @@ +import Foundation + +struct RichLocationAttributes: Sendable { + let verticalAccuracy: Double? + let altitude: Double? + let speed: Double? + let course: Double? + let timestamp: Date? + let motionActivityState: Int? + let locationLabel: String? + + var motionStateDescription: String { + switch motionActivityState { + case 0: return "Unknown" + case 1: return "Stationary" + case 2: return "Walking" + case 3: return "Running" + case 4: return "Automotive" + case 5: return "Cycling" + default: return "Unknown" + } + } +} diff --git a/FindMySyncPlus/Models/SettingsStore.swift b/FindMySyncPlus/Models/SettingsStore.swift index 83e5bd1..6f67f31 100644 --- a/FindMySyncPlus/Models/SettingsStore.swift +++ b/FindMySyncPlus/Models/SettingsStore.swift @@ -50,6 +50,11 @@ enum EndpointAuthStatus: String, Codable { case invalid } +enum TransportMode: String, CaseIterable { + case rest + case mqtt +} + @MainActor final class SettingsStore: ObservableObject { init() { @@ -57,6 +62,9 @@ final class SettingsStore: ObservableObject { if let s = Keychain.getString(for: .endpointAuth) { self.endpointAuth = s } + if let p = Keychain.getString(for: .mqttPassword) { + self.mqttPassword = p + } if Keychain.getData(for: .fmipSymmetricKey) != nil { self.fmipKeyStatus = .present @@ -77,9 +85,23 @@ final class SettingsStore: ObservableObject { } self.loadAliasesFromStorage() + + // Migration: existing REST users keep REST as default transport + if !transportModeExplicitlySet && !endpointURL.isEmpty { + transportMode = .rest + } + } + + func setTransportMode(_ mode: TransportMode) { + transportMode = mode + transportModeExplicitlySet = true } - // Endpoint + // Transport mode + @AppStorage("transportMode") var transportMode: TransportMode = .rest + @AppStorage("transportModeExplicitlySet") private var transportModeExplicitlySet: Bool = false + + // Endpoint (REST) @AppStorage("endpointURL") var endpointURL: String = "" @Published var endpointAuth: String = "" { @@ -92,6 +114,23 @@ final class SettingsStore: ObservableObject { } } + // MQTT broker settings + @AppStorage("mqttHost") var mqttHost: String = "" + @AppStorage("mqttPort") var mqttPort: Int = 1883 + @AppStorage("mqttUseTLS") var mqttUseTLS: Bool = false + @AppStorage("mqttUsername") var mqttUsername: String = "" + @AppStorage("mqttTopicPrefix") var mqttTopicPrefix: String = "findmysyncplus/" + + @Published var mqttPassword: String = "" { + didSet { + _ = Keychain.setString(mqttPassword, for: .mqttPassword) + } + } + + func updateMqttPassword(_ newValue: String) { + mqttPassword = newValue + } + // Interval (seconds on disk, minutes in UI) @AppStorage("updateIntervalSec") var updateIntervalSec: Double = 300 @Published var fmipKeyStatus: KeyStatus = .notPresent diff --git a/FindMySyncPlus/SyncEngine.swift b/FindMySyncPlus/SyncEngine.swift index cc3d7da..80dc298 100644 --- a/FindMySyncPlus/SyncEngine.swift +++ b/FindMySyncPlus/SyncEngine.swift @@ -48,6 +48,9 @@ final class SyncEngine { private let cacheDecryptor = CacheDecryptor() private let localStorageDecryptor = LocalStorageDecryptor() + private let mqttClient = MQTTClient() + + var mqtt: MQTTClient { mqttClient } private weak var settings: SettingsStore? private weak var logger: LogStore? @@ -57,6 +60,7 @@ final class SyncEngine { self.settings = settings self.logger = logger self.app = app + mqttClient.bind(logger: logger) } // MARK: - Key invalidation @@ -118,6 +122,7 @@ final class SyncEngine { // MARK: - Run pipeline + // swiftlint:disable:next cyclomatic_complexity function_body_length func run(kind: RunKind, dryRun: Bool) async { guard let settings, let logger, let app else { return } @@ -284,11 +289,21 @@ final class SyncEngine { let summary = summaryParts.joined(separator: " ") logger.debug(dryRun ? "[DRY] Summary — \(summary)" : "Plan — \(summary)") - let postSummary = await HAClient.post(toPost, + let postSummary: PostSummary + switch settings.transportMode { + case .rest: + postSummary = await HAClient.post(toPost, aliasByUUID: aliasByUUID, settings: settings, logger: logger, dryRun: dryRun) + case .mqtt: + postSummary = await mqttClient.post(toPost, + aliasByUUID: aliasByUUID, + settings: settings, + logger: logger, + dryRun: dryRun) + } if !dryRun { logger.debug("Result — posted=\(postSummary.successCount) auth_rejected=\(postSummary.authRejectedCount) transient=\(postSummary.transientCount)") @@ -297,8 +312,8 @@ final class SyncEngine { let trackedCount = planToPost let postedCount = postSummary.successCount - // Promote/demote auth status based on real posts (never in dry run) - if !dryRun { + // Promote/demote auth status based on real posts (REST only, never in dry run) + if !dryRun && settings.transportMode == .rest { if postSummary.successCount > 0 { updateEndpointAuthStatus(outcome: .success, dryRun: false) } else if postSummary.authRejectedCount > 0 { @@ -635,36 +650,46 @@ final class SyncEngine { return false } - // 3) Endpoint auth (normal runs only) + // 3) Transport connectivity (normal runs only) if dryRun { - logger.info("[DRY] Skipping pre-flight endpoint authentication test") + logger.info("[DRY] Skipping pre-flight transport test") } else { - if settings.endpointAuth.isEmpty { - settings.endpointAuthStatus = .notSet - } - do { - try await HAClient.testEndpointAuthentication(settings: settings) - updateEndpointAuthStatus(outcome: .success, dryRun: false) - logger.debug("Pre-flight check passed: Endpoint authentication is valid.") - } catch let auth as AuthError { - switch auth { - case .authRejected: - updateEndpointAuthStatus(outcome: .authRejected, dryRun: false) - logger.error(auth.localizedDescription) - return false - case .requestFailed(let status) where (500...599).contains(status): - logger.warn("Pre-flight auth check: endpoint unavailable (HTTP \(status)). Aborting run.") - return false - case .networkError: - logger.warn("Pre-flight auth check: network error. Aborting run. \(auth.localizedDescription)") - return false - default: - logger.warn("Pre-flight auth check warning: \(auth.localizedDescription). Aborting run.") + switch settings.transportMode { + case .rest: + if settings.endpointAuth.isEmpty { + settings.endpointAuthStatus = .notSet + } + do { + try await HAClient.testEndpointAuthentication(settings: settings) + updateEndpointAuthStatus(outcome: .success, dryRun: false) + logger.debug("Pre-flight check passed: Endpoint authentication is valid.") + } catch let auth as AuthError { + switch auth { + case .authRejected: + updateEndpointAuthStatus(outcome: .authRejected, dryRun: false) + logger.error(auth.localizedDescription) + return false + case .requestFailed(let status) where (500...599).contains(status): + logger.warn("Pre-flight auth check: endpoint unavailable (HTTP \(status)). Aborting run.") + return false + case .networkError: + logger.warn("Pre-flight auth check: network error. Aborting run. \(auth.localizedDescription)") + return false + default: + logger.warn("Pre-flight auth check warning: \(auth.localizedDescription). Aborting run.") + return false + } + } catch { + logger.warn("Pre-flight auth check warning: \(error.localizedDescription). Aborting run.") return false } - } catch { - logger.warn("Pre-flight auth check warning: \(error.localizedDescription). Aborting run.") - return false + case .mqtt: + let connected = await mqttClient.ensureConnected(settings: settings) + if connected { + logger.debug("Pre-flight check passed: MQTT broker connected.") + } else { + logger.warn("Pre-flight: MQTT broker not reachable. Continuing run (will retry at post time).") + } } } return true diff --git a/FindMySyncPlus/Views/AccessSettingsView.swift b/FindMySyncPlus/Views/AccessSettingsView.swift index 4fc919e..e9323b1 100644 --- a/FindMySyncPlus/Views/AccessSettingsView.swift +++ b/FindMySyncPlus/Views/AccessSettingsView.swift @@ -8,13 +8,15 @@ struct AccessSettingsView: View { @EnvironmentObject var app: AppModel @State private var showAuth: Bool = false + @State private var showMqttPassword: Bool = false @State private var authLastTest: Date? = nil @State private var hoveringEye: Bool = false + @State private var hoveringMqttEye: Bool = false - private enum AuthStatus { + private enum ConnectionTestStatus { case idle, running, success, rejected, failed, invalidURL(String) } - @State private var authStatus: AuthStatus = .idle + @State private var connectionTestStatus: ConnectionTestStatus = .idle @State private var bulkImportResult: String? = nil private enum KeyTab: String, CaseIterable { case all, fmip, fmf, localStorage } @@ -23,10 +25,19 @@ struct AccessSettingsView: View { var body: some View { ScrollView { VStack(spacing: 16) { - SectionHeader(title: "ENDPOINT", tip: "Configure Home Assistant endpoint and authorization.") - endpointCard - authCard - authTestCard + SectionHeader(title: "TRANSPORT", tip: "Choose how locations are sent to Home Assistant.") + transportPickerCard + + if settings.transportMode == .rest { + SectionHeader(title: "REST ENDPOINT", tip: "Configure Home Assistant endpoint and authorization.") + endpointCard + authCard + } else { + SectionHeader(title: "MQTT BROKER", tip: "Configure MQTT broker connection for Home Assistant.") + mqttBrokerCard + } + + connectionTestCard SectionHeader(title: "LOCAL", tip: "Local key and macOS permissions required for decryption.") .padding(.top, 8) @@ -113,6 +124,107 @@ struct AccessSettingsView: View { } } + // MARK: - Transport Picker + private var transportPickerCard: some View { + Card { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text("Transport Mode") + .font(.title3).fontWeight(.semibold) + InfoTip(message: """ + REST uses HTTP POST to device_tracker/see. \ + MQTT uses HA auto-discovery with richer attributes.\n\n\ + Switching transport modes creates new entities in Home Assistant. \ + Old entities from the previous mode will become stale and should be \ + removed manually. Update any automations or dashboards that reference \ + the old entities. + """) + Spacer() + } + Picker("", selection: Binding( + get: { settings.transportMode }, + set: { settings.setTransportMode($0) } + )) { + Text("REST").tag(TransportMode.rest) + Text("MQTT").tag(TransportMode.mqtt) + } + .pickerStyle(.segmented) + .onChange(of: settings.transportMode) { _, _ in + connectionTestStatus = .idle + } + } + } + } + + // MARK: - MQTT Broker + private var mqttBrokerCard: some View { + Card { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text("Broker") + .font(.title3).fontWeight(.semibold) + InfoTip(message: "MQTT broker connection details. HA's built-in Mosquitto add-on typically runs on port 1883 (or 8883 with TLS).") + Spacer() + } + + HStack(spacing: 8) { + TextField("homeassistant.local", text: $settings.mqttHost) + .textFieldStyle(.roundedBorder) + Text(":") + .foregroundStyle(.secondary) + TextField("1883", value: $settings.mqttPort, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 70) + } + + Toggle("Use TLS", isOn: $settings.mqttUseTLS) + .onChange(of: settings.mqttUseTLS) { _, useTLS in + if useTLS && settings.mqttPort == 1883 { + settings.mqttPort = 8883 + } else if !useTLS && settings.mqttPort == 8883 { + settings.mqttPort = 1883 + } + } + + TextField("Username", text: $settings.mqttUsername) + .textFieldStyle(.roundedBorder) + + HStack(spacing: 8) { + let passwordBinding = Binding( + get: { settings.mqttPassword }, + set: { settings.updateMqttPassword($0) } + ) + Group { + if showMqttPassword { + TextField("Password", text: passwordBinding) + } else { + SecureField("Password", text: passwordBinding) + } + } + .textFieldStyle(.roundedBorder) + .layoutPriority(1) + + Button { showMqttPassword.toggle() } label: { + Image(systemName: showMqttPassword ? "eye.slash" : "eye") + .symbolRenderingMode(.monochrome) + .foregroundStyle(hoveringMqttEye ? Color.accentColor.opacity(0.9) : Color.accentColor.opacity(0.7)) + } + .buttonStyle(.borderless) + .onHover { hoveringMqttEye = $0 } + .help(showMqttPassword ? "Hide password" : "Show password") + } + + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text("Topic Prefix") + .font(.callout).foregroundStyle(.secondary) + Spacer() + } + TextField("findmysyncplus/", text: $settings.mqttTopicPrefix) + .textFieldStyle(.roundedBorder) + } + } + } + // MARK: - Endpoint private var endpointCard: some View { Card { @@ -127,7 +239,7 @@ struct AccessSettingsView: View { TextField("http://homeassistant.local:8123/api/services/device_tracker/see", text: $settings.endpointURL) .textFieldStyle(.roundedBorder) .onChange(of: settings.endpointURL) { _, _ in - authStatus = .idle + connectionTestStatus = .idle } } } @@ -176,57 +288,80 @@ struct AccessSettingsView: View { } } - // MARK: - Auth Test - private var authTestCard: some View { + // MARK: - Connection Test (generic for REST/MQTT) + private var connectionTestCard: some View { Card { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline, spacing: 8) { - Text("Auth Test") + Text("Connection Test") .font(.title3).fontWeight(.semibold) - InfoTip(message: #"Performs a GET request on the base /api/ endpoint to verify the Authorization header."#) + InfoTip(message: settings.transportMode == .rest + ? "Performs a GET request on the base /api/ endpoint to verify the Authorization header." + : "Attempts to connect to the MQTT broker to verify host, port, and credentials.") Spacer() } HStack { - authStatusDisplay + connectionTestStatusDisplay Spacer() Button { Task { await MainActor.run { - authStatus = .running + connectionTestStatus = .running authLastTest = nil } - let outcome = await app.triggerManualAuthTestAsync() - await MainActor.run { - authLastTest = Date() - switch outcome { - case .success: - authStatus = .success - case .authRejected: - authStatus = .rejected - case .badConfig(let msg): - authStatus = .invalidURL(msg ?? "Invalid configuration") - case .transient: - authStatus = .failed + if settings.transportMode == .rest { + let outcome = await app.triggerManualAuthTestAsync() + await MainActor.run { + authLastTest = Date() + switch outcome { + case .success: + connectionTestStatus = .success + case .authRejected: + connectionTestStatus = .rejected + case .badConfig(let msg): + connectionTestStatus = .invalidURL(msg ?? "Invalid configuration") + case .transient: + connectionTestStatus = .failed + } + } + } else { + let (ok, msg) = await app.triggerManualMQTTTestAsync() + await MainActor.run { + authLastTest = Date() + connectionTestStatus = ok ? .success : .failed + if !ok { + connectionTestStatus = .invalidURL(msg) + } } } } } label: { if app.isPerformingRun { - Label("Wait…", systemImage: "key.fill") + Label("Wait…", systemImage: settings.transportMode == .rest ? "key.fill" : "network") } else { - Label("Test Auth", systemImage: "key.fill") + Label(settings.transportMode == .rest ? "Test Auth" : "Test Connection", + systemImage: settings.transportMode == .rest ? "key.fill" : "network") } } - .disabled(app.isPerformingRun || settings.endpointURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(app.isPerformingRun || testButtonDisabled) } } } } + private var testButtonDisabled: Bool { + switch settings.transportMode { + case .rest: + return settings.endpointURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .mqtt: + return settings.mqttHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + @ViewBuilder - private var authStatusDisplay: some View { + private var connectionTestStatusDisplay: some View { HStack(alignment: .firstTextBaseline, spacing: 6) { - switch authStatus { + switch connectionTestStatus { case .idle: Image(systemName: "minus.circle").foregroundStyle(.secondary) Text("Not tested") @@ -239,7 +374,7 @@ struct AccessSettingsView: View { case .success: Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) - Text("Auth OK") + Text(settings.transportMode == .rest ? "Auth OK" : "Connected") if let t = authLastTest { Text("· \(t.formatted(date: .omitted, time: .shortened))") .font(.caption).foregroundStyle(.secondary) @@ -255,7 +390,7 @@ struct AccessSettingsView: View { case .failed: Image(systemName: "wifi.exclamationmark").foregroundStyle(.orange) - Text("Request failed") + Text(settings.transportMode == .rest ? "Request failed" : "Connection failed") if let t = authLastTest { Text("· \(t.formatted(date: .omitted, time: .shortened))") .font(.caption).foregroundStyle(.secondary) diff --git a/FindMySyncPlus/Views/DeviceManagerView.swift b/FindMySyncPlus/Views/DeviceManagerView.swift index 4e66c18..3b35b04 100644 --- a/FindMySyncPlus/Views/DeviceManagerView.swift +++ b/FindMySyncPlus/Views/DeviceManagerView.swift @@ -501,6 +501,7 @@ Renaming an alias creates a new HA Entity ID (dev_id/host_name). lastSeenName: rec.lastSeenName, sourceBadge: singleSource, nameLabel: "Name:", + transportMode: settings.transportMode, onToggleTracked: { (newValue: Bool) in settings.setAlias(rec.alias, tracked: newValue) }, @@ -566,6 +567,7 @@ Renaming an alias creates a new HA Entity ID (dev_id/host_name). let lastSeenName: String? let sourceBadge: DeviceSource? let nameLabel: String + let transportMode: TransportMode var onToggleTracked: (Bool) -> Void var onRename: () -> Void @@ -667,15 +669,17 @@ Renaming an alias creates a new HA Entity ID (dev_id/host_name). .foregroundStyle(.secondary) Spacer() } - GridRow { - Text("MAC:") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - Text(macFromAlias(aliasKey)) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - Spacer() + if transportMode == .rest { + GridRow { + Text("MAC:") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + Text(macFromAlias(aliasKey)) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + Spacer() + } } } diff --git a/FindMySyncPlus/Views/HomeView.swift b/FindMySyncPlus/Views/HomeView.swift index d43c859..cc69bf8 100644 --- a/FindMySyncPlus/Views/HomeView.swift +++ b/FindMySyncPlus/Views/HomeView.swift @@ -150,28 +150,52 @@ struct HomeView: View { } } + GridRow { + Text("Transport").fontWeight(.semibold) + Text(settings.transportMode == .rest ? "REST" : "MQTT") + } + GridRow { Text("Endpoint").fontWeight(.semibold) - if settings.endpointURL.isEmpty { - Button { - NotificationCenter.default.post(name: .navigateToAccess, object: nil) - } label: { - Text("Not set") - .foregroundStyle(.red) - .underline() + if settings.transportMode == .rest { + if settings.endpointURL.isEmpty { + Button { + NotificationCenter.default.post(name: .navigateToAccess, object: nil) + } label: { + Text("Not set") + .foregroundStyle(.red) + .underline() + } + .buttonStyle(.link) + } else { + Text(settings.endpointURL) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(.primary) } - .buttonStyle(.link) } else { - Text(settings.endpointURL) - .lineLimit(1) - .truncationMode(.middle) - .foregroundStyle(.primary) + if settings.mqttHost.isEmpty { + Button { + NotificationCenter.default.post(name: .navigateToAccess, object: nil) + } label: { + Text("Not set") + .foregroundStyle(.red) + .underline() + } + .buttonStyle(.link) + } else { + Text("\(settings.mqttHost):\(settings.mqttPort)") + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(.primary) + } } } - if settings.endpointAuthStatus == .notSet || settings.endpointAuthStatus == .invalid { + if settings.transportMode == .rest && + (settings.endpointAuthStatus == .notSet || settings.endpointAuthStatus == .invalid) { GridRow { - Text("Auth Header").fontWeight(.semibold) + Text("Authorization").fontWeight(.semibold) switch settings.endpointAuthStatus { case .notSet: Button { diff --git a/README.md b/README.md index 125c5d8..099a7af 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,10 @@ directly to your Home Assistant instance on a configurable schedule. No cloud re service. Your Find My data goes straight to your home network and nowhere else. It tracks Devices (iPhones, Apple Watch), Items (AirTags), and Friends — including live friend -coordinates by decrypting `LocalStorage.db`, something no other tool supports. Rotating UUIDs are -handled automatically, and all sensitive credentials are stored securely in the macOS Keychain. +coordinates by decrypting `LocalStorage.db`, something no other tool supports. Supports both +**REST** (`device_tracker/see`) and **MQTT** (with HA auto-discovery and rich attributes) transports. +Rotating UUIDs are handled automatically, and all sensitive credentials are stored securely in the +macOS Keychain. Based on [FindMySync](https://github.com/MartinPham/FindMySync) and the decryption research by [Pnut-GGG](https://github.com/Pnut-GGG/findmy-cache-decryptor). @@ -43,6 +45,8 @@ Based on [FindMySync](https://github.com/MartinPham/FindMySync) and the decrypti ## Features - Tracks **Devices** (iPhone, Apple Watch), **Items** (AirTags), and **Friends** +- **Two transport modes** — REST (`device_tracker/see`) or MQTT with Home Assistant auto-discovery +- **MQTT rich attributes** — altitude, speed, course, motion state, location labels via `json_attributes_topic` - **Friend location tracking** — decrypts `LocalStorage.db` for live friend coordinates; family members already tracked via Devices are automatically deduplicated using Apple's universal person identifier (DSID) - **Device Manager** — assign friendly aliases to devices, items, and friends; aliases become stable HA entity IDs even as UUIDs rotate - **Auto-learn UUIDs** — automatically re-maps devices when Apple rotates their identifier @@ -91,7 +95,7 @@ person identifier (DSID) and skips duplicates, so each family member is only tra ## Requirements - macOS 15 (Sequoia) or higher -- A running Home Assistant instance with the `device_tracker.see` API enabled +- A running Home Assistant instance — either the `device_tracker.see` REST API or an MQTT broker (e.g. Mosquitto add-on) - Find My encryption keys extracted from Keychain (see Phase 1 below) --- @@ -124,9 +128,10 @@ or clone the repo and build in Xcode with automatic signing enabled. Launch the app and open the **Access** pane (the Home screen will show "Not Set" errors — click one): -1. Enter your Home Assistant `device_tracker.see` endpoint URL -2. Enter your Authorization header (include the `Bearer` prefix) -3. Click **Test Auth** to verify the connection +1. **Choose transport mode** — select **REST** or **MQTT** at the top of the Access pane +2. **REST users:** Enter your HA `device_tracker.see` endpoint URL and Authorization header (include the `Bearer` prefix) + **MQTT users:** Enter your broker host, port, username, and password. Set topic prefix if desired (default: `findmysyncplus/`). HA auto-discovery creates entities automatically — no `known_devices.yaml` needed +3. Click **Test Connection** to verify 4. Under **Decryption Keys**, select the **All** tab and click **Import All from Folder** — point it at the `keys/` directory from Phase 1 5. Click **Open Preferences** and grant Full Disk Access 6. Quit and relaunch the app @@ -156,13 +161,15 @@ Under the **General** pane, recommended settings: 4. Give each entry an alias — the Home Assistant entity will be `findmy_` 5. Return to the **Home** pane and enable the **Scheduler** -**Optional:** In Home Assistant, edit `known_devices.yaml` to add friendly names: +**REST users:** Optionally edit `known_devices.yaml` in Home Assistant to add friendly names: ```yaml findmy_alias1: name: "Alice's AirTag" track: true ``` +**MQTT users:** Entities are created automatically via HA auto-discovery — no `known_devices.yaml` needed. Rich attributes (altitude, speed, motion state, etc.) are available on each entity's `json_attributes`. + --- ## A Note on AI Co-creation @@ -182,3 +189,5 @@ reflects that journey. - `LocalStorage.db` cipher — reverse-engineered from Apple's `sqliteCodecCCCrypto` in `libsqlite3.dylib` via `lldb` disassembly, with [Claude](https://claude.ai) - [findmy-key-extractor](https://github.com/manonstreet/findmy-key-extractor) — extracts all 3 Find My encryption keys required for setup - [Ink](https://github.com/JohnSundell/Ink) — MIT-licensed Markdown parser used in-app +- [FollowMyFriends](https://github.com/bytePatrol/FollowMyFriends) — reference for MQTT transport pattern +- [CocoaMQTT](https://github.com/emqx/CocoaMQTT) — MIT-licensed MQTT client for Swift