diff --git a/Localizable.xcstrings b/Localizable.xcstrings index dde69cfe..27047cb7 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -55318,7 +55318,6 @@ } }, "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 5ff2936e..815a8db1 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -102,6 +102,7 @@ 8EED425B7820DA4FEB40C375 /* CoTXMLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 748E4806582595DE80D455CD /* CoTXMLParser.swift */; }; 9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0A8ABAEF1E587683970927 /* EXICodec.swift */; }; A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */; }; + AA0001032F07A4B000600001 /* TAKModuleConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001042F07A4B000600001 /* TAKModuleConfig.swift */; }; AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; }; ABA8E6402E2F2A2300E27791 /* AppIconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */; }; ABB99DEB2E2EA1C500CFBD05 /* AppIconPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB99DEA2E2EA1C500CFBD05 /* AppIconPicker.swift */; }; @@ -351,6 +352,7 @@ 01028778B8BFD81F7A039593 /* TAKConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKConnection.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 = ""; }; + AA0001042F07A4B000600001 /* TAKModuleConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TAKModuleConfig.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 = ""; }; @@ -1085,6 +1087,7 @@ DD15E4F22B8BA56E00654F61 /* PaxCounterConfig.swift */, DD6193782863875F00E59241 /* SerialConfig.swift */, DDF6B2472A9AEBF500BA6931 /* StoreForwardConfig.swift */, + AA0001042F07A4B000600001 /* TAKModuleConfig.swift */, DD415827285859C4009B0E59 /* TelemetryConfig.swift */, ); path = Module; @@ -1946,6 +1949,7 @@ E3ED80145D0E873011982556 /* TAKServerManager.swift in Sources */, FE508F9AF5AD5DA20AA64DBF /* AccessoryManager+TAK.swift in Sources */, A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */, + AA0001032F07A4B000600001 /* TAKModuleConfig.swift in Sources */, DCC919C6B47C15BB0795456C /* Tools.swift in Sources */, 8398407DBA32EE7CFC16A385 /* TAKDataPackageGenerator.swift in Sources */, 655AF7816E76D5F310DF87A6 /* FountainCodec.swift in Sources */, diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift index 4fa73d2d..29870d16 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift @@ -1824,7 +1824,9 @@ extension AccessoryManager { var adminPacket = AdminMessage() adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.takConfig - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } var meshPacket: MeshPacket = MeshPacket() meshPacket.to = UInt32(toUser.num) meshPacket.from = UInt32(fromUser.num) diff --git a/Meshtastic/Views/Settings/Config/Module/TAKModuleConfig.swift b/Meshtastic/Views/Settings/Config/Module/TAKModuleConfig.swift new file mode 100644 index 00000000..56803bb8 --- /dev/null +++ b/Meshtastic/Views/Settings/Config/Module/TAKModuleConfig.swift @@ -0,0 +1,264 @@ +// +// TAKModuleConfig.swift +// Meshtastic +import SwiftUI +import CoreData +import OSLog +import MeshtasticProtobufs + +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 ?? node?.user?.role else { return nil } + return DeviceRoles(rawValue: Int(role)) + } + + private var isConnectedNode: Bool { + guard let node else { return false } + return node.num == accessoryManager.activeDeviceNum + } + + 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 || (!isConnectedNode && 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 ?? "?" + ) + } + ) + .onAppear { + // Need to request a TAKModuleConfig from the connected node before allowing changes. + if let deviceNum = accessoryManager.activeDeviceNum, + let node, + node.num == deviceNum, + node.takConfig == nil { + let connectedNode = getNodeInfo(id: deviceNum, context: context) + if let connectedNode { + Task { + do { + Logger.mesh.info("⚙️ Empty TAK module config requesting from connected node") + try await accessoryManager.requestTAKModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.error("🚨 TAK module config request failed: \(error.localizedDescription)") + } + } + } + } + } + .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) +} diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 26a2d247..363842a2 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -33,14 +33,48 @@ struct Settings: View { // MARK: Helper + private var moduleConfigurationNode: NodeInfoEntity? { + let nodeNum = selectedNode > 0 ? selectedNode : preferredNodeNum + return nodes.first(where: { $0.num == nodeNum }) + } + + private var showsAnyModuleConfiguration: Bool { + isAnySupported([ + .ambientlightingConfig, + .cannedmsgConfig, + .detectionsensorConfig, + .extnotifConfig, + .mqttConfig, + .rangetestConfig, + .paxcounterConfig, + .serialConfig, + .storeforwardConfig, + .telemetryConfig + ]) || isTAKModuleSupported() + } + private func isModuleSupported(_ module: ExcludedModules) -> Bool { - return Int(nodes.first(where: { $0.num == preferredNodeNum })?.metadata?.excludedModules ?? Int32.zero) & module.rawValue == 0 + return Int(moduleConfigurationNode?.metadata?.excludedModules ?? Int32.zero) & module.rawValue == 0 } private func isAnySupported(_ modules: [ExcludedModules]) -> Bool { return modules.map(isModuleSupported).contains(true) } + private func isTAKModuleSupported() -> Bool { + guard let node = moduleConfigurationNode else { return false } + if node.takConfig != nil { + return true + } + + guard let roleValue = node.deviceConfig?.role ?? node.user?.role, + let deviceRole = DeviceRoles(rawValue: Int(roleValue)) else { + return false + } + + return deviceRole == .tak || deviceRole == .takTracker + } + // MARK: Views var radioConfigurationSection: some View { @@ -276,13 +310,20 @@ struct Settings: View { } } - NavigationLink(value: SettingsNavigationState.takConfig) { - Label { - Text("TAK") - } icon: { - Image(systemName: "shield.checkered") + if isTAKModuleSupported() { + NavigationLink(value: SettingsNavigationState.takConfig) { + Label { + Text("TAK") + } icon: { + Image(systemName: "shield.checkered") + } } } + + if !showsAnyModuleConfiguration { + Text("This node does not support any configurable modules.") + .foregroundColor(.secondary) + } } header: { Text("Module Configuration") } diff --git a/Meshtastic/Views/Settings/TAKServerConfig.swift b/Meshtastic/Views/Settings/TAKServerConfig.swift index 3f615f04..7e8b6502 100644 --- a/Meshtastic/Views/Settings/TAKServerConfig.swift +++ b/Meshtastic/Views/Settings/TAKServerConfig.swift @@ -9,7 +9,6 @@ import SwiftUI import UniformTypeIdentifiers import OSLog import CoreData -import MeshtasticProtobufs enum CertificateImportType { case p12 @@ -564,236 +563,3 @@ 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) -}