Tak server improvements (#1603)
Some checks failed
Upload dSYM Files / build (push) Has been cancelled

* 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 <garthvh@yahoo.com>

* 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 <joelperez91@gmail.com>
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 <jake-b@users.noreply.github.com>
Co-authored-by: Garth Vander Houwen <garthvh@yahoo.com>
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 <benmmeadors@gmail.com>
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 <joelperez91@gmail.com>
Co-authored-by: axunes <axunes@axunes.net>

* 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 <garthvh@yahoo.com>

* 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 <joelperez91@gmail.com>
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 <jake-b@users.noreply.github.com>
Co-authored-by: Garth Vander Houwen <garthvh@yahoo.com>
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 <benmmeadors@gmail.com>
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 <joelperez91@gmail.com>
Co-authored-by: axunes <axunes@axunes.net>

* 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 <garthvh@yahoo.com>

* 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 <joelperez91@gmail.com>
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 <jake-b@users.noreply.github.com>
Co-authored-by: Garth Vander Houwen <garthvh@yahoo.com>
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 <benmmeadors@gmail.com>
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 <joelperez91@gmail.com>
Co-authored-by: axunes <axunes@axunes.net>

---------

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 <jake-b@users.noreply.github.com>
Co-authored-by: Garth Vander Houwen <garthvh@yahoo.com>
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 <benmmeadors@gmail.com>
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
Co-authored-by: Joel Pérez Izquierdo <joelperez91@gmail.com>
Co-authored-by: axunes <axunes@axunes.net>
This commit is contained in:
Benjamin Faershtein 2026-04-02 10:34:01 -07:00 committed by GitHub
parent 9ceb34f1d5
commit 2cabd9e575
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1827 additions and 44 deletions

View file

@ -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" : {

View file

@ -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.")

View file

@ -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
)
}

View file

@ -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 "📍"
}
}
}

View file

@ -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> = 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> = 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 = "<usericon iconsetpath='\(iconPath)'/>"
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 = "<precisionlocation geopointsrc='GPS' altsrc='GPS'></precisionlocation>\(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")
}
}
}

View file

@ -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"
}
}
}

View file

@ -26,6 +26,7 @@ struct TAKServerConfig: View {
) private var channels: FetchedResults<ChannelEntity>
@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() {