diff --git a/Localizable.xcstrings b/Localizable.xcstrings index a21426e4..dde69cfe 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -26820,6 +26820,10 @@ } } }, + "Identity" : { + "comment" : "A section", + "isCommentAutoGenerated" : true + }, "If DOP is set, use HDOP / VDOP values instead of PDOP" : { "localizations" : { "da" : { @@ -52839,6 +52843,10 @@ "comment" : "A label for the TAK channel index.", "isCommentAutoGenerated" : true }, + "TAK Config" : { + "comment" : "The title of the TAK module configuration screen.", + "isCommentAutoGenerated" : true + }, "TAK Server" : { }, @@ -53079,6 +53087,10 @@ } } }, + "Team" : { + "comment" : "A label for the team picker.", + "isCommentAutoGenerated" : true + }, "Telemetry" : { "localizations" : { "da" : { @@ -54794,6 +54806,9 @@ } } } + }, + "These settings only apply when the device role is TAK or TAK Tracker." : { + }, "These settings will %@" : { "comment" : "A paragraph below the title that explains what the user is about to do.", @@ -54854,6 +54869,10 @@ } } }, + "These values are included in TAK position reports. Leave either setting at Default to let firmware use Cyan and Team Member." : { + "comment" : "A", + "isCommentAutoGenerated" : true + }, "Thirty Minutes" : { "localizations" : { "da" : { @@ -55299,6 +55318,7 @@ } }, "This node does not support any configurable modules." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 4a83769b..5ff2936e 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -101,7 +101,6 @@ 8E587743574CE17703E892C6 /* Certificates in Resources */ = {isa = PBXBuildFile; fileRef = 518D504DED9874EBF9D76578 /* Certificates */; }; 8EED425B7820DA4FEB40C375 /* CoTXMLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748E4806582595DE80D455CD /* CoTXMLParser.swift */; }; 9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0A8ABAEF1E587683970927 /* EXICodec.swift */; }; - DCC919C6B47C15BB0795456C /* Tools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D5AD8037A0D583C614B0597 /* Tools.swift */; }; A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */; }; AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; }; ABA8E6402E2F2A2300E27791 /* AppIconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */; }; @@ -135,6 +134,7 @@ D9C9839D2B79CFD700BDBE6A /* TextMessageSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C9839C2B79CFD700BDBE6A /* TextMessageSize.swift */; }; D9C983A02B79D0E800BDBE6A /* AlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C9839F2B79D0E800BDBE6A /* AlertButton.swift */; }; D9C983A22B79D1A600BDBE6A /* RequestPositionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C983A12B79D1A600BDBE6A /* RequestPositionButton.swift */; }; + DCC919C6B47C15BB0795456C /* Tools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D5AD8037A0D583C614B0597 /* Tools.swift */; }; DD007BAE2AA4E91200F5FA12 /* MyInfoEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD007BAD2AA4E91200F5FA12 /* MyInfoEntityExtension.swift */; }; DD007BB02AA5981000F5FA12 /* NodeInfoEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD007BAF2AA5981000F5FA12 /* NodeInfoEntityExtension.swift */; }; DD09240001E7FAD600E70001 /* MapLegend.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD09240002E7FAD600E70001 /* MapLegend.swift */; }; @@ -349,11 +349,11 @@ /* Begin PBXFileReference section */ 01028778B8BFD81F7A039593 /* TAKConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKConnection.swift; sourceTree = ""; }; - 1D5AD8037A0D583C614B0597 /* Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tools.swift; sourceTree = ""; }; 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerConfig.swift; sourceTree = ""; }; 09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKCertificateManager.swift; sourceTree = ""; }; 108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContactQRDialog.swift; sourceTree = ""; }; 108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = ""; }; + 1D5AD8037A0D583C614B0597 /* Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tools.swift; sourceTree = ""; }; 230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Discovery.swift"; sourceTree = ""; }; 231251372E3BC96400E6ED07 /* BLEAuthorizationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEAuthorizationHelper.swift; sourceTree = ""; }; 231A53772E69ADB900216B99 /* NodeFilterParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeFilterParameters.swift; sourceTree = ""; }; @@ -437,6 +437,7 @@ 8D3F8A402D44C2A6009EAAA4 /* PowerMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetricsLog.swift; sourceTree = ""; }; 9155703C39B55FC9DDF3E4C1 /* TAKDataPackageGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKDataPackageGenerator.swift; sourceTree = ""; }; AA00010022E2730EC0060000 /* ConnectViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewTests.swift; sourceTree = ""; }; + AA0001022F07A4B000600001 /* MeshtasticDataModelV 57.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 57.xcdatamodel"; sourceTree = ""; }; ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconButton.swift; sourceTree = ""; }; ABB99DEA2E2EA1C500CFBD05 /* AppIconPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconPicker.swift; sourceTree = ""; }; B399E8A32B6F486400E4488E /* RetryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryButton.swift; sourceTree = ""; }; @@ -2439,6 +2440,7 @@ DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + AA0001022F07A4B000600001 /* MeshtasticDataModelV 57.xcdatamodel */, DD7D46262F833B520028AC1A /* MeshtasticDataModelV 56.xcdatamodel */, DD04804A2E9295A5005F946C /* MeshtasticDataModelV 55.xcdatamodel */, DDDF34392E2CB8E600356DC3 /* MeshtasticDataModelV 54.xcdatamodel */, @@ -2496,7 +2498,7 @@ DD5D0A9A2931AD6B00F7EA61 /* MeshtasticDataModelV2.xcdatamodel */, DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */, ); - currentVersion = DD7D46262F833B520028AC1A /* MeshtasticDataModelV 56.xcdatamodel */; + currentVersion = AA0001022F07A4B000600001 /* MeshtasticDataModelV 57.xcdatamodel */; name = Meshtastic.xcdatamodeld; path = Meshtastic/Meshtastic.xcdatamodeld; sourceTree = ""; diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift index f34b5ae4..4fa73d2d 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift @@ -1820,6 +1820,32 @@ extension AccessoryManager { try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) } + public func requestTAKModuleConfig(fromUser: UserEntity, toUser: UserEntity) async throws { + + var adminPacket = AdminMessage() + adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.takConfig + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setModuleConfig.tak = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. _XCCurrentVersionName - MeshtasticDataModelV 56.xcdatamodel + MeshtasticDataModelV 57.xcdatamodel diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 57.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 57.xcdatamodel/contents new file mode 100644 index 00000000..cd39db2a --- /dev/null +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 57.xcdatamodel/contents @@ -0,0 +1,519 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index 32d9bc99..5a141ed3 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -1791,4 +1791,51 @@ extension MeshPackets { Logger.data.error("💥 [TelemetryConfigEntity] Fetching node for core data TelemetryConfigEntity failed: \(nsError, privacy: .public)") } } + + func upsertTAKModuleConfigPacket(config: ModuleConfig.TAKConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertTAKModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertTAKModuleConfigPacket(config: ModuleConfig.TAKConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("TAK module config received: %@".localized, String(nodeNum)) + Logger.data.info("🎯 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + if !fetchedNode.isEmpty { + if fetchedNode[0].takConfig == nil { + let newTAKConfig = TAKConfigEntity(context: context) + newTAKConfig.team = Int32(config.team.rawValue) + newTAKConfig.role = Int32(config.role.rawValue) + fetchedNode[0].takConfig = newTAKConfig + } else { + fetchedNode[0].takConfig?.team = Int32(config.team.rawValue) + fetchedNode[0].takConfig?.role = Int32(config.role.rawValue) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [TAKConfigEntity] Updated TAK Module Config for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [TAKConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [TAKConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save TAK Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [TAKConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } } diff --git a/Meshtastic/Router/NavigationState.swift b/Meshtastic/Router/NavigationState.swift index 173a2c4e..c087f478 100644 --- a/Meshtastic/Router/NavigationState.swift +++ b/Meshtastic/Router/NavigationState.swift @@ -53,6 +53,7 @@ enum SettingsNavigationState: String { case appFiles case firmwareUpdates case tak + case takConfig case tools } diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index d4fe2712..26a2d247 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -276,14 +276,12 @@ struct Settings: View { } } - // Update this list with the modules that are shown above. If all are not supported - // Then show a message. - if !isAnySupported([.ambientlightingConfig, .cannedmsgConfig, - .detectionsensorConfig, .extnotifConfig, - .mqttConfig, .rangetestConfig, .paxcounterConfig, - .audioConfig, .serialConfig, .storeforwardConfig, - .telemetryConfig]) { - Text("This node does not support any configurable modules.") + NavigationLink(value: SettingsNavigationState.takConfig) { + Label { + Text("TAK") + } icon: { + Image(systemName: "shield.checkered") + } } } header: { Text("Module Configuration") @@ -545,6 +543,8 @@ struct Settings: View { Tools() case .tak: TAKServerConfig() + case .takConfig: + TAKModuleConfig(node: nodes.first(where: { $0.num == selectedNode })) } } .onChange(of: UserDefaults.preferredPeripheralNum ) { _, newConnectedNode in diff --git a/Meshtastic/Views/Settings/TAKServerConfig.swift b/Meshtastic/Views/Settings/TAKServerConfig.swift index 7e8b6502..3f615f04 100644 --- a/Meshtastic/Views/Settings/TAKServerConfig.swift +++ b/Meshtastic/Views/Settings/TAKServerConfig.swift @@ -9,6 +9,7 @@ import SwiftUI import UniformTypeIdentifiers import OSLog import CoreData +import MeshtasticProtobufs enum CertificateImportType { case p12 @@ -563,3 +564,236 @@ struct ZipDocument: FileDocument { FileWrapper(regularFileWithContents: data) } } + +struct TAKModuleConfig: View { + @Environment(\.managedObjectContext) private var context + @EnvironmentObject private var accessoryManager: AccessoryManager + @Environment(\.dismiss) private var goBack + + let node: NodeInfoEntity? + + @State private var hasChanges = false + @State private var team = Team.unspecifedColor.rawValue + @State private var role = MemberRole.unspecifed.rawValue + + private var selectedTeam: Team { + Team(rawValue: team) ?? .unspecifedColor + } + + private var selectedRole: MemberRole { + MemberRole(rawValue: role) ?? .unspecifed + } + + private var deviceRole: DeviceRoles? { + guard let role = node?.deviceConfig?.role else { return nil } + return DeviceRoles(rawValue: Int(role)) + } + + var body: some View { + Form { + ConfigHeader(title: "TAK", config: \.takConfig, node: node, onAppear: setTAKValues) + + if let deviceRole, deviceRole != .tak && deviceRole != .takTracker { + Section { + Text("These settings only apply when the device role is TAK or TAK Tracker.") + .font(.callout) + .foregroundColor(.orange) + } + } + + Section(header: Text("Identity")) { + VStack(alignment: .leading) { + Picker("Team", selection: $team) { + ForEach(Team.allCases, id: \.rawValue) { teamOption in + Text(teamTitle(teamOption)).tag(teamOption.rawValue) + } + } + .pickerStyle(DefaultPickerStyle()) + Text(teamHelpText(selectedTeam)) + .foregroundColor(.gray) + .font(.callout) + } + + VStack(alignment: .leading) { + Picker("Role", selection: $role) { + ForEach(MemberRole.allCases, id: \.rawValue) { roleOption in + Text(roleTitle(roleOption)).tag(roleOption.rawValue) + } + } + .pickerStyle(DefaultPickerStyle()) + Text(roleHelpText(selectedRole)) + .foregroundColor(.gray) + .font(.callout) + } + } + + Section { + Text("These values are included in TAK position reports. Leave either setting at Default to let firmware use Cyan and Team Member.") + .foregroundColor(.gray) + .font(.callout) + } + } + .disabled(!accessoryManager.isConnected || node?.takConfig == nil) + .safeAreaInset(edge: .bottom, alignment: .center) { + HStack(spacing: 0) { + SaveConfigButton(node: node, hasChanges: $hasChanges) { + guard let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? -1, context: context), + let fromUser = connectedNode.user, + let toUser = node?.user else { + return + } + + var config = ModuleConfig.TAKConfig() + config.team = selectedTeam + config.role = selectedRole + + Task { + _ = try await accessoryManager.saveTAKModuleConfig(config: config, fromUser: fromUser, toUser: toUser) + Task { @MainActor in + hasChanges = false + goBack() + } + } + } + } + } + .navigationTitle("TAK Config") + .navigationBarItems( + trailing: ZStack { + ConnectedDevice( + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?" + ) + } + ) + .onFirstAppear { + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) + if let connectedNode, node.num != deviceNum { + if UserDefaults.enableAdministration { + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.takConfig == nil { + Task { + do { + Logger.mesh.info("⚙️ Empty or expired TAK module config requesting via PKI admin") + try await accessoryManager.requestTAKModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.info("🚨 TAK module config request failed: \(error.localizedDescription)") + } + } + } + } else { + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") + } + } + } + } + .onChange(of: team) { _, newTeam in + if newTeam != Int(node?.takConfig?.team ?? Int32(Team.unspecifedColor.rawValue)) { + hasChanges = true + } + } + .onChange(of: role) { _, newRole in + if newRole != Int(node?.takConfig?.role ?? Int32(MemberRole.unspecifed.rawValue)) { + hasChanges = true + } + } + } + + private func setTAKValues() { + team = Int(node?.takConfig?.team ?? Int32(Team.unspecifedColor.rawValue)) + role = Int(node?.takConfig?.role ?? Int32(MemberRole.unspecifed.rawValue)) + hasChanges = false + } + + private func teamTitle(_ team: Team) -> String { + switch team { + case .unspecifedColor: + return "Default (Cyan)" + case .white: + return "White" + case .yellow: + return "Yellow" + case .orange: + return "Orange" + case .magenta: + return "Magenta" + case .red: + return "Red" + case .maroon: + return "Maroon" + case .purple: + return "Purple" + case .darkBlue: + return "Dark Blue" + case .blue: + return "Blue" + case .cyan: + return "Cyan" + case .teal: + return "Teal" + case .green: + return "Green" + case .darkGreen: + return "Dark Green" + case .brown: + return "Brown" + case .UNRECOGNIZED: + return "Unknown" + } + } + + private func roleTitle(_ role: MemberRole) -> String { + switch role { + case .unspecifed: + return "Default (Team Member)" + case .teamMember: + return "Team Member" + case .teamLead: + return "Team Lead" + case .hq: + return "HQ" + case .sniper: + return "Sniper" + case .medic: + return "Medic" + case .forwardObserver: + return "Forward Observer" + case .rto: + return "RTO" + case .k9: + return "K9" + case .UNRECOGNIZED: + return "Unknown" + } + } + + private func teamHelpText(_ team: Team) -> String { + switch team { + case .unspecifedColor: + return "Default uses Cyan." + case .UNRECOGNIZED: + return "Unknown team color." + default: + return "Shown to TAK clients as the \(teamTitle(team)) team color." + } + } + + private func roleHelpText(_ role: MemberRole) -> String { + switch role { + case .unspecifed: + return "Default uses Team Member." + case .UNRECOGNIZED: + return "Unknown TAK role." + default: + return "Shown to TAK clients as the \(roleTitle(role)) role." + } + } +} + +#Preview { + let context = PersistenceController.preview.container.viewContext + return TAKModuleConfig(node: nil) + .environmentObject(AccessoryManager.shared) + .environment(\.managedObjectContext, context) +}