diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 143341f33..908dc2b01 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -403,6 +403,10 @@ DD86D40F2881BE4C00BAEB7A /* CsvDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD86D40E2881BE4C00BAEB7A /* CsvDocument.swift */; }; AA20D0002D00000000000011 /* DeviceProfileExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA20D0002D00000000000010 /* DeviceProfileExport.swift */; }; AA20D0002D00000000000013 /* DeviceProfileDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA20D0002D00000000000012 /* DeviceProfileDocument.swift */; }; + AA20D0002D00000000000031 /* DeviceProfileImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA20D0002D00000000000030 /* DeviceProfileImport.swift */; }; + AA20D0002D00000000000033 /* DeviceProfileImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA20D0002D00000000000032 /* DeviceProfileImporter.swift */; }; + AA20D0002D00000000000035 /* ImportDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA20D0002D00000000000034 /* ImportDeviceProfileView.swift */; }; + AA20D0002D00000000000041 /* DeviceProfileImportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA20D0002D00000000000040 /* DeviceProfileImportTests.swift */; }; DD86D4112881D16900BAEB7A /* WriteCsvFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD86D4102881D16900BAEB7A /* WriteCsvFile.swift */; }; DD8EBF43285058FA00426DCA /* DisplayConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8EBF42285058FA00426DCA /* DisplayConfig.swift */; }; DD8ED9C52898D51F00B3B0AB /* NetworkConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8ED9C42898D51F00B3B0AB /* NetworkConfig.swift */; }; @@ -1034,6 +1038,10 @@ DD86D40E2881BE4C00BAEB7A /* CsvDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CsvDocument.swift; sourceTree = ""; }; AA20D0002D00000000000010 /* DeviceProfileExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfileExport.swift; sourceTree = ""; }; AA20D0002D00000000000012 /* DeviceProfileDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfileDocument.swift; sourceTree = ""; }; + AA20D0002D00000000000030 /* DeviceProfileImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfileImport.swift; sourceTree = ""; }; + AA20D0002D00000000000032 /* DeviceProfileImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfileImporter.swift; sourceTree = ""; }; + AA20D0002D00000000000034 /* ImportDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportDeviceProfileView.swift; sourceTree = ""; }; + AA20D0002D00000000000040 /* DeviceProfileImportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfileImportTests.swift; sourceTree = ""; }; DD86D4102881D16900BAEB7A /* WriteCsvFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteCsvFile.swift; sourceTree = ""; }; DD8EBF42285058FA00426DCA /* DisplayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayConfig.swift; sourceTree = ""; }; DD8ED9C42898D51F00B3B0AB /* NetworkConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkConfig.swift; sourceTree = ""; }; @@ -1601,6 +1609,7 @@ DD9C70112E9F2A0000029299 /* DeviceOnboardingTests.swift */, 25F5D5D02C4375DF008036E3 /* RouterTests.swift */, AA20D0002D00000000000020 /* DeviceProfileExportTests.swift */, + AA20D0002D00000000000040 /* DeviceProfileImportTests.swift */, 05DCA1AE2F646B3B00D0724C /* NodeFilterParametersTests.swift */, DD0000A20EDD5A00000000B2 /* XEdDSASigningTests.swift */, 05A1B2C42F70000100D0FC40 /* SettingsNodeSortTests.swift */, @@ -1897,6 +1906,7 @@ DDAB580C2B0DAA9E00147258 /* Routes.swift */, DDE9659B2B1C3B6A00531070 /* RouteRecorder.swift */, DD86D40B287F401000BAEB7A /* SaveChannelQRCode.swift */, + AA20D0002D00000000000034 /* ImportDeviceProfileView.swift */, DD3501882852FC3B000FC853 /* Settings.swift */, DD3CC6B428E33FD100FA9159 /* ShareChannels.swift */, DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */, @@ -2043,6 +2053,15 @@ path = Export; sourceTree = ""; }; + AA20D0002D0000000000003A /* Import */ = { + isa = PBXGroup; + children = ( + AA20D0002D00000000000030 /* DeviceProfileImport.swift */, + AA20D0002D00000000000032 /* DeviceProfileImporter.swift */, + ); + path = Import; + sourceTree = ""; + }; DD8ED9C6289CE4A100B3B0AB /* Enums */ = { isa = PBXGroup; children = ( @@ -2159,6 +2178,7 @@ DD90860A26F645B700DC5189 /* Meshtastic.entitlements */, DD8ED9C6289CE4A100B3B0AB /* Enums */, DD86D40D2881BDB300BAEB7A /* Export */, + AA20D0002D0000000000003A /* Import */, DDDB443E29F79A9400EE2349 /* Extensions */, DDC2E1A526CEB32B0042C5E4 /* Helpers */, DDC2E18826CE24EE0042C5E4 /* Model */, @@ -2743,6 +2763,7 @@ DD9C70102E9F2A0000029299 /* DeviceOnboardingTests.swift in Sources */, 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */, AA20D0002D00000000000021 /* DeviceProfileExportTests.swift in Sources */, + AA20D0002D00000000000041 /* DeviceProfileImportTests.swift in Sources */, 0516B3FE2F68892000D0FC40 /* NodeFilterParametersTests.swift in Sources */, DD0000A10EDD5A00000000B1 /* XEdDSASigningTests.swift in Sources */, 05A1B2C32F70000100D0FC40 /* SettingsNodeSortTests.swift in Sources */, @@ -3120,6 +3141,9 @@ DD86D40F2881BE4C00BAEB7A /* CsvDocument.swift in Sources */, AA20D0002D00000000000011 /* DeviceProfileExport.swift in Sources */, AA20D0002D00000000000013 /* DeviceProfileDocument.swift in Sources */, + AA20D0002D00000000000031 /* DeviceProfileImport.swift in Sources */, + AA20D0002D00000000000033 /* DeviceProfileImporter.swift in Sources */, + AA20D0002D00000000000035 /* ImportDeviceProfileView.swift in Sources */, AA10D0012D00000000000001 /* DiscoverySummaryPDF.swift in Sources */, 23A1AFB72E42BD2500E46C96 /* RXTXIndicatorView.swift in Sources */, DDB75A1E2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift in Sources */, diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift index 0aef72281..c0b83bb30 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift @@ -1811,6 +1811,36 @@ extension AccessoryManager { try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) } + /// Sets an explicit fixed position from supplied coordinates (e.g. restored from an imported device + /// profile), rather than the phone's current GPS fix. The companion `setFixedPosition(fromUser:channel:)` + /// pulls coordinates from `getPositionFromPhoneGPS` and can't set arbitrary coordinates; this overload + /// fills that gap. The position config's `fixedPosition` flag (sent separately) is what tells the + /// firmware to actually use these coordinates. + public func setFixedPosition(_ position: Position, fromUser: UserEntity, toUser: UserEntity, channel: Int32 = 0) async throws -> Int64 { + var adminPacket = AdminMessage() + adminPacket.setFixedPosition = position + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. DeviceProfile { + guard !data.isEmpty else { throw DeviceProfileImportError.emptyFile } + guard data.count <= maxProfileBytes else { throw DeviceProfileImportError.tooLarge } + do { + return try DeviceProfile(serializedBytes: data) + } catch { + throw DeviceProfileImportError.malformed + } + } + + /// Merges a profile's optional owner name onto the connected node's existing `User`, overriding only + /// the long/short name and preserving id, hardware model, keys, role, and licensed/unmessagable flags. + /// Sending a bare name-only `User` to `setOwner` would wipe those, so the base must be the live user. + static func ownerUser(from profile: DeviceProfile, base: User) -> User { + var user = base + if profile.hasLongName { user.longName = profile.longName } + if profile.hasShortName { user.shortName = profile.shortName } + return user + } + + /// Builds the plan as the has*-gated inverse of `exportDeviceProfile()`, emitting items in apply order. + /// - Parameters: + /// - profile: the parsed device profile. + /// - currentUser: the connected node's current `User` (the merge base for the owner step); when nil, + /// the owner step is omitted entirely. + /// - Throws: `.nothingToImport` when the profile yields no applyable items. + init(profile: DeviceProfile, currentUser: User?) throws { + var items: [ImportItem] = [] + let config = profile.config + let module = profile.moduleConfig + + // 1. Owner name (non-sensitive, no reboot). Only when the profile carries a name and we have a + // base user to merge onto. + if profile.hasLongName || profile.hasShortName, let base = currentUser { + let merged = DeviceProfileImportPlan.ownerUser(from: profile, base: base) + let name = [profile.hasLongName ? profile.longName : merged.longName, + profile.hasShortName ? "(\(profile.shortName))" : nil] + .compactMap { $0 }.joined(separator: " ") + items.append(ImportItem(kind: .owner, section: .owner, summary: name.isEmpty ? "Owner name" : name, + isSensitive: false, causesReboot: false, payload: .owner(merged))) + } + + // 2-5. Cheap radio/device configs (Radio & Device section). LoRa is deliberately excluded here β€” it + // belongs to the terminal Channels & LoRa step because it reboots. + if profile.hasConfig { + if config.hasDevice { + items.append(ImportItem(kind: .deviceConfig, section: .radioAndDevice, summary: "Device role & behavior", + isSensitive: false, causesReboot: false, payload: .deviceConfig(config.device))) + } + if config.hasDisplay { + items.append(ImportItem(kind: .displayConfig, section: .radioAndDevice, summary: "Display settings", + isSensitive: false, causesReboot: false, payload: .displayConfig(config.display))) + } + if config.hasPosition { + items.append(ImportItem(kind: .positionConfig, section: .radioAndDevice, summary: "Position settings", + isSensitive: false, causesReboot: false, payload: .positionConfig(config.position))) + } + if config.hasPower { + items.append(ImportItem(kind: .powerConfig, section: .radioAndDevice, summary: "Power settings", + isSensitive: false, causesReboot: false, payload: .powerConfig(config.power))) + } + } + + // 6. Module configs, in the same order export writes them. meshBeacon/remoteHardware are absent by + // design (no save function β€” mirrors export coverage). MQTT is flagged sensitive (password). + if profile.hasModuleConfig { + if module.hasMqtt { + items.append(ImportItem(kind: .mqtt, section: .modules, summary: "MQTT", + isSensitive: true, causesReboot: false, payload: .mqtt(module.mqtt))) + } + if module.hasSerial { + items.append(ImportItem(kind: .serial, section: .modules, summary: "Serial", + isSensitive: false, causesReboot: false, payload: .serial(module.serial))) + } + if module.hasExternalNotification { + items.append(ImportItem(kind: .externalNotification, section: .modules, summary: "External Notification", + isSensitive: false, causesReboot: false, payload: .externalNotification(module.externalNotification))) + } + if module.hasStoreForward { + items.append(ImportItem(kind: .storeForward, section: .modules, summary: "Store & Forward", + isSensitive: false, causesReboot: false, payload: .storeForward(module.storeForward))) + } + if module.hasRangeTest { + items.append(ImportItem(kind: .rangeTest, section: .modules, summary: "Range Test", + isSensitive: false, causesReboot: false, payload: .rangeTest(module.rangeTest))) + } + if module.hasTelemetry { + items.append(ImportItem(kind: .telemetry, section: .modules, summary: "Telemetry", + isSensitive: false, causesReboot: false, payload: .telemetry(module.telemetry))) + } + if module.hasCannedMessage { + items.append(ImportItem(kind: .cannedMessage, section: .modules, summary: "Canned Message", + isSensitive: false, causesReboot: false, payload: .cannedMessage(module.cannedMessage))) + } + if module.hasAudio { + items.append(ImportItem(kind: .audio, section: .modules, summary: "Audio", + isSensitive: false, causesReboot: false, payload: .audio(module.audio))) + } + if module.hasNeighborInfo { + items.append(ImportItem(kind: .neighborInfo, section: .modules, summary: "Neighbor Info", + isSensitive: false, causesReboot: false, payload: .neighborInfo(module.neighborInfo))) + } + if module.hasAmbientLighting { + items.append(ImportItem(kind: .ambientLighting, section: .modules, summary: "Ambient Lighting", + isSensitive: false, causesReboot: false, payload: .ambientLighting(module.ambientLighting))) + } + if module.hasDetectionSensor { + items.append(ImportItem(kind: .detectionSensor, section: .modules, summary: "Detection Sensor", + isSensitive: false, causesReboot: false, payload: .detectionSensor(module.detectionSensor))) + } + if module.hasPaxcounter { + items.append(ImportItem(kind: .paxcounter, section: .modules, summary: "PAX Counter", + isSensitive: false, causesReboot: false, payload: .paxcounter(module.paxcounter))) + } + if module.hasTak { + items.append(ImportItem(kind: .tak, section: .modules, summary: "TAK", + isSensitive: false, causesReboot: false, payload: .tak(module.tak))) + } + if module.hasTrafficManagement { + items.append(ImportItem(kind: .trafficManagement, section: .modules, summary: "Traffic Management", + isSensitive: false, causesReboot: false, payload: .trafficManagement(module.trafficManagement))) + } + if module.hasStatusmessage { + items.append(ImportItem(kind: .statusMessage, section: .modules, summary: "Status Message", + isSensitive: false, causesReboot: false, payload: .statusMessage(module.statusmessage))) + } + } + + // 7. Personalization: ringtone + canned-message text. Emitted after the Canned Message module + // config (step 6) so the text isn't clobbered by the module config that follows it. + if profile.hasRingtone, !profile.ringtone.isEmpty { + items.append(ImportItem(kind: .ringtone, section: .personalization, summary: "Ringtone", + isSensitive: false, causesReboot: false, payload: .ringtone(profile.ringtone))) + } + if profile.hasCannedMessages, !profile.cannedMessages.isEmpty { + items.append(ImportItem(kind: .cannedMessagesText, section: .personalization, summary: "Canned messages", + isSensitive: false, causesReboot: false, payload: .cannedMessagesText(profile.cannedMessages))) + } + + // 8. Fixed position β€” only when set to real coordinates (inverse of export's guard). Ordered after + // the position config so the fixed-position flag it carries isn't overwritten. + if profile.hasFixedPosition, + profile.fixedPosition.latitudeI != 0 || profile.fixedPosition.longitudeI != 0 { + items.append(ImportItem(kind: .fixedPosition, section: .fixedPosition, summary: "Fixed position", + isSensitive: false, causesReboot: false, payload: .fixedPosition(profile.fixedPosition))) + } + + // 9. Network β€” placed late because enabling Wi-Fi/Ethernet can drop a TCP transport mid-import. + // Sensitive (Wi-Fi PSK). + if profile.hasConfig, config.hasNetwork { + let ssid = config.network.wifiSsid + items.append(ImportItem(kind: .networkConfig, section: .network, + summary: ssid.isEmpty ? "Wi-Fi / Ethernet" : "Wi-Fi SSID: \(ssid)", + isSensitive: true, causesReboot: false, payload: .networkConfig(config.network))) + } + + // 10. Bluetooth β€” just before the terminal step because changing BLE mode/pin can drop the BLE + // transport the import runs over. + if profile.hasConfig, config.hasBluetooth { + items.append(ImportItem(kind: .bluetoothConfig, section: .radioAndDevice, summary: "Bluetooth settings", + isSensitive: false, causesReboot: false, payload: .bluetoothConfig(config.bluetooth))) + } + + // 11. Security β€” opt-in, sensitive. Late because admin-key/isManaged changes can restrict further + // local admin. + if profile.hasConfig, config.hasSecurity { + items.append(ImportItem(kind: .securityConfig, section: .security, + summary: "Node identity, keys & admin access", + isSensitive: true, causesReboot: false, payload: .securityConfig(config.security))) + } + + // 12. TERMINAL: Channels & LoRa (reboots). Export puts the SAME LoRaConfig in both config.lora and + // the channel URL's ChannelSet, so exactly one owner is emitted: prefer the channel URL (it + // also restores channels), fall back to the standalone LoRa config. A channel URL that can't be + // parsed or lacks a LoRa config for replace-mode falls back to the standalone LoRa config so the + // region is still restored. + var addedChannelOrLoRa = false + if profile.hasChannelURL, !profile.channelURL.isEmpty, + let parsed = try? MeshtasticChannelURL.parse(profile.channelURL), + !parsed.addChannels, parsed.channelSet.hasLoraConfig { + items.append(ImportItem(kind: .channelURL, section: .channelsAndLoRa, + summary: "Channels & LoRa region (\(parsed.channelSet.settings.count) channel\(parsed.channelSet.settings.count == 1 ? "" : "s"))", + isSensitive: true, causesReboot: true, payload: .channelURL(profile.channelURL))) + addedChannelOrLoRa = true + } + if !addedChannelOrLoRa, profile.hasConfig, config.hasLora { + items.append(ImportItem(kind: .loraConfig, section: .channelsAndLoRa, + summary: "LoRa region: \(config.lora.region)", + isSensitive: false, causesReboot: true, payload: .loraConfig(config.lora))) + } + + guard !items.isEmpty else { throw DeviceProfileImportError.nothingToImport } + self.items = items + } + + // MARK: Selection helpers + + /// The sections actually present in this profile, in canonical order. + var presentSections: [ImportSection] { + ImportSection.allCases.filter { section in items.contains { $0.section == section } } + } + + /// The items belonging to the selected sections, preserving apply order. + func items(for selection: Set) -> [ImportItem] { + items.filter { selection.contains($0.section) } + } + + /// Whether the selected sections include any item that overwrites sensitive material. + func containsSensitive(in selection: Set) -> Bool { + items(for: selection).contains { $0.isSensitive } + } + + /// Whether applying the selected sections will reboot the radio. + func willReboot(in selection: Set) -> Bool { + items(for: selection).contains { $0.causesReboot } + } +} diff --git a/Meshtastic/Import/DeviceProfileImporter.swift b/Meshtastic/Import/DeviceProfileImporter.swift new file mode 100644 index 000000000..dc991c947 --- /dev/null +++ b/Meshtastic/Import/DeviceProfileImporter.swift @@ -0,0 +1,179 @@ +// +// DeviceProfileImporter.swift +// Meshtastic +// +// The effectful half of "Import Device Configuration": walks a `DeviceProfileImportPlan` in order and +// sends each item to the radio. All ordering, abort-on-failure, and terminal-reboot logic lives in the +// engine here; the `ProfileApplyGateway` seam turns each item into the matching AccessoryManager admin +// send, so the engine can be unit-tested with a mock gateway and no device. +// + +import Foundation +import MeshtasticProtobufs +import OSLog + +// MARK: - Gateway seam + +/// Turns a single `ImportItem` into an admin send. The production conformer forwards to the existing +/// `save*Config` / `saveUser` / `saveChannelSet` / `setFixedPosition` calls; tests use a mock to assert +/// order and abort behavior. All work happens on the main actor because it drives `AccessoryManager`. +@MainActor +protocol ProfileApplyGateway { + /// Whether the radio is currently connected. The engine checks this before each send. + var isConnected: Bool { get } + /// Applies one item, throwing on a genuine failure. Reboot-induced disconnects for the terminal + /// Channels & LoRa item are absorbed by the conformer, so a throw here always means a real failure. + func apply(_ item: ImportItem) async throws +} + +// MARK: - Result + +/// The outcome of an import run. `failed` is set for the first item that failed (the run stops there); +/// `skipped` lists items that were never attempted as a result. +struct DeviceProfileImportResult { + var applied: [ImportItemKind] = [] + var skipped: [ImportItemKind] = [] + var failed: (kind: ImportItemKind, message: String)? = nil + /// True when a reboot-causing item (Channels & LoRa) was applied β€” the radio will disconnect briefly. + var rebooting: Bool = false + + var isCompleteSuccess: Bool { failed == nil } +} + +// MARK: - Engine + +enum DeviceProfileImporter { + + /// Applies the selected sections of a plan, in order, aborting on the first failure. + /// - Parameters: + /// - plan: the parsed import plan. + /// - selection: the sections the user chose to import. + /// - gateway: the send seam (production or mock). + /// - progress: called just before each item is sent, with (item, zero-based index, total count). + /// - Returns: what was applied, skipped, or failed, and whether the radio is rebooting. + @MainActor + static func apply( + plan: DeviceProfileImportPlan, + selection: Set, + gateway: ProfileApplyGateway, + progress: ((ImportItem, Int, Int) -> Void)? = nil + ) async -> DeviceProfileImportResult { + var result = DeviceProfileImportResult() + let items = plan.items(for: selection) + + for (index, item) in items.enumerated() { + // A disconnect before the terminal reboot step is a genuine partial failure β€” record it and + // mark the untried items skipped rather than blindly sending into a dead link. + guard gateway.isConnected else { + result.failed = (item.kind, "The radio disconnected before this step could be applied.") + // The current item is the failure, not a skip; only the items after it were never attempted. + if index + 1 < items.count { + result.skipped = items[(index + 1)...].map(\.kind) + } + return result + } + + progress?(item, index, items.count) + + do { + try await gateway.apply(item) + result.applied.append(item.kind) + if item.causesReboot { result.rebooting = true } + } catch { + result.failed = (item.kind, error.localizedDescription) + // Everything after the failed item never ran. + if index + 1 < items.count { + result.skipped = items[(index + 1)...].map(\.kind) + } + return result + } + } + + return result + } +} + +// MARK: - Production gateway + +/// Forwards each import item to the connected node via AccessoryManager. `fromUser == toUser == node` +/// for every send (self-admin), so no session passkey is ever involved. +@MainActor +struct AccessoryProfileApplyGateway: ProfileApplyGateway { + let accessoryManager: AccessoryManager + /// The connected node's user; both the source and destination of every admin message. + let node: UserEntity + + var isConnected: Bool { accessoryManager.isConnected } + + func apply(_ item: ImportItem) async throws { + switch item.payload { + case .owner(let user): + _ = try await accessoryManager.saveUser(config: user, fromUser: node, toUser: node) + case .deviceConfig(let config): + _ = try await accessoryManager.saveDeviceConfig(config: config, fromUser: node, toUser: node) + case .displayConfig(let config): + _ = try await accessoryManager.saveDisplayConfig(config: config, fromUser: node, toUser: node) + case .positionConfig(let config): + _ = try await accessoryManager.savePositionConfig(config: config, fromUser: node, toUser: node) + case .powerConfig(let config): + _ = try await accessoryManager.savePowerConfig(config: config, fromUser: node, toUser: node) + case .networkConfig(let config): + _ = try await accessoryManager.saveNetworkConfig(config: config, fromUser: node, toUser: node) + case .bluetoothConfig(let config): + _ = try await accessoryManager.saveBluetoothConfig(config: config, fromUser: node, toUser: node) + case .securityConfig(let config): + _ = try await accessoryManager.saveSecurityConfig(config: config, fromUser: node, toUser: node) + case .mqtt(let config): + _ = try await accessoryManager.saveMQTTConfig(config: config, fromUser: node, toUser: node) + case .serial(let config): + _ = try await accessoryManager.saveSerialModuleConfig(config: config, fromUser: node, toUser: node) + case .externalNotification(let config): + _ = try await accessoryManager.saveExternalNotificationModuleConfig(config: config, fromUser: node, toUser: node) + case .storeForward(let config): + _ = try await accessoryManager.saveStoreForwardModuleConfig(config: config, fromUser: node, toUser: node) + case .rangeTest(let config): + _ = try await accessoryManager.saveRangeTestModuleConfig(config: config, fromUser: node, toUser: node) + case .telemetry(let config): + _ = try await accessoryManager.saveTelemetryModuleConfig(config: config, fromUser: node, toUser: node) + case .cannedMessage(let config): + _ = try await accessoryManager.saveCannedMessageModuleConfig(config: config, fromUser: node, toUser: node) + case .audio(let config): + _ = try await accessoryManager.saveAudioModuleConfig(config: config, fromUser: node, toUser: node) + case .neighborInfo(let config): + _ = try await accessoryManager.saveNeighborInfoModuleConfig(config: config, fromUser: node, toUser: node) + case .ambientLighting(let config): + _ = try await accessoryManager.saveAmbientLightingModuleConfig(config: config, fromUser: node, toUser: node) + case .detectionSensor(let config): + _ = try await accessoryManager.saveDetectionSensorModuleConfig(config: config, fromUser: node, toUser: node) + case .paxcounter(let config): + _ = try await accessoryManager.savePaxcounterModuleConfig(config: config, fromUser: node, toUser: node) + case .tak(let config): + _ = try await accessoryManager.saveTAKModuleConfig(config: config, fromUser: node, toUser: node) + case .trafficManagement(let config): + _ = try await accessoryManager.saveTrafficManagementModuleConfig(config: config, fromUser: node, toUser: node) + case .statusMessage(let config): + _ = try await accessoryManager.saveStatusMessageModuleConfig(config: config, fromUser: node, toUser: node) + case .ringtone(let ringtone): + _ = try await accessoryManager.saveRtttlConfig(ringtone: ringtone, fromUser: node, toUser: node) + case .cannedMessagesText(let messages): + _ = try await accessoryManager.saveCannedMessageModuleMessages(messages: messages, fromUser: node, toUser: node) + case .fixedPosition(let position): + _ = try await accessoryManager.setFixedPosition(position, fromUser: node, toUser: node) + case .channelURL(let url): + // saveChannelSet sends the channels + LoRa config and already tolerates the resulting reboot + // (it swallows the post-reboot wantConfig internally), so a throw here is a real validation error. + try await accessoryManager.saveChannelSet(base64UrlString: url, addChannels: false) + case .loraConfig(let config): + // saveLoRaConfig has no built-in reboot tolerance: sending it reboots the radio and the trailing + // wantConfig/ack fails as the link drops. Only that reboot disconnect is expected success β€” so + // swallow the error only when the link has actually dropped; if we're still connected the send + // genuinely failed (e.g. serialization or a transport write error) and must surface as a failure. + do { + _ = try await accessoryManager.saveLoRaConfig(config: config, fromUser: node, toUser: node) + } catch { + if accessoryManager.isConnected { throw error } + Logger.services.warning("LoRa config send did not complete; radio is likely rebooting: \(error.localizedDescription, privacy: .public)") + } + } + } +} diff --git a/Meshtastic/Resources/docs/index.json b/Meshtastic/Resources/docs/index.json index 20594bc7b..011a88a57 100644 --- a/Meshtastic/Resources/docs/index.json +++ b/Meshtastic/Resources/docs/index.json @@ -395,38 +395,38 @@ "section": "user", "navOrder": 7, "keywords": [ - "app", "radio", - "device", "node", - "firmware", - "region", + "app", + "settings", + "device", "position", - "language", + "region", "from", - "settings", + "firmware", + "connected", + "language", + "mesh", + "configuration", "traffic", + "only", "nodes", - "mesh", + "lora", + "default", "translation", "rate", + "module", + "messages", + "display", + "amp", "translated", "set", - "only", "management", - "enable", - "documentation", - "display", - "default", - "connected", - "configure", - "unknown", - "preset", - "pages", - "packets", - "module" + "import", + "file", + "enable" ], - "charCount": 8766 + "charCount": 11135 }, { "id": "signal-meter", diff --git a/Meshtastic/Resources/docs/markdown/user/settings.md b/Meshtastic/Resources/docs/markdown/user/settings.md index 892e97239..70185175a 100644 --- a/Meshtastic/Resources/docs/markdown/user/settings.md +++ b/Meshtastic/Resources/docs/markdown/user/settings.md @@ -135,6 +135,43 @@ The Traffic Management module helps reduce unnecessary mesh traffic and improve Check for and apply OTA firmware updates to your connected radio directly from the app. See [Firmware Updates](firmware.md) for full details. +## Tools + +The **Tools** screen (Settings β†’ Tools) provides utilities for backing up, restoring, and sharing your connected radio's configuration. + +### Create Node Contact NFC Tag + +Write your node's contact card to an NFC tag so others can add you by tapping it. Requires a connected node and NFC-capable hardware. + +### Export Device Configuration + +Save the connected node's full configuration β€” radio, module, and channel settings β€” to a `.cfg` file you can back up or import onto another device. The format matches the Android app and the `meshtastic` CLI. + +> **Warning β€” Backups contain secrets** +> The exported file includes your node's private key, admin keys, channel keys (PSKs), and Wi-Fi/MQTT passwords. Anyone with the file can join and administer your mesh, so store and share it only with people you trust. + +### Import Device Configuration + +Apply a saved `.cfg` configuration file to the connected node. After you pick a file, a review sheet shows what it contains, grouped into sections you can turn on or off before applying: + +| Section | What it restores | +|---------|------------------| +| Owner Name | Long and short name | +| Radio & Device | Device, display, position, power, and Bluetooth settings | +| Network | Wi-Fi / Ethernet settings | +| Modules | All module settings (MQTT, Telemetry, Canned Messages, and so on) | +| Ringtone & Canned Messages | Notification melody and canned-message text | +| Fixed Position | A saved fixed GPS position | +| Security & Identity | Private key, admin keys, and admin access | +| Channels & LoRa | Channels and LoRa/region settings | + +All present sections are selected by default **except Security & Identity**, which is off by default: importing it replaces this node's cryptographic identity (its private/public key) and admin keys, which can break existing direct messages and lock you out of local administration. Leave it off when cloning a configuration onto a second radio that should keep its own identity. + +> **Warning β€” Import overwrites settings on your radio** +> Importing writes the selected settings β€” including any secrets in the file β€” onto the connected node. Only import files from a source you trust. + +Applying **Channels & LoRa** reboots the radio, so it briefly disconnects; the app reports this so you can reconnect to verify. Settings are applied in order and the import stops at the first failure, telling you exactly what was and wasn't applied. Re-running an import is safe. + ## Automatic Documentation Translation On devices running iOS 26 or later, the in-app documentation is automatically translated to your device language when it differs from English. diff --git a/Meshtastic/Resources/docs/user/settings.html b/Meshtastic/Resources/docs/user/settings.html index 3f105fbaf..05594c2ff 100644 --- a/Meshtastic/Resources/docs/user/settings.html +++ b/Meshtastic/Resources/docs/user/settings.html @@ -253,6 +253,62 @@

Traffic Management

Firmware Updates

Check for and apply OTA firmware updates to your connected radio directly from the app. See Firmware Updates for full details.

+

Tools

+

The Tools screen (Settings β†’ Tools) provides utilities for backing up, restoring, and sharing your connected radio's configuration.

+

Create Node Contact NFC Tag

+

Write your node's contact card to an NFC tag so others can add you by tapping it. Requires a connected node and NFC-capable hardware.

+

Export Device Configuration

+

Save the connected node's full configuration β€” radio, module, and channel settings β€” to a .cfg file you can back up or import onto another device. The format matches the Android app and the meshtastic CLI.

+
Warning β€” Backups contain secrets +The exported file includes your node's private key, admin keys, channel keys (PSKs), and Wi-Fi/MQTT passwords. Anyone with the file can join and administer your mesh, so store and share it only with people you trust.
+

Import Device Configuration

+

Apply a saved .cfg configuration file to the connected node. After you pick a file, a review sheet shows what it contains, grouped into sections you can turn on or off before applying:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SectionWhat it restores
Owner NameLong and short name
Radio & DeviceDevice, display, position, power, and Bluetooth settings
NetworkWi-Fi / Ethernet settings
ModulesAll module settings (MQTT, Telemetry, Canned Messages, and so on)
Ringtone & Canned MessagesNotification melody and canned-message text
Fixed PositionA saved fixed GPS position
Security & IdentityPrivate key, admin keys, and admin access
Channels & LoRaChannels and LoRa/region settings
+

All present sections are selected by default except Security & Identity, which is off by default: importing it replaces this node's cryptographic identity (its private/public key) and admin keys, which can break existing direct messages and lock you out of local administration. Leave it off when cloning a configuration onto a second radio that should keep its own identity.

+
Warning β€” Import overwrites settings on your radio +Importing writes the selected settings β€” including any secrets in the file β€” onto the connected node. Only import files from a source you trust.
+

Applying Channels & LoRa reboots the radio, so it briefly disconnects; the app reports this so you can reconnect to verify. Settings are applied in order and the import stops at the first failure, telling you exactly what was and wasn't applied. Re-running an import is safe.

Automatic Documentation Translation

On devices running iOS 26 or later, the in-app documentation is automatically translated to your device language when it differs from English.

How It Works

diff --git a/Meshtastic/Views/Settings/ImportDeviceProfileView.swift b/Meshtastic/Views/Settings/ImportDeviceProfileView.swift new file mode 100644 index 000000000..3dbdefa78 --- /dev/null +++ b/Meshtastic/Views/Settings/ImportDeviceProfileView.swift @@ -0,0 +1,290 @@ +// +// ImportDeviceProfileView.swift +// Meshtastic +// +// Review sheet for "Import Device Configuration": shows what a parsed `.cfg` profile will apply to the +// connected node, lets the user toggle coarse sections (Security & Identity defaults off), warns about +// sensitive material and the LoRa/channel reboot, and β€” after an explicit confirmation β€” applies the +// selected sections in order, reporting per-item progress and a final summary. +// + +import SwiftUI +import MeshtasticProtobufs +import OSLog + +@available(iOS 18, *) +struct ImportDeviceProfileView: View { + @EnvironmentObject private var accessoryManager: AccessoryManager + @Environment(\.modelContext) private var context + @Environment(\.dismiss) private var dismiss + + let plan: DeviceProfileImportPlan + + @State private var selection: Set + @State private var phase: Phase = .review + @State private var progress: ProgressState? + @State private var isPresentingConfirm = false + + private enum Phase: Equatable { + case review + case applying + case done(DeviceProfileImportResult) + + static func == (lhs: Phase, rhs: Phase) -> Bool { + switch (lhs, rhs) { + case (.review, .review), (.applying, .applying): return true + case (.done, .done): return true + default: return false + } + } + } + + private struct ProgressState { + let title: String + let index: Int + let total: Int + } + + init(plan: DeviceProfileImportPlan) { + self.plan = plan + // Default every present section ON except Security & Identity. + _selection = State(initialValue: Set(plan.presentSections.filter(\.defaultsOn))) + } + + private var connectedNode: NodeInfoEntity? { + guard let num = accessoryManager.activeDeviceNum else { return nil } + return getNodeInfo(id: num, context: context) + } + + private var isApplying: Bool { phase == .applying } + + private var canImport: Bool { + accessoryManager.isConnected && !selection.isEmpty && !isApplying + } + + var body: some View { + NavigationStack { + Group { + switch phase { + case .review, .applying: + reviewList + case .done(let result): + resultList(result) + } + } + .navigationTitle("Import Configuration") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(isReviewing ? "Cancel" : "Done") { dismiss() } + .disabled(isApplying) + } + if isReviewing { + ToolbarItem(placement: .confirmationAction) { + Button(isApplying ? "Importing…" : "Import") { isPresentingConfirm = true } + .disabled(!canImport) + } + } + } + .confirmationDialog("Apply Configuration", isPresented: $isPresentingConfirm, titleVisibility: .visible) { + Button("Apply Configuration", role: .destructive) { Task { await runImport() } } + Button("Cancel", role: .cancel) { } + } message: { + Text(confirmationMessage) + } + } + .interactiveDismissDisabled(isApplying) + } + + private var isReviewing: Bool { + if case .done = phase { return false } + return true + } + + // MARK: Review + + @ViewBuilder + private var reviewList: some View { + List { + Section { + Text("Apply this saved configuration to \(connectedNode?.user?.longName ?? "the connected node").") + .font(.callout) + if !accessoryManager.isConnected { + Label("Connect to a radio before importing.", systemImage: "antenna.radiowaves.left.and.right.slash") + .font(.caption) + .foregroundColor(.orange) + } + } + + if plan.containsSensitive(in: selection) { + Section { + Label { + VStack(alignment: .leading, spacing: 4) { + Text("This import overwrites sensitive material") + .font(.callout.bold()) + Text(sensitiveDetail) + .font(.caption) + .foregroundColor(.secondary) + } + } icon: { + Image(systemName: "exclamationmark.shield.fill").foregroundColor(.orange) + } + } + } + + if plan.willReboot(in: selection) { + Section { + Label("Applying LoRa/channel settings reboots the radio; it will briefly disconnect.", systemImage: "arrow.clockwise.circle") + .font(.caption) + .foregroundColor(.orange) + } + } + + Section("Sections to Import") { + ForEach(plan.presentSections) { section in + sectionRow(section) + } + } + } + } + + @ViewBuilder + private func sectionRow(_ section: ImportSection) -> some View { + let sectionItems = plan.items.filter { $0.section == section } + let isOn = Binding( + get: { selection.contains(section) }, + set: { newValue in + if newValue { selection.insert(section) } else { selection.remove(section) } + } + ) + Toggle(isOn: isOn) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(section.title) + if sectionItems.contains(where: \.isSensitive) { + Image(systemName: "key.fill").font(.caption2).foregroundColor(.orange) + } + } + Text(sectionItems.map(\.summary).joined(separator: ", ")) + .font(.caption) + .foregroundColor(.secondary) + if section == .security { + Text("Changes this node's identity (private/public key) and admin keys β€” can break existing direct messages and lock local administration.") + .font(.caption2) + .foregroundColor(.orange) + } + } + } + .disabled(isApplying) + .overlay(alignment: .trailing) { + if let progress, isApplying, currentSection == section { + ProgressView().padding(.trailing, 44) + } + } + } + + private var currentSection: ImportSection? { + guard let progress else { return nil } + let items = plan.items(for: selection) + guard progress.index < items.count else { return nil } + return items[progress.index].section + } + + // MARK: Result + + @ViewBuilder + private func resultList(_ result: DeviceProfileImportResult) -> some View { + List { + Section { + if result.isCompleteSuccess { + Label("Applied \(result.applied.count) setting\(result.applied.count == 1 ? "" : "s").", systemImage: "checkmark.circle.fill") + .foregroundColor(.green) + } else { + Label("Import stopped after a failure.", systemImage: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + } + if result.rebooting { + Text("Your radio is rebooting to apply LoRa/channel changes β€” reconnect to verify.") + .font(.caption) + .foregroundColor(.secondary) + } + } + if let failed = result.failed { + Section("Failed") { + VStack(alignment: .leading, spacing: 2) { + Text(failed.kind.rawValue) + Text(failed.message).font(.caption).foregroundColor(.secondary) + } + } + } + if !result.applied.isEmpty { + Section("Applied") { + ForEach(result.applied, id: \.rawValue) { kind in + Label(kind.rawValue, systemImage: "checkmark").foregroundColor(.secondary) + } + } + } + if !result.skipped.isEmpty { + Section("Not Applied") { + ForEach(result.skipped, id: \.rawValue) { kind in + Text(kind.rawValue).foregroundColor(.secondary) + } + } + } + } + } + + // MARK: Apply + + private func runImport() async { + // Re-resolve the connected node at apply time so a device that dropped while the sheet was open + // can't leave us acting on a stale/faulted entity. + guard let node = connectedNode, let user = node.user else { + phase = .done(DeviceProfileImportResult( + failed: (kind: plan.items(for: selection).first?.kind ?? .owner, message: "No connected node.") + )) + return + } + phase = .applying + let gateway = AccessoryProfileApplyGateway(accessoryManager: accessoryManager, node: user) + let result = await DeviceProfileImporter.apply( + plan: plan, + selection: selection, + gateway: gateway, + progress: { item, index, total in + progress = ProgressState(title: item.summary, index: index, total: total) + } + ) + progress = nil + phase = .done(result) + } + + // MARK: Copy + + private var sensitiveDetail: String { + var parts: [String] = [] + if selection.contains(.security) { parts.append("your node's private key & admin keys") } + if selection.contains(.channelsAndLoRa) { parts.append("channel keys (PSKs)") } + if selection.contains(.network) { parts.append("Wi-Fi password") } + if plan.items(for: selection).contains(where: { $0.kind == .mqtt }) { parts.append("MQTT password") } + let list = parts.isEmpty ? "sensitive credentials" : parts.joined(separator: ", ") + return "Includes \(list). Only import files from a source you trust." + } + + private var confirmationMessage: String { + var lines: [String] = [] + if plan.containsSensitive(in: selection) { + lines.append("This overwrites sensitive security material on the connected node.") + } + if selection.contains(.security) { + lines.append("It replaces this node's identity and admin keys, which can break existing direct messages.") + } + if plan.willReboot(in: selection) { + lines.append("The radio will reboot to apply LoRa/channel changes.") + } + if lines.isEmpty { + lines.append("Apply the selected configuration to the connected node?") + } + return lines.joined(separator: " ") + } +} diff --git a/Meshtastic/Views/Settings/Tools.swift b/Meshtastic/Views/Settings/Tools.swift index e6b26f787..517243407 100644 --- a/Meshtastic/Views/Settings/Tools.swift +++ b/Meshtastic/Views/Settings/Tools.swift @@ -29,6 +29,17 @@ struct Tools: View { @State private var exportFailedMessage = "The device configuration could not be prepared for export." @State private var isPresentingExportWarning = false + @State private var isImportingConfig = false + @State private var pendingImport: PendingImport? + @State private var isPresentingImportFailedAlert = false + @State private var importFailedMessage = "This file isn't a valid Meshtastic configuration." + + /// Identifiable wrapper so a parsed plan can drive `.sheet(item:)`. + private struct PendingImport: Identifiable { + let id = UUID() + let plan: DeviceProfileImportPlan + } + var connectedNode: NodeInfoEntity? { if let num = accessoryManager.activeDeviceNum { return getNodeInfo(id: num, context: context) @@ -88,6 +99,23 @@ struct Tools: View { .foregroundColor(.secondary) } } + + Section(header: Text("Import Device Configuration")) { + if connectedNode != nil { + Text("Apply a saved configuration file (radio, module, and channel settings) to the connected node.") + .font(.caption) + .foregroundColor(.secondary) + Button { + isImportingConfig = true + } label: { + Label("Import Configuration", systemImage: "square.and.arrow.down") + } + } else { + Text("Connect to a node to import a configuration.") + .font(.caption) + .foregroundColor(.secondary) + } + } } } .navigationTitle("Tools") @@ -136,6 +164,66 @@ struct Tools: View { } message: { Text(exportFailedMessage) } + .fileImporter( + isPresented: $isImportingConfig, + allowedContentTypes: [.meshtasticDeviceProfile], + allowsMultipleSelection: false + ) { result in + handleImport(result) + } + .sheet(item: $pendingImport) { pending in + ImportDeviceProfileView(plan: pending.plan) + .environmentObject(accessoryManager) + } + .alert("Import Failed", isPresented: $isPresentingImportFailedAlert) { + Button("OK") { }.keyboardShortcut(.defaultAction) + } message: { + Text(importFailedMessage) + } + } + + /// Reads at most `cap + 1` bytes from a file so an oversized file is caught by the caller's size guard + /// without loading the whole file into memory β€” independent of whether the file provider reports a size. + private static func readCapped(_ url: URL, cap: Int) throws -> Data { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + return try handle.read(upToCount: cap + 1) ?? Data() + } + + private func handleImport(_ result: Result<[URL], Error>) { + switch result { + case .success(let urls): + guard let url = urls.first else { return } + guard let node = connectedNode, let currentUser = node.user?.toProto() else { + importFailedMessage = "Connect to a node before importing a configuration." + isPresentingImportFailedAlert = true + return + } + // Access the security-scoped file the picker handed us, and always release it afterwards. + let didAccess = url.startAccessingSecurityScopedResource() + defer { if didAccess { url.stopAccessingSecurityScopedResource() } } + do { + // Read at most one byte past the cap so an oversized (or size-unreported) file is rejected + // by parseDeviceProfile without ever loading the whole thing into memory. + let data = try Self.readCapped(url, cap: DeviceProfileImportPlan.maxProfileBytes) + let profile = try DeviceProfileImportPlan.parseDeviceProfile(data) + let plan = try DeviceProfileImportPlan(profile: profile, currentUser: currentUser) + pendingImport = PendingImport(plan: plan) + } catch DeviceProfileImportError.nothingToImport { + importFailedMessage = "This configuration file doesn't contain anything to import." + isPresentingImportFailedAlert = true + } catch { + Logger.services.error("Device configuration import failed to parse: \(error.localizedDescription, privacy: .public)") + importFailedMessage = "This file isn't a valid Meshtastic configuration." + isPresentingImportFailedAlert = true + } + case .failure(let error): + // A user dismissing the picker can surface as a cancellation β€” don't treat that as an error. + if (error as? CocoaError)?.code == .userCancelled { return } + Logger.services.error("Device configuration import picker failed: \(error.localizedDescription, privacy: .public)") + importFailedMessage = "The configuration file could not be opened." + isPresentingImportFailedAlert = true + } } private func exportConfiguration(for node: NodeInfoEntity) { diff --git a/MeshtasticTests/DeviceProfileImportTests.swift b/MeshtasticTests/DeviceProfileImportTests.swift new file mode 100644 index 000000000..84acb5b2d --- /dev/null +++ b/MeshtasticTests/DeviceProfileImportTests.swift @@ -0,0 +1,492 @@ +// +// DeviceProfileImportTests.swift +// Meshtastic +// +// Verifies the pure device-profile import core (parse, plan, ordering, dedupe, gating, sensitivity, +// owner merge) and the apply engine's abort/reboot semantics via a mock gateway. No device required. +// + +import Foundation +import Testing +import MeshtasticProtobufs + +@testable import Meshtastic + +@Suite("DeviceProfile import") +struct DeviceProfileImportTests { + + // MARK: - Builders + + /// A profile with every applyable section populated, used to exercise coverage and ordering. + private func makeFullProfile() throws -> DeviceProfile { + var profile = DeviceProfile() + profile.longName = "Base Camp" + profile.shortName = "BC01" + + var config = LocalConfig() + config.device = Config.DeviceConfig() + config.display = Config.DisplayConfig() + config.position = Config.PositionConfig() + config.power = Config.PowerConfig() + var network = Config.NetworkConfig() + network.wifiSsid = "home" + network.wifiPsk = "secret" + config.network = network + config.bluetooth = Config.BluetoothConfig() + var security = Config.SecurityConfig() + security.privateKey = Data([1, 2, 3]) + config.security = security + var lora = Config.LoRaConfig() + lora.region = .us + config.lora = lora + profile.config = config + + var module = LocalModuleConfig() + module.mqtt = ModuleConfig.MQTTConfig() + module.serial = ModuleConfig.SerialConfig() + module.externalNotification = ModuleConfig.ExternalNotificationConfig() + module.storeForward = ModuleConfig.StoreForwardConfig() + module.rangeTest = ModuleConfig.RangeTestConfig() + module.telemetry = ModuleConfig.TelemetryConfig() + module.cannedMessage = ModuleConfig.CannedMessageConfig() + module.audio = ModuleConfig.AudioConfig() + module.neighborInfo = ModuleConfig.NeighborInfoConfig() + module.ambientLighting = ModuleConfig.AmbientLightingConfig() + module.detectionSensor = ModuleConfig.DetectionSensorConfig() + module.paxcounter = ModuleConfig.PaxcounterConfig() + module.tak = ModuleConfig.TAKConfig() + module.trafficManagement = ModuleConfig.TrafficManagementConfig() + module.statusmessage = ModuleConfig.StatusMessageConfig() + profile.moduleConfig = module + + profile.ringtone = "a:d=4,o=5,b=100:c" + profile.cannedMessages = "hi|bye" + + var fixed = Position() + fixed.latitudeI = 377_749_000 + fixed.longitudeI = -1_224_194_000 + profile.fixedPosition = fixed + + return profile + } + + private func channelURL(channelCount: Int = 2, withLoRa: Bool = true) throws -> String { + var channelSet = ChannelSet() + if withLoRa { + var lora = Config.LoRaConfig() + lora.region = .us + channelSet.loraConfig = lora + } + for i in 0.. Int? { + plan.items.firstIndex { $0.kind == kind } + } + + // MARK: - Parse + + @Test("Empty, oversize, and garbage data throw the right errors") + func parseErrors() { + #expect(throws: DeviceProfileImportError.emptyFile) { + _ = try DeviceProfileImportPlan.parseDeviceProfile(Data()) + } + let big = Data(count: DeviceProfileImportPlan.maxProfileBytes + 1) + #expect(throws: DeviceProfileImportError.tooLarge) { + _ = try DeviceProfileImportPlan.parseDeviceProfile(big) + } + // A run of non-zero bytes is not a decodable DeviceProfile. + let garbage = Data([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) + #expect(throws: DeviceProfileImportError.malformed) { + _ = try DeviceProfileImportPlan.parseDeviceProfile(garbage) + } + } + + @Test("A profile round-trips through serialize -> parse unchanged") + func parseRoundTrip() throws { + let profile = try makeFullProfile() + let data = try profile.serializedData() + let parsed = try DeviceProfileImportPlan.parseDeviceProfile(data) + #expect(parsed == profile) + } + + @Test("An empty profile has nothing to import") + func nothingToImport() { + #expect(throws: DeviceProfileImportError.nothingToImport) { + _ = try DeviceProfileImportPlan(profile: DeviceProfile(), currentUser: nil) + } + } + + // MARK: - Coverage & gating + + @Test("A full profile yields exactly one item per expected kind and no others") + func coverage() throws { + let plan = try DeviceProfileImportPlan(profile: makeFullProfile(), currentUser: User()) + let kinds = Set(plan.items.map(\.kind)) + let expected: Set = [ + .owner, .deviceConfig, .displayConfig, .positionConfig, .powerConfig, .networkConfig, + .bluetoothConfig, .securityConfig, .mqtt, .serial, .externalNotification, .storeForward, + .rangeTest, .telemetry, .cannedMessage, .audio, .neighborInfo, .ambientLighting, + .detectionSensor, .paxcounter, .tak, .trafficManagement, .statusMessage, .ringtone, + .cannedMessagesText, .fixedPosition, .loraConfig + ] + #expect(kinds == expected) + // One item per kind (no duplicates). + #expect(plan.items.count == expected.count) + } + + @Test("Absent has* fields produce no items") + func gating() throws { + var profile = DeviceProfile() + var config = LocalConfig() + config.device = Config.DeviceConfig() + profile.config = config + var module = LocalModuleConfig() + module.telemetry = ModuleConfig.TelemetryConfig() + profile.moduleConfig = module + + let plan = try DeviceProfileImportPlan(profile: profile, currentUser: nil) + #expect(plan.items.map(\.kind) == [.deviceConfig, .telemetry]) + } + + @Test("Owner step is omitted when there is no base user") + func ownerRequiresBase() throws { + var profile = DeviceProfile() + profile.longName = "Roamer" + var config = LocalConfig() + config.device = Config.DeviceConfig() + profile.config = config + + let plan = try DeviceProfileImportPlan(profile: profile, currentUser: nil) + #expect(!plan.items.contains { $0.kind == .owner }) + } + + // MARK: - Ordering + + @Test("Items are emitted in a safe apply order") + func ordering() throws { + let plan = try DeviceProfileImportPlan(profile: makeFullProfile(), currentUser: User()) + + // Owner first, Channels & LoRa last. + #expect(plan.items.first?.kind == .owner) + #expect(plan.items.last?.section == .channelsAndLoRa) + + let device = index(of: .deviceConfig, in: plan)! + let position = index(of: .positionConfig, in: plan)! + let fixed = index(of: .fixedPosition, in: plan)! + let canned = index(of: .cannedMessage, in: plan)! + let cannedText = index(of: .cannedMessagesText, in: plan)! + let network = index(of: .networkConfig, in: plan)! + let bluetooth = index(of: .bluetoothConfig, in: plan)! + let security = index(of: .securityConfig, in: plan)! + let lora = index(of: .loraConfig, in: plan)! + + #expect(fixed > position) // fixed position after the position config that flags it + #expect(cannedText > canned) // canned text after the canned-message module config + #expect(network > device) // network placed after the cheap configs + #expect(bluetooth > network) // bluetooth just before the terminal step + #expect(security > bluetooth) // security just before the terminal step + #expect(lora > security) // LoRa/channels strictly last + } + + // MARK: - LoRa / channel dedupe + + @Test("With both a channel URL and a LoRa config, only the channel URL terminal item is emitted") + func loraDedupeChannelURLWins() throws { + var profile = try makeFullProfile() + profile.channelURL = try channelURL() + let plan = try DeviceProfileImportPlan(profile: profile, currentUser: User()) + + let terminal = plan.items.filter { $0.section == .channelsAndLoRa } + #expect(terminal.count == 1) + #expect(terminal.first?.kind == .channelURL) + #expect(!plan.items.contains { $0.kind == .loraConfig }) + } + + @Test("With no channel URL, a standalone LoRa config is the terminal item") + func loraDedupeNoChannelURL() throws { + let plan = try DeviceProfileImportPlan(profile: makeFullProfile(), currentUser: User()) + let terminal = plan.items.filter { $0.section == .channelsAndLoRa } + #expect(terminal.count == 1) + #expect(terminal.first?.kind == .loraConfig) + } + + @Test("A malformed channel URL falls back to the standalone LoRa config") + func invalidChannelURLFallsBack() throws { + var profile = try makeFullProfile() + profile.channelURL = "https://meshtastic.org/e/#not-valid-base64!!!" + let plan = try DeviceProfileImportPlan(profile: profile, currentUser: User()) + + let terminal = plan.items.filter { $0.section == .channelsAndLoRa } + #expect(terminal.count == 1) + #expect(terminal.first?.kind == .loraConfig) + } + + @Test("A channel URL without a LoRa config falls back to the standalone LoRa config") + func channelURLWithoutLoRaFallsBack() throws { + var profile = try makeFullProfile() + // A replace-mode URL that lacks a LoRa config can't be used to replace channels, so the plan must + // fall back to the standalone LoRa config to still restore the region. + profile.channelURL = try channelURL(withLoRa: false) + let plan = try DeviceProfileImportPlan(profile: profile, currentUser: User()) + + let terminal = plan.items.filter { $0.section == .channelsAndLoRa } + #expect(terminal.count == 1) + #expect(terminal.first?.kind == .loraConfig) + } + + @Test("An add-mode channel URL falls back to the standalone LoRa config") + func addModeChannelURLFallsBack() throws { + var channelSet = ChannelSet() + var lora = Config.LoRaConfig() + lora.region = .us + channelSet.loraConfig = lora + var settings = ChannelSettings() + settings.name = "Ch0" + channelSet.settings.append(settings) + + var profile = try makeFullProfile() + // An add-mode URL (?add=true) drops its LoRa config on parse, so it can't own the terminal step. + profile.channelURL = try MeshtasticChannelURL.urlString(for: channelSet, addChannels: true) + let plan = try DeviceProfileImportPlan(profile: profile, currentUser: User()) + + let terminal = plan.items.filter { $0.section == .channelsAndLoRa } + #expect(terminal.count == 1) + #expect(terminal.first?.kind == .loraConfig) + } + + // MARK: - Fixed position gating + + @Test("A zero-coordinate fixed position is not imported") + func fixedPositionZeroSkipped() throws { + var profile = DeviceProfile() + var config = LocalConfig() + config.device = Config.DeviceConfig() + profile.config = config + profile.fixedPosition = Position() // lat/lon default to 0 + + let plan = try DeviceProfileImportPlan(profile: profile, currentUser: nil) + #expect(!plan.items.contains { $0.kind == .fixedPosition }) + } + + // MARK: - Sensitivity & reboot flags + + @Test("Sensitive and reboot flags are set on the right items") + func flags() throws { + let plan = try DeviceProfileImportPlan(profile: makeFullProfile(), currentUser: User()) + // A standalone LoRa config carries no secret (channel PSKs travel only in the channel URL), so it + // is a reboot but not sensitive. + let sensitiveKinds = Set(plan.items.filter(\.isSensitive).map(\.kind)) + #expect(sensitiveKinds == [.securityConfig, .networkConfig, .mqtt]) + + let rebootKinds = plan.items.filter(\.causesReboot).map(\.kind) + #expect(rebootKinds == [.loraConfig]) + + let all = Set(plan.presentSections) + #expect(plan.containsSensitive(in: all)) + #expect(plan.willReboot(in: all)) + // Deselecting the sensitive/reboot sections clears both aggregates. + let safe: Set = [.owner, .radioAndDevice, .modules, .personalization, .fixedPosition] + #expect(!plan.willReboot(in: safe)) + } + + @Test("A channel URL terminal item is flagged sensitive because it carries channel PSKs") + func channelURLIsSensitive() throws { + var profile = try makeFullProfile() + profile.channelURL = try channelURL() + let plan = try DeviceProfileImportPlan(profile: profile, currentUser: User()) + let channelItem = try #require(plan.items.first { $0.kind == .channelURL }) + #expect(channelItem.isSensitive) + #expect(channelItem.causesReboot) + } + + // MARK: - Owner merge + + @Test("Owner merge overrides only the names present and preserves identity") + func ownerMerge() { + var base = User() + base.id = "!deadbeef" + base.longName = "Original Long" + base.shortName = "OG" + base.hwModel = .tbeam + base.publicKey = Data([9, 9, 9]) + base.role = .router + + var profile = DeviceProfile() + profile.longName = "Imported Long" // shortName intentionally not set + + let merged = DeviceProfileImportPlan.ownerUser(from: profile, base: base) + #expect(merged.longName == "Imported Long") + #expect(merged.shortName == "OG") // preserved β€” profile had no short name + #expect(merged.id == "!deadbeef") + #expect(merged.hwModel == .tbeam) + #expect(merged.publicKey == Data([9, 9, 9])) + #expect(merged.role == .router) + } + + @Test("The emitted owner item carries the identity-preserving merged user") + func ownerItemPreservesIdentity() throws { + var base = User() + base.id = "!deadbeef" + base.longName = "Original Long" + base.shortName = "OG" + base.hwModel = .tbeam + base.publicKey = Data([9, 9, 9]) + base.role = .router + + var profile = DeviceProfile() + profile.longName = "Imported Long" // no short name in the profile + var config = LocalConfig() + config.device = Config.DeviceConfig() + profile.config = config + + // This pins the init -> ownerUser wiring: a regression that shipped a bare name-only User (wiping + // id/hwModel/publicKey/role via setOwner) would fail here. + let plan = try DeviceProfileImportPlan(profile: profile, currentUser: base) + let ownerItem = try #require(plan.items.first { $0.kind == .owner }) + guard case let .owner(user) = ownerItem.payload else { + Issue.record("owner item did not carry a .owner payload") + return + } + #expect(user.id == "!deadbeef") + #expect(user.hwModel == .tbeam) + #expect(user.publicKey == Data([9, 9, 9])) + #expect(user.role == .router) + #expect(user.longName == "Imported Long") + #expect(user.shortName == "OG") + } + + // MARK: - Selection filtering + + @Test("items(for:) preserves order and drops unselected sections") + func selectionFiltering() throws { + let plan = try DeviceProfileImportPlan(profile: makeFullProfile(), currentUser: User()) + let withoutSecurity = plan.presentSections.filter { $0 != .security } + let filtered = plan.items(for: Set(withoutSecurity)) + #expect(!filtered.contains { $0.kind == .securityConfig }) + // Order within the filtered list matches the original relative order. + let filteredKinds = filtered.map(\.kind) + let expectedOrder = plan.items.filter { $0.section != .security }.map(\.kind) + #expect(filteredKinds == expectedOrder) + // Empty selection -> empty. + #expect(plan.items(for: []).isEmpty) + } + + // MARK: - Apply engine + + @MainActor + private final class MockGateway: ProfileApplyGateway { + var isConnected = true + var failOn: ImportItemKind? + var attempted: [ImportItemKind] = [] + + func apply(_ item: ImportItem) async throws { + attempted.append(item.kind) + if item.kind == failOn { + throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "boom"]) + } + } + } + + @MainActor + @Test("The engine aborts on the first failure and skips the rest") + func engineAbortsOnFailure() async throws { + var profile = DeviceProfile() + var config = LocalConfig() + config.device = Config.DeviceConfig() + config.display = Config.DisplayConfig() + profile.config = config + var module = LocalModuleConfig() + module.telemetry = ModuleConfig.TelemetryConfig() + profile.moduleConfig = module + // Order: device, display, telemetry. + let plan = try DeviceProfileImportPlan(profile: profile, currentUser: nil) + + let gateway = MockGateway() + gateway.failOn = .displayConfig + let result = await DeviceProfileImporter.apply( + plan: plan, + selection: Set(plan.presentSections), + gateway: gateway + ) + + #expect(result.applied == [.deviceConfig]) + #expect(result.failed?.kind == .displayConfig) + #expect(result.skipped == [.telemetry]) + #expect(!result.isCompleteSuccess) + // The item after the failure was never even attempted. + #expect(!gateway.attempted.contains(.telemetry)) + } + + @MainActor + @Test("A fully applied plan with a LoRa step reports rebooting") + func engineReportsReboot() async throws { + var profile = DeviceProfile() + var config = LocalConfig() + config.device = Config.DeviceConfig() + config.lora = Config.LoRaConfig() + profile.config = config + let plan = try DeviceProfileImportPlan(profile: profile, currentUser: nil) + + let gateway = MockGateway() + let result = await DeviceProfileImporter.apply( + plan: plan, + selection: Set(plan.presentSections), + gateway: gateway + ) + + #expect(result.isCompleteSuccess) + #expect(result.applied.contains(.loraConfig)) + #expect(result.rebooting) + } + + @MainActor + @Test("A disconnected gateway fails the first step and skips everything") + func engineHandlesDisconnect() async throws { + let plan = try DeviceProfileImportPlan(profile: makeFullProfile(), currentUser: User()) + let gateway = MockGateway() + gateway.isConnected = false + let result = await DeviceProfileImporter.apply( + plan: plan, + selection: Set(plan.presentSections), + gateway: gateway + ) + + #expect(result.applied.isEmpty) + // The first item is the failure; the remaining items (not it) are the skips. + #expect(result.failed?.kind == plan.items.first?.kind) + #expect(gateway.attempted.isEmpty) + #expect(result.skipped.count == plan.items.count - 1) + // The failed item must not also appear in the skipped list. + #expect(!result.skipped.contains(where: { $0 == result.failed?.kind })) + } + + @MainActor + @Test("A failing terminal channel URL surfaces as a real failure, not a reboot") + func channelURLFailureSurfaces() async throws { + var profile = DeviceProfile() + var config = LocalConfig() + config.device = Config.DeviceConfig() + profile.config = config + profile.channelURL = try channelURL() + let plan = try DeviceProfileImportPlan(profile: profile, currentUser: nil) + + // The production gateway does NOT swallow channelURL errors (only the standalone-lora reboot + // disconnect), so a throwing channelURL must be reported as a failure. + let gateway = MockGateway() + gateway.failOn = .channelURL + let result = await DeviceProfileImporter.apply( + plan: plan, + selection: Set(plan.presentSections), + gateway: gateway + ) + #expect(result.failed?.kind == .channelURL) + #expect(!result.rebooting) + } +} diff --git a/docs/user/settings.md b/docs/user/settings.md index 892e97239..70185175a 100644 --- a/docs/user/settings.md +++ b/docs/user/settings.md @@ -135,6 +135,43 @@ The Traffic Management module helps reduce unnecessary mesh traffic and improve Check for and apply OTA firmware updates to your connected radio directly from the app. See [Firmware Updates](firmware.md) for full details. +## Tools + +The **Tools** screen (Settings β†’ Tools) provides utilities for backing up, restoring, and sharing your connected radio's configuration. + +### Create Node Contact NFC Tag + +Write your node's contact card to an NFC tag so others can add you by tapping it. Requires a connected node and NFC-capable hardware. + +### Export Device Configuration + +Save the connected node's full configuration β€” radio, module, and channel settings β€” to a `.cfg` file you can back up or import onto another device. The format matches the Android app and the `meshtastic` CLI. + +> **Warning β€” Backups contain secrets** +> The exported file includes your node's private key, admin keys, channel keys (PSKs), and Wi-Fi/MQTT passwords. Anyone with the file can join and administer your mesh, so store and share it only with people you trust. + +### Import Device Configuration + +Apply a saved `.cfg` configuration file to the connected node. After you pick a file, a review sheet shows what it contains, grouped into sections you can turn on or off before applying: + +| Section | What it restores | +|---------|------------------| +| Owner Name | Long and short name | +| Radio & Device | Device, display, position, power, and Bluetooth settings | +| Network | Wi-Fi / Ethernet settings | +| Modules | All module settings (MQTT, Telemetry, Canned Messages, and so on) | +| Ringtone & Canned Messages | Notification melody and canned-message text | +| Fixed Position | A saved fixed GPS position | +| Security & Identity | Private key, admin keys, and admin access | +| Channels & LoRa | Channels and LoRa/region settings | + +All present sections are selected by default **except Security & Identity**, which is off by default: importing it replaces this node's cryptographic identity (its private/public key) and admin keys, which can break existing direct messages and lock you out of local administration. Leave it off when cloning a configuration onto a second radio that should keep its own identity. + +> **Warning β€” Import overwrites settings on your radio** +> Importing writes the selected settings β€” including any secrets in the file β€” onto the connected node. Only import files from a source you trust. + +Applying **Channels & LoRa** reboots the radio, so it briefly disconnects; the app reports this so you can reconnect to verify. Settings are applied in order and the import stops at the first failure, telling you exactly what was and wasn't applied. Re-running an import is safe. + ## Automatic Documentation Translation On devices running iOS 26 or later, the in-app documentation is automatically translated to your device language when it differs from English.