From 2cabd9e575538696a448f93930f031bf34f55106 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:34:01 -0700 Subject: [PATCH] Tak server improvements (#1603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added read only mode cot to meshtastic parsing and warning to not enable on pub channel * better icons * fully fixed markers * telemetry support * Update Meshtastic/Helpers/TAK/TAKServerManager.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fixes * fixes * Resolve merge conflicts for PR #1603 (TAK server improvements) (#1645) * Delete Messages fix * Bump version to 2.7.9 * Bump widgets version * TAK Server channel index picker Create a settings picker for the TAK Server's channel index. This allows users to specify TAK traffic to use the non-primary channel to help reduce channel congestion. * Changed capitalization from 'environment' to 'Environment' for section header. (#1591) * Add Danish (da) translations — resolves merge conflicts from PR #1609 (#1612) * Initial plan * Add Danish (da) translations from PR #1609 Resolves merge conflicts from PR #1609 by adding Danish translations to the Localizable.xcstrings file. The PR adds Danish translation strings throughout the app while preserving all existing translations for other languages. Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Migrate test project to Swift Testing and add connect view and router tests (#1643) * Migrate to Swift Testing and add connect view tests - Convert RouterTests.swift from XCTest to Swift Testing (@Suite, @Test, #expect, #require) - Create ConnectViewTests.swift with tests for connect view child types: - Device struct (creation, signal strength, RSSI, description, codable) - TransportType enum (cases, raw values, codable) - ConnectionState enum (equality, codable) - BLESignalStrength enum (raw values, init) - TransportStatus enum (equality) - NavigationState (defaults, tabs, sub-states) - InvalidVersion view (creation with versions) - ConnectedDevice view (connected/disconnected/MQTT states) - CircleText view (default/custom sizes, emoji) - BatteryCompact view (levels, nil, charging, plugged in) - SignalStrengthIndicator view (dimensions, strength levels) - Update Xcode project to include new test file Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/d7bb7a89-2105-4fcb-96bc-7ec794467c74 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Fix signal strength test boundary conditions The getSignalStrength() method uses NSNumber.compare(.orderedDescending), which is a strict greater-than check. Fix the boundary test cases: - RSSI -65 is .normal (not .strong), since -65 is not > -65 - RSSI -85 is .weak (not .normal), since -85 is not > -85 - Add -64 → .strong and -84 → .normal as adjacent boundary tests Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/4fcbc01e-cbea-4d11-b2c0-e923c6730d69 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Improve and complete router tests with comprehensive coverage Added tests for: - Custom initial state - Invalid scheme / unknown path handling (state unchanged) - navigateToNodeDetail public method - Messages edge cases: channelId only, userNum only, messageId only, non-numeric params - Nodes with non-numeric nodenum - Map with both nodenum+waypointId (nodeId priority), non-numeric params - Parameterized settings test covering all 31 SettingsNavigationState cases - State transitions: consecutive routes, invalid scheme preserves existing state Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/f69b7352-21aa-494c-8864-31fc0f4b21b8 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Localizable update * Merge translations file --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Garth Vander Houwen * Fix merge conflicts in PR #1614 (Spanish translations) (#1644) * 20% of strings translated to spanish * add more translations * add rest of translations * small fixes --------- Co-authored-by: Joel Pérez Izquierdo Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * fix typo in hop limit option description (#1631) O hop -> 0 hop --------- Co-authored-by: Jake-B Co-authored-by: Garth Vander Houwen Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com> Co-authored-by: Austin Hargis <25471876+austinhargis@users.noreply.github.com> Co-authored-by: Ben Meadors Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Joel Pérez Izquierdo Co-authored-by: axunes * Fix merge conflicts * Merge main into tak-server-improvements to resolve PR #1603 conflicts (#1646) * Delete Messages fix * Bump version to 2.7.9 * Bump widgets version * TAK Server channel index picker Create a settings picker for the TAK Server's channel index. This allows users to specify TAK traffic to use the non-primary channel to help reduce channel congestion. * Changed capitalization from 'environment' to 'Environment' for section header. (#1591) * Add Danish (da) translations — resolves merge conflicts from PR #1609 (#1612) * Initial plan * Add Danish (da) translations from PR #1609 Resolves merge conflicts from PR #1609 by adding Danish translations to the Localizable.xcstrings file. The PR adds Danish translation strings throughout the app while preserving all existing translations for other languages. Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Migrate test project to Swift Testing and add connect view and router tests (#1643) * Migrate to Swift Testing and add connect view tests - Convert RouterTests.swift from XCTest to Swift Testing (@Suite, @Test, #expect, #require) - Create ConnectViewTests.swift with tests for connect view child types: - Device struct (creation, signal strength, RSSI, description, codable) - TransportType enum (cases, raw values, codable) - ConnectionState enum (equality, codable) - BLESignalStrength enum (raw values, init) - TransportStatus enum (equality) - NavigationState (defaults, tabs, sub-states) - InvalidVersion view (creation with versions) - ConnectedDevice view (connected/disconnected/MQTT states) - CircleText view (default/custom sizes, emoji) - BatteryCompact view (levels, nil, charging, plugged in) - SignalStrengthIndicator view (dimensions, strength levels) - Update Xcode project to include new test file Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/d7bb7a89-2105-4fcb-96bc-7ec794467c74 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Fix signal strength test boundary conditions The getSignalStrength() method uses NSNumber.compare(.orderedDescending), which is a strict greater-than check. Fix the boundary test cases: - RSSI -65 is .normal (not .strong), since -65 is not > -65 - RSSI -85 is .weak (not .normal), since -85 is not > -85 - Add -64 → .strong and -84 → .normal as adjacent boundary tests Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/4fcbc01e-cbea-4d11-b2c0-e923c6730d69 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Improve and complete router tests with comprehensive coverage Added tests for: - Custom initial state - Invalid scheme / unknown path handling (state unchanged) - navigateToNodeDetail public method - Messages edge cases: channelId only, userNum only, messageId only, non-numeric params - Nodes with non-numeric nodenum - Map with both nodenum+waypointId (nodeId priority), non-numeric params - Parameterized settings test covering all 31 SettingsNavigationState cases - State transitions: consecutive routes, invalid scheme preserves existing state Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/f69b7352-21aa-494c-8864-31fc0f4b21b8 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Localizable update * Merge translations file --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Garth Vander Houwen * Fix merge conflicts in PR #1614 (Spanish translations) (#1644) * 20% of strings translated to spanish * add more translations * add rest of translations * small fixes --------- Co-authored-by: Joel Pérez Izquierdo Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * fix typo in hop limit option description (#1631) O hop -> 0 hop --------- Co-authored-by: Jake-B Co-authored-by: Garth Vander Houwen Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com> Co-authored-by: Austin Hargis <25471876+austinhargis@users.noreply.github.com> Co-authored-by: Ben Meadors Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Joel Pérez Izquierdo Co-authored-by: axunes * Merge main into tak-server-improvements to resolve PR #1603 conflicts (#1647) * Delete Messages fix * Bump version to 2.7.9 * Bump widgets version * TAK Server channel index picker Create a settings picker for the TAK Server's channel index. This allows users to specify TAK traffic to use the non-primary channel to help reduce channel congestion. * Changed capitalization from 'environment' to 'Environment' for section header. (#1591) * Add Danish (da) translations — resolves merge conflicts from PR #1609 (#1612) * Initial plan * Add Danish (da) translations from PR #1609 Resolves merge conflicts from PR #1609 by adding Danish translations to the Localizable.xcstrings file. The PR adds Danish translation strings throughout the app while preserving all existing translations for other languages. Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Migrate test project to Swift Testing and add connect view and router tests (#1643) * Migrate to Swift Testing and add connect view tests - Convert RouterTests.swift from XCTest to Swift Testing (@Suite, @Test, #expect, #require) - Create ConnectViewTests.swift with tests for connect view child types: - Device struct (creation, signal strength, RSSI, description, codable) - TransportType enum (cases, raw values, codable) - ConnectionState enum (equality, codable) - BLESignalStrength enum (raw values, init) - TransportStatus enum (equality) - NavigationState (defaults, tabs, sub-states) - InvalidVersion view (creation with versions) - ConnectedDevice view (connected/disconnected/MQTT states) - CircleText view (default/custom sizes, emoji) - BatteryCompact view (levels, nil, charging, plugged in) - SignalStrengthIndicator view (dimensions, strength levels) - Update Xcode project to include new test file Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/d7bb7a89-2105-4fcb-96bc-7ec794467c74 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Fix signal strength test boundary conditions The getSignalStrength() method uses NSNumber.compare(.orderedDescending), which is a strict greater-than check. Fix the boundary test cases: - RSSI -65 is .normal (not .strong), since -65 is not > -65 - RSSI -85 is .weak (not .normal), since -85 is not > -85 - Add -64 → .strong and -84 → .normal as adjacent boundary tests Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/4fcbc01e-cbea-4d11-b2c0-e923c6730d69 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Improve and complete router tests with comprehensive coverage Added tests for: - Custom initial state - Invalid scheme / unknown path handling (state unchanged) - navigateToNodeDetail public method - Messages edge cases: channelId only, userNum only, messageId only, non-numeric params - Nodes with non-numeric nodenum - Map with both nodenum+waypointId (nodeId priority), non-numeric params - Parameterized settings test covering all 31 SettingsNavigationState cases - State transitions: consecutive routes, invalid scheme preserves existing state Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/f69b7352-21aa-494c-8864-31fc0f4b21b8 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * Localizable update * Merge translations file --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Garth Vander Houwen * Fix merge conflicts in PR #1614 (Spanish translations) (#1644) * 20% of strings translated to spanish * add more translations * add rest of translations * small fixes --------- Co-authored-by: Joel Pérez Izquierdo Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * fix typo in hop limit option description (#1631) O hop -> 0 hop --------- Co-authored-by: Jake-B Co-authored-by: Garth Vander Houwen Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com> Co-authored-by: Austin Hargis <25471876+austinhargis@users.noreply.github.com> Co-authored-by: Ben Meadors Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Joel Pérez Izquierdo Co-authored-by: axunes --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Jake-B Co-authored-by: Garth Vander Houwen Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com> Co-authored-by: Austin Hargis <25471876+austinhargis@users.noreply.github.com> Co-authored-by: Ben Meadors Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> Co-authored-by: Joel Pérez Izquierdo Co-authored-by: axunes --- Localizable.xcstrings | 171 ++++ .../Accessory Manager/AccessoryManager.swift | 38 + Meshtastic/Helpers/TAK/CoTMessage.swift | 6 +- .../Helpers/TAK/MeshToCoTConverter.swift | 271 +++++ .../Helpers/TAK/TAKMeshtasticBridge.swift | 940 +++++++++++++++++- Meshtastic/Helpers/TAK/TAKServerManager.swift | 304 +++++- .../Views/Settings/TAKServerConfig.swift | 141 +++ 7 files changed, 1827 insertions(+), 44 deletions(-) create mode 100644 Meshtastic/Helpers/TAK/MeshToCoTConverter.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 948176b5..30e6245e 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -59,6 +59,90 @@ }, "shouldTranslate" : false }, + " : %@" : { + "extractionState" : "stale", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %@" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %@" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %@" + } + } + }, + "shouldTranslate" : false + }, + " : %d" : { + "extractionState" : "stale", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %d" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %d" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %d" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %d" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %d" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %d" + } + } + }, + "shouldTranslate" : false + }, " %@" : { "localizations" : { "da" : { @@ -149,6 +233,12 @@ "value" : " : %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -190,6 +280,12 @@ "value" : " : %d" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %d" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6611,6 +6707,10 @@ } } }, + "Auto-Fix Channel" : { + "comment" : "A button label that initiates the process of automatically fixing the TAK server's primary communication channel.", + "isCommentAutoGenerated" : true + }, "Automatically Connect" : { "localizations" : { "es" : { @@ -8179,6 +8279,10 @@ } } }, + "Bridge Meshtastic positions, nodes, waypoints, and messages to TAK/CoT format" : { + "comment" : "A description of the Mesh to CoT Converter feature.", + "isCommentAutoGenerated" : true + }, "Broadcast Device Metrics" : { "localizations" : { "es" : { @@ -10435,6 +10539,10 @@ } } }, + "Channel Fixed!" : { + "comment" : "A message displayed when the primary channel is successfully fixed.", + "isCommentAutoGenerated" : true + }, "Channel Name" : { "localizations" : { "da" : { @@ -16332,6 +16440,10 @@ } } }, + "Device role is \"%@\". Consider setting to TAK or TAK Tracker for optimal operation." : { + "comment" : "A warning about a device's role on the TAK network. The argument is the name of the device role.", + "isCommentAutoGenerated" : true + }, "Device Screen" : { "localizations" : { "da" : { @@ -19619,6 +19731,7 @@ "Enter P12 Password" : {}, "Enter the password for the PKCS#12 file" : {}, "environment" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -22460,6 +22573,14 @@ } } }, + "Fix Channel" : { + "comment" : "The text on a button that, when pressed, will attempt to fix the primary LoRa channel.", + "isCommentAutoGenerated" : true + }, + "Fix Primary Channel?" : { + "comment" : "A confirmation alert title.", + "isCommentAutoGenerated" : true + }, "Fixed Pin" : { "localizations" : { "da" : { @@ -28268,6 +28389,10 @@ } } }, + "Later" : { + "comment" : "A button that dismisses an alert without taking any action.", + "isCommentAutoGenerated" : true + }, "Latitude" : { "localizations" : { "da" : { @@ -31199,6 +31324,10 @@ } } }, + "Mesh to CoT Converter" : { + "comment" : "A feature that bridges Meshtastic positions, nodes, waypoints, and messages to TAK/CoT format.", + "isCommentAutoGenerated" : true + }, "Meshtastic" : { "localizations" : { "es" : { @@ -31221,6 +31350,10 @@ } } }, + "Meshtastic -> TAK works, TAK -> Meshtastic blocked" : { + "comment" : "A description of the read-only mode feature in TAK Server.", + "isCommentAutoGenerated" : true + }, "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. You can opt out under app settings." : { "localizations" : { "es" : { @@ -37112,6 +37245,10 @@ } } }, + "Or fix it yourself in Channels settings, then return here." : { + "comment" : "A message explaining that the user can fix the primary channel settings manually and then return to the current view.", + "isCommentAutoGenerated" : true + }, "OS Log Entry Details" : { "localizations" : { "da" : { @@ -41768,6 +41905,10 @@ } } }, + "Read-Only Mode" : { + "comment" : "A toggle that allows the user to enable or disable read-only mode for the TAK server.", + "isCommentAutoGenerated" : true + }, "Reboot" : { "localizations" : { "da" : { @@ -46308,6 +46449,11 @@ } }, "Secure mTLS connection on port 8089. Both server and client certificates are required." : {}, + + "Secure mTLS connection on port 8089. Both server and client certificates are required. TAK Channel Index selects the channel index where TAK messages will be sent." : { + "comment" : "A footer for the TAK Server configuration section.", + "isCommentAutoGenerated" : true + }, "Security" : { "localizations" : { "da" : { @@ -52510,6 +52656,7 @@ } } } + }, "TAK Server" : {}, "TAK Tracker" : { @@ -55014,6 +55161,10 @@ } } }, + "This will change your primary channel to:\n• Name: TAK\n• Encryption: New 256-bit AES key\n• LoRa preset: Short Fast (recommended for TAK)\n\nThis is required for TAK Server to work properly. Any existing channel sharing links will become invalid." : { + "comment" : "The message shown in the \"Fix Primary Channel?\" alert.", + "isCommentAutoGenerated" : true + }, "This will disable fixed position and remove the currently set position." : { "localizations" : { "da" : { @@ -58899,6 +59050,10 @@ } } }, + "Use a 256-bit encryption key" : { + "comment" : "A bullet point describing the importance of using a 256-bit encryption key for the primary channel.", + "isCommentAutoGenerated" : true + }, "Use a PWM output (like the RAK Buzzer) for tunes instead of an on/off output. This will ignore the output, output duration and active settings and use the device config buzzer GPIO option instead." : { "localizations" : { "da" : { @@ -60587,6 +60742,10 @@ } } }, + "Warning" : { + "comment" : "The header text for the \"Warning\" section in the TAKServerConfig view.", + "isCommentAutoGenerated" : true + }, "Wave" : { "extractionState" : "stale", "localizations" : { @@ -62167,6 +62326,10 @@ } } }, + "You can fix this yourself by changing your primary channel:" : { + "comment" : "A description of how to fix the primary channel in the TAK Server configuration view.", + "isCommentAutoGenerated" : true + }, "You can send and receive channel (group chats) and direct messages. From any message you can long press to see available actions like copy, reply, tapback and delete as well as delivery details." : { "localizations" : { "da" : { @@ -62249,6 +62412,10 @@ } } }, + "Your channel has been configured for TAK. To share the QR code: go to Settings > Share QR Code" : { + "comment" : "A message displayed when a user successfully configures their primary channel for TAK. It instructs the user to share the QR code to invite TAK buddies.", + "isCommentAutoGenerated" : true + }, "Your current location will be set as the fixed position and broadcast over the mesh on the position interval." : { "localizations" : { "da" : { @@ -62543,6 +62710,10 @@ } } }, + "Your primary channel is using the default settings (no name or default encryption key). TAK Server is running in read-only mode." : { + "comment" : "A description of a situation where the user's primary channel is not configured with a name or encryption key, and TAK Server is running in read-only mode.", + "isCommentAutoGenerated" : true + }, "Your public key is generated from your private key and sent to other nodes on the mesh so they can compute a shared secret key with you." : { "localizations" : { "es" : { diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift index cff4ab5a..5e1a46bd 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift @@ -512,12 +512,50 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate { switch data.portnum { case .textMessageApp, .detectionSensorApp, .alertApp: await handleTextMessageAppPacket(packet) + // Broadcast text message to TAK clients + if let text = String(bytes: data.payload, encoding: .utf8) { + Logger.tak.debug("Text message received, calling broadcast") + let server = TAKServerManager.shared + if server.ensureBridgeReadyForMeshToCot() { + await server.bridge?.broadcastMeshTextMessageToTAK(text: text, from: packet.from, channel: packet.channel, to: packet.to) + } + } case .remoteHardwareApp: Logger.mesh.info("🕸️ MESH PACKET received for Remote Hardware App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") case .positionApp: await MeshPackets.shared.upsertPositionPacket(packet: packet) + // Broadcast position to TAK clients + if let position = try? Position(serializedBytes: data.payload) { + Logger.tak.debug("Position received, calling broadcast") + let server = TAKServerManager.shared + if server.ensureBridgeReadyForMeshToCot() { + await server.bridge?.broadcastMeshPositionToTAK(position: position, from: packet.from) + } + } case .waypointApp: + Logger.tak.info("WAYPOINT APP CASE REACHED") await MeshPackets.shared.waypointPacket(packet: packet) + // Broadcast waypoint to TAK clients + if let waypoint = try? Waypoint(serializedBytes: data.payload) { + Logger.tak.info("WAYPOINT PARSED: \(waypoint.name)") + // Ensure bridge is initialized before calling (not optional chaining, or lazy init won't run) + let server = TAKServerManager.shared + if server.meshToCotEnabled && server.isRunning && !server.connectedClients.isEmpty { + // Force bridge initialization if needed + if server.bridge == nil { + Logger.tak.info("Initializing bridge on demand") + let bridge = TAKMeshtasticBridge( + accessoryManager: AccessoryManager.shared, + takServerManager: server + ) + bridge.context = AccessoryManager.shared.context + server.bridge = bridge + } + await server.bridge?.broadcastMeshWaypointToTAK(waypoint: waypoint, from: packet.from) + } else { + Logger.tak.info("Waypoint broadcast skipped: server not ready or no clients") + } + } case .nodeinfoApp: guard let connectedNodeNum = self.activeDeviceNum else { Logger.mesh.error("🕸️ Unable to determine connectedNodeNum for node info upsert.") diff --git a/Meshtastic/Helpers/TAK/CoTMessage.swift b/Meshtastic/Helpers/TAK/CoTMessage.swift index 12aff014..68a6b063 100644 --- a/Meshtastic/Helpers/TAK/CoTMessage.swift +++ b/Meshtastic/Helpers/TAK/CoTMessage.swift @@ -131,7 +131,8 @@ struct CoTMessage: Identifiable, Sendable { team: String = "Cyan", role: String = "Team Member", battery: Int = 100, - staleMinutes: Int = 10 + staleMinutes: Int = 10, + remarks: String? = nil ) -> CoTMessage { let now = Date() return CoTMessage( @@ -149,7 +150,8 @@ struct CoTMessage: Identifiable, Sendable { contact: CoTContact(callsign: callsign, endpoint: "0.0.0.0:4242:tcp"), group: CoTGroup(name: team, role: role), status: CoTStatus(battery: battery), - track: CoTTrack(speed: speed, course: course) + track: CoTTrack(speed: speed, course: course), + remarks: remarks ) } diff --git a/Meshtastic/Helpers/TAK/MeshToCoTConverter.swift b/Meshtastic/Helpers/TAK/MeshToCoTConverter.swift new file mode 100644 index 00000000..6c9f9029 --- /dev/null +++ b/Meshtastic/Helpers/TAK/MeshToCoTConverter.swift @@ -0,0 +1,271 @@ +// +// MeshToCoTConverter.swift +// Meshtastic +// +// Converts Meshtastic packets to CoT format for TAK Server +// + +import Foundation +import MeshtasticProtobufs +import CoreLocation +import OSLog +import Combine + +/// Converts Meshtastic packets to CoT format for bridging to TAK Server +final class MeshToCoTConverter: ObservableObject { + + static let shared = MeshToCoTConverter() + + private let logger = Logger(subsystem: "Meshtastic", category: "MeshToCoT") + + private init() {} + + // MARK: - Position // MARK: Packet to CoT + + /// Convert a Meshtastic position packet to CoT message + func convertPosition(_ position: Position, from node: NodeInfoEntity) -> CoTMessage? { + guard let user = node.user else { + logger.warning("Cannot convert position: node has no user info") + return nil + } + + let callsign = user.longName ?? user.shortName ?? "Unknown" + let uid = "MESHTASTIC-\(node.num.toHex())" + + let latitude = Double(position.latitudeI) / 1e7 + let longitude = Double(position.longitudeI) / 1e7 + let altitude = Double(position.altitude) + + var speed: Double = 0 + var course: Double = 0 + if position.speed != 0 { + speed = Double(position.speed) * 0.194384 // Convert to knots + } + if position.heading != 0 { + course = Double(position.heading) + } + + let battery = Int(position.batteryLevel) + + return CoTMessage.pli( + uid: uid, + callsign: callsign, + latitude: latitude, + longitude: longitude, + altitude: altitude, + speed: speed, + course: course, + team: "Meshtastic", + role: "Team Member", + battery: battery > 0 ? battery : 100, + staleMinutes: 10 + ) + } + + // MARK: - Node Info to CoT + + /// Convert node info to CoT message (for node presence updates) + func convertNodeInfo(_ node: NodeInfoEntity) -> CoTMessage? { + guard let user = node.user else { + logger.warning("Cannot convert node info: node has no user info") + return nil + } + + let callsign = user.longName ?? user.shortName ?? "Unknown" + let uid = "MESHTASTIC-\(node.num.toHex())" + + var latitude = 0.0 + var longitude = 0.0 + var altitude = 9999999.0 + + if let position = node.position { + latitude = Double(position.latitudeI) / 1e7 + longitude = Double(position.longitudeI) / 1e7 + if position.altitude != 0 { + altitude = Double(position.altitude) + } + } + + // Determine CoT type based on device role + let cotType = getCoTTypeForRole(user.role) + + let now = Date() + return CoTMessage( + uid: uid, + type: cotType, + time: now, + start: now, + stale: now.addingTimeInterval(3600), // 1 hour stale for node info + how: "m-g", + latitude: latitude, + longitude: longitude, + hae: altitude, + ce: 9999999.0, + le: 9999999.0, + contact: CoTContact(callsign: callsign, endpoint: "0.0.0.0:4242:tcp"), + group: CoTGroup(name: "Meshtastic", role: getRoleNameForDeviceRole(user.role)), + remarks: "Meshtastic Node: \(callsign)" + ) + } + + // MARK: - Waypoint to CoT + + /// Convert a Meshtastic waypoint to CoT message + func convertWaypoint(_ waypoint: Waypoint, from node: NodeInfoEntity?) -> CoTMessage? { + let uid = "WAYPOINT-\(waypoint.id)" + + let latitude = Double(waypoint.latitudeI) / 1e7 + let longitude = Double(waypoint.longitudeI) / 1e7 + let altitude = waypoint.altitude > 0 ? Double(waypoint.altitude) : 9999999.0 + + let name = waypoint.name.isEmpty ? "Unnamed Waypoint" : waypoint.name + let description = waypoint.description_p.isEmpty ? "Meshtastic Waypoint" : waypoint.description_p + + // Get emoji based on waypoint icon/expire time + let iconEmoji = getEmojiForWaypoint(waypoint) + + // Handle expiry - if expire is 0, never expire. Otherwise use the expire time as Unix timestamp + let stale: Date + if waypoint.expire == 0 { + // Never expire - set to 1 year from now + stale = Date().addingTimeInterval(365 * 24 * 60 * 60) + } else { + // expire is Unix timestamp when waypoint expires + let expireDate = Date(timeIntervalSince1970: TimeInterval(waypoint.expire)) + if expireDate > Date() { + stale = expireDate + } else { + // Already expired, don't broadcast + return nil + } + } + + return CoTMessage( + uid: uid, + type: "b-ttf-ff", // Point feature friend - standard CoT type for waypoints/markers + time: Date(), + start: Date(), + stale: stale, + how: "m-g", + latitude: latitude, + longitude: longitude, + hae: altitude, + ce: 100.0, + le: 100.0, + contact: CoTContact(callsign: "\(iconEmoji) \(name)", endpoint: "0.0.0.0:4242:tcp"), + remarks: "\(description)\nCreated by: \(node?.user?.longName ?? "Unknown")" + ) + } + + // MARK: - Text Message to CoT + + /// Convert a Meshtastic text message to CoT chat message + func convertTextMessage(_ message: MessageEntity, from sender: NodeInfoEntity) -> CoTMessage? { + guard let user = sender.user, + let text = message.text else { + return nil + } + + let senderName = user.longName ?? user.shortName ?? "Unknown" + let senderUid = "MESHTASTIC-\(sender.num.toHex())" + let messageId = "MSG-\(message.id)" + + return CoTMessage.chat( + senderUid: senderUid, + senderCallsign: senderName, + message: text, + chatroom: "Primary" + ) + } + + // MARK: - Helper Methods + + /// Get CoT type based on device role + private func getCoTTypeForRole(_ role: UInt32) -> String { + switch DeviceRoles(rawValue: Int(role)) { + case .router, .routerLate: + return "a-f-G-E" // Group entity (router) + case .tracker: + return "a-f-G-T-C" // Ground unit tracker + case .tak: + return "a-f-G-U-C" // TAK client + case .takTracker: + return "a-f-G-T-C" // TAK tracker + case .sensor: + return "a-f-G-s" // Sensor with friendly affiliation + case .client, .clientMute, .clientHidden, .lostAndFound: + return "a-f-G-U-C" // Friendly ground unit + default: + return "a-f-G-U-C" // Default to friendly unit + } + } + + /// Get role name for device role + private func getRoleNameForDeviceRole(_ role: UInt32) -> String { + switch DeviceRoles(rawValue: Int(role)) { + case .router, .routerLate: + return "Router" + case .tracker: + return "Tracker" + case .tak: + return "TAK" + case .takTracker: + return "TAK Tracker" + case .sensor: + return "Sensor" + case .client: + return "Client" + case .clientMute: + return "Muted" + case .clientHidden: + return "Hidden" + default: + return "User" + } + } + + /// Get emoji for waypoint based on icon + private func getEmojiForWaypoint(_ waypoint: Waypoint) -> String { + // Use icon field if available, otherwise use expire time to guess + if waypoint.icon != 0 { + switch waypoint.icon { + case 1: return "📍" // Marker + case 2: return "🚗" // Car + case 3: return "🚶" // Person + case 4: return "🏠" // Home + case 5: return "⛺" // Camp + case 6: return "⚠️" // Warning + case 7: return "🏁" // Flag + case 8: return "🔍" // Search + case 9: return "🏥" // Medical + case 10: return "🔥" // Fire + case 11: return "🚁" // Helicopter + case 12: return "⛵" // Boat + case 13: return "🛸" // UFO + default: return "📍" + } + } + + // Fallback based on name + let name = waypoint.name.lowercased() + if name.contains("help") || name.contains("emergency") { + return "🆘" + } else if name.contains("medical") || name.contains("hospital") { + return "🏥" + } else if name.contains("danger") || name.contains("warning") { + return "⚠️" + } else if name.contains("camp") { + return "⛺" + } else if name.contains("home") || name.contains("house") { + return "🏠" + } else if name.contains("car") || name.contains("vehicle") { + return "🚗" + } else if name.contains("flag") { + return "🏁" + } else if name.contains("person") || name.contains("me") { + return "🚶" + } else { + return "📍" + } + } +} diff --git a/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift b/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift index 23a08afe..8985accf 100644 --- a/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift +++ b/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift @@ -137,6 +137,16 @@ final class TAKMeshtasticBridge { /// Send a CoT message received from TAK to the Meshtastic mesh func sendToMesh(_ cotMessage: CoTMessage) async { + guard let takServerManager else { + Logger.tak.warning("Cannot send to mesh: TAKServerManager not available") + return + } + + guard !takServerManager.userReadOnlyMode else { + Logger.tak.info("TAK Server in read-only mode: Ignoring message from TAK client") + return + } + guard let accessoryManager else { Logger.tak.warning("Cannot send to mesh: AccessoryManager not available") return @@ -452,11 +462,37 @@ final class TAKMeshtasticBridge { } let uid = "MESHTASTIC-\(String(format: "%08X", node.num))" - let callsign = node.user?.shortName ?? node.user?.longName ?? "MESH-\(node.num)" - - // Get battery level from device metrics - let battery = Int(node.latestDeviceMetrics?.batteryLevel ?? 100) + // Format: "SHORT - Long Name" or just "ShortName" if no long name + let callsign: String + if let shortName = node.user?.shortName, let longName = node.user?.longName, !longName.isEmpty { + callsign = "\(shortName) - \(longName)" + } else { + callsign = node.user?.shortName ?? node.user?.longName ?? "MESH-\(node.num)" + } + // Get telemetry from device metrics + let deviceMetrics = node.latestDeviceMetrics + let battery = Int(deviceMetrics?.batteryLevel ?? 100) + let voltage = deviceMetrics?.voltage ?? 0 + let channelUtil = deviceMetrics?.channelUtilization ?? 0 + let rssi = deviceMetrics?.rssi ?? 0 + let snr = deviceMetrics?.snr ?? 0 + + // Build remarks with telemetry info + var remarks = "Battery: \(battery)%" + if voltage > 0 { + remarks += " | Voltage: \(String(format: "%.2f", voltage))V" + } + if channelUtil > 0 { + remarks += " | Chan Util: \(String(format: "%.1f", channelUtil))%" + } + if rssi != 0 { + remarks += " | RSSI: \(rssi) dBm" + } + if snr != 0 { + remarks += " | SNR: \(String(format: "%.1f", snr)) dB" + } + return CoTMessage.pli( uid: uid, callsign: callsign, @@ -468,7 +504,8 @@ final class TAKMeshtasticBridge { team: "Green", // Meshtastic nodes shown as green by default role: "Team Member", battery: battery, - staleMinutes: 15 // Meshtastic positions can be older + staleMinutes: 15, // Meshtastic positions can be older + remarks: remarks ) } @@ -476,24 +513,78 @@ final class TAKMeshtasticBridge { /// Send all known mesh node positions to TAK clients /// Useful when a new TAK client connects + /// Only sends nodes with positions updated within the last 2 hours + /// Excludes the node we're currently connected to func broadcastAllNodesToTAK() async { guard let takServerManager, takServerManager.isRunning else { return } - guard let context else { return } - + + // Get context - try the bridge's context first, then fall back to PersistenceController + let context = self.context ?? PersistenceController.shared.container.viewContext + + let twoHoursAgo = Date().addingTimeInterval(-7200) + + // Get the connected node number to exclude it + let connectedNodeNum = AccessoryManager.shared.activeDeviceNum ?? 0 + + Logger.tak.info("Starting broadcast of all mesh nodes to TAK (excluding node \(connectedNodeNum))") + + // Fetch all nodes - be more lenient, include any node that's been heard from + // We'll check positions when creating CoT messages let fetchRequest: NSFetchRequest = NodeInfoEntity.fetchRequest() - // Only nodes with valid positions - fetchRequest.predicate = NSPredicate(format: "latestPosition != nil") - + fetchRequest.predicate = NSPredicate( + format: "user != nil" + ) + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "lastHeard", ascending: false)] + do { let nodes = try context.fetch(fetchRequest) - + Logger.tak.info("Found \(nodes.count) total nodes with user info, connected node: \(connectedNodeNum)") + + var broadcastCount = 0 + var skippedNoPosition = 0 + var skippedConnected = 0 + var skippedInvalidPosition = 0 + var skippedTooOld = 0 + for node in nodes { + // Skip the connected node - it's our own device + // Use the same pattern as other parts of the codebase: node.num == accessoryManager.activeDeviceNum + if node.num == connectedNodeNum { + Logger.tak.info("Skipping connected node \(node.num)") + skippedConnected += 1 + continue + } + + // Get position - use the extension's latestPosition computed property + guard let position = node.latestPosition, + let latitude = position.latitude, + let longitude = position.longitude else { + skippedNoPosition += 1 + continue + } + + // Skip nodes with invalid positions (0,0) + guard latitude != 0 || longitude != 0 else { + skippedInvalidPosition += 1 + continue + } + + // Check if node has been heard from recently (within last 2 hours) + if let lastHeard = node.lastHeard, lastHeard < twoHoursAgo { + skippedTooOld += 1 + continue + } + if let cotMessage = createCoTFromNode(node) { await takServerManager.broadcast(cotMessage) + broadcastCount += 1 + + // Small delay to avoid flooding the client + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms } } - Logger.tak.info("Broadcast \(nodes.count) mesh node positions to TAK clients") + Logger.tak.info("Broadcast complete: \(broadcastCount) nodes sent, \(skippedConnected) skipped (connected), \(skippedNoPosition) skipped (no position), \(skippedInvalidPosition) skipped (invalid position), \(skippedTooOld) skipped (too old)") } catch { Logger.tak.error("Failed to fetch nodes for TAK broadcast: \(error.localizedDescription)") } @@ -502,10 +593,12 @@ final class TAKMeshtasticBridge { // MARK: - Helper Methods private func lookupNodeInfo(nodeNum: UInt32) -> NodeInfoEntity? { - guard let context else { return nil } + // Use PersistenceController's viewContext directly to ensure we can find nodes + let context = PersistenceController.shared.container.viewContext + // Use the same format as MeshPackets - num is Int64 let fetchRequest: NSFetchRequest = NodeInfoEntity.fetchRequest() - fetchRequest.predicate = NSPredicate(format: "num == %d", Int64(nodeNum)) + fetchRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) fetchRequest.fetchLimit = 1 do { @@ -515,4 +608,823 @@ final class TAKMeshtasticBridge { return nil } } + + // MARK: - Mesh to CoT Broadcasting + + /// Broadcast a Meshtastic position packet to connected TAK clients + /// Called when a new position is received from the mesh + func broadcastMeshPositionToTAK(position: Position, from nodeNum: UInt32) async { + // Lazy initialization of bridge if needed + if TAKServerManager.shared.bridge == nil { + Logger.tak.info("Initializing bridge lazily for position broadcast") + let bridge = TAKMeshtasticBridge( + accessoryManager: AccessoryManager.shared, + takServerManager: TAKServerManager.shared + ) + bridge.context = AccessoryManager.shared.context + TAKServerManager.shared.bridge = bridge + } + + let server = TAKServerManager.shared + guard server.meshToCotEnabled, server.isRunning else { return } + guard server.connectedClients.isEmpty == false else { return } + + guard let node = lookupNodeInfo(nodeNum: nodeNum) else { return } + + if let cotMessage = createCoTFromNode(node) { + await server.broadcast(cotMessage) + Logger.tak.info("Broadcast mesh position to TAK: \(node.user?.longName ?? "Unknown")") + } + } + + /// Broadcast a Meshtastic text message to connected TAK clients + /// Called when a text message is received from the mesh + /// - Parameters: + /// - text: The message text + /// - from: The sender node number + /// - channel: The channel index + /// - to: The destination node number (UInt32.max for broadcast) + func broadcastMeshTextMessageToTAK(text: String, from nodeNum: UInt32, channel: UInt32, to destination: UInt32) async { + // Lazy initialization of bridge if needed + if TAKServerManager.shared.bridge == nil { + Logger.tak.info("Initializing bridge lazily for text message broadcast") + let bridge = TAKMeshtasticBridge( + accessoryManager: AccessoryManager.shared, + takServerManager: TAKServerManager.shared + ) + bridge.context = AccessoryManager.shared.context + TAKServerManager.shared.bridge = bridge + } + + let server = TAKServerManager.shared + guard server.meshToCotEnabled, server.isRunning else { return } + guard server.connectedClients.isEmpty == false else { return } + + guard let node = lookupNodeInfo(nodeNum: nodeNum), + let user = node.user else { return } + + let senderName = user.longName ?? user.shortName ?? "Unknown" + let uid = "MSG-\(nodeNum)-\(Int(Date().timeIntervalSince1970))" + + // Determine if this is a DM or broadcast + let isDirectMessage = destination != UInt32.max + + // For now, send all messages to general chat but mark DMs in the message + let chatroom = "All Chat Rooms" + + Logger.tak.info("Text message: isDM=\(isDirectMessage), chatroom=\(chatroom), from=\(senderName)") + + let senderUid = "MESHTASTIC-\(String(format: "%08X", nodeNum))" + + // Prefix DM messages with "DM:" so users know it's a direct message + let messageText = isDirectMessage ? "DM: \(text)" : text + + let cotMessage = CoTMessage( + uid: "GeoChat.\(senderUid).\(chatroom.replacingOccurrences(of: " ", with: "_")).\(uid)", + type: "b-t-f", + time: Date(), + start: Date(), + stale: Date().addingTimeInterval(86400), + how: "h-g-i-g-o", + latitude: 0, + longitude: 0, + hae: 9999999.0, + ce: 9999999.0, + le: 9999999.0, + contact: CoTContact(callsign: senderName, endpoint: "0.0.0.0:4242:tcp"), + chat: CoTChat( + message: messageText, + senderCallsign: senderName, + chatroom: chatroom + ), + remarks: messageText + ) + + await server.broadcast(cotMessage) + Logger.tak.info("Broadcast mesh text message to TAK: \(senderName) to \(chatroom)") + } + + /// Broadcast a Meshtastic waypoint to connected TAK clients + /// Called when a waypoints is received from the mesh + func broadcastMeshWaypointToTAK(waypoint: Waypoint, from nodeNum: UInt32) async { + // Lazy initialization of bridge if needed - set on singleton + if TAKServerManager.shared.bridge == nil { + Logger.tak.info("Initializing bridge lazily on singleton") + let bridge = TAKMeshtasticBridge( + accessoryManager: AccessoryManager.shared, + takServerManager: TAKServerManager.shared + ) + bridge.context = AccessoryManager.shared.context + TAKServerManager.shared.bridge = bridge + } + + let server = TAKServerManager.shared + Logger.tak.info("Waypoint broadcast check: meshToCot=\(server.meshToCotEnabled), isRunning=\(server.isRunning), clients=\(server.connectedClients.count)") + + guard server.meshToCotEnabled, server.isRunning else { + Logger.tak.warning("Waypoint broadcast skipped: server not ready") + return + } + guard let context, server.connectedClients.isEmpty == false else { + Logger.tak.warning("Waypoint broadcast skipped: no clients") + return + } + + let node = lookupNodeInfo(nodeNum: nodeNum) + Logger.tak.info("Node lookup for \(nodeNum) (0x\(String(format: "%08X", nodeNum))): \(node != nil ? "found" : "NOT FOUND")") + if let node = node { + Logger.tak.info(" Node user: \(node.user?.longName ?? "nil"), shortName: \(node.user?.shortName ?? "nil")") + } + let senderName = node?.user?.longName ?? node?.user?.shortName ?? "Unknown Node" + + let uid = "WAYPOINT-\(waypoint.id)" + let latitude = Double(waypoint.latitudeI) / 1e7 + let longitude = Double(waypoint.longitudeI) / 1e7 + + let name = waypoint.name.isEmpty ? "Dropped Pin" : waypoint.name + let description = waypoint.description_p.isEmpty ? "Meshtastic Waypoint" : waypoint.description_p + + Logger.tak.info("Broadcasting waypoint: \(name) at \(latitude), \(longitude), sender: \(senderName)") + + // Map Meshtastic emoji icon to appropriate TAK icon + let (cotType, iconPath, colorArgb) = getTakIconForWaypoint(waypoint: waypoint) + let userIconXML = "" + Logger.tak.info("Waypoint icon: emoji=0x\(String(format: "%08X", waypoint.icon)) -> \(iconPath)") + + // Handle expiry - if expire is 0, never expire. Otherwise use the expire time + let stale: Date + if waypoint.expire == 0 { + // Never expire - set to 1 year from now + stale = Date().addingTimeInterval(365 * 24 * 60 * 60) + Logger.tak.info("Waypoint set to never expire") + } else { + // expire is Unix timestamp when waypoint expires + let expireDate = Date(timeIntervalSince1970: TimeInterval(waypoint.expire)) + if expireDate > Date() { + stale = expireDate + } else { + // Already expired, don't broadcast + Logger.tak.warning("Waypoint already expired, skipping broadcast") + return + } + } + + // Include the usericon in the detail (no color to avoid background in TAKware) + let rawDetail = "\(userIconXML)" + + let cotMessage = CoTMessage( + uid: uid, + type: cotType, + time: Date(), + start: Date(), + stale: stale, + how: "m-g", + latitude: latitude, + longitude: longitude, + hae: 0, + ce: 10.0, + le: 10.0, + contact: CoTContact(callsign: "\(name) - \(senderName)", endpoint: "0.0.0.0:4242:tcp"), + remarks: "\(description)\nFrom: \(senderName) [\(String(format: "%08X", nodeNum))]", + rawDetailXML: rawDetail + ) + + await server.broadcast(cotMessage) + Logger.tak.info("Broadcast mesh waypoint to TAK: \(name) from \(senderName)") + } + + /// Map Meshtastic waypoint emoji to TAK icon + /// Returns (cotType, iconPath, colorArgb) + /// Icon paths use format: UUID/Category/icon.png + /// Priority: Google > Generic Icons (fallback) + private func getTakIconForWaypoint(waypoint: Waypoint) -> (String, String, String) { + let icon = waypoint.icon + + // Icon set UUIDs + let googleUUID = "f7f71666-8b28-4b57-9fbb-e38e61d33b79" + let genericUUID = "ad78aafb-83a6-4c07-b2b9-a897a8b6a38f" + + switch icon { + // 📍 📌 Pushpin - RED pushpin (default) + case 0x1F4CD, 0x1F4CC, 1: // 📍 📌 + return ("a-u-G", "\(genericUUID)/Tacks/red-pushpin.png", "-16776961") + + // === EMERGENCY === + // 🔥 Fire - Google firedept + case 0x1F525, 10: // 🔥 + return ("a-u-G", "\(googleUUID)/Google/firedept.png", "-16776961") + // 🚨 Siren - Google caution + case 0x1F6A8, 6: // 🚨 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-256") + // 🏥 Hospital - Google hospitals + case 0x1F3E5, 0x2695, 9: // 🏥 ➕ + return ("a-u-G", "\(googleUUID)/Google/hospitals.png", "-16776961") + // 🚑 Ambulance - Google hospitals (no ambulance in Google) + case 0x1F691: // 🚑 + return ("a-u-G", "\(googleUUID)/Google/hospitals.png", "-16776961") + // ⚠️ Warning - Google caution + case 0x26A0: // ⚠️ + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-256") + // 🚓 Police - Google police + case 0x1F693: // 🚓 + return ("a-u-G", "\(googleUUID)/Google/police.png", "-16776961") + // 🏃 Runner - Google man + case 0x1F3C3: // 🏃 + return ("a-u-G", "\(googleUUID)/Google/man.png", "-16711936") + // 💀 Skull - Google caution + case 0x1F480: // 💀 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-1") + // 💣 Bomb - Google caution + case 0x1F4A3: // 💣 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-16776961") + + // === TRANSPORT === + // 🚗 Car - Google bus (closest) + case 0x1F697, 0x1F695, 2: // 🚗 🚕 + return ("a-u-G", "\(googleUUID)/Google/bus.png", "-256") + // 🚁 Helicopter - Google heliport + case 0x1F681, 11: // 🚁 + return ("a-u-G", "\(googleUUID)/Google/heliport.png", "-16776961") + // ⛵ Boat - Google marina + case 0x26F5, 12: // ⛵ + return ("a-u-G", "\(googleUUID)/Google/marina.png", "-16776961") + // 🚢 Ship - Google marina + case 0x1F6A2: // 🚢 + return ("a-u-G", "\(googleUUID)/Google/marina.png", "-16776961") + // 🚀 Rocket - Google target + case 0x1F680: // 🚀 + return ("a-u-G", "\(googleUUID)/Google/target.png", "-16776961") + // 🛸 UFO - Generic purple pushpin + case 0x1F6B8, 13: // 🛸 + return ("a-u-G", "\(genericUUID)/Tacks/purple-pushpin.png", "-65281") + // 🚲 Bicycle - Google cycling + case 0x1F6B2: // 🚲 + return ("a-u-G", "\(googleUUID)/Google/cycling.png", "-16711936") + // 🚆 Train - Google rail + case 0x1F686: // 🚆 + return ("a-u-G", "\(googleUUID)/Google/rail.png", "-16711936") + // ✈️ Plane - Google airports + case 0x2708: // ✈️ + return ("a-u-G", "\(googleUUID)/Google/airports.png", "-16776961") + // 🚛 Truck - Google bus + case 0x1F69A: // 🚛 + return ("a-u-G", "\(googleUUID)/Google/bus.png", "-16711936") + // 🚌 Bus - Google bus + case 0x1F68C: // 🚌 + return ("a-u-G", "\(googleUUID)/Google/bus.png", "-256") + + // === PLACES === + // 🏨 Hotel - Google lodging + case 0x1F3E8: // 🏨 + return ("a-u-G", "\(googleUUID)/Google/lodging.png", "-16776961") + // 🏪 Store - Google convenience + case 0x1F3EA: // 🏪 + return ("a-u-G", "\(googleUUID)/Google/convenience.png", "-16711936") + // ⛽ Gas - Google gas_stations + case 0x1F6FD: // ⛽ + return ("a-u-G", "\(googleUUID)/Google/gas_stations.png", "-16776961") + // 🏰 Castle - Google info + case 0x1F3F0: // 🏰 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🏛️ Government - Google info + case 0x1F3DB: // 🏛️ + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // ⛲ Fountain - Generic fountain (use info) + case 0x1F6F1: // ⛲ + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🏞️ Park - Google parks + case 0x1F3DE: // 🏞️ + return ("a-u-G", "\(googleUUID)/Google/parks.png", "-16711936") + + // === PEOPLE === + // 🚶 Person - Google hiker + case 0x1F464, 0x1F465, 3: // 👤 👥 + return ("a-u-G", "\(googleUUID)/Google/hiker.png", "-16711936") + + // === STRUCTURES === + // 🏠 House - Google homegardenbusiness + case 0x1F3E0, 0x1F3E1, 4: // 🏠 🏡 + return ("a-u-G", "\(googleUUID)/Google/homegardenbusiness.png", "-16711936") + // ⛺ Tent - Google campground + case 0x26FA, 0x1F3D5, 5: // ⛺ 🏕 + return ("a-u-G", "\(googleUUID)/Google/campground.png", "-256") + // 🏚️ Abandoned - Google info + case 0x1F6DA: // 🏚️ + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🏗️ Construction - Google caution + case 0x1F6D7: // 🏗️ + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-16776961") + // 🏭 Factory - Google info + case 0x1F3ED: // 🏭 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + + // === NATURE / TERRAIN === + // 🌲 Tree - Google parks + case 0x1F332: // 🌲 + return ("a-u-G", "\(googleUUID)/Google/parks.png", "-16711936") + // 🌳 Tree - Google parks + case 0x1F333: // 🌳 + return ("a-u-G", "\(googleUUID)/Google/parks.png", "-16711936") + // 🏔️ Mountain - Google cross-hairs + case 0x1F3D4: // 🏔️ + return ("a-u-G", "\(googleUUID)/Google/cross-hairs.png", "-1") + // ⛰️ Mountain - Google cross-hairs + case 0x26F0: // ⛰️ + return ("a-u-G", "\(googleUUID)/Google/cross-hairs.png", "-1") + // 💧 Water - Google water + case 0x1F4A7: // 💧 + return ("a-u-G", "\(googleUUID)/Google/water.png", "-16776961") + // 🌊 Wave - Google water + case 0x1F30A: // 🌊 + return ("a-u-G", "\(googleUUID)/Google/water.png", "-16776961") + // ☁️ Cloud - Google partly_cloudy + case 0x2601, 0x2602: // ☁ ☂ + return ("a-u-G", "\(googleUUID)/Google/partly_cloudy.png", "-1") + // 🌙 Moon - Google star + case 0x1F319: // 🌙 + return ("a-u-G", "\(googleUUID)/Google/star.png", "-16776961") + // ⚓ Anchor - Google marina + case 0x2693: // ⚓ + return ("a-u-G", "\(googleUUID)/Google/marina.png", "-16776961") + // ⭐ Star - Google star + case 0x2B50, 0x1F31F: // ⭐ 🌟 + return ("a-u-G", "\(googleUUID)/Google/star.png", "-256") + // 🌞 Sun - Google sunny + case 0x1F31E: // 🌞 + return ("a-u-G", "\(googleUUID)/Google/sunny.png", "-256") + + // === FLAGS/MARKERS === + // 🚩 Flag - Google flag + case 0x1F6A9: // 🚩 + return ("a-u-G", "\(googleUUID)/Google/flag.png", "-16776961") + // 🏁 Checkered flag - Google flag + case 0x1F3C1, 7: // 🏁 + return ("a-u-G", "\(googleUUID)/Google/flag.png", "-1") + // 🎌 Flags - Google flag + case 0x1F38C: // 🎌 + return ("a-u-G", "\(googleUUID)/Google/flag.png", "-16776961") + + // === OBJECTS === + // 📷 Camera - Google camera + case 0x1F4F7: // 📷 + return ("a-u-G", "\(googleUUID)/Google/camera.png", "-16711936") + // 🔒 Lock - Google info + case 0x1F512: // 🔒 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + // 🔑 Key - Google info + case 0x1F511: // 🔑 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + // 📦 Package - Google shopping + case 0x1F4E6: // 📦 + return ("a-u-G", "\(googleUUID)/Google/shopping.png", "-16711936") + // 🚧 Construction - Google caution + case 0x1F6A7: // 🚧 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-256") + // 🎯 Target - Google target + case 0x1F3AF: // 🎯 + return ("a-u-G", "\(googleUUID)/Google/target.png", "-16776961") + // 🏹 Sports bow - Google target + case 0x1F3F9: // 🏹 + return ("a-u-G", "\(googleUUID)/Google/target.png", "-16776961") + // 🔧 Wrench - Google mechanic + case 0x1F527: // 🔧 + return ("a-u-G", "\(googleUUID)/Google/mechanic.png", "-16711936") + // 🛠️ Tools - Google mechanic + case 0x1F6E0: // 🛠️ + return ("a-u-G", "\(googleUUID)/Google/mechanic.png", "-16711936") + // 📮 Post box - Google post_office + case 0x1F4EE: // 📮 + return ("a-u-G", "\(googleUUID)/Google/post_office.png", "-16776961") + // 💎 Gem - Google star + case 0x1F48E: // 💎 + return ("a-u-G", "\(googleUUID)/Google/star.png", "-16776961") + // 🔔 Bell - Google info + case 0x1F514: // 🔔 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-256") + // 🎁 Gift - Google shopping + case 0x1F381: // 🎁 + return ("a-u-G", "\(googleUUID)/Google/shopping.png", "-16776961") + // ❄️ Snowflake - Google snowflake_simple + case 0x2744: // ❄ + return ("a-u-G", "\(googleUUID)/Google/snowflake_simple.png", "-1") + // ☂️ Umbrella - Google sunny + case 0x26F1: // ⛱ + return ("a-u-G", "\(googleUUID)/Google/sunny.png", "-16776961") + // 💡 Light - Google info-i + case 0x1F4A1: // 💡 + return ("a-u-G", "\(googleUUID)/Google/info-i.png", "-256") + // 🔋 Battery - Google bars + case 0x1F50B: // 🔋 + return ("a-u-G", "\(googleUUID)/Google/bars.png", "-16711936") + // 📻 Radio - Google radio + case 0x1F4FB: // 📻 + return ("a-u-G", "\(googleUUID)/Google/radio.png", "-16711936") + // 📞 Phone - Google phone + case 0x1F4DE, 0x1F4F1: // 📞 📱 + return ("a-u-G", "\(googleUUID)/Google/phone.png", "-16711936") + // 💥 Collision - Google caution + case 0x1F4A5: // 💥 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-16776961") + // 🔦 Flashlight - Google sunny + case 0x1F526: // 🔦 + return ("a-u-G", "\(googleUUID)/Google/sunny.png", "-16711936") + // 🕯️ Candle - Google sunny + case 0x1F56F: // 🕯️ + return ("a-u-G", "\(googleUUID)/Google/sunny.png", "-16776961") + // 📺 TV - Google camera + case 0x1F4FA: // 📺 + return ("a-u-G", "\(googleUUID)/Google/camera.png", "-16711936") + // 💾 Disk - Google info + case 0x1F4BE: // 💾 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + // 📀 DVD - Google info + case 0x1F4C0: // 📀 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🖥️ Computer - Google info + case 0x1F5A5: // 🖥️ + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + // ⌨️ Keyboard - Google info + case 0x1F5A8: // ⌨️ + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + // 🖱️ Mouse - Google info + case 0x1F5B1: // 🖱️ + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + + // === SYMBOLS === + // ❤️ Heart - Google flag + case 0x2764, 0x1F493, 0x1F49A, 0x1F499: // ❤️ 💓 💚 💙 + return ("a-u-G", "\(googleUUID)/Google/flag.png", "-16776961") + // ✅ Check - Google star + case 0x2705, 0x1F7E2: // ✅ 🟢 + return ("a-u-G", "\(googleUUID)/Google/star.png", "-16711936") + // ❌ X - Google caution + case 0x274C, 0x1F6AB: // ❌ 🚫 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-16776961") + // ➰ Curly loop - Google trail + case 0x1F0: // ➰ + return ("a-u-G", "\(googleUUID)/Google/trail.png", "-16776961") + // ➿ Double curly loop - Google trail + case 0x1F1F: // ➿ + return ("a-u-G", "\(googleUUID)/Google/trail.png", "-16776961") + + // === WEATHER === + // 🌤️ Sun behind cloud - Google partly_cloudy + case 0x1F324: // 🌤️ + return ("a-u-G", "\(googleUUID)/Google/partly_cloudy.png", "-256") + // 🌧️ Rain - Google rainy + case 0x1F327: // 🌧️ + return ("a-u-G", "\(googleUUID)/Google/rainy.png", "-16776961") + // 🌨️ Snow - Google snowflake_simple + case 0x1F328: // 🌨️ + return ("a-u-G", "\(googleUUID)/Google/snowflake_simple.png", "-1") + // 🌩️ Lightning - Google caution + case 0x1F329: // 🌩 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-256") + // 🌀 Cyclone - Google sunny + case 0x1F300: // 🌀 + return ("a-u-G", "\(googleUUID)/Google/sunny.png", "-16776961") + // 🌈 Rainbow - Google star + case 0x1F308: // 🌈 + return ("a-u-G", "\(googleUUID)/Google/star.png", "-16776961") + // 🌪️ Tornado - Google caution + case 0x1F32A: // 🌪️ + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-1") + // 🌋 Volcano - Google volcano + case 0x1F30B: // 🌋 + return ("a-u-G", "\(googleUUID)/Google/volcano.png", "-16776961") + // 🏜️ Desert - Google parks + case 0x1F3DC: // 🏜️ + return ("a-u-G", "\(googleUUID)/Google/parks.png", "-16776961") + // 🌫️ Fog - Google partly_cloudy + case 0x1F32B: // 🌫️ + return ("a-u-G", "\(googleUUID)/Google/partly_cloudy.png", "-16776961") + // 🌬️ Wind - Google partly_cloudy + case 0x1F32C: // 🌬️ + return ("a-u-G", "\(googleUUID)/Google/partly_cloudy.png", "-16711936") + + // === GLOBE === + // 🌍 Globe - Generic placemark_circle + case 0x1F30D, 0x1F30E, 0x1F30F, 0x1F310: // 🌍 🌎 🌏 🌐 + return ("a-u-G", "\(genericUUID)/Shapes/placemark_circle.png", "-16776961") + // 🗺️ Map - Generic placemark_square + case 0x1F5FA: // 🗺 + return ("a-u-G", "\(genericUUID)/Shapes/placemark_square.png", "-16776961") + // 🧭 Compass - Generic compass (use trail) + case 0x1F6AD: // 🧭 + return ("a-u-G", "\(googleUUID)/Google/trail.png", "-16776961") + + // === FOOD === + // 🍔 Burger - Google dining + case 0x1F354: // 🍔 + return ("a-u-G", "\(googleUUID)/Google/dining.png", "-256") + // 🍕 Pizza - Google dining + case 0x1F355: // 🍕 + return ("a-u-G", "\(googleUUID)/Google/dining.png", "-256") + // ☕ Coffee - Google coffee + case 0x2615: // ☕ + return ("a-u-G", "\(googleUUID)/Google/coffee.png", "-256") + // 🍺 Beer - Google bars + case 0x1F37A: // 🍺 + return ("a-u-G", "\(googleUUID)/Google/bars.png", "-256") + // 🍷 Wine - Google bars + case 0x1F377: // 🍷 + return ("a-u-G", "\(googleUUID)/Google/bars.png", "-65281") + // 🥗 Salad - Google dining + case 0x1F957: // 🥗 + return ("a-u-G", "\(googleUUID)/Google/dining.png", "-16711936") + // 🍿 Popcorn - Google movies + case 0x1F37F: // 🍿 + return ("a-u-G", "\(googleUUID)/Google/movies.png", "-16776961") + // 🍩 Donut - Google donut + case 0x1F369: // 🍩 + return ("a-u-G", "\(googleUUID)/Google/donut.png", "-16776961") + // 🍪 Cookie - Google donut + case 0x1F36A: // 🍪 + return ("a-u-G", "\(googleUUID)/Google/donut.png", "-16776961") + // 🍫 Chocolate - Google donut + case 0x1F36B: // 🍫 + return ("a-u-G", "\(googleUUID)/Google/donut.png", "-16776961") + // 🍬 Candy - Google donut + case 0x1F36C: // 🍬 + return ("a-u-G", "\(googleUUID)/Google/donut.png", "-16776961") + // 🍭 Lollipop - Google donut + case 0x1F36D: // 🍭 + return ("a-u-G", "\(googleUUID)/Google/donut.png", "-16776961") + // 🍦 Ice Cream - Google donut + case 0x1F368: // 🍦 + return ("a-u-G", "\(googleUUID)/Google/donut.png", "-16776961") + // 🥤 Cup - Google coffee + case 0x1F964: // 🥤 + return ("a-u-G", "\(googleUUID)/Google/coffee.png", "-16776961") + // 🍵 Tea - Google coffee + case 0x1F375: // 🍵 + return ("a-u-G", "\(googleUUID)/Google/coffee.png", "-16711936") + // 🥃 Whiskey - Google bars + case 0x1F943: // 🥃 + return ("a-u-G", "\(googleUUID)/Google/bars.png", "-16776961") + // 🥂 Cheers - Google bars + case 0x1F942: // 🥂 + return ("a-u-G", "\(googleUUID)/Google/bars.png", "-16776961") + // 🍾 Bottle - Google bars + case 0x1F37E: // 🍾 + return ("a-u-G", "\(googleUUID)/Google/bars.png", "-16776961") + + // === RECREATION === + // 🎣 Fishing - Google fishing + case 0x1F3A3: // 🎣 + return ("a-u-G", "\(googleUUID)/Google/fishing.png", "-16776961") + // ⛳ Golf - Google golf + case 0x1F3CC: // ⛳ + return ("a-u-G", "\(googleUUID)/Google/golf.png", "-16711936") + // ⛷️ Ski - Google ski + case 0x1F3BF: // ⛷️ + return ("a-u-G", "\(googleUUID)/Google/ski.png", "-16711936") + // 🏊 Swimming - Google swimming + case 0x1F3CA: // 🏊 + return ("a-u-G", "\(googleUUID)/Google/swimming.png", "-16776961") + // 🏄 Surfing - Google swimming + case 0x1F3C4: // 🏄 + return ("a-u-G", "\(googleUUID)/Google/swimming.png", "-16776961") + // 🐟 Fish - Google fishing + case 0x1F41F: // 🐟 + return ("a-u-G", "\(googleUUID)/Google/fishing.png", "-16776961") + // 🌾 Farm - Google parks + case 0x1F33E: // 🌾 + return ("a-u-G", "\(googleUUID)/Google/parks.png", "-16711936") + // 🐄 Farm Animal - Google parks + case 0x1F404: // 🐄 + return ("a-u-G", "\(googleUUID)/Google/parks.png", "-16711936") + // 🐕 Dog - Google hiker + case 0x1F415: // 🐕 + return ("a-u-G", "\(googleUUID)/Google/hiker.png", "-16711936") + // 🐈 Cat - Google hiker + case 0x1F431: // 🐈 + return ("a-u-G", "\(googleUUID)/Google/hiker.png", "-16711936") + // 🐓 Rooster - Google info + case 0x1F413: // 🐓 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦅 Eagle - Google info + case 0x1F425: // 🦅 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦋 Butterfly - Google info + case 0x1F98B: // 🦋 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐝 Bee - Google info + case 0x1F41D: // 🐝 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐞 Beetle - Google info + case 0x1F41E: // 🐞 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦀 Crab - Google fishing + case 0x1F980: // 🦀 + return ("a-u-G", "\(googleUUID)/Google/fishing.png", "-16776961") + // 🦞 Lobster - Google fishing + case 0x1F99E: // 🦞 + return ("a-u-G", "\(googleUUID)/Google/fishing.png", "-16776961") + // 🐚 Shell - Google fishing + case 0x1F41A: // 🐚 + return ("a-u-G", "\(googleUUID)/Google/fishing.png", "-16776961") + // 🐙 Octopus - Google fishing + case 0x1F419: // 🐙 + return ("a-u-G", "\(googleUUID)/Google/fishing.png", "-16776961") + // 🦑 Squid - Google fishing + case 0x1F991: // 🦑 + return ("a-u-G", "\(googleUUID)/Google/fishing.png", "-16776961") + // 🦎 Lizard - Google info + case 0x1F98E: // 🦎 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐍 Snake - Google info + case 0x1F40D: // 🐍 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦖 T-Rex - Google info + case 0x1F996: // 🦖 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦕 Sauropod - Google info + case 0x1F995: // 🦕 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦈 Shark - Google fishing + case 0x1F988: // 🦈 + return ("a-u-G", "\(googleUUID)/Google/fishing.png", "-16776961") + // 🐳 Whale - Google water + case 0x1F433: // 🐳 + return ("a-u-G", "\(googleUUID)/Google/water.png", "-16776961") + // 🐬 Dolphin - Google water + case 0x1F42C: // 🐬 + return ("a-u-G", "\(googleUUID)/Google/water.png", "-16776961") + // 🐊 Crocodile - Google water + case 0x1F40A: // 🐊 + return ("a-u-G", "\(googleUUID)/Google/water.png", "-16776961") + // 🐆 Leopard - Google info + case 0x1F406: // 🐆 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐅 Tiger - Google info + case 0x1F405: // 🐅 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐃 Buffalo - Google info + case 0x1F403: // 🐃 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐂 Ox - Google info + case 0x1F402: // 🐂 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐎 Horse - Google info + case 0x1F434: // 🐎 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐏 Ram - Google info + case 0x1F40F: // 🐏 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐑 Sheep - Google info + case 0x1F411: // 🐑 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐐 Goat - Google info + case 0x1F410: // 🐐 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦙 Llama - Google info + case 0x1F999: // 🦙 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐕‍🦺 Service Dog - Google hiker + case 0x1F9BA: // 🐕‍🦺 + return ("a-u-G", "\(googleUUID)/Google/hiker.png", "-16776961") + // 🐩 Poodle - Google hiker + case 0x1F429: // 🐩 + return ("a-u-G", "\(googleUUID)/Google/hiker.png", "-16776961") + // 🐈‍⬛ Black Cat - Google hiker + case 0x1F408: // 🐈‍⬛ + return ("a-u-G", "\(googleUUID)/Google/hiker.png", "-16776961") + // 🦝 Raccoon - Google info + case 0x1F99D: // 🦝 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦊 Fox - Google info + case 0x1F98A: // 🦊 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐻 Bear - Google info + case 0x1F43B: // 🐻 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐼 Panda - Google info + case 0x1F43C: // 🐼 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐨 Koala - Google info + case 0x1F428: // 🐨 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐯 Tiger - Google info + case 0x1F42F: // 🐯 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦁 Lion - Google info + case 0x1F981: // 🦁 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐮 Cow - Google info + case 0x1F42E: // 🐮 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐷 Pig - Google info + case 0x1F437: // 🐷 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐖 Pig (big) - Google info + case 0x1F416: // 🐖 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐗 Boar - Google info + case 0x1F417: // 🐗 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐘 Elephant - Google info + case 0x1F418: // 🐘 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦏 Rhino - Google info + case 0x1F98F: // 🦏 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦛 Hippo - Google info + case 0x1F99B: // 🦛 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦒 Giraffe - Google info + case 0x1F992: // 🦒 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦬 Bison - Google info + case 0x1F9AC: // 🦬 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦣 Mammoth - Google info + case 0x1F9A3: // 🦣 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦌 Deer - Google info + case 0x1F98C: // 🦌 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦌 Moose - Google info + case 0x1F98D: // 🦌 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + + // === INFRASTRUCTURE === + // 🚩 Checkpoint - Google flag + case 0x1F6A6: // 🚩 + return ("a-u-G", "\(googleUUID)/Google/flag.png", "-16776961") + // ⛔ No Entry - Google caution + case 0x26D4: // ⛔ + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-16776961") + // 🛑 Stop - Google caution + case 0x1F6D1: // 🛑 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-16776961") + // 🏢 Office Building - Google homegardenbusiness + case 0x1F3E2: // 🏢 + return ("a-u-G", "\(googleUUID)/Google/homegardenbusiness.png", "-16776961") + // 🏬 Bank - Google info + case 0x1F3E6: // 🏬 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🏩 Love Hotel - Google lodging + case 0x1F3E9: // 🏩 + return ("a-u-G", "\(googleUUID)/Google/lodging.png", "-16776961") + // 🛤️ Railway - Google rail + case 0x1F6E2: // 🛤️ + return ("a-u-G", "\(googleUUID)/Google/rail.png", "-16711936") + // 🛣️ Motorway - Google info + case 0x1F6E3: // 🛣️ + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🚎 Trolleybus - Google bus + case 0x1F68E: // 🚎 + return ("a-u-G", "\(googleUUID)/Google/bus.png", "-16776961") + // 🚈 Metro - Google rail + case 0x1F688: // 🚈 + return ("a-u-G", "\(googleUUID)/Google/rail.png", "-16711936") + // 🚊 Tram - Google tram + case 0x1F68A: // 🚊 + return ("a-u-G", "\(googleUUID)/Google/tram.png", "-16776961") + // 🚉 Station - Google rail + case 0x1F689: // 🚉 + return ("a-u-G", "\(googleUUID)/Google/rail.png", "-16776961") + // 🛃 Custom - Google info + case 0x1F6C3: // 🛃 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🛂 Passport control - Google info + case 0x1F6C2: // 🛂 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🚮 Litter - Google info + case 0x1F6AE: // 🚮 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + // 🚰 Water - Google water + case 0x1F6B0: // 🚰 + return ("a-u-G", "\(googleUUID)/Google/water.png", "-16776961") + // 🚱 Non-potable - Google caution + case 0x1F6B1: // 🚱 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-16776961") + // ♿ Wheelchair - Google wheel_chair_accessible + case 0x267F: // ♿ + return ("a-u-G", "\(googleUUID)/Google/wheel_chair_accessible.png", "-16711936") + // 🚻 Bathroom - Google info + case 0x1F6BB: // 🚻 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + // 🚹 Men's - Google info + case 0x1F6B9: // 🚹 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🚺 Women's - Google info + case 0x1F6BA: // 🚺 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🚼 Baby - Google info + case 0x1F6BC: // 🚼 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🚾 Loo - Google info + case 0x1F6BE: // 🚾 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🅿️ Parking - Google info + case 0x1F17F: // 🅿️ + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + + // === Default - RED pushpin === + default: + return ("a-u-G", "\(genericUUID)/Tacks/red-pushpin.png", "-16776961") + } + } } diff --git a/Meshtastic/Helpers/TAK/TAKServerManager.swift b/Meshtastic/Helpers/TAK/TAKServerManager.swift index 182e47bb..b619af98 100644 --- a/Meshtastic/Helpers/TAK/TAKServerManager.swift +++ b/Meshtastic/Helpers/TAK/TAKServerManager.swift @@ -10,6 +10,44 @@ import Network import OSLog import Combine import SwiftUI +import CoreData +import MeshtasticProtobufs + +enum TAKServerError: LocalizedError { + case noServerCertificate + case noClientCACertificate + case tlsConfigurationFailed + case listenerFailed(String) + case clientNotFound + case notRunning + case primaryChannelInvalid(String) + + var errorDescription: String? { + switch self { + case .noServerCertificate: + return "No server certificate configured. Import a .p12 file with the server certificate and private key." + case .noClientCACertificate: + return "No client CA certificate configured. Import the CA certificate (.pem) used to sign client certificates." + case .tlsConfigurationFailed: + return "Failed to configure TLS settings." + case .listenerFailed(let reason): + return "Failed to start listener: \(reason)" + case .clientNotFound: + return "Client not found" + case .notRunning: + return "TAK Server is not running" + case .primaryChannelInvalid(let reason): + return reason + } + } +} + +struct PrimaryChannelIssue: Identifiable { + let id = UUID() + let title: String + let description: String + let canAutoFix: Bool +} /// Manages the TAK Server lifecycle, TLS connections, and client management /// Runs on MainActor for thread safety, following the AccessoryManager pattern @@ -23,6 +61,14 @@ final class TAKServerManager: ObservableObject { @Published private(set) var isRunning = false @Published private(set) var connectedClients: [TAKClientInfo] = [] @Published var lastError: String? + @Published private(set) var primaryChannelIssues: [PrimaryChannelIssue] = [] + @Published private(set) var readOnlyMode = false + + /// User toggle for read-only mode - locked to true if channel has issues + @AppStorage("takServerReadOnly") var userReadOnlyMode = false + + /// Enable Mesh to CoT converter - bridges Meshtastic packets to TAK format + @AppStorage("takServerMeshToCot") var meshToCotEnabled = false // MARK: - Configuration (persisted via AppStorage) @@ -89,6 +135,103 @@ final class TAKServerManager: ObservableObject { } } + // MARK: - Primary Channel Validation + + /// Check the primary channel for validity + /// Returns true if the primary channel is valid for TAK server operation + func checkPrimaryChannelValidity() { + let context = PersistenceController.shared.container.viewContext + let fetchRequest = MyInfoEntity.fetchRequest() + + var issues: [PrimaryChannelIssue] = [] + var isValid = true + + do { + let myInfos = try context.fetch(fetchRequest) + guard let myInfo = myInfos.first, + let channels = myInfo.channels?.array as? [ChannelEntity], + let primaryChannel = channels.first(where: { $0.index == 0 || $0.role == 1 }) else { + issues.append(PrimaryChannelIssue( + title: "No Primary Channel", + description: "No primary channel found on device", + canAutoFix: false + )) + isValid = false + updateChannelStatus(issues: issues, isValid: isValid) + return + } + + let channelName = primaryChannel.name ?? "" + let channelPsk = primaryChannel.psk ?? Data() + let pskBase64 = channelPsk.base64EncodedString() + + if channelName.isEmpty { + issues.append(PrimaryChannelIssue( + title: "Unnamed Primary Channel", + description: "TAK Server requires a private channel. Please set up a dedicated TAK channel (name 'TAK' recommended). Tap the button below to auto-configure.", + canAutoFix: true + )) + isValid = false + } + + // Use byte length for encryption strength checks (not Base64 string length) + let pskBytes = channelPsk.count + if pskBytes == 0 { + issues.append(PrimaryChannelIssue( + title: "Public Channel Not Supported", + description: "TAK Server requires a private channel with encryption. Public channels expose your location and messages. Tap the button below to set up a private TAK channel.", + canAutoFix: true + )) + isValid = false + } else if channelPsk == Data([0x01]) { + // Default key is single byte 0x01 + issues.append(PrimaryChannelIssue( + title: "Default Encryption Key", + description: "TAK Server requires a unique private channel key. The default key is not secure. Tap the button below to set up a proper private TAK channel.", + canAutoFix: true + )) + isValid = false + } else if pskBytes < 16 { + // Less than 128-bit (16 bytes) + issues.append(PrimaryChannelIssue( + title: "Weak Encryption Key", + description: "TAK Server requires at least 128-bit encryption for your privacy. Tap the button below to set up a secure private TAK channel.", + canAutoFix: true + )) + isValid = false + } + + updateChannelStatus(issues: issues, isValid: isValid) + + } catch { + Logger.tak.error("Failed to fetch MyInfo for channel validation: \(error.localizedDescription)") + issues.append(PrimaryChannelIssue( + title: "Error Checking Channel", + description: "Could not verify primary channel settings", + canAutoFix: false + )) + updateChannelStatus(issues: issues, isValid: false) + } + } + + private func updateChannelStatus(issues: [PrimaryChannelIssue], isValid: Bool) { + primaryChannelIssues = issues + readOnlyMode = !isValid + + if !isValid { + userReadOnlyMode = true + } + + if !isValid && isRunning { + Logger.tak.warning("TAK Server running in read-only mode due to primary channel issues") + } + } + + /// Check if TAK client messages should be forwarded to mesh + var shouldForwardTAKToMesh: Bool { + return !userReadOnlyMode + } + // MARK: - Server Lifecycle /// Start the TAK server (TLS or TCP based on configuration) @@ -98,6 +241,8 @@ final class TAKServerManager: ObservableObject { return } + checkPrimaryChannelValidity() + let mode = useTLS ? "TLS" : "TCP" Logger.tak.info("Starting TAK Server on port \(self.port) (\(mode) mode)") @@ -333,6 +478,11 @@ final class TAKServerManager: ObservableObject { case .connected(let clientInfo): connectedClients.append(clientInfo) Logger.tak.info("TAK client connected: \(clientInfo.displayName)") + + // Send all mesh node positions to the newly connected client + if meshToCotEnabled { + await bridge?.broadcastAllNodesToTAK() + } case .clientInfoUpdated(let clientInfo): // Update the client info in our list @@ -382,6 +532,25 @@ final class TAKServerManager: ObservableObject { } } } + + /// Ensure bridge is initialized and ready for mesh-to-CoT broadcasting + /// Returns true if broadcasting is possible (meshToCotEnabled, server running, clients connected) + /// Call this before any mesh-to-CoT broadcast operations + func ensureBridgeReadyForMeshToCot() -> Bool { + guard meshToCotEnabled, isRunning, !connectedClients.isEmpty else { return false } + + if bridge == nil { + Logger.tak.info("Initializing bridge for mesh-to-CoT broadcast") + let accessoryManager = AccessoryManager.shared + let newBridge = TAKMeshtasticBridge( + accessoryManager: accessoryManager, + takServerManager: self + ) + newBridge.context = accessoryManager.context + bridge = newBridge + } + return true + } /// Send a CoT message to a specific client func send(_ cotMessage: CoTMessage, to clientId: UUID) async throws { @@ -400,6 +569,113 @@ final class TAKServerManager: ObservableObject { throw TAKServerError.clientNotFound } + // MARK: - Auto-fix Primary Channel + + /// Automatically fix the primary channel to TAK-compatible settings + /// Sets: Name="TAK", 256-bit AES key, preserves existing LoRa channel + /// Returns true if successful + func autoFixPrimaryChannel() async -> Bool { + let accessoryManager = AccessoryManager.shared + + guard accessoryManager.isConnected else { + Logger.tak.error("Cannot fix channel: Not connected to device") + return false + } + + Logger.tak.info("Auto-fixing primary channel for TAK compatibility") + + let context = PersistenceController.shared.container.viewContext + + guard let connectedNodeNum = accessoryManager.activeDeviceNum else { + Logger.tak.error("Cannot fix channel: No active device number") + return false + } + + guard let connectedNode = getNodeInfo(id: connectedNodeNum, context: context), + let user = connectedNode.user else { + Logger.tak.error("Cannot fix channel: No connected node or user found") + return false + } + + let fetchRequest = MyInfoEntity.fetchRequest() + + do { + let myInfos = try context.fetch(fetchRequest) + guard let myInfo = myInfos.first, + let channels = myInfo.channels?.array as? [ChannelEntity], + let primaryChannel = channels.first(where: { $0.index == 0 || $0.role == 1 }) else { + Logger.tak.error("Cannot fix channel: No primary channel found") + return false + } + + let newKey = generateChannelKey(size: 32) + guard let newPsk = Data(base64Encoded: newKey) else { + Logger.tak.error("Failed to decode generated channel key; aborting primary channel fix") + return false + } + + primaryChannel.name = "TAK" + primaryChannel.psk = newPsk + primaryChannel.role = 1 + primaryChannel.index = 0 + + if let mutableChannels = myInfo.channels?.mutableCopy() as? NSMutableOrderedSet { + if mutableChannels.contains(primaryChannel) { + mutableChannels.remove(primaryChannel) + mutableChannels.insert(primaryChannel, at: 0) + myInfo.channels = mutableChannels.copy() as? NSOrderedSet + } + } + + try context.save() + + var channel = Channel() + channel.index = 0 + channel.role = .primary + channel.settings.name = "TAK" + channel.settings.psk = newPsk + channel.settings.uplinkEnabled = primaryChannel.uplinkEnabled + channel.settings.downlinkEnabled = primaryChannel.downlinkEnabled + channel.settings.moduleSettings.positionPrecision = UInt32(primaryChannel.positionPrecision) + + try await accessoryManager.saveChannel(channel: channel, fromUser: user, toUser: user) + + Logger.tak.info("Successfully fixed primary channel: name=TAK, key=256-bit") + + // Also set LoRa modem preset to shortFast for optimal TAK performance + var loraConfig = Config.LoRaConfig() + loraConfig.modemPreset = .shortFast + loraConfig.usePreset = true + loraConfig.txEnabled = true + loraConfig.hopLimit = 3 + + // Get current LoRa config to preserve other settings + if let currentLoRa = connectedNode.loRaConfig { + loraConfig.region = Config.LoRaConfig.RegionCode(rawValue: Int(currentLoRa.regionCode)) ?? .unset + loraConfig.channelNum = UInt32(currentLoRa.channelNum) + loraConfig.txPower = Int32(currentLoRa.txPower) + loraConfig.bandwidth = UInt32(currentLoRa.bandwidth) + loraConfig.codingRate = UInt32(currentLoRa.codingRate) + loraConfig.spreadFactor = UInt32(currentLoRa.spreadFactor) + } + + do { + try await accessoryManager.saveLoRaConfig(config: loraConfig, fromUser: user, toUser: user) + Logger.tak.info("Successfully set LoRa modem preset to shortFast") + } catch { + Logger.tak.warning("Failed to set LoRa modem preset: \(error.localizedDescription)") + } + + checkPrimaryChannelValidity() + + return true + + } catch { + Logger.tak.error("Failed to fix primary channel: \(error.localizedDescription)") + return false + } + } + // MARK: - Status /// Get server status description @@ -414,31 +690,3 @@ final class TAKServerManager: ObservableObject { } } } - -// MARK: - Server Errors - -enum TAKServerError: LocalizedError { - case noServerCertificate - case noClientCACertificate - case tlsConfigurationFailed - case listenerFailed(String) - case clientNotFound - case notRunning - - var errorDescription: String? { - switch self { - case .noServerCertificate: - return "No server certificate configured. Import a .p12 file with the server certificate and private key." - case .noClientCACertificate: - return "No client CA certificate configured. Import the CA certificate (.pem) used to sign client certificates." - case .tlsConfigurationFailed: - return "Failed to configure TLS settings." - case .listenerFailed(let reason): - return "Failed to start listener: \(reason)" - case .clientNotFound: - return "Client not found" - case .notRunning: - return "TAK Server is not running" - } - } -} diff --git a/Meshtastic/Views/Settings/TAKServerConfig.swift b/Meshtastic/Views/Settings/TAKServerConfig.swift index 09d9d60d..cb8e3666 100644 --- a/Meshtastic/Views/Settings/TAKServerConfig.swift +++ b/Meshtastic/Views/Settings/TAKServerConfig.swift @@ -26,6 +26,7 @@ struct TAKServerConfig: View { ) private var channels: FetchedResults @StateObject private var takServer = TAKServerManager.shared + @Environment(\.dismiss) private var dismiss @State private var showingFileImporter = false @State private var importType: CertificateImportType = .p12 @State private var p12Password = "" @@ -35,17 +36,40 @@ struct TAKServerConfig: View { @State private var showingImportError = false @State private var showingFileExporter = false @State private var dataPackageURL: URL? + @State private var showingFixWarning = false + @State private var isFixingChannel = false + @State private var showShareChannels = false + @State private var showShareChannelsAlert = false + @State private var connectedNode: NodeInfoEntity? + @State private var isWarningExpanded = true private let certManager = TAKCertificateManager.shared var body: some View { Form { + if !takServer.primaryChannelIssues.isEmpty { + primaryChannelWarningSection + } serverStatusSection serverConfigSection certificatesSection dataPackageSection } .navigationTitle("TAK Server") + .onAppear { + takServer.checkPrimaryChannelValidity() + if let nodeNum = accessoryManager.activeDeviceNum { + connectedNode = getNodeInfo(id: nodeNum, context: context) + } + } + .alert("Fix Primary Channel?", isPresented: $showingFixWarning) { + Button("Cancel", role: .cancel) {} + Button("Fix Channel", role: .destructive) { + fixPrimaryChannel() + } + } message: { + Text("This will change your primary channel to:\n• Name: TAK\n• Encryption: New 256-bit AES key\n• LoRa preset: Short Fast (recommended for TAK)\n\nThis is required for TAK Server to work properly. Any existing channel sharing links will become invalid.") + } .fileImporter( isPresented: $showingFileImporter, allowedContentTypes: importType == .p12 ? [UTType(filenameExtension: "p12") ?? .pkcs12, .pkcs12] : [UTType(filenameExtension: "pem") ?? .plainText], @@ -75,6 +99,14 @@ struct TAKServerConfig: View { } message: { Text(importError ?? "Unknown error") } + .alert("Channel Fixed!", isPresented: $showShareChannelsAlert) { + Button("Share with TAK Buddies") { + showShareChannels = true + } + Button("Later", role: .cancel) {} + } message: { + Text("Your channel has been configured for TAK. To share the QR code: go to Settings > Share QR Code") + } .fileExporter( isPresented: $showingFileExporter, document: dataPackageURL.map { ZipDocument(url: $0) }, @@ -94,6 +126,65 @@ struct TAKServerConfig: View { } dataPackageURL = nil } + .navigationDestination(isPresented: $showShareChannels) { + if let node = connectedNode { + ShareChannels(node: node) + } + } + } + + // MARK: - Primary Channel Warning Section + + private var primaryChannelWarningSection: some View { + Section { + DisclosureGroup(isExpanded: $isWarningExpanded) { + VStack(alignment: .leading, spacing: 12) { + if takServer.readOnlyMode { + Text("Your primary channel is using the default settings (no name or default encryption key). TAK Server is running in read-only mode.") + .font(.subheadline) + .foregroundColor(.secondary) + } + + Text("You can fix this yourself by changing your primary channel:") + .font(.subheadline) + + VStack(alignment: .leading, spacing: 4) { + Label("Set a channel name", systemImage: "1.circle.fill") + Label("Use a 256-bit encryption key", systemImage: "2.circle.fill") + } + .font(.caption) + .foregroundColor(.secondary) + + Divider() + + Button { + showingFixWarning = true + } label: { + Label("Auto-Fix Channel", systemImage: "wand.and.stars") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isFixingChannel) + + Text("Or fix it yourself in Channels settings, then return here.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + .padding(.vertical, 8) + } label: { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("TAK Cannot Be Used on Public Channel") + .font(.headline) + } + } + } header: { + Text("Warning") + } } // MARK: - Server Status Section @@ -122,6 +213,19 @@ struct TAKServerConfig: View { .foregroundColor(.orange) } } + + if let node = connectedNode, + let role = node.user?.role, + let deviceRole = DeviceRoles(rawValue: Int(role)), + deviceRole != .tak && deviceRole != .takTracker { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("Device role is \"\(deviceRole.name)\". Consider setting to TAK or TAK Tracker for optimal operation.") + .font(.caption) + .foregroundColor(.orange) + } + } } header: { Text("Server Status") } @@ -150,6 +254,26 @@ struct TAKServerConfig: View { .foregroundColor(.secondary) } + Toggle(isOn: $takServer.userReadOnlyMode) { + VStack(alignment: .leading, spacing: 2) { + Text("Read-Only Mode") + Text("Meshtastic -> TAK works, TAK -> Meshtastic blocked") + .font(.caption) + .foregroundColor(.secondary) + } + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .disabled(takServer.readOnlyMode) + + Toggle(isOn: $takServer.meshToCotEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Mesh to CoT Converter") + Text("Bridge Meshtastic positions, nodes, waypoints, and messages to TAK/CoT format") + .font(.caption) + .foregroundColor(.secondary) + } + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) if !channels.isEmpty { Picker(selection: $takServer.channel) { ForEach(channels, id: \.index) { channel in @@ -387,6 +511,23 @@ struct TAKServerConfig: View { } } + private func fixPrimaryChannel() { + isFixingChannel = true + Task { + let success = await takServer.autoFixPrimaryChannel() + await MainActor.run { + isFixingChannel = false + if success { + takServer.userReadOnlyMode = false + showShareChannelsAlert = true + } else { + importError = "Failed to fix primary channel. Make sure you are connected to a device." + showingImportError = true + } + } + } + } + // MARK: - Data Package Generation private func generateAndShareDataPackage() {