mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
added read only mode cot to meshtastic parsing and warning to not enable on pub channel
This commit is contained in:
parent
d9e169142e
commit
740b194af2
6 changed files with 1239 additions and 40 deletions
|
|
@ -5179,6 +5179,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" : {
|
||||
"ru" : {
|
||||
|
|
@ -6489,6 +6493,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" : {
|
||||
"ru" : {
|
||||
|
|
@ -8283,6 +8291,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Channel Fixed!" : {
|
||||
"comment" : "A message displayed when the primary channel is successfully fixed.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Channel Name" : {
|
||||
"localizations" : {
|
||||
"it" : {
|
||||
|
|
@ -12940,6 +12952,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" : {
|
||||
"it" : {
|
||||
|
|
@ -17804,6 +17820,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Fix Channel" : {
|
||||
"comment" : "The text on a button that, when pressed, will attempt to fix the primary LoRaWAN channel.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Fix Primary Channel?" : {
|
||||
"comment" : "A confirmation alert title.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Fixed Pin" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
|
|
@ -22356,6 +22380,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Later" : {
|
||||
"comment" : "A button that dismisses an alert without taking any action.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Latitude" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
|
|
@ -24705,6 +24733,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Mesh to CoT Converter" : {
|
||||
"comment" : "A feature that bridges Meshtastic positions, nodes, waypoints, and messages to TAK/CoT format.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Meshtastic" : {
|
||||
"localizations" : {
|
||||
"ru" : {
|
||||
|
|
@ -24721,6 +24753,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" : {
|
||||
"ru" : {
|
||||
|
|
@ -29456,6 +29492,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" : {
|
||||
"it" : {
|
||||
|
|
@ -33154,6 +33194,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" : {
|
||||
"de" : {
|
||||
|
|
@ -38947,6 +38991,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Set a channel name" : {
|
||||
"comment" : "A bullet point describing the action to set a name for the primary channel.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Set LoRa Region" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
|
|
@ -39452,6 +39500,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Share with TAK Buddies" : {
|
||||
"comment" : "A button that, when pressed, will present a view allowing the user to share the TAK channel with other TAK-enabled devices.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Share your location in real-time and keep your group coordinated with integrated GPS features." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
|
|
@ -41604,7 +41656,6 @@
|
|||
}
|
||||
},
|
||||
"TAK" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -41650,6 +41701,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"TAK Cannot Be Used on Public Channel" : {
|
||||
"comment" : "A warning label explaining that TAK cannot be used on the public channel.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"TAK Server" : {
|
||||
|
||||
},
|
||||
|
|
@ -43645,6 +43700,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" : {
|
||||
"it" : {
|
||||
|
|
@ -46758,6 +46817,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" : {
|
||||
"it" : {
|
||||
|
|
@ -48086,6 +48149,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Warning" : {
|
||||
"comment" : "The header text for the \"Warning\" section in the TAKServerConfig view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Wave" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
|
|
@ -49318,6 +49385,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" : {
|
||||
"de" : {
|
||||
|
|
@ -49388,6 +49459,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" : {
|
||||
"it" : {
|
||||
|
|
@ -49610,6 +49685,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" : {
|
||||
"ru" : {
|
||||
|
|
@ -49740,4 +49819,4 @@
|
|||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -512,12 +512,68 @@ 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.meshToCotEnabled && server.isRunning && !server.connectedClients.isEmpty {
|
||||
if server.bridge == nil {
|
||||
Logger.tak.info("Initializing bridge for text message")
|
||||
let bridge = TAKMeshtasticBridge(
|
||||
accessoryManager: AccessoryManager.shared,
|
||||
takServerManager: server
|
||||
)
|
||||
bridge.context = AccessoryManager.shared.context
|
||||
server.bridge = bridge
|
||||
}
|
||||
await server.bridge?.broadcastMeshTextMessageToTAK(text: text, from: packet.from, channel: packet.channel)
|
||||
}
|
||||
}
|
||||
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.meshToCotEnabled && server.isRunning && !server.connectedClients.isEmpty {
|
||||
if server.bridge == nil {
|
||||
Logger.tak.info("Initializing bridge for position")
|
||||
let bridge = TAKMeshtasticBridge(
|
||||
accessoryManager: AccessoryManager.shared,
|
||||
takServerManager: server
|
||||
)
|
||||
bridge.context = AccessoryManager.shared.context
|
||||
server.bridge = bridge
|
||||
}
|
||||
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.")
|
||||
|
|
|
|||
255
Meshtastic/Helpers/TAK/MeshToCoTConverter.swift
Normal file
255
Meshtastic/Helpers/TAK/MeshToCoTConverter.swift
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
//
|
||||
// MeshToCoTConverter.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Converts Meshtastic packets to CoT format for TAK Server
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MeshtasticProtobufs
|
||||
import CoreLocation
|
||||
import OSLog
|
||||
|
||||
/// 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)
|
||||
|
||||
return CoTMessage(
|
||||
uid: uid,
|
||||
type: "b-ttf-ff", // Point feature friend
|
||||
time: Date(),
|
||||
start: Date(),
|
||||
stale: Date().addingTimeInterval(TimeInterval(waypoint.expire * 60)),
|
||||
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,
|
||||
messageId: messageId,
|
||||
message: text,
|
||||
channelName: "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-s" // Sensor
|
||||
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 "📍"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -474,24 +484,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 hour
|
||||
/// 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)")
|
||||
}
|
||||
|
|
@ -500,10 +564,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 {
|
||||
|
|
@ -513,4 +579,380 @@ 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
|
||||
func broadcastMeshTextMessageToTAK(text: String, from nodeNum: UInt32, channel: 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))"
|
||||
|
||||
let cotMessage = CoTMessage(
|
||||
uid: "GeoChat.MESHTASTIC-\(String(format: "%08X", nodeNum)).Channel\(channel).\(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,
|
||||
chat: CoTChat(
|
||||
message: text,
|
||||
senderCallsign: senderName,
|
||||
chatroom: "All Chat Rooms"
|
||||
),
|
||||
remarks: text
|
||||
)
|
||||
|
||||
await server.broadcast(cotMessage)
|
||||
Logger.tak.info("Broadcast mesh text message to TAK: \(senderName)")
|
||||
}
|
||||
|
||||
/// 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)'/>"
|
||||
let colorXML = "<color argb='\(colorArgb)'/>"
|
||||
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 and color in the detail
|
||||
let rawDetail = "<precisionlocation geopointsrc='GPS' altsrc='GPS'></precisionlocation>\(userIconXML)\(colorXML)"
|
||||
|
||||
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 using Google markers
|
||||
/// Returns (cotType, iconPath, colorArgb)
|
||||
/// Google markers are the only reliable icon type across all TAK clients (ATAK, iTAK, WinTAK)
|
||||
private func getTakIconForWaypoint(waypoint: Waypoint) -> (String, String, String) {
|
||||
let icon = waypoint.icon
|
||||
|
||||
// Google marker base UUID
|
||||
let googleBase = "f7f71666-8b28-4b57-9fbb-e38e61d33b79/Google"
|
||||
|
||||
switch icon {
|
||||
// 📍 📌 Pushpin - RED pushpin
|
||||
case 0x1F4CD, 0x1F4CC, 1: // 📍 📌
|
||||
return ("a-u-G", "\(googleBase)/red-pushpin.png", "-16776961")
|
||||
|
||||
// === EMERGENCY ===
|
||||
// 🔥 Fire - firedept
|
||||
case 0x1F525, 10: // 🔥
|
||||
return ("a-u-G", "\(googleBase)/firedept.png", "-16776961")
|
||||
// 🚨 Siren - caution
|
||||
case 0x1F6A8, 6: // 🚨
|
||||
return ("a-u-G", "\(googleBase)/caution.png", "-256")
|
||||
// 🏥 Hospital - hospital
|
||||
case 0x1F3E5, 0x2695, 9: // 🏥 ➕
|
||||
return ("a-u-G", "\(googleBase)/hospital.png", "-16776961")
|
||||
// 🚑 Ambulance - ambulance
|
||||
case 0x1F691: // 🚑
|
||||
return ("a-u-G", "\(googleBase)/ambulance.png", "-16776961")
|
||||
// ⚠️ Warning - caution
|
||||
case 0x26A0: // ⚠️
|
||||
return ("a-u-G", "\(googleBase)/caution.png", "-256")
|
||||
|
||||
// === TRANSPORT ===
|
||||
// 🚗 Car - car
|
||||
case 0x1F697, 0x1F695, 2: // 🚗 🚕
|
||||
return ("a-u-G", "\(googleBase)/car.png", "-256")
|
||||
// 🚁 Helicopter - heliport
|
||||
case 0x1F681, 11: // 🚁
|
||||
return ("a-u-G", "\(googleBase)/heliport.png", "-16776961")
|
||||
// ⛵ Boat - marina
|
||||
case 0x1F6B5, 12: // ⛵
|
||||
return ("a-u-G", "\(googleBase)/marina.png", "-16776961")
|
||||
// 🚢 Ship - dock
|
||||
case 0x1F6A2: // 🚢
|
||||
return ("a-u-G", "\(googleBase)/dock.png", "-16776961")
|
||||
// 🚀 Rocket - rocket
|
||||
case 0x1F680: // 🚀
|
||||
return ("a-u-G", "\(googleBase)/rocket.png", "-16776961")
|
||||
// 🛸 UFO - unidentified (use pushpin)
|
||||
case 0x1F6B5, 13: // 🛸
|
||||
return ("a-u-G", "\(googleBase)/purple-p pushpin.png", "-65281")
|
||||
|
||||
// === PEOPLE ===
|
||||
// 🚶 Person - man
|
||||
case 0x1F464, 0x1F465, 3: // 👤 👥
|
||||
return ("a-u-G", "\(googleBase)/man.png", "-16711936")
|
||||
// 🏃 Runner - runner
|
||||
case 0x1F3C3: // 🏃
|
||||
return ("a-u-G", "\(googleBase)/runner.png", "-16711936")
|
||||
|
||||
// === STRUCTURES ===
|
||||
// 🏠 House - home
|
||||
case 0x1F3E0, 0x1F3E1, 4: // 🏠 🏡
|
||||
return ("a-u-G", "\(googleBase)/home.png", "-16711936")
|
||||
// ⛺ Tent - campground
|
||||
case 0x26FA, 0x1F3D5, 5: // ⛺ 🏕
|
||||
return ("a-u-G", "\(googleBase)/campground.png", "-256")
|
||||
// 🏰 Castle - castle
|
||||
case 0x1F3F0: // 🏰
|
||||
return ("a-u-G", "\(googleBase)/castle.png", "-16776961")
|
||||
|
||||
// === NATURE ===
|
||||
// 🌲 Tree - park
|
||||
case 0x1F332: // 🌲
|
||||
return ("a-u-G", "\(googleBase)/park.png", "-16711936")
|
||||
// 🌳 Tree - park
|
||||
case 0x1F333: // 🌳
|
||||
return ("a-u-G", "\(googleBase)/park.png", "-16711936")
|
||||
// 🏔️ Mountain - mountain
|
||||
case 0x1F3D4: // 🏔️
|
||||
return ("a-u-G", "\(googleBase)/mountain.png", "-1")
|
||||
// ⛰️ Mountain - mountain
|
||||
case 0x26F0: // ⛰️
|
||||
return ("a-u-G", "\(googleBase)/mountain.png", "-1")
|
||||
// 💧 Water - water
|
||||
case 0x1F4A7: // 💧
|
||||
return ("a-u-G", "\(googleBase)/water.png", "-16776961")
|
||||
// 🌊 Wave - water
|
||||
case 0x1F30A: // 🌊
|
||||
return ("a-u-G", "\(googleBase)/water.png", "-16776961")
|
||||
// ☁️ Cloud - cloudy
|
||||
case 0x2601, 0x2602: // ☁ ☂
|
||||
return ("a-u-G", "\(googleBase)/cloudy.png", "-1")
|
||||
// 🌙 Moon - moon
|
||||
case 0x1F319: // 🌙
|
||||
return ("a-u-G", "\(googleBase)/moon.png", "-16776961")
|
||||
// ⚓ Anchor - anchor
|
||||
case 0x2693: // ⚓
|
||||
return ("a-u-G", "\(googleBase)/anchor.png", "-16776961")
|
||||
// ⭐ Star - star
|
||||
case 0x2B50, 0x1F31F: // ⭐ 🌟
|
||||
return ("a-u-G", "\(googleBase)/star.png", "-256")
|
||||
|
||||
// === FLAGS/MARKERS ===
|
||||
// 🚩 Flag - flag
|
||||
case 0x1F6A9: // 🚩
|
||||
return ("a-u-G", "\(googleBase)/flag.png", "-16776961")
|
||||
// 🏁 Checkered flag - finish
|
||||
case 0x1F3C1, 7: // 🏁
|
||||
return ("a-u-G", "\(googleBase)/finish.png", "-1")
|
||||
|
||||
// === OBJECTS ===
|
||||
// 💎 Gem - diamond
|
||||
case 0x1F48E: // 💎
|
||||
return ("a-u-G", "\(googleBase)/diamond.png", "-16776961")
|
||||
// 🔔 Bell - bell
|
||||
case 0x1F514: // 🔔
|
||||
return ("a-u-G", "\(googleBase)/bell.png", "-256")
|
||||
// 🗺 Map - map
|
||||
case 0x1F5FA: // 🗺
|
||||
return ("a-u-G", "\(googleBase)/map.png", "-1")
|
||||
// 🎁 Gift - gift
|
||||
case 0x1F381: // 🎁
|
||||
return ("a-u-G", "\(googleBase)/gift.png", "-16776961")
|
||||
// 💀 Skull - skull
|
||||
case 0x1F480: // 💀
|
||||
return ("a-u-G", "\(googleBase)/skull.png", "-1")
|
||||
// ❄️ Snowflake - snow
|
||||
case 0x2744: // ❄
|
||||
return ("a-u-G", "\(googleBase)/snowflake.png", "-1")
|
||||
// ☂️ Umbrella - umbrella
|
||||
case 0x26F1: // ⛱
|
||||
return ("a-u-G", "\(googleBase)/umbrella.png", "-16776961")
|
||||
// 💡 Light - lightbulb
|
||||
case 0x1F4A1: // 💡
|
||||
return ("a-u-G", "\(googleBase)/lightbulb.png", "-256")
|
||||
// 🔋 Battery - battery
|
||||
case 0x1F50B: // 🔋
|
||||
return ("a-u-G", "\(googleBase)/battery.png", "-16711936")
|
||||
// 📻 Radio - radio
|
||||
case 0x1F4FB: // 📻
|
||||
return ("a-u-G", "\(googleBase)/radio.png", "-16711936")
|
||||
// 📞 Phone - phone
|
||||
case 0x1F4DE, 0x1F4F1: // 📞 📱
|
||||
return ("a-u-G", "\(googleBase)/phone.png", "-16711936")
|
||||
// 💥 Collision - warning
|
||||
case 0x1F4A5: // 💥
|
||||
return ("a-u-G", "\(googleBase)/warning.png", "-16776961")
|
||||
|
||||
// === SYMBOLS ===
|
||||
// ❤️ Heart - heart
|
||||
case 0x2764, 0x1F493, 0x1F49A, 0x1F499: // ❤️ 💓 💚 💙
|
||||
return ("a-u-G", "\(googleBase)/heart.png", "-16776961")
|
||||
// ✅ Check - check
|
||||
case 0x2705, 0x1F7E2: // ✅ 🟢
|
||||
return ("a-u-G", "\(googleBase)/check.png", "-16711936")
|
||||
// ❌ X - x
|
||||
case 0x274C, 0x1F6AB: // ❌ 🚫
|
||||
return ("a-u-G", "\(googleBase)/x.png", "-16776961")
|
||||
|
||||
// === WEATHER ===
|
||||
// 🌤️ Sun behind cloud - partlycloudy
|
||||
case 0x1F324: // 🌤️
|
||||
return ("a-u-G", "\(googleBase)/partlycloudy.png", "-256")
|
||||
// 🌧️ Rain - rainy
|
||||
case 0x1F327: // 🌧️
|
||||
return ("a-u-G", "\(googleBase)/rainy.png", "-16776961")
|
||||
// 🌨️ Snow - snow
|
||||
case 0x1F328: // 🌨️
|
||||
return ("a-u-G", "\(googleBase)/snow.png", "-1")
|
||||
// 🌩️ Lightning - lightning
|
||||
case 0x1F329: // 🌩
|
||||
return ("a-u-G", "\(googleBase)/lightning.png", "-256")
|
||||
// 🌀 Cyclone - windy
|
||||
case 0x1F300: // 🌀
|
||||
return ("a-u-G", "\(googleBase)/windy.png", "-16776961")
|
||||
// 🌈 Rainbow - rainbow
|
||||
case 0x1F308: // 🌈
|
||||
return ("a-u-G", "\(googleBase)/rainbow.png", "-16776961")
|
||||
// 🌪️ Tornado - tornado
|
||||
case 0x1F32A: // 🌪️
|
||||
return ("a-u-G", "\(googleBase)/tornado.png", "-1")
|
||||
|
||||
// === GLOBE ===
|
||||
// 🌍 Globe - globe
|
||||
case 0x1F30D, 0x1F30E, 0x1F30F, 0x1F310: // 🌍 🌎 🌏 🌐
|
||||
return ("a-u-G", "\(googleBase)/globe.png", "-16776961")
|
||||
|
||||
// === FOOD ===
|
||||
// 🍔 Burger - restaurant
|
||||
case 0x1F354: // 🍔
|
||||
return ("a-u-G", "\(googleBase)/restaurant.png", "-256")
|
||||
// 🍕 Pizza - restaurant
|
||||
case 0x1F355: // 🍕
|
||||
return ("a-u-G", "\(googleBase)/restaurant.png", "-256")
|
||||
// ☕ Coffee - coffee
|
||||
case 0x2615: // ☕
|
||||
return ("a-u-G", "\(googleBase)/coffee.png", "-256")
|
||||
// 🍺 Beer - bar
|
||||
case 0x1F37A: // 🍺
|
||||
return ("a-u-G", "\(googleBase)/bar.png", "-256")
|
||||
// 🍷 Wine - wine
|
||||
case 0x1F377: // 🍷
|
||||
return ("a-u-G", "\(googleBase)/wine.png", "-65281")
|
||||
|
||||
// === Default - RED pushpin ===
|
||||
default:
|
||||
return ("a-u-G", "\(googleBase)/red-p pushpin.png", "-16776961")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -87,6 +133,100 @@ 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
|
||||
}
|
||||
|
||||
let pskLength = pskBase64.count
|
||||
if pskLength == 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 pskBase64 == "AQ==" {
|
||||
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 pskLength == 4 {
|
||||
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)
|
||||
|
|
@ -96,6 +236,8 @@ final class TAKServerManager: ObservableObject {
|
|||
return
|
||||
}
|
||||
|
||||
checkPrimaryChannelValidity()
|
||||
|
||||
let mode = useTLS ? "TLS" : "TCP"
|
||||
Logger.tak.info("Starting TAK Server on port \(self.port) (\(mode) mode)")
|
||||
|
||||
|
|
@ -331,6 +473,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
|
||||
|
|
@ -398,6 +545,110 @@ 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, LoRa channel=0
|
||||
/// 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)
|
||||
let newPsk = Data(base64Encoded: newKey) ?? Data()
|
||||
|
||||
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
|
||||
|
|
@ -412,31 +663,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ enum CertificateImportType {
|
|||
|
||||
struct TAKServerConfig: View {
|
||||
@StateObject private var takServer = TAKServerManager.shared
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showingFileImporter = false
|
||||
@State private var importType: CertificateImportType = .p12
|
||||
@State private var p12Password = ""
|
||||
|
|
@ -25,17 +28,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],
|
||||
|
|
@ -65,6 +91,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) },
|
||||
|
|
@ -84,6 +118,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
|
||||
|
|
@ -112,6 +205,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")
|
||||
}
|
||||
|
|
@ -140,6 +246,27 @@ 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 takServer.isRunning {
|
||||
Button {
|
||||
Task {
|
||||
|
|
@ -351,6 +478,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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue