diff --git a/Localizable.xcstrings b/Localizable.xcstrings index a203a8ce..da63da8a 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -229,6 +229,22 @@ "comment": "A button that initiates", "isCommentAutoGenerated": true }, + "\"Disconnect Meshtastic\" — disconnect from the connected BLE node.": { + "comment": "A description of how to use the \"Disconnect Node\" Siri shortcut.", + "isCommentAutoGenerated": true + }, + "\"Send a Meshtastic direct message\" — send a private message to a node.": { + "comment": "A description of how to send a direct message to a node using Siri.", + "isCommentAutoGenerated": true + }, + "\"Send a Meshtastic group message\" — send a message to a mesh channel.": { + "comment": "A description of how to send a group message using Siri.", + "isCommentAutoGenerated": true + }, + "\"Shut down my Meshtastic node\" or \"Restart my Meshtastic node\".": { + "comment": "A description of how to use Siri to restart or shut down a node.", + "isCommentAutoGenerated": true + }, "%@": { "localizations": { "da": { @@ -4031,7 +4047,12 @@ } } }, + "Additional Help": { + "comment": "A button that opens a link to the Meshtastic FAQ.", + "isCommentAutoGenerated": true + }, "Additional help": { + "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -7105,6 +7126,14 @@ } } }, + "Background Activity": { + "comment": "A title for a screen that describes the benefits of enabling background location tracking.", + "isCommentAutoGenerated": true + }, + "Background Mesh Tracking": { + "comment": "A description of the background mesh tracking feature.", + "isCommentAutoGenerated": true + }, "Backup": { "localizations": { "ja": { @@ -7814,6 +7843,10 @@ } } }, + "Battery Usage": { + "comment": "A description of the battery usage of enabling background activity.", + "isCommentAutoGenerated": true + }, "Baud": { "localizations": { "da": { @@ -9450,6 +9483,10 @@ } } }, + "CarPlay Messaging": { + "comment": "A description of how to send a message to a mesh channel using CarPlay.", + "isCommentAutoGenerated": true + }, "Carousel Interval": { "localizations": { "da": { @@ -12060,7 +12097,6 @@ } }, "Community Support": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -12407,6 +12443,10 @@ } } }, + "Configure Siri & Shortcuts": { + "comment": "A button that will open the app's settings to configure Siri and Shortcuts.", + "isCommentAutoGenerated": true + }, "Configure notification permissions": { "localizations": { "de": { @@ -12641,6 +12681,14 @@ } } }, + "Connect to nodes on your local Wi-Fi network.": { + "comment": "A description of how to connect to nodes on your local Wi-Fi network.", + "isCommentAutoGenerated": true + }, + "Connect to your Meshtastic node via Bluetooth Low Energy for the best messaging experience.": { + "comment": "A description of the Bluetooth connectivity feature.", + "isCommentAutoGenerated": true + }, "Connected": { "localizations": { "da": { @@ -12815,6 +12863,10 @@ } } }, + "Connected firmware: **%@**": { + "comment": "A label displaying the firmware version of a device. The argument is the firmware version.", + "isCommentAutoGenerated": true + }, "Connecting . .": { "localizations": { "da": { @@ -13001,6 +13053,64 @@ } } }, + "Connection Attempt %lld of 10": { + "localizations": { + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilslutningsforsøg %lld af 10" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbindungsversuch %lld von 10" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Intento de conexión %lld de 10" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tentativo di connessione %lld di 10" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続試行 %lld / 10" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Количество попыток подключения, %lld из 10" + } + }, + "sr": { + "stringUnit": { + "state": "translated", + "value": "Покушај повезивања %lld од 10" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "连接尝试 %lld,共 10 次" + } + }, + "zh-Hant-TW": { + "stringUnit": { + "state": "translated", + "value": "嘗試連接 %lld / 10" + } + } + } + }, "Connection Name": { "localizations": { "es": { @@ -13209,6 +13319,14 @@ } } }, + "Continue": { + "comment": "A button that will continue to the next step in the onboarding process.", + "isCommentAutoGenerated": true + }, + "Continuous Location Updates": { + "comment": "A description of the continuous location updates feature.", + "isCommentAutoGenerated": true + }, "Control Type": { "localizations": { "da": { @@ -13926,7 +14044,6 @@ }, "Current Firmware Version": {}, "Current Firmware Version: %@": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -14043,6 +14160,16 @@ } } }, + "Current Firmware Version: %@, Minimum Required Version: %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "new", + "value": "Current Firmware Version: %1$@, Minimum Required Version: %2$@" + } + } + } + }, "Current: %lld": { "localizations": { "da": { @@ -14096,7 +14223,6 @@ } }, "Currently the recommended way to update ESP32 devices is using the web flasher on a desktop computer from a chrome based browser. It does not work on mobile devices or over BLE.": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -18194,7 +18320,6 @@ }, "Download TAK Server Data Package": {}, "Drag & Drop Firmware Update": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -18247,7 +18372,6 @@ } }, "Drag & Drop Firmware Update Documentation": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -18300,7 +18424,6 @@ } }, "Drag & Drop is the recommended way to update firmware for NRF devices. If your iPhone or iPad is USB-C it will work with your regular USB-C charging cable, for lightning devices you need the Apple Lightning to USB camera adaptor.": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -18458,7 +18581,6 @@ } }, "ESP 32 OTA update is a work in progress, click the button below to send your device a reboot into ota admin message.": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -18515,7 +18637,6 @@ "isCommentAutoGenerated": true }, "ESP32 Device Firmware Update": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -18958,6 +19079,10 @@ } } }, + "Enable Background Activity": { + "comment": "A toggle to enable or disable background activity.", + "isCommentAutoGenerated": true + }, "Enable Location Sharing": { "localizations": { "de": { @@ -19558,6 +19683,10 @@ } } }, + "Enabling background activity may increase battery usage. You can toggle this at any time in the app settings.": { + "comment": "A description of the battery usage of enabling background activity.", + "isCommentAutoGenerated": true + }, "Encoder Press Event": { "localizations": { "da": { @@ -19786,7 +19915,6 @@ } }, "Enter DFU Mode": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -22107,6 +22235,14 @@ "comment": "A section header that lists available firmware releases.", "isCommentAutoGenerated": true }, + "Firmware Update Docs": { + "comment": "A link to the firmware update documentation.", + "isCommentAutoGenerated": true + }, + "Firmware Update Required": { + "comment": "A title for a screen that displays a firmware update is required message.", + "isCommentAutoGenerated": true + }, "Firmware Updates": { "localizations": { "da": { @@ -22252,6 +22388,7 @@ "isCommentAutoGenerated": true }, "Firmware update docs": { + "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -23724,7 +23861,6 @@ } }, "Full Support": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -24320,7 +24456,6 @@ } }, "Get NRF DFU from the App Store": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -24505,7 +24640,6 @@ } }, "Get the latest stable firmware": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -26192,8 +26326,11 @@ } } }, + "How to Update": { + "comment": "A label displayed above the list of available firmware update options.", + "isCommentAutoGenerated": true + }, "How to update Firmware": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -26893,7 +27030,6 @@ } }, "If it is hard to access your device's reset button enter DFU mode here.": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -28101,6 +28237,10 @@ } } }, + "Keep the mesh map updated and send your position to the mesh even while using other apps.": { + "comment": "A description of the benefits of continuous location updates.", + "isCommentAutoGenerated": true + }, "Key": { "localizations": { "da": { @@ -31760,7 +31900,12 @@ } } }, + "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app.": { + "comment": "A description of how user data is used by Meshtastic.", + "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.": { + "extractionState": "stale", "localizations": { "es": { "stringUnit": { @@ -31984,6 +32129,10 @@ } } }, + "Message Notifications": { + "comment": "A description of the message notifications feature.", + "isCommentAutoGenerated": true + }, "Message Size": { "comment": "VoiceOver label for message size", "localizations": { @@ -32559,6 +32708,10 @@ } } }, + "Minimum required: **%@**": { + "comment": "A label displaying the minimum required firmware version.", + "isCommentAutoGenerated": true + }, "Minimum time between detection broadcasts": { "extractionState": "stale", "localizations": { @@ -34109,7 +34262,6 @@ } }, "Newer firmware is available": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -36415,7 +36567,6 @@ } }, "OTA Updates are not supported on this NRF Device.": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -36468,7 +36619,6 @@ } }, "OTA Updates are not supported on your platform.": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -37193,7 +37343,7 @@ } }, "Open Web Flasher": { - "comment": "A link label that says \"Open Web Flasher\" and has an arrow icon pointing to it.", + "comment": "A button that opens the Web Flasher app.", "isCommentAutoGenerated": true }, "Optimized for 2 color displays": { @@ -42276,6 +42426,10 @@ } } }, + "Read and reply to Meshtastic channel and direct messages directly from your car's display using CarPlay.": { + "comment": "A description of how to use CarPlay with Meshtastic.", + "isCommentAutoGenerated": true + }, "Read-Only Mode": { "comment": "A toggle that allows the user to enable or disable read-only mode for the TAK server.", "isCommentAutoGenerated": true @@ -42587,6 +42741,14 @@ } } }, + "Receive notifications for incoming messages and critical alerts even when the app is in the background.": { + "comment": "A description of the notification feature.", + "isCommentAutoGenerated": true + }, + "Receive position updates from other nodes and maintain an accurate picture of the mesh while in the background.": { + "comment": "A description of the benefits of enabling background mesh tracking.", + "isCommentAutoGenerated": true + }, "Received Ack": { "extractionState": "stale", "localizations": { @@ -42856,6 +43018,10 @@ } } }, + "Recommended secure version: **%@**": { + "comment": "A label displaying the recommended secure version of the connected device.", + "isCommentAutoGenerated": true + }, "Recording route": { "localizations": { "da": { @@ -46863,6 +47029,10 @@ } } }, + "Security Advisory": { + "comment": "A title for a security advisory displayed in a card.", + "isCommentAutoGenerated": true + }, "Security Config": { "localizations": { "da": { @@ -46979,6 +47149,10 @@ } } }, + "Security Update Recommended": { + "comment": "A title for a view that warns the user that their device is running an outdated firmware version.", + "isCommentAutoGenerated": true + }, "Select": { "extractionState": "stale", "localizations": { @@ -47797,7 +47971,6 @@ } }, "Send Reboot OTA": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -48205,6 +48378,10 @@ } } }, + "Send and receive Meshtastic messages hands-free using Siri and CarPlay.": { + "comment": "A description of how to use Siri and CarPlay with Meshtastic.", + "isCommentAutoGenerated": true + }, "Sender Interval": { "extractionState": "stale", "localizations": { @@ -50960,6 +51137,10 @@ } } }, + "Shut Down / Restart Node": { + "comment": "A Siri shortcut to restart or shut down a node.", + "isCommentAutoGenerated": true + }, "Shut Down Node?": { "localizations": { "da": { @@ -51316,6 +51497,14 @@ } } }, + "Siri & CarPlay": { + "comment": "A description of how to use Siri and CarPlay with Meshtastic.", + "isCommentAutoGenerated": true + }, + "Siri, Shortcuts & CarPlay": { + "comment": "A label displayed above the Siri, Shortcuts & CarPlay onboarding view.", + "isCommentAutoGenerated": true + }, "Six Hours": { "extractionState": "stale", "localizations": { @@ -53867,7 +54056,12 @@ "comment": "A footnote explaining that the Web Flasher does not support updating on this device or over USB or BLE.", "isCommentAutoGenerated": true }, + "The Meshtastic Apple app requires firmware version %@ or later. Older firmware versions are no longer supported and may have compatibility issues or missing features.": { + "comment": "A body text that explains that the app requires a certain version of the firmware.", + "isCommentAutoGenerated": true + }, "The Meshtastic Apple apps support firmware version %@ and above.": { + "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -60229,6 +60423,7 @@ } }, "Version %@ includes substantial network optimizations and extensive changes to devices and client apps. Only nodes version %@ and above are supported.": { + "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -61214,7 +61409,6 @@ } }, "Web Flasher": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -61359,6 +61553,7 @@ } }, "Welcome to": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -61386,6 +61581,10 @@ } } }, + "Welcome to Meshtastic": { + "comment": "The title of the onboarding screen.", + "isCommentAutoGenerated": true + }, "What does the lock mean?": { "localizations": { "da": { @@ -62354,7 +62553,6 @@ } }, "You can also update your Meshtastic device over bluetooth using the Nordic DFU app.": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -62493,7 +62691,6 @@ } }, "Your Firmware is up to date": { - "extractionState": "stale", "localizations": { "da": { "stringUnit": { @@ -62601,6 +62798,10 @@ "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 connected device is running firmware older than **%@**, which contains known security vulnerabilities. Updating your firmware is strongly recommended to protect your device and mesh network.": { + "comment": "A body text that describes the security advisory.", + "isCommentAutoGenerated": true + }, "Your current location will be set as the fixed position and broadcast over the mesh on the position interval.": { "localizations": { "da": { @@ -63284,6 +63485,7 @@ } }, "🦕 End of life Version 🦖 ☄️": { + "extractionState": "stale", "localizations": { "da": { "stringUnit": { diff --git a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9c71d39c..cb5d36cf 100644 --- a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "943f1047f8d99b0600f1c91f14d7bf4808ab1caf172ae4d7f3ebea325c27437f", + "originHash" : "7d747a138ea225de00b815c2d9ed46c704c081d98cc8d1018c8d11cb91f39bc4", "pins" : [ { "identity" : "cocoamqtt", @@ -19,15 +19,6 @@ "version" : "3.4.0" } }, - { - "identity" : "ios-dfu-library", - "kind" : "remoteSourceControl", - "location" : "https://github.com/NordicSemiconductor/IOS-DFU-Library", - "state" : { - "revision" : "4773d7eed944684dfdd177a4a91af8a89ebbaac8", - "version" : "4.16.0" - } - }, { "identity" : "mqttcocoaasyncsocket", "kind" : "remoteSourceControl", @@ -72,24 +63,6 @@ "revision" : "c169a5744230951031770e27e475ff6eefe51f9d", "version" : "1.33.3" } - }, - { - "identity" : "swiftdraw", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swhitty/SwiftDraw", - "state" : { - "revision" : "17d55c17540f3eb10685058e803d7ae73d9bf9d3", - "version" : "0.25.3" - } - }, - { - "identity" : "zipfoundation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/weichsel/ZIPFoundation", - "state" : { - "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", - "version" : "0.9.19" - } } ], "version" : 3 diff --git a/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9a4d9cb7..ba8776f7 100644 --- a/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2ef98d89234a9436246b308320a5af24fe6a09930ba8db91f4e0a48cc09dcd60", + "originHash" : "25240dd07109fa832be10093f5d97529f872f18e8d9df6468e5e4212bc0b487e", "pins" : [ { "identity" : "cocoamqtt", @@ -19,15 +19,6 @@ "version" : "3.3.0" } }, - { - "identity" : "ios-dfu-library", - "kind" : "remoteSourceControl", - "location" : "https://github.com/NordicSemiconductor/IOS-DFU-Library", - "state" : { - "revision" : "4773d7eed944684dfdd177a4a91af8a89ebbaac8", - "version" : "4.16.0" - } - }, { "identity" : "mqttcocoaasyncsocket", "kind" : "remoteSourceControl", @@ -72,24 +63,6 @@ "revision" : "c169a5744230951031770e27e475ff6eefe51f9d", "version" : "1.33.3" } - }, - { - "identity" : "swiftdraw", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swhitty/SwiftDraw", - "state" : { - "revision" : "17d55c17540f3eb10685058e803d7ae73d9bf9d3", - "version" : "0.25.3" - } - }, - { - "identity" : "zipfoundation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/weichsel/ZIPFoundation", - "state" : { - "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", - "version" : "0.9.19" - } } ], "version" : 3 diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift index 3daabf13..5d30acd7 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift @@ -376,7 +376,10 @@ extension AccessoryManager { do { try context.save() Logger.data.info("💾 Saved a new sent message from \(self.activeDeviceNum?.toHex() ?? "0", privacy: .public) to \(toUserNum.toHex(), privacy: .public)") - + // Donate outgoing message to SiriKit for CarPlay + if !isEmoji { + CarPlayIntentDonation.donateOutgoingMessage(content: message, toUserNum: toUserNum, channel: channel) + } } catch { context.rollback() let nsError = error as NSError diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift index 8196e068..008eb8e5 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift @@ -116,7 +116,8 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate { // Constants let NONCE_ONLY_CONFIG = 69420 let NONCE_ONLY_DB = 69421 - let minimumVersion = "2.3.15" + let minimumVersion = "2.5.18" + let securityVersion = "2.6.0" // Global Objects // Chicken/Egg problem. Set in the App object immediately after diff --git a/Meshtastic/CarPlay/CarPlayIntentDonation.swift b/Meshtastic/CarPlay/CarPlayIntentDonation.swift new file mode 100644 index 00000000..fa7aef40 --- /dev/null +++ b/Meshtastic/CarPlay/CarPlayIntentDonation.swift @@ -0,0 +1,142 @@ +// +// CarPlayIntentDonation.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 4/16/26. +// +// Donates SiriKit interactions when messages are received so that +// conversations appear in CarPlay's messaging interface and Siri +// can read them aloud. +// + +import CoreData +import Intents +import OSLog + +enum CarPlayIntentDonation { + + /// Donates an incoming message interaction so it appears in CarPlay Messages. + /// Call this after saving a new `MessageEntity` to Core Data. + static func donateReceivedMessage(_ message: MessageEntity) { + guard let fromUser = message.fromUser else { return } + guard !message.isEmoji, !message.admin else { return } + + let sender = IntentMessageConverters.inPerson(from: fromUser) + let me = mePerson() + + let intent: INSendMessageIntent + if message.toUser != nil { + // Direct message + intent = INSendMessageIntent( + recipients: [me], + outgoingMessageType: .outgoingMessageText, + content: message.messagePayload, + speakableGroupName: nil, + conversationIdentifier: "dm-\(fromUser.num)", + serviceName: "Meshtastic", + sender: sender, + attachments: nil + ) + } else { + // Channel message + let channelName = channelDisplayName(for: message.channel) + let groupName = INSpeakableString(spokenPhrase: channelName) + intent = INSendMessageIntent( + recipients: [me], + outgoingMessageType: .outgoingMessageText, + content: message.messagePayload, + speakableGroupName: groupName, + conversationIdentifier: "channel-\(message.channel)", + serviceName: "Meshtastic", + sender: sender, + attachments: nil + ) + intent.setImage( + INImage(named: "antenna.radiowaves.left.and.right"), + forParameterNamed: \.speakableGroupName + ) + } + + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + interaction.donate { error in + if let error { + Logger.services.error("🚗 [CarPlay] Failed to donate interaction: \(error.localizedDescription, privacy: .public)") + } else { + Logger.services.debug("🚗 [CarPlay] Donated incoming message from \(fromUser.longName ?? "unknown", privacy: .public)") + } + } + } + + /// Donates an outgoing message interaction after the user sends a message. + static func donateOutgoingMessage(content: String, toUserNum: Int64, channel: Int32) { + let me = mePerson() + + let intent: INSendMessageIntent + if toUserNum != 0 { + let handleValue = "\(toUserNum)@meshtastic.local" + let recipientHandle = INPersonHandle(value: handleValue, type: .emailAddress) + let recipient = INPerson( + personHandle: recipientHandle, + nameComponents: nil, + displayName: "Node \(toUserNum.toHex())", + image: nil, + contactIdentifier: String(toUserNum), + customIdentifier: String(toUserNum) + ) + intent = INSendMessageIntent( + recipients: [recipient], + outgoingMessageType: .outgoingMessageText, + content: content, + speakableGroupName: nil, + conversationIdentifier: "dm-\(toUserNum)", + serviceName: "Meshtastic", + sender: me, + attachments: nil + ) + } else { + let channelName = channelDisplayName(for: channel) + let groupName = INSpeakableString(spokenPhrase: channelName) + intent = INSendMessageIntent( + recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: content, + speakableGroupName: groupName, + conversationIdentifier: "channel-\(channel)", + serviceName: "Meshtastic", + sender: me, + attachments: nil + ) + } + + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .outgoing + interaction.donate { error in + if let error { + Logger.services.error("🚗 [CarPlay] Failed to donate outgoing interaction: \(error.localizedDescription, privacy: .public)") + } + } + } + + // MARK: - Helpers + + static func mePerson() -> INPerson { + let meHandle = INPersonHandle(value: "me", type: .unknown) + return INPerson( + personHandle: meHandle, + nameComponents: nil, + displayName: "Me", + image: nil, + contactIdentifier: "me", + customIdentifier: "me", + isMe: true + ) + } + + static func channelDisplayName(for index: Int32) -> String { + if index == 0 { + return "Primary Channel" + } + return "Channel \(index)" + } +} diff --git a/Meshtastic/CarPlay/CarPlaySceneDelegate.swift b/Meshtastic/CarPlay/CarPlaySceneDelegate.swift new file mode 100644 index 00000000..dc1c9d02 --- /dev/null +++ b/Meshtastic/CarPlay/CarPlaySceneDelegate.swift @@ -0,0 +1,510 @@ +// +// CarPlaySceneDelegate.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 4/16/26. +// +// CarPlay Communication app scene delegate. +// Uses a tab bar with Channels and Direct Messages tabs, +// matching the main app's Messages navigation structure. +// + +import CarPlay +import Combine +import CoreData +import Intents +import OSLog +#if canImport(ActivityKit) +import ActivityKit +#endif + +class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPInterfaceControllerDelegate { + + var interfaceController: CPInterfaceController? + private var cancellables = Set() + // Retained template references so we can call updateSections rather than replacing the whole tree. + private var channelsTemplate: CPListTemplate? + private var directMessagesTemplate: CPListTemplate? + // Tracks which conversation identifiers have already had a contact intent donated + // during this CarPlay session so we don't re-donate on every refresh. + private var donatedConversationIds = Set() + + private lazy var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext + + /// Returns a human-readable "last heard" string. + /// `now` is passed in so all rows in a single render share one `Date()` allocation. + private func lastHeardText(_ date: Date?, now: Date) -> String { + guard let date else { return "Never heard" } + let interval = now.timeIntervalSince(date) + if interval < 60 { return "Just now" } + if interval < 3600 { return "\(Int(interval / 60))m ago" } + if interval < 86400 { return "\(Int(interval / 3600))h ago" } + return "\(Int(interval / 86400))d ago" + } + + // MARK: - CPTemplateApplicationSceneDelegate + + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didConnect interfaceController: CPInterfaceController + ) { + Logger.services.info("🚗 [CarPlay] Connected") + self.interfaceController = interfaceController + interfaceController.delegate = self + + buildAndSetRootTemplate(animated: false) + + // Observe connection state changes and refresh sections (not the whole template tree). + // Debounce absorbs reconnect spikes that would otherwise fire multiple expensive refreshes. + AccessoryManager.shared.$isConnected + .removeDuplicates() + .dropFirst() // Skip initial value — we already built sections above + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { [weak self] isConnected in + self?.refreshSections() + if isConnected { + self?.startLiveActivityIfNeeded() + } + } + .store(in: &cancellables) + + // Start Live Activity immediately if already connected + if AccessoryManager.shared.isConnected { + startLiveActivityIfNeeded() + } + } + + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didDisconnectInterfaceController interfaceController: CPInterfaceController + ) { + Logger.services.info("🚗 [CarPlay] Disconnected") + endLiveActivity() + cancellables.removeAll() + donatedConversationIds.removeAll() + channelsTemplate = nil + directMessagesTemplate = nil + self.interfaceController = nil + } + + // MARK: - CPInterfaceControllerDelegate + + func templateWillAppear(_ aTemplate: CPTemplate, animated: Bool) {} + func templateDidAppear(_ aTemplate: CPTemplate, animated: Bool) {} + func templateWillDisappear(_ aTemplate: CPTemplate, animated: Bool) {} + func templateDidDisappear(_ aTemplate: CPTemplate, animated: Bool) {} + + // MARK: - Root Template + + /// Called once at connection time. Builds and caches the two `CPListTemplate` tabs. + private func buildAndSetRootTemplate(animated: Bool) { + let connected = AccessoryManager.shared.isConnected + + let chTemplate = CPListTemplate(title: "Channels", sections: buildChannelSections(connected: connected)) + chTemplate.tabImage = UIImage(systemName: "bubble.left.and.bubble.right") + channelsTemplate = chTemplate + + let dmTemplate = CPListTemplate(title: "Direct Messages", sections: buildDirectMessageSections(connected: connected)) + dmTemplate.tabImage = UIImage(systemName: "bubble.left.and.text.bubble.right") + directMessagesTemplate = dmTemplate + + let tabBar = CPTabBarTemplate(templates: [chTemplate, dmTemplate]) + interfaceController?.setRootTemplate(tabBar, animated: animated, completion: nil) + } + + /// Called on subsequent connection-state changes — updates sections in-place + /// instead of tearing down and rebuilding the entire template hierarchy. + private func refreshSections() { + let connected = AccessoryManager.shared.isConnected + channelsTemplate?.updateSections(buildChannelSections(connected: connected)) + directMessagesTemplate?.updateSections(buildDirectMessageSections(connected: connected)) + } + + // MARK: - Section Builders + + private func buildChannelSections(connected: Bool) -> [CPListSection] { + guard connected else { + let statusItem = CPListItem( + text: "Not Connected", + detailText: "Open Meshtastic to connect", + image: UIImage(systemName: "antenna.radiowaves.left.and.right.slash") + ) + statusItem.isEnabled = false + return [CPListSection(items: [statusItem])] + } + + let channelItems = fetchChannelItems() + if channelItems.isEmpty { + let emptyItem = CPListItem(text: "No Channels", detailText: nil) + emptyItem.isEnabled = false + return [CPListSection(items: [emptyItem])] + } + return [CPListSection(items: channelItems)] + } + + private func buildDirectMessageSections(connected: Bool) -> [CPListSection] { + guard connected else { + let statusItem = CPListItem( + text: "Not Connected", + detailText: "Open Meshtastic to connect", + image: UIImage(systemName: "antenna.radiowaves.left.and.right.slash") + ) + statusItem.isEnabled = false + return [CPListSection(items: [statusItem])] + } + + var sections = [CPListSection]() + + let favoriteItems = fetchFavoriteContactItems() + if !favoriteItems.isEmpty { + sections.append(CPListSection(items: favoriteItems, header: "Favorites", sectionIndexTitle: nil)) + } + + let dmItems = fetchDirectMessageItems() + if !dmItems.isEmpty { + sections.append(CPListSection(items: dmItems, header: "Recent", sectionIndexTitle: nil)) + } + + if favoriteItems.isEmpty && dmItems.isEmpty { + let emptyItem = CPListItem(text: "No Messages", detailText: "No direct message history") + emptyItem.isEnabled = false + sections.append(CPListSection(items: [emptyItem])) + } + + return sections + } + + // MARK: - Data Fetching + + private func fetchFavoriteContactItems() -> [CPMessageListItem] { + let request: NSFetchRequest = NodeInfoEntity.fetchRequest() + request.predicate = NSPredicate(format: "favorite == YES AND num != %lld", AccessoryManager.shared.activeDeviceNum ?? 0) + request.sortDescriptors = [NSSortDescriptor(key: "lastHeard", ascending: false)] + request.relationshipKeyPathsForPrefetching = ["user"] + + do { + let nodes = try context.fetch(request) + let nodeNums = nodes.compactMap { $0.user != nil ? $0.num : nil as Int64? } + let unreadCounts = fetchUnreadCountsForDMs(nodeNums: nodeNums) + let now = Date() + + return nodes.compactMap { node -> CPMessageListItem? in + guard let user = node.user else { return nil } + let name = user.longName ?? user.shortName ?? "Unknown" + let unreadCount = unreadCounts[node.num] ?? 0 + let hasUnread = unreadCount > 0 + let convId = "dm-\(node.num)" + + let leadingConfig = CPMessageListItemLeadingConfiguration( + leadingItem: .star, + leadingImage: UIImage(systemName: "person.circle.fill"), + unread: hasUnread + ) + + let item = CPMessageListItem( + fullName: name, + phoneOrEmailAddress: "\(node.num)@meshtastic.local", + leadingConfiguration: leadingConfig, + trailingConfiguration: nil, + detailText: hasUnread ? "\(unreadCount) unread" : nil, + trailingText: lastHeardText(node.lastHeard, now: now) + ) + item.conversationIdentifier = convId + item.userInfo = node.num + + donateMessageIntentIfNeeded(conversationId: convId, toNodeNum: node.num, name: name) + + return item + } + } catch { + Logger.services.error("🚗 [CarPlay] Failed to fetch favorites: \(error.localizedDescription, privacy: .public)") + return [] + } + } + + private func fetchChannelItems() -> [CPMessageListItem] { + guard let connectedNum = AccessoryManager.shared.activeDeviceNum, + let connectedNode = getNodeInfo(id: connectedNum, context: context), + let myInfo = connectedNode.myInfo, + let channels = myInfo.channels?.array as? [ChannelEntity] else { + return [] + } + + let activeChannels = channels.filter { $0.role > 0 } + let channelIndices = activeChannels.map { $0.index } + let unreadCounts = fetchUnreadCountsForChannels(channelIndices: channelIndices) + + return activeChannels.compactMap { channel -> CPMessageListItem? in + let name = (channel.name?.isEmpty ?? true) + ? (channel.index == 0 ? "Primary Channel" : "Channel \(channel.index)") + : channel.name! + let channelIndex = Int(channel.index) + let unreadCount = unreadCounts[channel.index] ?? 0 + let hasUnread = unreadCount > 0 + let convId = "channel-\(channelIndex)" + + let leadingConfig = CPMessageListItemLeadingConfiguration( + leadingItem: .none, + leadingImage: UIImage(systemName: channel.index == 0 ? "bubble.left.and.bubble.right.fill" : "bubble.left.and.bubble.right"), + unread: hasUnread + ) + + let item = CPMessageListItem( + conversationIdentifier: convId, + text: name, + leadingConfiguration: leadingConfig, + trailingConfiguration: nil, + detailText: hasUnread ? "\(unreadCount) unread" : (channel.index == 0 ? "Primary" : "Ch \(channel.index)"), + trailingText: nil + ) + item.phoneOrEmailAddress = "\(convId)@meshtastic.local" + item.userInfo = channelIndex + + donateChannelIntentIfNeeded(conversationId: convId, channelIndex: channelIndex, channelName: name) + + return item + } + } + + private func fetchDirectMessageItems() -> [CPMessageListItem] { + let request: NSFetchRequest = UserEntity.fetchRequest() + let connectedNum = AccessoryManager.shared.activeDeviceNum ?? 0 + + // Match the app's UserList: exclude self, ignored, favorites (shown above). + // Use `lastMessage != nil` instead of the expensive `@count` aggregate predicate + // to find nodes that have exchanged at least one message. + let notSelf = NSPredicate(format: "userNode.num != %lld", connectedNum) + let notIgnored = NSPredicate(format: "userNode.ignored == NO") + let notFavorite = NSPredicate(format: "userNode.favorite == NO") + let hasMessagesOrMessagable = NSCompoundPredicate(type: .or, subpredicates: [ + NSPredicate(format: "unmessagable == NO"), + NSPredicate(format: "lastMessage != nil") + ]) + request.predicate = NSCompoundPredicate(type: .and, subpredicates: [notSelf, notIgnored, notFavorite, hasMessagesOrMessagable]) + request.sortDescriptors = [ + NSSortDescriptor(key: "userNode.lastHeard", ascending: false), + NSSortDescriptor(key: "lastMessage", ascending: false), + NSSortDescriptor(key: "longName", ascending: true) + ] + request.fetchLimit = 24 // CarPlay limits list items + request.relationshipKeyPathsForPrefetching = ["userNode"] + + do { + let users = try context.fetch(request) + let nodeNums = users.compactMap { $0.userNode?.num } + let unreadCounts = fetchUnreadCountsForDMs(nodeNums: nodeNums) + let now = Date() + + return users.compactMap { user -> CPMessageListItem? in + guard let node = user.userNode else { return nil } + let name = user.longName ?? user.shortName ?? "Unknown" + let nodeNum = node.num + let unreadCount = unreadCounts[nodeNum] ?? 0 + let hasUnread = unreadCount > 0 + let convId = "dm-\(nodeNum)" + + let leadingConfig = CPMessageListItemLeadingConfiguration( + leadingItem: .none, + leadingImage: UIImage(systemName: "person.circle.fill"), + unread: hasUnread + ) + + let item = CPMessageListItem( + fullName: name, + phoneOrEmailAddress: "\(nodeNum)@meshtastic.local", + leadingConfiguration: leadingConfig, + trailingConfiguration: nil, + detailText: hasUnread ? "\(unreadCount) unread" : nil, + trailingText: lastHeardText(node.lastHeard, now: now) + ) + item.conversationIdentifier = convId + item.userInfo = nodeNum + + donateMessageIntentIfNeeded(conversationId: convId, toNodeNum: nodeNum, name: name) + + return item + } + } catch { + Logger.services.error("🚗 [CarPlay] Failed to fetch DM users: \(error.localizedDescription, privacy: .public)") + return [] + } + } + + // MARK: - Unread Count Batch Fetching + + /// Fetches unread message counts for multiple DM node numbers in a single query, + /// then groups the results in-memory. This avoids the N+1 count-per-row pattern + /// while staying compatible with Core Data's relationship keypath restrictions. + private func fetchUnreadCountsForDMs(nodeNums: [Int64]) -> [Int64: Int] { + guard !nodeNums.isEmpty else { return [:] } + + let fetchRequest: NSFetchRequest = MessageEntity.fetchRequest() + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "read == NO"), + NSPredicate(format: "fromUser.num IN %@", nodeNums) + ]) + fetchRequest.relationshipKeyPathsForPrefetching = ["fromUser"] + + let results = (try? context.fetch(fetchRequest)) ?? [] + var counts = [Int64: Int]() + for message in results { + if let num = message.fromUser?.num { + counts[num, default: 0] += 1 + } + } + return counts + } + + /// Fetches unread message counts for multiple channel indices in a single query, + /// then groups the results in-memory. + private func fetchUnreadCountsForChannels(channelIndices: [Int32]) -> [Int32: Int] { + guard !channelIndices.isEmpty else { return [:] } + + let fetchRequest: NSFetchRequest = MessageEntity.fetchRequest() + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "read == NO"), + NSPredicate(format: "toUser == nil"), + NSPredicate(format: "channel IN %@", channelIndices) + ]) + + let results = (try? context.fetch(fetchRequest)) ?? [] + var counts = [Int32: Int]() + for message in results { + counts[message.channel, default: 0] += 1 + } + return counts + } + + // MARK: - Intent Donation + + /// Donates a contact intent for a DM conversation the first time it is seen this session. + /// Subsequent renders are no-ops, avoiding repeated IPC calls to the intents daemon. + private func donateMessageIntentIfNeeded(conversationId: String, toNodeNum: Int64, name: String) { + guard donatedConversationIds.insert(conversationId).inserted else { return } + + let handleValue = "\(toNodeNum)@meshtastic.local" + let person = INPerson( + personHandle: INPersonHandle(value: handleValue, type: .emailAddress), + nameComponents: nil, + displayName: name, + image: nil, + contactIdentifier: "\(toNodeNum)", + customIdentifier: "\(toNodeNum)" + ) + let intent = INSendMessageIntent( + recipients: [person], + outgoingMessageType: .outgoingMessageText, + content: nil, + speakableGroupName: nil, + conversationIdentifier: conversationId, + serviceName: "Meshtastic", + sender: nil, + attachments: nil + ) + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .outgoing + interaction.donate { error in + if let error { + Logger.services.error("🚗 [CarPlay] DM intent donation error: \(error.localizedDescription, privacy: .public)") + } + } + } + + /// Donates a contact intent for a channel conversation the first time it is seen this session. + private func donateChannelIntentIfNeeded(conversationId: String, channelIndex: Int, channelName: String) { + guard donatedConversationIds.insert(conversationId).inserted else { return } + + let channelHandle = "channel-\(channelIndex)@meshtastic.local" + let recipient = INPerson( + personHandle: INPersonHandle(value: channelHandle, type: .emailAddress), + nameComponents: nil, + displayName: channelName, + image: nil, + contactIdentifier: channelHandle, + customIdentifier: channelHandle + ) + let groupName = INSpeakableString(spokenPhrase: channelName) + let intent = INSendMessageIntent( + recipients: [recipient], + outgoingMessageType: .outgoingMessageText, + content: nil, + speakableGroupName: groupName, + conversationIdentifier: conversationId, + serviceName: "Meshtastic", + sender: nil, + attachments: nil + ) + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .outgoing + interaction.donate { error in + if let error { + Logger.services.error("🚗 [CarPlay] Channel intent donation error: \(error.localizedDescription, privacy: .public)") + } + } + } + + // MARK: - Live Activity + +#if canImport(ActivityKit) && !targetEnvironment(macCatalyst) + private func startLiveActivityIfNeeded() { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + Logger.services.info("🚗 [CarPlay] Live Activities not enabled") + return + } + + // Don't start another if one is already running + guard Activity.activities.isEmpty else { + Logger.services.info("🚗 [CarPlay] Live Activity already active") + return + } + + guard let connectedNum = AccessoryManager.shared.activeDeviceNum else { return } + let connectedNode = getNodeInfo(id: connectedNum, context: context) + let nodeName = connectedNode?.user?.longName ?? "Meshtastic" + let nodeShortName = connectedNode?.user?.shortName ?? "?" + + // Fetch latest local stats telemetry + let localStats = connectedNode?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4")) + let mostRecent = localStats?.lastObject as? TelemetryEntity + + let timerSeconds = 900 // 15 minute local stats interval + let future = Date(timeIntervalSinceNow: Double(timerSeconds)) + let initialState = MeshActivityAttributes.ContentState( + uptimeSeconds: UInt32(mostRecent?.uptimeSeconds ?? 0), + channelUtilization: mostRecent?.channelUtilization ?? 0.0, + airtime: mostRecent?.airUtilTx ?? 0.0, + sentPackets: UInt32(mostRecent?.numPacketsTx ?? 0), + receivedPackets: UInt32(mostRecent?.numPacketsRx ?? 0), + badReceivedPackets: UInt32(mostRecent?.numPacketsRxBad ?? 0), + dupeReceivedPackets: UInt32(mostRecent?.numRxDupe ?? 0), + packetsSentRelay: UInt32(mostRecent?.numTxRelay ?? 0), + packetsCanceledRelay: UInt32(mostRecent?.numTxRelayCanceled ?? 0), + nodesOnline: UInt32(mostRecent?.numOnlineNodes ?? 0), + totalNodes: UInt32(mostRecent?.numTotalNodes ?? 0), + timerRange: Date.now...future + ) + + let attributes = MeshActivityAttributes(nodeNum: Int(connectedNum), name: nodeName, shortName: nodeShortName) + let content = ActivityContent(state: initialState, staleDate: Calendar.current.date(byAdding: .minute, value: 15, to: Date())!) + + do { + let activity = try Activity.request(attributes: attributes, content: content, pushType: nil) + Logger.services.info("🚗 [CarPlay] Started Live Activity: \(activity.id)") + } catch { + Logger.services.error("🚗 [CarPlay] Failed to start Live Activity: \(error.localizedDescription, privacy: .public)") + } + } + + private func endLiveActivity() { + Task { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + Logger.services.info("🚗 [CarPlay] Ended Live Activity: \(activity.id)") + } + } + } +#else + private func startLiveActivityIfNeeded() {} + private func endLiveActivity() {} +#endif +} diff --git a/Meshtastic/Extensions/UserDefaults.swift b/Meshtastic/Extensions/UserDefaults.swift index 0d3639b0..67b4781f 100644 --- a/Meshtastic/Extensions/UserDefaults.swift +++ b/Meshtastic/Extensions/UserDefaults.swift @@ -218,9 +218,10 @@ extension UserDefaults { } @UserDefault(.lastDeviceAPIUpdate, defaultValue: .distantPast) static var lastDeviceAPIUpdate: Date - + @UserDefault(.lastFirmwareAPIUpdate, defaultValue: .distantPast) static var lastFirmwareAPIUpdate: Date +} enum TestIntEnum: Int, Decodable { case one = 1 diff --git a/Meshtastic/Extensions/View.swift b/Meshtastic/Extensions/View.swift index b28f5e8e..3009a089 100644 --- a/Meshtastic/Extensions/View.swift +++ b/Meshtastic/Extensions/View.swift @@ -47,6 +47,26 @@ extension View { } } + /// Standard capsule-shaped prominent button styling. + /// On iOS 26+ the button also receives a glass background effect. + @ViewBuilder + func capsuleButtonStyle() -> some View { + if #available(iOS 26.0, macOS 26.0, *) { + self + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + .buttonStyle(.borderedProminent) + .glassEffect(in: .capsule) + } else { + self + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + .buttonStyle(.borderedProminent) + } + } + @ViewBuilder func glassButtonStyle() -> some View { if #available(iOS 26.0, macOS 26.0, *) { diff --git a/Meshtastic/Helpers/GeoJSONOverlayConfig.swift b/Meshtastic/Helpers/GeoJSONOverlayConfig.swift index b25b8509..e900e4d4 100644 --- a/Meshtastic/Helpers/GeoJSONOverlayConfig.swift +++ b/Meshtastic/Helpers/GeoJSONOverlayConfig.swift @@ -174,10 +174,20 @@ struct GeoJSONStyledFeature: Identifiable { let id = UUID() let feature: GeoJSONFeature let overlayId: String + /// MKOverlay pre-computed once at init — avoids repeated JSONSerialization + MKGeoJSONDecoder + /// calls on every map render pass. + let precomputedOverlay: MKOverlay? - /// Create MKOverlay from this styled feature - func createOverlay() -> MKOverlay? { - // Convert feature to standard GeoJSON format for MKGeoJSONDecoder + init(feature: GeoJSONFeature, overlayId: String) { + self.feature = feature + self.overlayId = overlayId + // Call the static helper after all stored properties are assigned so `self` is available + // for the instance — but we don't actually need self here, so this is safe. + self.precomputedOverlay = GeoJSONStyledFeature.makeOverlay(for: feature) + } + + /// Builds an MKOverlay from a GeoJSON feature. Static so it can be called from init. + private static func makeOverlay(for feature: GeoJSONFeature) -> MKOverlay? { let featureDict: [String: Any] = [ "type": feature.type, "geometry": [ @@ -188,31 +198,23 @@ struct GeoJSONStyledFeature: Identifiable { ] do { - // Serialize feature dictionary to JSON data let geojsonData = try JSONSerialization.data(withJSONObject: featureDict) - do { - // Decode GeoJSON data into MKGeoJSONFeature objects - let mkFeatures = try MKGeoJSONDecoder().decode(geojsonData) - if let mkFeature = mkFeatures.first as? MKGeoJSONFeature { - // Extract geometry and create overlay - if let geometry = mkFeature.geometry.first as? MKOverlay { - // Successfully created overlay - return geometry - } else { - Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to create overlay - Geometry is not an MKOverlay.") - } - } else { - Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to decode GeoJSON - No valid MKGeoJSONFeature found.") - } - } catch { - Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to decode GeoJSON data: \(error.localizedDescription)") + let mkFeatures = try MKGeoJSONDecoder().decode(geojsonData) + if let mkFeature = mkFeatures.first as? MKGeoJSONFeature, + let geometry = mkFeature.geometry.first as? MKOverlay { + return geometry + } else { + Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to create overlay - no valid MKOverlay geometry.") } } catch { - Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to serialize feature dictionary to JSON: \(error.localizedDescription)") + Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to build overlay: \(error.localizedDescription)") } return nil } + /// Returns the pre-computed overlay. Retained for API compatibility. + func createOverlay() -> MKOverlay? { precomputedOverlay } + /// Get stroke style for this feature var strokeStyle: StrokeStyle { let dashArray = feature.lineDashArray diff --git a/Meshtastic/Helpers/GeoJSONOverlayManager.swift b/Meshtastic/Helpers/GeoJSONOverlayManager.swift index 82801db0..c0953668 100644 --- a/Meshtastic/Helpers/GeoJSONOverlayManager.swift +++ b/Meshtastic/Helpers/GeoJSONOverlayManager.swift @@ -8,6 +8,10 @@ class GeoJSONOverlayManager { private init() {} private var featureCollection: GeoJSONFeatureCollection? + // Cache the last styled-features result keyed by the enabled-configs set. + // GeoJSONStyledFeature instances have stable UUIDs once created, so SwiftUI's + // ForEach diffing correctly skips unchanged overlays between renders. + private var styledFeaturesCache: (configs: Set, features: [GeoJSONStyledFeature])? /// Load raw GeoJSON feature collection from user uploads func loadFeatureCollection() -> GeoJSONFeatureCollection? { @@ -24,36 +28,35 @@ class GeoJSONOverlayManager { return nil } - /// Load styled features for specific enabled configs + /// Load styled features for specific enabled configs. + /// Results are cached per unique `enabledConfigs` set — file I/O and JSON decoding + /// only happen when the set changes, not on every map render. func loadStyledFeaturesForConfigs(_ enabledConfigs: Set) -> [GeoJSONStyledFeature] { - // Get files that match the enabled configs - let enabledFiles = MapDataManager.shared.getUploadedFiles().filter { enabledConfigs.contains($0.id) } + if let cache = styledFeaturesCache, cache.configs == enabledConfigs { + return cache.features + } + let enabledFiles = MapDataManager.shared.getUploadedFiles().filter { enabledConfigs.contains($0.id) } guard !enabledFiles.isEmpty else { + styledFeaturesCache = (configs: enabledConfigs, features: []) return [] } - // Load feature collection from enabled files only guard let collection = MapDataManager.shared.loadFeatureCollectionForFiles(enabledFiles) else { + styledFeaturesCache = (configs: enabledConfigs, features: []) return [] } var styledFeatures: [GeoJSONStyledFeature] = [] - for feature in collection.features { - // Skip invisible features - guard feature.isVisible else { - continue - } - - let layerId = feature.layerId ?? "default" - let styledFeature = GeoJSONStyledFeature( + guard feature.isVisible else { continue } + styledFeatures.append(GeoJSONStyledFeature( feature: feature, - overlayId: layerId - ) - styledFeatures.append(styledFeature) + overlayId: feature.layerId ?? "default" + )) } + styledFeaturesCache = (configs: enabledConfigs, features: styledFeatures) return styledFeatures } @@ -106,9 +109,10 @@ class GeoJSONOverlayManager { return Array(layerIds).sorted() } - /// Clear cached data (useful for testing or memory management) + /// Clear cached data (called when files are added, deleted, or toggled). func clearCache() { featureCollection = nil + styledFeaturesCache = nil } /// Check if user-uploaded data is available (regardless of active state) diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 43a627b0..b77cfca8 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -142,7 +142,7 @@ actor MeshPackets { myInfoEntity.rebootCount = Int32(myInfo.rebootCount) myInfoEntity.deviceId = myInfo.deviceID myInfoEntity.pioEnv = myInfo.pioEnv - + do { try context.save() Logger.data.info("💾 Saved a new myInfo for node: \(myInfo.myNodeNum.toHex(), privacy: .public)") @@ -1088,6 +1088,9 @@ actor MeshPackets { } // Send notifications if the message saved properly to core data if messageSaved { + // Donate to SiriKit so the message appears in CarPlay Messages + CarPlayIntentDonation.donateReceivedMessage(newMessage) + if packet.decoded.portnum == PortNum.detectionSensorApp && !UserDefaults.enableDetectionNotifications { return } diff --git a/Meshtastic/Info.plist b/Meshtastic/Info.plist index f2c16668..c76c73cf 100644 --- a/Meshtastic/Info.plist +++ b/Meshtastic/Info.plist @@ -87,6 +87,12 @@ INSearchForMessagesIntent INSetMessageAttributeIntent + NSUserActivityTypes + + INSendMessageIntent + INSearchForMessagesIntent + INSetMessageAttributeIntent + ITSAppUsesNonExemptEncryption LSApplicationCategoryType @@ -113,6 +119,8 @@ We use the camera to share channels using a QR Code NSLocalNetworkUsageDescription We use local networking to connect to network-based nodes. + NSSiriUsageDescription + Siri and Shortcuts let you control Meshtastic hands-free — send messages, disconnect, restart, or shut down your node with your voice. NSLocationAlwaysAndWhenInUseUsageDescription We use your location to display it on the mesh map, show and filter by distance as well as to have GPS coordinates to send to the connected device. Route Recording uses location in the background. NSLocationAlwaysUsageDescription @@ -131,6 +139,27 @@ UIApplicationSupportsMultipleScenes + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + + + CPTemplateApplicationSceneSessionRoleApplication + + + UISceneClassName + CPTemplateApplicationScene + UISceneConfigurationName + CarPlay + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate + + + UIApplicationSupportsIndirectInputEvents @@ -324,7 +353,5 @@ - com.apple.developer.carplay-communication - diff --git a/Meshtastic/Intents/IntentMessageConverters.swift b/Meshtastic/Intents/IntentMessageConverters.swift index cfd1a724..01b63fd5 100644 --- a/Meshtastic/Intents/IntentMessageConverters.swift +++ b/Meshtastic/Intents/IntentMessageConverters.swift @@ -10,10 +10,13 @@ import CoreData import Intents enum IntentMessageConverters { + static let meshtasticDomain = "@meshtastic.local" /// Converts a `UserEntity` to an `INPerson` for use with SiriKit intents. + /// Uses the `@meshtastic.local` email format so the handle matches `CPContactMessageButton` identifiers. static func inPerson(from user: UserEntity) -> INPerson { - let handle = INPersonHandle(value: String(user.num), type: .unknown) + let handleValue = "\(user.num)\(meshtasticDomain)" + let handle = INPersonHandle(value: handleValue, type: .emailAddress) return INPerson( personHandle: handle, nameComponents: nil, @@ -29,8 +32,8 @@ enum IntentMessageConverters { let sender: INPerson? = message.fromUser.map { inPerson(from: $0) } let recipients: [INPerson]? = message.toUser.map { [inPerson(from: $0)] } let dateSent = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp)) - let groupName: INSpeakableString? = message.channel > 0 - ? INSpeakableString(spokenPhrase: "Channel \(message.channel)") + let groupName: INSpeakableString? = message.toUser == nil + ? INSpeakableString(spokenPhrase: channelDisplayName(for: message.channel, named: nil)) : nil return INMessage( @@ -56,16 +59,30 @@ enum IntentMessageConverters { /// Searches for `UserEntity` objects whose name matches the given search term. static func findUsers(matching searchTerm: String, in context: NSManagedObjectContext) -> [UserEntity] { + if let nodeNum = directMessageNodeNum(from: searchTerm) { + let fetchRequest: NSFetchRequest = UserEntity.fetchRequest() + fetchRequest.fetchLimit = 1 + fetchRequest.predicate = NSPredicate(format: "num == %lld", nodeNum) + return (try? context.fetch(fetchRequest)) ?? [] + } + let fetchRequest: NSFetchRequest = UserEntity.fetchRequest() fetchRequest.predicate = NSPredicate( - format: "longName CONTAINS[cd] %@ OR shortName CONTAINS[cd] %@", - searchTerm, searchTerm + format: "longName CONTAINS[cd] %@ OR shortName CONTAINS[cd] %@ OR userId CONTAINS[cd] %@", + searchTerm, searchTerm, searchTerm ) return (try? context.fetch(fetchRequest)) ?? [] } /// Looks up a `ChannelEntity` by matching name. static func findChannels(matching name: String, in context: NSManagedObjectContext) -> [ChannelEntity] { + if let explicitIndex = channelIndex(fromHandleOrName: name) { + let fetchRequest: NSFetchRequest = ChannelEntity.fetchRequest() + fetchRequest.fetchLimit = 1 + fetchRequest.predicate = NSPredicate(format: "index == %d", explicitIndex) + return (try? context.fetch(fetchRequest)) ?? [] + } + let fetchRequest: NSFetchRequest = ChannelEntity.fetchRequest() fetchRequest.predicate = NSPredicate( format: "name != nil AND name != '' AND name CONTAINS[cd] %@", name @@ -75,7 +92,57 @@ enum IntentMessageConverters { /// Resolves a channel index from a spoken group name, defaulting to the primary channel. static func channelIndex(for name: String, in context: NSManagedObjectContext) -> Int { + if let explicitIndex = channelIndex(fromHandleOrName: name) { + return explicitIndex + } + let channels = findChannels(matching: name, in: context) return channels.first.map { Int($0.index) } ?? 0 } + + static func directMessageNodeNum(from value: String) -> Int64? { + if let nodeNum = Int64(value) { + return nodeNum + } + + if value.hasSuffix(meshtasticDomain) { + let rawValue = String(value.dropLast(meshtasticDomain.count)) + return Int64(rawValue) + } + + return nil + } + + static func channelIndex(fromHandleOrName value: String) -> Int? { + if value.caseInsensitiveCompare("Primary Channel") == .orderedSame { + return 0 + } + + if value.hasPrefix("Channel "), let index = Int(value.dropFirst("Channel ".count)) { + return index + } + + let channelPrefix = "channel-" + if value.hasPrefix(channelPrefix) { + let remainder = String(value.dropFirst(channelPrefix.count)) + let rawIndex = remainder.hasSuffix(meshtasticDomain) + ? String(remainder.dropLast(meshtasticDomain.count)) + : remainder + return Int(rawIndex) + } + + return nil + } + + static func channelDisplayName(for index: Int32, named name: String?) -> String { + if let name, !name.isEmpty { + return name + } + + if index == 0 { + return "Primary Channel" + } + + return "Channel \(index)" + } } diff --git a/Meshtastic/Intents/SearchForMessagesIntentHandler.swift b/Meshtastic/Intents/SearchForMessagesIntentHandler.swift index 42d6208e..9ac87f47 100644 --- a/Meshtastic/Intents/SearchForMessagesIntentHandler.swift +++ b/Meshtastic/Intents/SearchForMessagesIntentHandler.swift @@ -19,9 +19,11 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH // MARK: - Handling func handle(intent: INSearchForMessagesIntent) async -> INSearchForMessagesIntentResponse { - let context = PersistenceController.shared.container.viewContext + // Use a private background context so the fetch does not block the main thread. + let bgContext = PersistenceController.shared.container.newBackgroundContext() + bgContext.automaticallyMergesChangesFromParent = true - let messages: [INMessage] = await MainActor.run { + let messages: [INMessage] = await bgContext.perform { let fetchRequest: NSFetchRequest = MessageEntity.fetchRequest() var predicates: [NSPredicate] = [] @@ -29,6 +31,22 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH predicates.append(NSPredicate(format: "admin == NO")) predicates.append(NSPredicate(format: "isEmoji == NO")) + // Filter by conversation identifiers (e.g., "dm-123456" or "channel-0") + // This is the primary filter when Siri reads messages for a CarPlay contact. + if let conversationIds = intent.conversationIdentifiers, !conversationIds.isEmpty { + var conversationPredicates: [NSPredicate] = [] + for convId in conversationIds { + if convId.hasPrefix("dm-"), let nodeNum = Int64(convId.dropFirst("dm-".count)) { + conversationPredicates.append(NSPredicate(format: "fromUser.num == %lld", nodeNum)) + } else if convId.hasPrefix("channel-"), let channelIndex = Int32(convId.dropFirst("channel-".count)) { + conversationPredicates.append(NSPredicate(format: "channel == %d AND toUser == nil", channelIndex)) + } + } + if !conversationPredicates.isEmpty { + predicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: conversationPredicates)) + } + } + // Filter by identifiers (specific message IDs) if let identifiers = intent.identifiers, !identifiers.isEmpty { let messageIds = identifiers.compactMap { Int64($0) } @@ -37,9 +55,12 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH } } - // Filter by sender + // Filter by sender — parse @meshtastic.local email-format handles if let senders = intent.senders, !senders.isEmpty { - let senderNums = senders.compactMap { $0.personHandle?.value }.compactMap { Int64($0) } + let senderNums = senders.compactMap { sender -> Int64? in + guard let handleValue = sender.personHandle?.value else { return nil } + return IntentMessageConverters.directMessageNodeNum(from: handleValue) + } if !senderNums.isEmpty { predicates.append(NSPredicate(format: "fromUser.num IN %@", senderNums)) } @@ -62,16 +83,19 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH } } - // Filter by group/channel name + // Filter by group/channel name or handle if let groupNames = intent.speakableGroupNames, !groupNames.isEmpty { let channelIndices: [Int32] = groupNames.compactMap { groupName in + if let idx = IntentMessageConverters.channelIndex(fromHandleOrName: groupName.spokenPhrase) { + return Int32(idx) + } let channels = IntentMessageConverters.findChannels( - matching: groupName.spokenPhrase, in: context + matching: groupName.spokenPhrase, in: bgContext ) return channels.first.map { Int32($0.index) } } if !channelIndices.isEmpty { - predicates.append(NSPredicate(format: "channel IN %@", channelIndices)) + predicates.append(NSPredicate(format: "channel IN %@ AND toUser == nil", channelIndices)) } } @@ -91,7 +115,7 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH fetchRequest.relationshipKeyPathsForPrefetching = ["fromUser", "toUser"] do { - let results = try context.fetch(fetchRequest) + let results = try bgContext.fetch(fetchRequest) return results.map { IntentMessageConverters.inMessage(from: $0) } } catch { Logger.services.error("CarPlay/Siri: Failed to search messages: \(error.localizedDescription)") diff --git a/Meshtastic/Intents/SendMessageIntentHandler.swift b/Meshtastic/Intents/SendMessageIntentHandler.swift index 7ab11234..7a42937d 100644 --- a/Meshtastic/Intents/SendMessageIntentHandler.swift +++ b/Meshtastic/Intents/SendMessageIntentHandler.swift @@ -30,7 +30,21 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling { } let context = PersistenceController.shared.container.viewContext - let searchTerm = recipients[0].displayName + let recipient = recipients[0] + let handleValue = recipient.personHandle?.value ?? "" + + // If this is a channel handle, accept it directly + if IntentMessageConverters.channelIndex(fromHandleOrName: handleValue) != nil { + return [.success(with: recipient)] + } + + // If the handle resolves to a node number, accept it directly + if IntentMessageConverters.directMessageNodeNum(from: handleValue) != nil { + return [.success(with: recipient)] + } + + // Otherwise search by display name + let searchTerm = recipient.displayName ?? handleValue let matchingUsers = await MainActor.run { IntentMessageConverters.findUsers(matching: searchTerm, in: context) } @@ -71,11 +85,15 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling { } if matchingChannels.count == 1, let channel = matchingChannels.first { - let speakable = INSpeakableString(spokenPhrase: channel.name ?? "Channel \(channel.index)") + let speakable = INSpeakableString( + spokenPhrase: IntentMessageConverters.channelDisplayName(for: channel.index, named: channel.name) + ) return .success(with: speakable) } else if matchingChannels.count > 1 { let speakables = matchingChannels.map { - INSpeakableString(spokenPhrase: $0.name ?? "Channel \($0.index)") + INSpeakableString( + spokenPhrase: IntentMessageConverters.channelDisplayName(for: $0.index, named: $0.name) + ) } return .disambiguation(with: speakables) } @@ -120,16 +138,27 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling { replyID: 0 ) } else if let recipient = intent.recipients?.first, - let handleValue = recipient.personHandle?.value, - let nodeNum = Int64(handleValue) { + let handleValue = recipient.personHandle?.value { + if let channelIndex = IntentMessageConverters.channelIndex(fromHandleOrName: handleValue) { + try await AccessoryManager.shared.sendMessage( + message: content, + toUserNum: 0, + channel: Int32(channelIndex), + isEmoji: false, + replyID: 0 + ) + } else if let nodeNum = IntentMessageConverters.directMessageNodeNum(from: handleValue) { // Direct message to a single node - try await AccessoryManager.shared.sendMessage( - message: content, - toUserNum: nodeNum, - channel: 0, - isEmoji: false, - replyID: 0 - ) + try await AccessoryManager.shared.sendMessage( + message: content, + toUserNum: nodeNum, + channel: 0, + isEmoji: false, + replyID: 0 + ) + } else { + return INSendMessageIntentResponse(code: .failure, userActivity: nil) + } } else { return INSendMessageIntentResponse(code: .failure, userActivity: nil) } diff --git a/Meshtastic/Intents/SetMessageAttributeIntentHandler.swift b/Meshtastic/Intents/SetMessageAttributeIntentHandler.swift index c95529b9..0d424516 100644 --- a/Meshtastic/Intents/SetMessageAttributeIntentHandler.swift +++ b/Meshtastic/Intents/SetMessageAttributeIntentHandler.swift @@ -39,9 +39,11 @@ final class SetMessageAttributeIntentHandler: NSObject, INSetMessageAttributeInt } let attribute = intent.attribute - let context = PersistenceController.shared.container.viewContext + // Use a private background context so Core Data work does not block the main thread. + let bgContext = PersistenceController.shared.container.newBackgroundContext() + bgContext.automaticallyMergesChangesFromParent = true - let success: Bool = await MainActor.run { + let success: Bool = await bgContext.perform { let messageIds = identifiers.compactMap { Int64($0) } guard !messageIds.isEmpty else { return false } @@ -49,7 +51,7 @@ final class SetMessageAttributeIntentHandler: NSObject, INSetMessageAttributeInt fetchRequest.predicate = NSPredicate(format: "messageId IN %@", messageIds) do { - let messages = try context.fetch(fetchRequest) + let messages = try bgContext.fetch(fetchRequest) guard !messages.isEmpty else { return false } for message in messages { @@ -66,8 +68,8 @@ final class SetMessageAttributeIntentHandler: NSObject, INSetMessageAttributeInt } } - if context.hasChanges { - try context.save() + if bgContext.hasChanges { + try bgContext.save() } Logger.services.info("CarPlay/Siri: Updated \(messages.count) message(s) to \(String(describing: attribute))") return true diff --git a/Meshtastic/Meshtastic.entitlements b/Meshtastic/Meshtastic.entitlements index 2882289f..0356996f 100644 --- a/Meshtastic/Meshtastic.entitlements +++ b/Meshtastic/Meshtastic.entitlements @@ -2,44 +2,44 @@ -com.apple.developer.siri - -com.apple.developer.associated-domains - -applinks:meshtastic.org/e/* -applinks:meshtastic.org/v/* - -com.apple.developer.carplay-communication - -com.apple.developer.networking.custom-protocol - -com.apple.developer.nfc.readersession.formats - -TAG - -com.apple.developer.usernotifications.critical-alerts - -com.apple.developer.weatherkit - -com.apple.security.app-sandbox - -com.apple.security.device.bluetooth - -com.apple.security.device.serial - -com.apple.security.device.usb - -com.apple.security.files.user-selected.read-write - -com.apple.security.network.client - -com.apple.security.network.server - -com.apple.security.personal-information.location - -keychain-access-groups - -$(AppIdentifierPrefix)gvh.MeshtasticClient - + com.apple.developer.siri + + com.apple.developer.associated-domains + + applinks:meshtastic.org/e/* + applinks:meshtastic.org/v/* + + com.apple.developer.carplay-communication + + com.apple.developer.networking.custom-protocol + + com.apple.developer.nfc.readersession.formats + + TAG + + com.apple.developer.usernotifications.critical-alerts + + com.apple.developer.weatherkit + + com.apple.security.app-sandbox + + com.apple.security.device.bluetooth + + com.apple.security.device.serial + + com.apple.security.device.usb + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.personal-information.location + + keychain-access-groups + + $(AppIdentifierPrefix)gvh.MeshtasticClient + diff --git a/Meshtastic/MeshtasticAppDelegate.swift b/Meshtastic/MeshtasticAppDelegate.swift index b0158c8d..f89ee6ca 100644 --- a/Meshtastic/MeshtasticAppDelegate.swift +++ b/Meshtastic/MeshtasticAppDelegate.swift @@ -36,7 +36,7 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat } else { Logger.services.info("📋 Device list API data update is not needed...") } - + // Initialize TAK Server if enabled Task { @MainActor in TAKServerManager.shared.initializeOnStartup() diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index 66bdbe08..e5a079c7 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -184,7 +184,3700 @@ extension MeshPackets { nonisolated public func deleteUserMessages(user: UserEntity, context: NSManagedObjectContext) { do { - let objects = user.messageList + // Fetch messages using the same context that will perform the deletes. + // user.messageList fetches from viewContext, which would cause a context-mismatch + // crash when this method is called with a background context. + let fetchRequest = MessageEntity.fetchRequest() + fetchRequest.predicate = user.messageFetchRequest.predicate + let objects = (try? context.fetch(fetchRequest)) ?? [] + for object in objects { + context.delete(object) + } + try context.save() + } catch let error as NSError { + Logger.data.error("\(error.localizedDescription, privacy: .public)") + } + } + + public func clearCoreDataDatabase(includeRoutes: Bool, includeAppLevelData: Bool = false) async { + let context = self.backgroundContext + await context.perform { + self.clearCoreDataDatabase(context: context, includeRoutes: includeRoutes, includeAppLevelData: includeAppLevelData) + } + } + + nonisolated public func clearCoreDataDatabase(context: NSManagedObjectContext, includeRoutes: Bool, includeAppLevelData: Bool = false) { + let persistenceController = PersistenceController.shared.container + for i in 0...persistenceController.managedObjectModel.entities.count-1 { + + let entity = persistenceController.managedObjectModel.entities[i] + let query = NSFetchRequest(entityName: entity.name!) + let entityName = entity.name ?? "UNK" + + if !includeRoutes, ["RouteEntity", "LocationEntity"].contains(entityName) { + continue + } + + if !includeAppLevelData, ["DeviceHardwareEntity", "DeviceHardwareImageEntity", "DeviceHardwareTagEntity"].contains(entityName) { + // These are non-node-specific "app level" data, keep them even when switching nodes + continue + } + + // Execute the delete for this entry + let deleteRequest = NSBatchDeleteRequest(fetchRequest: query) + do { + try context.executeAndMergeChanges(using: deleteRequest) + } catch { + Logger.data.error("\(error.localizedDescription, privacy: .public)") + } + } + } + + func updateAnyPacketFrom (packet: MeshPacket, activeDeviceNum: Int64) async { + let context = self.backgroundContext + await context.perform { + self.updateAnyPacketFrom(packet: packet, activeDeviceNum: activeDeviceNum, context: context) + } + } + + nonisolated func updateAnyPacketFrom (packet: MeshPacket, activeDeviceNum: Int64, context: NSManagedObjectContext) { + // Update NodeInfoEntity for any packet received. This mirrors the firmware's NodeDB::updateFrom, which sniffs ALL received packets and updates the radio's nodeDB with packet.from's: + // - last_heard (from rxTime) + // - snr + // - via_mqtt + // - hops_away + + // However, unlike the firmware, this function will NOT create a new NodeInfoEntity if we don't have it already. We'll leave that to the existing code paths. + + // We do NOT update fetchedNode[0].channel, because we may hear a node over multiple channels, and only some packet types should update what we consider the node's channel to be. (Example: primary private channel, secondary public channel. A text message on the secondary public channel should NOT change fetchedNode[0].channel.) + + guard packet.from > 0 else { return } + guard packet.from != activeDeviceNum else { return } // Ignore if packet is from our own node + + let fetchNodeInfoAppRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoAppRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoAppRequest) + if fetchedNode.count >= 1 { + fetchedNode[0].id = Int64(packet.from) + fetchedNode[0].num = Int64(packet.from) + + if packet.rxTime > 0 { + fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) + Logger.data.info("💾 [updateAnyPacketFrom] Updating node \(packet.from.toHex(), privacy: .public) lastHeard from rxTime=\(packet.rxTime)") + } else { + fetchedNode[0].lastHeard = Date() + Logger.data.info("💾 [updateAnyPacketFrom] Updating node \(packet.from.toHex(), privacy: .public) lastHeard to now (rxTime==0)") + } + + fetchedNode[0].snr = packet.rxSnr + fetchedNode[0].rssi = packet.rxRssi + fetchedNode[0].viaMqtt = packet.viaMqtt + + if packet.hopStart != 0 && packet.hopLimit <= packet.hopStart { + fetchedNode[0].hopsAway = Int32(packet.hopStart - packet.hopLimit) + Logger.data.info("💾 [updateAnyPacketFrom] Updating node \(packet.from.toHex(), privacy: .public) hopsAway=\(fetchedNode[0].hopsAway)") + } + + do { + try context.save() + Logger.data.info("💾 [updateAnyPacketFrom] Updating node \(fetchedNode[0].num.toHex(), privacy: .public) snr=\(fetchedNode[0].snr), rssi=\(fetchedNode[0].rssi) from packet \(packet.id.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [updateAnyPacketFrom] Error Saving node \(fetchedNode[0].num.toHex(), privacy: .public) from packet \(packet.id.toHex(), privacy: .public) \(nsError, privacy: .public)") + } + } + } catch { + Logger.data.error("💥 [updateAnyPacketFrom] fetch data error") + } + } + + func upsertNodeInfoPacket (packet: MeshPacket, favorite: Bool = false) async { + let context = self.backgroundContext + await context.perform { + self.upsertNodeInfoPacket(packet: packet, favorite: favorite, context: context) + } + } + + nonisolated func upsertNodeInfoPacket (packet: MeshPacket, favorite: Bool = false, context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("[NodeInfo] received for: %@".localized, packet.from.toHex()) + Logger.mesh.info("📟 \(logString, privacy: .public)") + + guard packet.from > 0 else { return } + + let fetchNodeInfoAppRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoAppRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + + do { + + let fetchedNode = try context.fetch(fetchNodeInfoAppRequest) + if fetchedNode.count == 0 { + // Not Found Insert + let newNode = NodeInfoEntity(context: context) + newNode.id = Int64(packet.from) + newNode.num = Int64(packet.from) + newNode.favorite = favorite + if packet.rxTime > 0 { + newNode.firstHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) + newNode.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) + } else { + newNode.firstHeard = Date() + newNode.lastHeard = Date() + } + newNode.snr = packet.rxSnr + newNode.rssi = packet.rxRssi + newNode.viaMqtt = packet.viaMqtt + + if packet.to == Constants.maximumNodeNum || packet.to == UserDefaults.preferredPeripheralNum { + newNode.channel = Int32(packet.channel) + } + if let nodeInfoMessage = try? NodeInfo(serializedBytes: packet.decoded.payload) { + if nodeInfoMessage.hasHopsAway { + newNode.hopsAway = Int32(nodeInfoMessage.hopsAway) + } + newNode.favorite = nodeInfoMessage.isFavorite + } + + if let newUserMessage = try? User(serializedBytes: packet.decoded.payload) { + + if newUserMessage.id.isEmpty { + if packet.from > Constants.minimumNodeNum { + do { + let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context) + newNode.user = newUser + } catch CoreDataError.invalidInput(let message) { + Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)") + } catch { + Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") + } + } + } else { + + let newUser = UserEntity(context: context) + newUser.userId = newNode.num.toHex() + newUser.num = Int64(packet.from) + newUser.longName = newUserMessage.longName + newUser.shortName = newUserMessage.shortName + newUser.role = Int32(newUserMessage.role.rawValue) + newUser.hwModel = String(describing: newUserMessage.hwModel).uppercased() + newUser.hwModelId = Int32(newUserMessage.hwModel.rawValue) + /// For nodes that have the optional isUnmessagable boolean use that, otherwise excluded roles that are unmessagable by default + if newUserMessage.hasIsUnmessagable { + newUser.unmessagable = newUserMessage.isUnmessagable + } else { + let roles = [2, 4, 5, 6, 7, 10, 11] + let containsRole = roles.contains(Int(newUser.role)) + if containsRole { + newUser.unmessagable = true + } else { + newUser.unmessagable = false + } + } + if !newUserMessage.publicKey.isEmpty { + newUser.pkiEncrypted = true + newUser.publicKey = newUserMessage.publicKey + } + + let fetchRequest1 = DeviceHardwareEntity.fetchRequest() + fetchRequest1.predicate = NSPredicate(format: "hwModel == %d", newUser.hwModelId) + let fetchedHardware1 = (try? context.fetch(fetchRequest1)) ?? [] + if let hardwareEntity = fetchedHardware1.first { + newUser.hwDisplayName = hardwareEntity.displayName + } + newNode.user = newUser + + if UserDefaults.newNodeNotifications { + Task { @MainActor in + let manager = LocalNotificationManager() + manager.notifications = [ + Notification( + id: (UUID().uuidString), + title: "New Node".localized, + subtitle: "\(newUser.longName ?? "Unknown".localized)", + content: "New Node has been discovered".localized, + target: "nodes", + path: "meshtastic:///nodes?nodenum=\(newUser.num)" + ) + ] + manager.schedule() + } + } + } + } else { + if packet.from > Constants.minimumNodeNum { + do { + let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context) + if !packet.publicKey.isEmpty { + newNode.user?.pkiEncrypted = true + newNode.user?.publicKey = packet.publicKey + } + newNode.user = newUser + } catch CoreDataError.invalidInput(let message) { + Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)") + } catch { + Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") + } + } + } + // User is messed up and has failed to create at least once, if this fails bail out + if newNode.user == nil && packet.from > Constants.minimumNodeNum { + do { + let newUser = try createUser(num: Int64(packet.from), context: context) + newNode.user = newUser + } catch CoreDataError.invalidInput(let message) { + Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)") + context.rollback() + return + } catch { + Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") + context.rollback() + return + } + } + + do { + try context.save() + Logger.data.info("💾 [NodeInfo] Saved a NodeInfo for node number: \(packet.from.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [NodeInfoEntity] Error Inserting New Core Data: \(nsError, privacy: .public)") + } + + } else { + // Update an existing node + if packet.to == Constants.maximumNodeNum || packet.to == UserDefaults.preferredPeripheralNum { + fetchedNode[0].channel = Int32(packet.channel) + } + + if let nodeInfoMessage = try? NodeInfo(serializedBytes: packet.decoded.payload) { + + fetchedNode[0].hopsAway = Int32(nodeInfoMessage.hopsAway) + fetchedNode[0].favorite = nodeInfoMessage.isFavorite + if nodeInfoMessage.hasDeviceMetrics { + let telemetry = TelemetryEntity(context: context) + telemetry.batteryLevel = Int32(nodeInfoMessage.deviceMetrics.batteryLevel) + telemetry.voltage = nodeInfoMessage.deviceMetrics.voltage + telemetry.channelUtilization = nodeInfoMessage.deviceMetrics.channelUtilization + telemetry.airUtilTx = nodeInfoMessage.deviceMetrics.airUtilTx + var newTelemetries = [TelemetryEntity]() + newTelemetries.append(telemetry) + fetchedNode[0].telemetries? = NSOrderedSet(array: newTelemetries) + } + if nodeInfoMessage.hasUser { + fetchedNode[0].user?.userId = nodeInfoMessage.num.toHex() + fetchedNode[0].user?.num = Int64(nodeInfoMessage.num) + fetchedNode[0].user?.longName = nodeInfoMessage.user.longName + fetchedNode[0].user?.shortName = nodeInfoMessage.user.shortName + fetchedNode[0].user?.role = Int32(nodeInfoMessage.user.role.rawValue) + fetchedNode[0].user?.hwModel = String(describing: nodeInfoMessage.user.hwModel).uppercased() + fetchedNode[0].user?.hwModelId = Int32(nodeInfoMessage.user.hwModel.rawValue) + /// For nodes that have the optional isUnmessagable boolean use that, otherwise excluded roles that are unmessagable by default + if nodeInfoMessage.user.hasIsUnmessagable { + fetchedNode[0].user?.unmessagable = nodeInfoMessage.user.isUnmessagable + } else { + let roles = [-1, 2, 4, 5, 6, 7, 10, 11] + let containsRole = roles.contains(Int(fetchedNode[0].user?.role ?? -1)) + if containsRole { + fetchedNode[0].user?.unmessagable = true + } else { + fetchedNode[0].user?.unmessagable = false + } + } + if !nodeInfoMessage.user.publicKey.isEmpty { + fetchedNode[0].user?.pkiEncrypted = true + fetchedNode[0].user?.publicKey = nodeInfoMessage.user.publicKey + } + if let user = fetchedNode.first?.user { + let fetchRequest2 = DeviceHardwareEntity.fetchRequest() + fetchRequest2.predicate = NSPredicate(format: "hwModel == %d", user.hwModelId) + let fetchedHardware2 = (try? context.fetch(fetchRequest2)) ?? [] + if let hardwareEntity = fetchedHardware2.first { + user.hwDisplayName = hardwareEntity.displayName + } + } + } + } else if packet.hopStart != 0 && packet.hopLimit <= packet.hopStart { + fetchedNode[0].hopsAway = Int32(packet.hopStart - packet.hopLimit) + } + if fetchedNode[0].user == nil { + do { + let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context) + fetchedNode[0].user = newUser + } catch CoreDataError.invalidInput(let message) { + Logger.data.error("Error Creating a new Core Data UserEntity on an existing node (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)") + } catch { + Logger.data.error("Error Creating a new Core Data UserEntity on an existing node from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") + } + } + do { + try context.save() + Logger.data.info("💾 [NodeInfoEntity] Updated from Node Info App Packet For: \(fetchedNode[0].num.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [NodeInfoEntity] Error Saving from NODEINFO_APP \(nsError, privacy: .public)") + } + } + } catch { + Logger.data.error("💥 [NodeInfoEntity] fetch data error for NODEINFO_APP") + } + } + + func upsertPositionPacket (packet: MeshPacket) async { + let context = self.backgroundContext + await context.perform { + self.upsertPositionPacket(packet: packet, context: context) + } + } + + nonisolated func upsertPositionPacket (packet: MeshPacket, context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("[Position] received from node: %@".localized, String(packet.from)) + Logger.mesh.info("📍 \(logString, privacy: .public)") + + let fetchNodePositionRequest = NodeInfoEntity.fetchRequest() + fetchNodePositionRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + + do { + + if let positionMessage = try? Position(serializedBytes: packet.decoded.payload) { + + /// Don't save empty position packets from null island or apple park + if (positionMessage.longitudeI != 0 && positionMessage.latitudeI != 0) && (positionMessage.latitudeI != 373346000 && positionMessage.longitudeI != -1220090000) { + let fetchedNode = try context.fetch(fetchNodePositionRequest) + if fetchedNode.count == 1 { + + // Unset the current latest position for this node + let fetchCurrentLatestPositionsRequest = PositionEntity.fetchRequest() + fetchCurrentLatestPositionsRequest.predicate = NSPredicate(format: "nodePosition.num == %lld && latest = true", Int64(packet.from)) + + let fetchedPositions = try context.fetch(fetchCurrentLatestPositionsRequest) + if fetchedPositions.count > 0 { + for position in fetchedPositions { + position.latest = false + } + } + let position = PositionEntity(context: context) + position.latest = true + position.snr = packet.rxSnr + position.rssi = packet.rxRssi + position.seqNo = Int32(positionMessage.seqNumber) + position.latitudeI = positionMessage.latitudeI + position.longitudeI = positionMessage.longitudeI + position.altitude = positionMessage.altitude + position.satsInView = Int32(positionMessage.satsInView) + position.speed = Int32(positionMessage.groundSpeed) + let heading = Int32(positionMessage.groundTrack) + // Throw out bad haeadings from the device + if heading >= 0 && heading <= 360 { + position.heading = Int32(positionMessage.groundTrack) + } + position.precisionBits = Int32(positionMessage.precisionBits) + if positionMessage.timestamp != 0 { + position.time = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.timestamp))) + } else { + position.time = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.time))) + } + guard let mutablePositions = fetchedNode[0].positions?.mutableCopy() as? NSMutableOrderedSet else { + return + } + /// Don't save nearly the same position over and over. If the next position is less than 10 meters from the new position, delete the previous position and save the new one. + if mutablePositions.count > 0 && (position.precisionBits == 32 || position.precisionBits == 0) { + if let mostRecent = mutablePositions.lastObject as? PositionEntity, mostRecent.coordinate.distance(from: position.coordinate) < 9.0 { + mutablePositions.remove(mostRecent) + } + } else if mutablePositions.count > 0 { + /// Don't store any history for reduced accuracy positions, we will just show a circle + mutablePositions.removeAllObjects() + } + mutablePositions.add(position) + + fetchedNode[0].channel = Int32(packet.channel) + fetchedNode[0].positions = mutablePositions.copy() as? NSOrderedSet + + do { + try context.save() + Logger.data.info("💾 [Position] Saved from Position App Packet For: \(fetchedNode[0].num.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 Error Saving NodeInfoEntity from POSITION_APP \(nsError, privacy: .public)") + } + } + } else { + Logger.data.error("💥 Empty POSITION_APP Packet: \((try? packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + } + } + } catch { + Logger.data.error("💥 Error Deserializing POSITION_APP packet.") + } + } + + func upsertBluetoothConfigPacket(config: Config.BluetoothConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertBluetoothConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertBluetoothConfigPacket(config: Config.BluetoothConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Bluetooth config received: %@".localized, String(nodeNum)) + Logger.mesh.info("📶 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Device Config + if !fetchedNode.isEmpty { + if fetchedNode[0].bluetoothConfig == nil { + let newBluetoothConfig = BluetoothConfigEntity(context: context) + newBluetoothConfig.enabled = config.enabled + newBluetoothConfig.mode = Int32(config.mode.rawValue) + newBluetoothConfig.fixedPin = Int32(config.fixedPin) + fetchedNode[0].bluetoothConfig = newBluetoothConfig + } else { + fetchedNode[0].bluetoothConfig?.enabled = config.enabled + fetchedNode[0].bluetoothConfig?.mode = Int32(config.mode.rawValue) + fetchedNode[0].bluetoothConfig?.fixedPin = Int32(config.fixedPin) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [BluetoothConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [BluetoothConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [BluetoothConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Bluetooth Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [BluetoothConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertDeviceConfigPacket(config: Config.DeviceConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertDeviceConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertDeviceConfigPacket(config: Config.DeviceConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Device config received: %@".localized, String(nodeNum)) + Logger.mesh.info("📟 \(logString, privacy: .public)") + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Device Config + if !fetchedNode.isEmpty { + if fetchedNode[0].deviceConfig == nil { + let newDeviceConfig = DeviceConfigEntity(context: context) + newDeviceConfig.role = Int32(config.role.rawValue) + newDeviceConfig.buttonGpio = Int32(config.buttonGpio) + newDeviceConfig.buzzerGpio = Int32(config.buzzerGpio) + newDeviceConfig.rebroadcastMode = Int32(config.rebroadcastMode.rawValue) + newDeviceConfig.nodeInfoBroadcastSecs = Int32(truncating: config.nodeInfoBroadcastSecs as NSNumber) + newDeviceConfig.doubleTapAsButtonPress = config.doubleTapAsButtonPress + newDeviceConfig.tripleClickAsAdHocPing = !config.disableTripleClick + newDeviceConfig.ledHeartbeatEnabled = !config.ledHeartbeatDisabled + newDeviceConfig.isManaged = config.isManaged + newDeviceConfig.tzdef = config.tzdef + fetchedNode[0].deviceConfig = newDeviceConfig + } else { + fetchedNode[0].deviceConfig?.role = Int32(config.role.rawValue) + fetchedNode[0].deviceConfig?.buttonGpio = Int32(config.buttonGpio) + fetchedNode[0].deviceConfig?.buzzerGpio = Int32(config.buzzerGpio) + fetchedNode[0].deviceConfig?.rebroadcastMode = Int32(config.rebroadcastMode.rawValue) + fetchedNode[0].deviceConfig?.nodeInfoBroadcastSecs = Int32(truncating: config.nodeInfoBroadcastSecs as NSNumber) + fetchedNode[0].deviceConfig?.doubleTapAsButtonPress = config.doubleTapAsButtonPress + fetchedNode[0].deviceConfig?.tripleClickAsAdHocPing = !config.disableTripleClick + fetchedNode[0].deviceConfig?.ledHeartbeatEnabled = !config.ledHeartbeatDisabled + fetchedNode[0].deviceConfig?.isManaged = config.isManaged + fetchedNode[0].deviceConfig?.tzdef = config.tzdef + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [DeviceConfigEntity] Updated Device Config for node number: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [DeviceConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [DeviceConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertDisplayConfigPacket(config: Config.DisplayConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertDisplayConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertDisplayConfigPacket(config: Config.DisplayConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Display config received: %@".localized, nodeNum.toHex()) + Logger.data.info("🖥️ \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Device Config + if !fetchedNode.isEmpty { + + if fetchedNode[0].displayConfig == nil { + + let newDisplayConfig = DisplayConfigEntity(context: context) + newDisplayConfig.screenOnSeconds = Int32(truncatingIfNeeded: config.screenOnSecs) + newDisplayConfig.screenCarouselInterval = Int32(truncatingIfNeeded: config.autoScreenCarouselSecs) + newDisplayConfig.compassNorthTop = config.compassNorthTop + newDisplayConfig.flipScreen = config.flipScreen + newDisplayConfig.oledType = Int32(config.oled.rawValue) + newDisplayConfig.displayMode = Int32(config.displaymode.rawValue) + newDisplayConfig.units = Int32(config.units.rawValue) + newDisplayConfig.headingBold = config.headingBold + newDisplayConfig.use12HClock = config.use12HClock + fetchedNode[0].displayConfig = newDisplayConfig + } else { + fetchedNode[0].displayConfig?.screenOnSeconds = Int32(truncatingIfNeeded: config.screenOnSecs) + fetchedNode[0].displayConfig?.screenCarouselInterval = Int32(truncatingIfNeeded: config.autoScreenCarouselSecs) + fetchedNode[0].displayConfig?.compassNorthTop = config.compassNorthTop + fetchedNode[0].displayConfig?.flipScreen = config.flipScreen + fetchedNode[0].displayConfig?.oledType = Int32(config.oled.rawValue) + fetchedNode[0].displayConfig?.displayMode = Int32(config.displaymode.rawValue) + fetchedNode[0].displayConfig?.units = Int32(config.units.rawValue) + fetchedNode[0].displayConfig?.headingBold = config.headingBold + fetchedNode[0].displayConfig?.use12HClock = config.use12HClock + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + + try context.save() + Logger.data.info("💾 [DisplayConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [DisplayConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [DisplayConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Display Config") + } + + } catch { + let nsError = error as NSError + Logger.data.error("💥 [DisplayConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertLoRaConfigPacket(config: Config.LoRaConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertLoRaConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertLoRaConfigPacket(config: Config.LoRaConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("LoRa config received: %@".localized, nodeNum.toHex()) + Logger.data.info("📻 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", nodeNum) + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save LoRa Config + if fetchedNode.count > 0 { + if fetchedNode[0].loRaConfig == nil { + // No lora config for node, save a new lora config + let newLoRaConfig = LoRaConfigEntity(context: context) + newLoRaConfig.regionCode = Int32(config.region.rawValue) + newLoRaConfig.usePreset = config.usePreset + newLoRaConfig.modemPreset = Int32(config.modemPreset.rawValue) + newLoRaConfig.bandwidth = Int32(config.bandwidth) + newLoRaConfig.spreadFactor = Int32(config.spreadFactor) + newLoRaConfig.codingRate = Int32(config.codingRate) + newLoRaConfig.frequencyOffset = config.frequencyOffset + newLoRaConfig.overrideFrequency = config.overrideFrequency + newLoRaConfig.overrideDutyCycle = config.overrideDutyCycle + newLoRaConfig.hopLimit = Int32(config.hopLimit) + newLoRaConfig.txPower = Int32(config.txPower) + newLoRaConfig.txEnabled = config.txEnabled + newLoRaConfig.channelNum = Int32(config.channelNum) + newLoRaConfig.sx126xRxBoostedGain = config.sx126XRxBoostedGain + newLoRaConfig.ignoreMqtt = config.ignoreMqtt + newLoRaConfig.okToMqtt = config.configOkToMqtt + fetchedNode[0].loRaConfig = newLoRaConfig + } else { + fetchedNode[0].loRaConfig?.regionCode = Int32(config.region.rawValue) + fetchedNode[0].loRaConfig?.usePreset = config.usePreset + fetchedNode[0].loRaConfig?.modemPreset = Int32(config.modemPreset.rawValue) + fetchedNode[0].loRaConfig?.bandwidth = Int32(config.bandwidth) + fetchedNode[0].loRaConfig?.spreadFactor = Int32(config.spreadFactor) + fetchedNode[0].loRaConfig?.codingRate = Int32(config.codingRate) + fetchedNode[0].loRaConfig?.frequencyOffset = config.frequencyOffset + fetchedNode[0].loRaConfig?.overrideFrequency = config.overrideFrequency + fetchedNode[0].loRaConfig?.overrideDutyCycle = config.overrideDutyCycle + fetchedNode[0].loRaConfig?.hopLimit = Int32(config.hopLimit) + fetchedNode[0].loRaConfig?.txPower = Int32(config.txPower) + fetchedNode[0].loRaConfig?.txEnabled = config.txEnabled + fetchedNode[0].loRaConfig?.channelNum = Int32(config.channelNum) + fetchedNode[0].loRaConfig?.sx126xRxBoostedGain = config.sx126XRxBoostedGain + fetchedNode[0].loRaConfig?.ignoreMqtt = config.ignoreMqtt + fetchedNode[0].loRaConfig?.okToMqtt = config.configOkToMqtt + fetchedNode[0].loRaConfig?.sx126xRxBoostedGain = config.sx126XRxBoostedGain + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [LoRaConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [LoRaConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [LoRaConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Lora Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [LoRaConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertNetworkConfigPacket(config: Config.NetworkConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertNetworkConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertNetworkConfigPacket(config: Config.NetworkConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Network config received: %@".localized, String(nodeNum)) + Logger.data.info("🌐 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save WiFi Config + if !fetchedNode.isEmpty { + if fetchedNode[0].networkConfig == nil { + let newNetworkConfig = NetworkConfigEntity(context: context) + newNetworkConfig.wifiEnabled = config.wifiEnabled + newNetworkConfig.wifiSsid = config.wifiSsid + newNetworkConfig.wifiPsk = config.wifiPsk + newNetworkConfig.ethEnabled = config.ethEnabled + newNetworkConfig.enabledProtocols = Int32(config.enabledProtocols) + fetchedNode[0].networkConfig = newNetworkConfig + } else { + fetchedNode[0].networkConfig?.ethEnabled = config.ethEnabled + fetchedNode[0].networkConfig?.wifiEnabled = config.wifiEnabled + fetchedNode[0].networkConfig?.wifiSsid = config.wifiSsid + fetchedNode[0].networkConfig?.wifiPsk = config.wifiPsk + fetchedNode[0].networkConfig?.enabledProtocols = Int32(config.enabledProtocols) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [NetworkConfigEntity] Updated Network Config for node: \(nodeNum.toHex(), privacy: .public)") + + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [NetworkConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [NetworkConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Network Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [NetworkConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertPositionConfigPacket(config: Config.PositionConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertPositionConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertPositionConfigPacket(config: Config.PositionConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Position config received: %@".localized, String(nodeNum)) + Logger.data.info("🗺️ \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save LoRa Config + if !fetchedNode.isEmpty { + if fetchedNode[0].positionConfig == nil { + let newPositionConfig = PositionConfigEntity(context: context) + newPositionConfig.smartPositionEnabled = config.positionBroadcastSmartEnabled + newPositionConfig.deviceGpsEnabled = config.gpsEnabled + newPositionConfig.gpsMode = Int32(truncatingIfNeeded: config.gpsMode.rawValue) + newPositionConfig.rxGpio = Int32(truncatingIfNeeded: config.rxGpio) + newPositionConfig.txGpio = Int32(truncatingIfNeeded: config.txGpio) + newPositionConfig.gpsEnGpio = Int32(truncatingIfNeeded: config.gpsEnGpio) + newPositionConfig.fixedPosition = config.fixedPosition + newPositionConfig.positionBroadcastSeconds = Int32(truncatingIfNeeded: config.positionBroadcastSecs) + newPositionConfig.broadcastSmartMinimumIntervalSecs = Int32(truncatingIfNeeded: config.broadcastSmartMinimumIntervalSecs) + newPositionConfig.broadcastSmartMinimumDistance = Int32(truncatingIfNeeded: config.broadcastSmartMinimumDistance) + newPositionConfig.positionFlags = Int32(truncatingIfNeeded: config.positionFlags) + newPositionConfig.gpsAttemptTime = 900 + newPositionConfig.gpsUpdateInterval = Int32(truncatingIfNeeded: config.gpsUpdateInterval) + fetchedNode[0].positionConfig = newPositionConfig + } else { + fetchedNode[0].positionConfig?.smartPositionEnabled = config.positionBroadcastSmartEnabled + fetchedNode[0].positionConfig?.deviceGpsEnabled = config.gpsEnabled + fetchedNode[0].positionConfig?.gpsMode = Int32(truncatingIfNeeded: config.gpsMode.rawValue) + fetchedNode[0].positionConfig?.rxGpio = Int32(truncatingIfNeeded: config.rxGpio) + fetchedNode[0].positionConfig?.txGpio = Int32(truncatingIfNeeded: config.txGpio) + fetchedNode[0].positionConfig?.gpsEnGpio = Int32(truncatingIfNeeded: config.gpsEnGpio) + fetchedNode[0].positionConfig?.fixedPosition = config.fixedPosition + fetchedNode[0].positionConfig?.positionBroadcastSeconds = Int32(truncatingIfNeeded: config.positionBroadcastSecs) + fetchedNode[0].positionConfig?.broadcastSmartMinimumIntervalSecs = Int32(truncatingIfNeeded: config.broadcastSmartMinimumIntervalSecs) + fetchedNode[0].positionConfig?.broadcastSmartMinimumDistance = Int32(truncatingIfNeeded: config.broadcastSmartMinimumDistance) + fetchedNode[0].positionConfig?.gpsAttemptTime = 900 + fetchedNode[0].positionConfig?.gpsUpdateInterval = Int32(truncatingIfNeeded: config.gpsUpdateInterval) + fetchedNode[0].positionConfig?.positionFlags = Int32(truncatingIfNeeded: config.positionFlags) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [PositionConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [PositionConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [PositionConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Position Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [PositionConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertPowerConfigPacket(config: Config.PowerConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertPowerConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertPowerConfigPacket(config: Config.PowerConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + let logString = String.localizedStringWithFormat("Power config received: %@".localized, String(nodeNum)) + Logger.data.info("🗺️ \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Power Config + if !fetchedNode.isEmpty { + if fetchedNode[0].powerConfig == nil { + let newPowerConfig = PowerConfigEntity(context: context) + newPowerConfig.adcMultiplierOverride = config.adcMultiplierOverride + newPowerConfig.deviceBatteryInaAddress = Int32(config.deviceBatteryInaAddress) + newPowerConfig.isPowerSaving = config.isPowerSaving + newPowerConfig.lsSecs = Int32(truncatingIfNeeded: config.lsSecs) + newPowerConfig.minWakeSecs = Int32(truncatingIfNeeded: config.minWakeSecs) + newPowerConfig.onBatteryShutdownAfterSecs = Int32(truncatingIfNeeded: config.onBatteryShutdownAfterSecs) + newPowerConfig.waitBluetoothSecs = Int32(truncatingIfNeeded: config.waitBluetoothSecs) + fetchedNode[0].powerConfig = newPowerConfig + } else { + fetchedNode[0].powerConfig?.adcMultiplierOverride = config.adcMultiplierOverride + fetchedNode[0].powerConfig?.deviceBatteryInaAddress = Int32(config.deviceBatteryInaAddress) + fetchedNode[0].powerConfig?.isPowerSaving = config.isPowerSaving + fetchedNode[0].powerConfig?.lsSecs = Int32(truncatingIfNeeded: config.lsSecs) + fetchedNode[0].powerConfig?.minWakeSecs = Int32(truncatingIfNeeded: config.minWakeSecs) + fetchedNode[0].powerConfig?.onBatteryShutdownAfterSecs = Int32(truncatingIfNeeded: config.onBatteryShutdownAfterSecs) + fetchedNode[0].powerConfig?.waitBluetoothSecs = Int32(truncatingIfNeeded: config.waitBluetoothSecs) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [PowerConfigEntity] Updated Power Config for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [PowerConfigEntity] Error Updating Core Data PowerConfigEntity: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [PowerConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Power Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [PowerConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertSecurityConfigPacket(config: Config.SecurityConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertSecurityConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertSecurityConfigPacket(config: Config.SecurityConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("mesh.log.security.config %@".localized, String(nodeNum)) + Logger.data.info("🛡️ \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Security Config + if !fetchedNode.isEmpty { + if fetchedNode[0].securityConfig == nil { + let newSecurityConfig = SecurityConfigEntity(context: context) + newSecurityConfig.publicKey = config.publicKey + newSecurityConfig.privateKey = config.privateKey + if config.adminKey.count > 0 { + newSecurityConfig.adminKey = config.adminKey[0] + } + newSecurityConfig.isManaged = config.isManaged + newSecurityConfig.serialEnabled = config.serialEnabled + newSecurityConfig.debugLogApiEnabled = config.debugLogApiEnabled + newSecurityConfig.adminChannelEnabled = config.adminChannelEnabled + fetchedNode[0].securityConfig = newSecurityConfig + } else { + fetchedNode[0].securityConfig?.publicKey = config.publicKey + fetchedNode[0].securityConfig?.privateKey = config.privateKey + if config.adminKey.count > 0 { + fetchedNode[0].securityConfig?.adminKey = config.adminKey[0] + if config.adminKey.count > 1 { + fetchedNode[0].securityConfig?.adminKey2 = config.adminKey[1] + } + if config.adminKey.count > 2 { + fetchedNode[0].securityConfig?.adminKey3 = config.adminKey[2] + } + } + fetchedNode[0].securityConfig?.isManaged = config.isManaged + fetchedNode[0].securityConfig?.serialEnabled = config.serialEnabled + fetchedNode[0].securityConfig?.debugLogApiEnabled = config.debugLogApiEnabled + fetchedNode[0].securityConfig?.adminChannelEnabled = config.adminChannelEnabled + } + if sessionPasskey?.count != 0 { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [SecurityConfigEntity] Updated Security Config for node: \(nodeNum.toHex(), privacy: .public)") + + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [SecurityConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [SecurityConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Security Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [SecurityConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertAmbientLightingModuleConfigPacket(config: ModuleConfig.AmbientLightingConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertAmbientLightingModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertAmbientLightingModuleConfigPacket(config: ModuleConfig.AmbientLightingConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Ambient Lighting module config received: %@".localized, String(nodeNum)) + Logger.data.info("🏮 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Ambient Lighting Config + if !fetchedNode.isEmpty { + + if fetchedNode[0].cannedMessageConfig == nil { + let newAmbientLightingConfig = AmbientLightingConfigEntity(context: context) + newAmbientLightingConfig.ledState = config.ledState + newAmbientLightingConfig.current = Int32(config.current) + newAmbientLightingConfig.red = Int32(config.red) + newAmbientLightingConfig.green = Int32(config.green) + newAmbientLightingConfig.blue = Int32(config.blue) + fetchedNode[0].ambientLightingConfig = newAmbientLightingConfig + } else { + + if fetchedNode[0].ambientLightingConfig == nil { + fetchedNode[0].ambientLightingConfig = AmbientLightingConfigEntity(context: context) + } + fetchedNode[0].ambientLightingConfig?.ledState = config.ledState + fetchedNode[0].ambientLightingConfig?.current = Int32(config.current) + fetchedNode[0].ambientLightingConfig?.red = Int32(config.red) + fetchedNode[0].ambientLightingConfig?.green = Int32(config.green) + fetchedNode[0].ambientLightingConfig?.blue = Int32(config.blue) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [AmbientLightingConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [AmbientLightingConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [AmbientLightingConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Ambient Lighting Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [AmbientLightingConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertCannedMessagesModuleConfigPacket(config: ModuleConfig.CannedMessageConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertCannedMessagesModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertCannedMessagesModuleConfigPacket(config: ModuleConfig.CannedMessageConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Canned Message module config received: %@".localized, String(nodeNum)) + Logger.data.info("🥫 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Canned Message Config + if !fetchedNode.isEmpty { + + if fetchedNode[0].cannedMessageConfig == nil { + let newCannedMessageConfig = CannedMessageConfigEntity(context: context) + newCannedMessageConfig.enabled = config.enabled + newCannedMessageConfig.sendBell = config.sendBell + newCannedMessageConfig.rotary1Enabled = config.rotary1Enabled + newCannedMessageConfig.updown1Enabled = config.updown1Enabled + newCannedMessageConfig.inputbrokerPinA = Int32(config.inputbrokerPinA) + newCannedMessageConfig.inputbrokerPinB = Int32(config.inputbrokerPinB) + newCannedMessageConfig.inputbrokerPinPress = Int32(config.inputbrokerPinPress) + newCannedMessageConfig.inputbrokerEventCw = Int32(config.inputbrokerEventCw.rawValue) + newCannedMessageConfig.inputbrokerEventCcw = Int32(config.inputbrokerEventCcw.rawValue) + newCannedMessageConfig.inputbrokerEventPress = Int32(config.inputbrokerEventPress.rawValue) + fetchedNode[0].cannedMessageConfig = newCannedMessageConfig + } else { + fetchedNode[0].cannedMessageConfig?.enabled = config.enabled + fetchedNode[0].cannedMessageConfig?.sendBell = config.sendBell + fetchedNode[0].cannedMessageConfig?.rotary1Enabled = config.rotary1Enabled + fetchedNode[0].cannedMessageConfig?.updown1Enabled = config.updown1Enabled + fetchedNode[0].cannedMessageConfig?.inputbrokerPinA = Int32(config.inputbrokerPinA) + fetchedNode[0].cannedMessageConfig?.inputbrokerPinB = Int32(config.inputbrokerPinB) + fetchedNode[0].cannedMessageConfig?.inputbrokerPinPress = Int32(config.inputbrokerPinPress) + fetchedNode[0].cannedMessageConfig?.inputbrokerEventCw = Int32(config.inputbrokerEventCw.rawValue) + fetchedNode[0].cannedMessageConfig?.inputbrokerEventCcw = Int32(config.inputbrokerEventCcw.rawValue) + fetchedNode[0].cannedMessageConfig?.inputbrokerEventPress = Int32(config.inputbrokerEventPress.rawValue) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [CannedMessageConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [CannedMessageConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [CannedMessageConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Canned Message Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [CannedMessageConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertDetectionSensorModuleConfigPacket(config: ModuleConfig.DetectionSensorConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertDetectionSensorModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertDetectionSensorModuleConfigPacket(config: ModuleConfig.DetectionSensorConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Detection Sensor module config received: %@".localized, String(nodeNum)) + Logger.data.info("🕵️ \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Detection Sensor Config + if !fetchedNode.isEmpty { + if fetchedNode[0].detectionSensorConfig == nil { + let newConfig = DetectionSensorConfigEntity(context: context) + newConfig.enabled = config.enabled + newConfig.sendBell = config.sendBell + newConfig.name = config.name + newConfig.monitorPin = Int32(config.monitorPin) + newConfig.triggerType = Int32(config.detectionTriggerType.rawValue) + newConfig.usePullup = config.usePullup + newConfig.minimumBroadcastSecs = Int32(truncatingIfNeeded: config.minimumBroadcastSecs) + newConfig.stateBroadcastSecs = Int32(truncatingIfNeeded: config.stateBroadcastSecs) + fetchedNode[0].detectionSensorConfig = newConfig + } else { + fetchedNode[0].detectionSensorConfig?.enabled = config.enabled + fetchedNode[0].detectionSensorConfig?.sendBell = config.sendBell + fetchedNode[0].detectionSensorConfig?.name = config.name + fetchedNode[0].detectionSensorConfig?.monitorPin = Int32(config.monitorPin) + fetchedNode[0].detectionSensorConfig?.usePullup = config.usePullup + fetchedNode[0].detectionSensorConfig?.triggerType = Int32(config.detectionTriggerType.rawValue) + fetchedNode[0].detectionSensorConfig?.minimumBroadcastSecs = Int32(truncatingIfNeeded: config.minimumBroadcastSecs) + fetchedNode[0].detectionSensorConfig?.stateBroadcastSecs = Int32(truncatingIfNeeded: config.stateBroadcastSecs) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [DetectionSensorConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [DetectionSensorConfigEntity] Error Updating Core Data : \(nsError, privacy: .public)") + } + + } else { + Logger.data.error("💥 [DetectionSensorConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Detection Sensor Module Config") + } + + } catch { + let nsError = error as NSError + Logger.data.error("💥 [DetectionSensorConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertExternalNotificationModuleConfigPacket(config: ModuleConfig.ExternalNotificationConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertExternalNotificationModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertExternalNotificationModuleConfigPacket(config: ModuleConfig.ExternalNotificationConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("External Notification module config received: %@".localized, String(nodeNum)) + Logger.data.info("📣 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save External Notificaitone Config + if !fetchedNode.isEmpty { + + if fetchedNode[0].externalNotificationConfig == nil { + let newExternalNotificationConfig = ExternalNotificationConfigEntity(context: context) + newExternalNotificationConfig.enabled = config.enabled + newExternalNotificationConfig.usePWM = config.usePwm + newExternalNotificationConfig.alertBell = config.alertBell + newExternalNotificationConfig.alertBellBuzzer = config.alertBellBuzzer + newExternalNotificationConfig.alertBellVibra = config.alertBellVibra + newExternalNotificationConfig.alertMessage = config.alertMessage + newExternalNotificationConfig.alertMessageBuzzer = config.alertMessageBuzzer + newExternalNotificationConfig.alertMessageVibra = config.alertMessageVibra + newExternalNotificationConfig.active = config.active + newExternalNotificationConfig.output = Int32(config.output) + newExternalNotificationConfig.outputBuzzer = Int32(config.outputBuzzer) + newExternalNotificationConfig.outputVibra = Int32(config.outputVibra) + newExternalNotificationConfig.outputMilliseconds = Int32(config.outputMs) + newExternalNotificationConfig.nagTimeout = Int32(config.nagTimeout) + newExternalNotificationConfig.useI2SAsBuzzer = config.useI2SAsBuzzer + fetchedNode[0].externalNotificationConfig = newExternalNotificationConfig + } else { + fetchedNode[0].externalNotificationConfig?.enabled = config.enabled + fetchedNode[0].externalNotificationConfig?.usePWM = config.usePwm + fetchedNode[0].externalNotificationConfig?.alertBell = config.alertBell + fetchedNode[0].externalNotificationConfig?.alertBellBuzzer = config.alertBellBuzzer + fetchedNode[0].externalNotificationConfig?.alertBellVibra = config.alertBellVibra + fetchedNode[0].externalNotificationConfig?.alertMessage = config.alertMessage + fetchedNode[0].externalNotificationConfig?.alertMessageBuzzer = config.alertMessageBuzzer + fetchedNode[0].externalNotificationConfig?.alertMessageVibra = config.alertMessageVibra + fetchedNode[0].externalNotificationConfig?.active = config.active + fetchedNode[0].externalNotificationConfig?.output = Int32(config.output) + fetchedNode[0].externalNotificationConfig?.outputBuzzer = Int32(config.outputBuzzer) + fetchedNode[0].externalNotificationConfig?.outputVibra = Int32(config.outputVibra) + fetchedNode[0].externalNotificationConfig?.outputMilliseconds = Int32(config.outputMs) + fetchedNode[0].externalNotificationConfig?.nagTimeout = Int32(config.nagTimeout) + fetchedNode[0].externalNotificationConfig?.useI2SAsBuzzer = config.useI2SAsBuzzer + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [ExternalNotificationConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [ExternalNotificationConfigEntity] Error Updating Core Data : \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [ExternalNotificationConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save External Notification Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [ExternalNotificationConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertPaxCounterModuleConfigPacket(config: ModuleConfig.PaxcounterConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertPaxCounterModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertPaxCounterModuleConfigPacket(config: ModuleConfig.PaxcounterConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("PAX Counter config received: %@".localized, String(nodeNum)) + Logger.data.info("🧑‍🤝‍🧑 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save PAX Counter Config + if !fetchedNode.isEmpty { + if fetchedNode[0].paxCounterConfig == nil { + let newPaxCounterConfig = PaxCounterConfigEntity(context: context) + newPaxCounterConfig.enabled = config.enabled + newPaxCounterConfig.updateInterval = Int32(config.paxcounterUpdateInterval) + fetchedNode[0].paxCounterConfig = newPaxCounterConfig + } else { + fetchedNode[0].paxCounterConfig?.enabled = config.enabled + fetchedNode[0].paxCounterConfig?.updateInterval = Int32(config.paxcounterUpdateInterval) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [PaxCounterConfigEntity] Updated for node number: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [PaxCounterConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [PaxCounterConfigEntity] No Nodes found in local database matching node number \(nodeNum.toHex(), privacy: .public) unable to save PAX Counter Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [PaxCounterConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertRtttlConfigPacket(ringtone: String, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertRtttlConfigPacket(ringtone: ringtone, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertRtttlConfigPacket(ringtone: String, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("RTTTL Ringtone config received: %@".localized, String(nodeNum)) + Logger.data.info("⛰️ \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save RTTTL Config + if !fetchedNode.isEmpty { + if fetchedNode[0].rtttlConfig == nil { + let newRtttlConfig = RTTTLConfigEntity(context: context) + newRtttlConfig.ringtone = ringtone + fetchedNode[0].rtttlConfig = newRtttlConfig + } else { + fetchedNode[0].rtttlConfig?.ringtone = ringtone + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [RtttlConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [RtttlConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [RtttlConfigEntity] No nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save RTTTL Ringtone Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [RtttlConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertMqttModuleConfigPacket(config: ModuleConfig.MQTTConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertMqttModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertMqttModuleConfigPacket(config: ModuleConfig.MQTTConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("MQTT module config received: %@".localized, String(nodeNum)) + Logger.data.info("🌉 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save MQTT Config + if !fetchedNode.isEmpty { + if fetchedNode[0].mqttConfig == nil { + let newMQTTConfig = MQTTConfigEntity(context: context) + newMQTTConfig.enabled = config.enabled + newMQTTConfig.proxyToClientEnabled = config.proxyToClientEnabled + newMQTTConfig.address = config.address + newMQTTConfig.username = config.username + newMQTTConfig.password = config.password + newMQTTConfig.root = config.root + newMQTTConfig.encryptionEnabled = config.encryptionEnabled + newMQTTConfig.jsonEnabled = config.jsonEnabled + newMQTTConfig.tlsEnabled = config.tlsEnabled + newMQTTConfig.mapReportingEnabled = config.mapReportingEnabled + newMQTTConfig.mapReportingShouldReportLocation = config.mapReportSettings.shouldReportLocation + newMQTTConfig.mapPositionPrecision = Int32(config.mapReportSettings.positionPrecision) + newMQTTConfig.mapPublishIntervalSecs = Int32(config.mapReportSettings.publishIntervalSecs) + fetchedNode[0].mqttConfig = newMQTTConfig + } else { + fetchedNode[0].mqttConfig?.enabled = config.enabled + fetchedNode[0].mqttConfig?.proxyToClientEnabled = config.proxyToClientEnabled + fetchedNode[0].mqttConfig?.address = config.address + fetchedNode[0].mqttConfig?.username = config.username + fetchedNode[0].mqttConfig?.password = config.password + fetchedNode[0].mqttConfig?.root = config.root + fetchedNode[0].mqttConfig?.encryptionEnabled = config.encryptionEnabled + fetchedNode[0].mqttConfig?.jsonEnabled = config.jsonEnabled + fetchedNode[0].mqttConfig?.tlsEnabled = config.tlsEnabled + fetchedNode[0].mqttConfig?.mapReportingEnabled = config.mapReportingEnabled + fetchedNode[0].mqttConfig?.mapPositionPrecision = Int32(config.mapReportSettings.positionPrecision) + fetchedNode[0].mqttConfig?.mapPublishIntervalSecs = Int32(config.mapReportSettings.publishIntervalSecs) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [MQTTConfigEntity] Updated for node number: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [MQTTConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [MQTTConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save MQTT Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [MQTTConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertRangeTestModuleConfigPacket(config: ModuleConfig.RangeTestConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertRangeTestModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertRangeTestModuleConfigPacket(config: ModuleConfig.RangeTestConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Range Test module config received: %@".localized, String(nodeNum)) + Logger.data.info("⛰️ \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Device Config + if !fetchedNode.isEmpty { + if fetchedNode[0].rangeTestConfig == nil { + let newRangeTestConfig = RangeTestConfigEntity(context: context) + newRangeTestConfig.sender = Int32(config.sender) + newRangeTestConfig.enabled = config.enabled + newRangeTestConfig.save = config.save + fetchedNode[0].rangeTestConfig = newRangeTestConfig + } else { + fetchedNode[0].rangeTestConfig?.sender = Int32(config.sender) + fetchedNode[0].rangeTestConfig?.enabled = config.enabled + fetchedNode[0].rangeTestConfig?.save = config.save + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [RangeTestConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [RangeTestConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [RangeTestConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Range Test Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [RangeTestConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertSerialModuleConfigPacket(config: ModuleConfig.SerialConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertSerialModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertSerialModuleConfigPacket(config: ModuleConfig.SerialConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Serial module config received: %@".localized, String(nodeNum)) + Logger.data.info("🤖 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Device Config + if !fetchedNode.isEmpty { + if fetchedNode[0].serialConfig == nil { + let newSerialConfig = SerialConfigEntity(context: context) + newSerialConfig.enabled = config.enabled + newSerialConfig.echo = config.echo + newSerialConfig.rxd = Int32(config.rxd) + newSerialConfig.txd = Int32(config.txd) + newSerialConfig.baudRate = Int32(config.baud.rawValue) + newSerialConfig.timeout = Int32(config.timeout) + newSerialConfig.mode = Int32(config.mode.rawValue) + fetchedNode[0].serialConfig = newSerialConfig + } else { + fetchedNode[0].serialConfig?.enabled = config.enabled + fetchedNode[0].serialConfig?.echo = config.echo + fetchedNode[0].serialConfig?.rxd = Int32(config.rxd) + fetchedNode[0].serialConfig?.txd = Int32(config.txd) + fetchedNode[0].serialConfig?.baudRate = Int32(config.baud.rawValue) + fetchedNode[0].serialConfig?.timeout = Int32(config.timeout) + fetchedNode[0].serialConfig?.mode = Int32(config.mode.rawValue) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [SerialConfigEntity]Updated Serial Module Config for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + + context.rollback() + + let nsError = error as NSError + Logger.data.error("💥 [SerialConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [SerialConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Serial Module Config") + } + } catch { + + let nsError = error as NSError + Logger.data.error("💥 [SerialConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertStoreForwardModuleConfigPacket(config: ModuleConfig.StoreForwardConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertStoreForwardModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertStoreForwardModuleConfigPacket(config: ModuleConfig.StoreForwardConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Store & Forward module config received: %@".localized, String(nodeNum)) + Logger.data.info("📬 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Store & Forward Sensor Config + if !fetchedNode.isEmpty { + if fetchedNode[0].storeForwardConfig == nil { + let newConfig = StoreForwardConfigEntity(context: context) + newConfig.enabled = config.enabled + newConfig.heartbeat = config.heartbeat + newConfig.records = Int32(config.records) + newConfig.historyReturnMax = Int32(config.historyReturnMax) + newConfig.historyReturnWindow = Int32(config.historyReturnWindow) + newConfig.isRouter = config.isServer + fetchedNode[0].storeForwardConfig = newConfig + } else { + fetchedNode[0].storeForwardConfig?.enabled = config.enabled + fetchedNode[0].storeForwardConfig?.heartbeat = config.heartbeat + fetchedNode[0].storeForwardConfig?.records = Int32(config.records) + fetchedNode[0].storeForwardConfig?.historyReturnMax = Int32(config.historyReturnMax) + fetchedNode[0].storeForwardConfig?.historyReturnWindow = Int32(config.historyReturnWindow) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [StoreForwardConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [StoreForwardConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [StoreForwardConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Store & Forward Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [StoreForwardConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertTelemetryModuleConfigPacket(config: ModuleConfig.TelemetryConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertTelemetryModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertTelemetryModuleConfigPacket(config: ModuleConfig.TelemetryConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Telemetry module config received: %@".localized, String(nodeNum)) + Logger.data.info("📈 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Telemetry Config + if !fetchedNode.isEmpty { + if fetchedNode[0].telemetryConfig == nil { + let newTelemetryConfig = TelemetryConfigEntity(context: context) + newTelemetryConfig.deviceUpdateInterval = Int32(truncatingIfNeeded: config.deviceUpdateInterval) + newTelemetryConfig.deviceTelemetryEnabled = config.deviceTelemetryEnabled + newTelemetryConfig.environmentUpdateInterval = Int32(truncatingIfNeeded: config.environmentUpdateInterval) + newTelemetryConfig.environmentMeasurementEnabled = config.environmentMeasurementEnabled + newTelemetryConfig.environmentScreenEnabled = config.environmentScreenEnabled + newTelemetryConfig.environmentDisplayFahrenheit = config.environmentDisplayFahrenheit + newTelemetryConfig.powerMeasurementEnabled = config.powerMeasurementEnabled + newTelemetryConfig.powerUpdateInterval = Int32(truncatingIfNeeded: config.powerUpdateInterval) + newTelemetryConfig.powerScreenEnabled = config.powerScreenEnabled + fetchedNode[0].telemetryConfig = newTelemetryConfig + } else { + fetchedNode[0].telemetryConfig?.deviceUpdateInterval = Int32(truncatingIfNeeded: config.deviceUpdateInterval) + fetchedNode[0].telemetryConfig?.deviceTelemetryEnabled = config.deviceTelemetryEnabled + fetchedNode[0].telemetryConfig?.environmentUpdateInterval = Int32(truncatingIfNeeded: config.environmentUpdateInterval) + fetchedNode[0].telemetryConfig?.environmentMeasurementEnabled = config.environmentMeasurementEnabled + fetchedNode[0].telemetryConfig?.environmentScreenEnabled = config.environmentScreenEnabled + fetchedNode[0].telemetryConfig?.environmentDisplayFahrenheit = config.environmentDisplayFahrenheit + fetchedNode[0].telemetryConfig?.powerMeasurementEnabled = config.powerMeasurementEnabled + fetchedNode[0].telemetryConfig?.powerUpdateInterval = Int32(truncatingIfNeeded: config.powerUpdateInterval) + fetchedNode[0].telemetryConfig?.powerScreenEnabled = config.powerScreenEnabled + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [TelemetryConfigEntity] Updated Telemetry Module Config for node: \(nodeNum.toHex(), privacy: .public)") + + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [TelemetryConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + + } else { + Logger.data.error("💥 [TelemetryConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Telemetry Module Config") + } + + } catch { + let nsError = error as NSError + Logger.data.error("💥 [TelemetryConfigEntity] Fetching node for core data TelemetryConfigEntity failed: \(nsError, privacy: .public)") + } + } + + func upsertTAKModuleConfigPacket(config: ModuleConfig.TAKConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertTAKModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertTAKModuleConfigPacket(config: ModuleConfig.TAKConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("TAK module config received: %@".localized, String(nodeNum)) + Logger.data.info("🎯 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + if !fetchedNode.isEmpty { + if fetchedNode[0].takConfig == nil { + let newTAKConfig = TAKConfigEntity(context: context) + newTAKConfig.team = Int32(config.team.rawValue) + newTAKConfig.role = Int32(config.role.rawValue) + fetchedNode[0].takConfig = newTAKConfig + } else { + fetchedNode[0].takConfig?.team = Int32(config.team.rawValue) + fetchedNode[0].takConfig?.role = Int32(config.role.rawValue) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [TAKConfigEntity] Updated TAK Module Config for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [TAKConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [TAKConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save TAK Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [TAKConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } +}eCoreData.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 10/3/22. + +import CoreData +import MeshtasticProtobufs +import OSLog + +extension MeshPackets { + public func clearStaleNodes(nodeExpireDays: Int) async -> Bool { + let context = self.backgroundContext + return await context.perform { + return self.clearStaleNodes(nodeExpireDays: nodeExpireDays, context: context) + } + } + + nonisolated public func clearStaleNodes(nodeExpireDays: Int, context: NSManagedObjectContext) -> Bool { + var nodeExpireTime: TimeInterval { + return TimeInterval(-nodeExpireDays * 86400) + } + var nodePKIExpireTime: TimeInterval { + return TimeInterval((nodeExpireDays < 7 ? -7 : -nodeExpireDays) * 86400) + } + + if nodeExpireDays == 0 { + // Purge Disabled + Logger.data.info("💾 [NodeInfoEntity] Skip clearing stale nodes") + return false + } + let fetchRequest = NSFetchRequest(entityName: "NodeInfoEntity") + fetchRequest.predicate = NSPredicate(format: "favorite == false AND ignored == false AND ((user.pkiEncrypted == NO AND lastHeard < %@) OR (user.pkiEncrypted == YES AND lastHeard < %@))", + NSDate(timeIntervalSinceNow: nodeExpireTime), NSDate(timeIntervalSinceNow: nodePKIExpireTime)) + let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + batchDeleteRequest.resultType = .resultTypeCount + + do { + Logger.data.info("💾 [NodeInfoEntity] Clearing nodes older than \(nodeExpireDays) days") + if let batchDeleteResult = try context.execute(batchDeleteRequest) as? NSBatchDeleteResult { + try context.save() + let deletedNodes = batchDeleteResult.result as? Int ?? 0 + Logger.data.info("💾 [NodeInfoEntity] Cleared \(deletedNodes) stale nodes") + if deletedNodes > 0 { + return true + } + } else { + Logger.data.error("💥 [NodeInfoEntity] bad delete results") + } + } catch { + context.rollback() + Logger.data.error("💥 [NodeInfoEntity] Error deleting stale nodes") + } + return false + } + + func clearPax(destNum: Int64) async -> Bool { + let context = self.backgroundContext + return await context.perform { + return self.clearPax(destNum: destNum, context: context) + } + } + + nonisolated public func clearPax(destNum: Int64, context: NSManagedObjectContext) -> Bool { + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(destNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + let newPax = [PaxCounterLog]() + fetchedNode[0].pax? = NSOrderedSet(array: newPax) + do { + try context.save() + return true + + } catch { + context.rollback() + return false + } + } catch { + Logger.data.error("💥 [NodeInfoEntity] fetch data error") + return false + } + } + + public func clearPositions(destNum: Int64) async -> Bool { + let context = self.backgroundContext + return await context.perform { + return self.clearPositions(destNum: destNum, context: context) + } + } + + nonisolated public func clearPositions(destNum: Int64, context: NSManagedObjectContext) -> Bool { + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(destNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + let newPostions = [PositionEntity]() + fetchedNode[0].positions? = NSOrderedSet(array: newPostions) + do { + try context.save() + return true + + } catch { + context.rollback() + return false + } + } catch { + Logger.data.error("💥 [NodeInfoEntity] fetch data error") + return false + } + } + + public func clearTelemetry(destNum: Int64, metricsType: Int32) async -> Bool { + let context = self.backgroundContext + return await context.perform { + return self.clearTelemetry(destNum: destNum, metricsType: metricsType, context: context) + } + } + + nonisolated public func clearTelemetry(destNum: Int64, metricsType: Int32, context: NSManagedObjectContext) -> Bool { + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(destNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + let emptyTelemetry = [TelemetryEntity]() + fetchedNode[0].telemetries? = NSOrderedSet(array: emptyTelemetry) + do { + try context.save() + return true + + } catch { + context.rollback() + return false + } + } catch { + Logger.data.error("💥 [NodeInfoEntity] fetch data error") + return false + } + } + + public func deleteChannelMessages(channel: ChannelEntity) async { + let context = self.backgroundContext + let objectId = channel.objectID + await context.perform { + if let channelObject = context.object(with: objectId) as? ChannelEntity { + self.deleteChannelMessages(channel: channelObject, context: context) + } + } + } + + nonisolated public func deleteChannelMessages(channel: ChannelEntity, context: NSManagedObjectContext) { + do { + // Copied logic from ChannelEntity.allPrivateMessages, which is always on the MainActor + // But this code may not be on the MainActor. + let fetchRequest = MessageEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "channel == %ld AND toUser == nil AND isEmoji == false", channel.index) + let objects = (try? context.fetch(fetchRequest)) ?? [MessageEntity]() + + for object in objects { + context.delete(object) + } + + try context.save() + } catch let error as NSError { + Logger.data.error("\(error.localizedDescription, privacy: .public)") + } + } + + public func deleteUserMessages(user: UserEntity) async { + let context = self.backgroundContext + let objectId = user.objectID + await context.perform { + if let userObject = context.object(with: objectId) as? UserEntity { + self.deleteUserMessages(user: userObject, context: context) + } + } + } + + nonisolated public func deleteUserMessages(user: UserEntity, context: NSManagedObjectContext) { + do { + // Fetch messages using the same context that will perform the deletes. + // user.messageList fetches from viewContext, which would cause a context-mismatch + // crash when this method is called with a background context. + let fetchRequest = MessageEntity.fetchRequest() + fetchRequest.predicate = user.messageFetchRequest.predicate + let objects = (try? context.fetch(fetchRequest)) ?? [] + for object in objects { + context.delete(object) + } + try context.save() + } catch let error as NSError { + Logger.data.error("\(error.localizedDescription, privacy: .public)") + } + } + + public func clearCoreDataDatabase(includeRoutes: Bool, includeAppLevelData: Bool = false) async { + let context = self.backgroundContext + await context.perform { + self.clearCoreDataDatabase(context: context, includeRoutes: includeRoutes, includeAppLevelData: includeAppLevelData) + } + } + + nonisolated public func clearCoreDataDatabase(context: NSManagedObjectContext, includeRoutes: Bool, includeAppLevelData: Bool = false) { + let persistenceController = PersistenceController.shared.container + for i in 0...persistenceController.managedObjectModel.entities.count-1 { + + let entity = persistenceController.managedObjectModel.entities[i] + let query = NSFetchRequest(entityName: entity.name!) + let entityName = entity.name ?? "UNK" + + if !includeRoutes, ["RouteEntity", "LocationEntity"].contains(entityName) { + continue + } + + if !includeAppLevelData, ["DeviceHardwareEntity", "DeviceHardwareImageEntity", "DeviceHardwareTagEntity"].contains(entityName) { + // These are non-node-specific "app level" data, keep them even when switching nodes + continue + } + + // Execute the delete for this entry + let deleteRequest = NSBatchDeleteRequest(fetchRequest: query) + do { + try context.executeAndMergeChanges(using: deleteRequest) + } catch { + Logger.data.error("\(error.localizedDescription, privacy: .public)") + } + } + } + + func updateAnyPacketFrom (packet: MeshPacket, activeDeviceNum: Int64) async { + let context = self.backgroundContext + await context.perform { + self.updateAnyPacketFrom(packet: packet, activeDeviceNum: activeDeviceNum, context: context) + } + } + + nonisolated func updateAnyPacketFrom (packet: MeshPacket, activeDeviceNum: Int64, context: NSManagedObjectContext) { + // Update NodeInfoEntity for any packet received. This mirrors the firmware's NodeDB::updateFrom, which sniffs ALL received packets and updates the radio's nodeDB with packet.from's: + // - last_heard (from rxTime) + // - snr + // - via_mqtt + // - hops_away + + // However, unlike the firmware, this function will NOT create a new NodeInfoEntity if we don't have it already. We'll leave that to the existing code paths. + + // We do NOT update fetchedNode[0].channel, because we may hear a node over multiple channels, and only some packet types should update what we consider the node's channel to be. (Example: primary private channel, secondary public channel. A text message on the secondary public channel should NOT change fetchedNode[0].channel.) + + guard packet.from > 0 else { return } + guard packet.from != activeDeviceNum else { return } // Ignore if packet is from our own node + + let fetchNodeInfoAppRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoAppRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoAppRequest) + if fetchedNode.count >= 1 { + fetchedNode[0].id = Int64(packet.from) + fetchedNode[0].num = Int64(packet.from) + + if packet.rxTime > 0 { + fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) + Logger.data.info("💾 [updateAnyPacketFrom] Updating node \(packet.from.toHex(), privacy: .public) lastHeard from rxTime=\(packet.rxTime)") + } else { + fetchedNode[0].lastHeard = Date() + Logger.data.info("💾 [updateAnyPacketFrom] Updating node \(packet.from.toHex(), privacy: .public) lastHeard to now (rxTime==0)") + } + + fetchedNode[0].snr = packet.rxSnr + fetchedNode[0].rssi = packet.rxRssi + fetchedNode[0].viaMqtt = packet.viaMqtt + + if packet.hopStart != 0 && packet.hopLimit <= packet.hopStart { + fetchedNode[0].hopsAway = Int32(packet.hopStart - packet.hopLimit) + Logger.data.info("💾 [updateAnyPacketFrom] Updating node \(packet.from.toHex(), privacy: .public) hopsAway=\(fetchedNode[0].hopsAway)") + } + + do { + try context.save() + Logger.data.info("💾 [updateAnyPacketFrom] Updating node \(fetchedNode[0].num.toHex(), privacy: .public) snr=\(fetchedNode[0].snr), rssi=\(fetchedNode[0].rssi) from packet \(packet.id.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [updateAnyPacketFrom] Error Saving node \(fetchedNode[0].num.toHex(), privacy: .public) from packet \(packet.id.toHex(), privacy: .public) \(nsError, privacy: .public)") + } + } + } catch { + Logger.data.error("💥 [updateAnyPacketFrom] fetch data error") + } + } + + func upsertNodeInfoPacket (packet: MeshPacket, favorite: Bool = false) async { + let context = self.backgroundContext + await context.perform { + self.upsertNodeInfoPacket(packet: packet, favorite: favorite, context: context) + } + } + + nonisolated func upsertNodeInfoPacket (packet: MeshPacket, favorite: Bool = false, context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("[NodeInfo] received for: %@".localized, packet.from.toHex()) + Logger.mesh.info("📟 \(logString, privacy: .public)") + + guard packet.from > 0 else { return } + + let fetchNodeInfoAppRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoAppRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + + do { + + let fetchedNode = try context.fetch(fetchNodeInfoAppRequest) + if fetchedNode.count == 0 { + // Not Found Insert + let newNode = NodeInfoEntity(context: context) + newNode.id = Int64(packet.from) + newNode.num = Int64(packet.from) + newNode.favorite = favorite + if packet.rxTime > 0 { + newNode.firstHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) + newNode.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) + } else { + newNode.firstHeard = Date() + newNode.lastHeard = Date() + } + newNode.snr = packet.rxSnr + newNode.rssi = packet.rxRssi + newNode.viaMqtt = packet.viaMqtt + + if packet.to == Constants.maximumNodeNum || packet.to == UserDefaults.preferredPeripheralNum { + newNode.channel = Int32(packet.channel) + } + if let nodeInfoMessage = try? NodeInfo(serializedBytes: packet.decoded.payload) { + if nodeInfoMessage.hasHopsAway { + newNode.hopsAway = Int32(nodeInfoMessage.hopsAway) + } + newNode.favorite = nodeInfoMessage.isFavorite + } + + if let newUserMessage = try? User(serializedBytes: packet.decoded.payload) { + + if newUserMessage.id.isEmpty { + if packet.from > Constants.minimumNodeNum { + do { + let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context) + newNode.user = newUser + } catch CoreDataError.invalidInput(let message) { + Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)") + } catch { + Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") + } + } + } else { + + let newUser = UserEntity(context: context) + newUser.userId = newNode.num.toHex() + newUser.num = Int64(packet.from) + newUser.longName = newUserMessage.longName + newUser.shortName = newUserMessage.shortName + newUser.role = Int32(newUserMessage.role.rawValue) + newUser.hwModel = String(describing: newUserMessage.hwModel).uppercased() + newUser.hwModelId = Int32(newUserMessage.hwModel.rawValue) + /// For nodes that have the optional isUnmessagable boolean use that, otherwise excluded roles that are unmessagable by default + if newUserMessage.hasIsUnmessagable { + newUser.unmessagable = newUserMessage.isUnmessagable + } else { + let roles = [2, 4, 5, 6, 7, 10, 11] + let containsRole = roles.contains(Int(newUser.role)) + if containsRole { + newUser.unmessagable = true + } else { + newUser.unmessagable = false + } + } + if !newUserMessage.publicKey.isEmpty { + newUser.pkiEncrypted = true + newUser.publicKey = newUserMessage.publicKey + } + + let fetchRequest1 = DeviceHardwareEntity.fetchRequest() + fetchRequest1.predicate = NSPredicate(format: "hwModel == %d", newUser.hwModelId) + let fetchedHardware1 = (try? context.fetch(fetchRequest1)) ?? [] + if let hardwareEntity = fetchedHardware1.first { + newUser.hwDisplayName = hardwareEntity.displayName + } + newNode.user = newUser + + if UserDefaults.newNodeNotifications { + Task { @MainActor in + let manager = LocalNotificationManager() + manager.notifications = [ + Notification( + id: (UUID().uuidString), + title: "New Node".localized, + subtitle: "\(newUser.longName ?? "Unknown".localized)", + content: "New Node has been discovered".localized, + target: "nodes", + path: "meshtastic:///nodes?nodenum=\(newUser.num)" + ) + ] + manager.schedule() + } + } + } + } else { + if packet.from > Constants.minimumNodeNum { + do { + let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context) + if !packet.publicKey.isEmpty { + newNode.user?.pkiEncrypted = true + newNode.user?.publicKey = packet.publicKey + } + newNode.user = newUser + } catch CoreDataError.invalidInput(let message) { + Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)") + } catch { + Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") + } + } + } + // User is messed up and has failed to create at least once, if this fails bail out + if newNode.user == nil && packet.from > Constants.minimumNodeNum { + do { + let newUser = try createUser(num: Int64(packet.from), context: context) + newNode.user = newUser + } catch CoreDataError.invalidInput(let message) { + Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)") + context.rollback() + return + } catch { + Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") + context.rollback() + return + } + } + + do { + try context.save() + Logger.data.info("💾 [NodeInfo] Saved a NodeInfo for node number: \(packet.from.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [NodeInfoEntity] Error Inserting New Core Data: \(nsError, privacy: .public)") + } + + } else { + // Update an existing node + if packet.to == Constants.maximumNodeNum || packet.to == UserDefaults.preferredPeripheralNum { + fetchedNode[0].channel = Int32(packet.channel) + } + + if let nodeInfoMessage = try? NodeInfo(serializedBytes: packet.decoded.payload) { + + fetchedNode[0].hopsAway = Int32(nodeInfoMessage.hopsAway) + fetchedNode[0].favorite = nodeInfoMessage.isFavorite + if nodeInfoMessage.hasDeviceMetrics { + let telemetry = TelemetryEntity(context: context) + telemetry.batteryLevel = Int32(nodeInfoMessage.deviceMetrics.batteryLevel) + telemetry.voltage = nodeInfoMessage.deviceMetrics.voltage + telemetry.channelUtilization = nodeInfoMessage.deviceMetrics.channelUtilization + telemetry.airUtilTx = nodeInfoMessage.deviceMetrics.airUtilTx + var newTelemetries = [TelemetryEntity]() + newTelemetries.append(telemetry) + fetchedNode[0].telemetries? = NSOrderedSet(array: newTelemetries) + } + if nodeInfoMessage.hasUser { + fetchedNode[0].user?.userId = nodeInfoMessage.num.toHex() + fetchedNode[0].user?.num = Int64(nodeInfoMessage.num) + fetchedNode[0].user?.longName = nodeInfoMessage.user.longName + fetchedNode[0].user?.shortName = nodeInfoMessage.user.shortName + fetchedNode[0].user?.role = Int32(nodeInfoMessage.user.role.rawValue) + fetchedNode[0].user?.hwModel = String(describing: nodeInfoMessage.user.hwModel).uppercased() + fetchedNode[0].user?.hwModelId = Int32(nodeInfoMessage.user.hwModel.rawValue) + /// For nodes that have the optional isUnmessagable boolean use that, otherwise excluded roles that are unmessagable by default + if nodeInfoMessage.user.hasIsUnmessagable { + fetchedNode[0].user?.unmessagable = nodeInfoMessage.user.isUnmessagable + } else { + let roles = [-1, 2, 4, 5, 6, 7, 10, 11] + let containsRole = roles.contains(Int(fetchedNode[0].user?.role ?? -1)) + if containsRole { + fetchedNode[0].user?.unmessagable = true + } else { + fetchedNode[0].user?.unmessagable = false + } + } + if !nodeInfoMessage.user.publicKey.isEmpty { + fetchedNode[0].user?.pkiEncrypted = true + fetchedNode[0].user?.publicKey = nodeInfoMessage.user.publicKey + } + if let user = fetchedNode.first?.user { + let fetchRequest2 = DeviceHardwareEntity.fetchRequest() + fetchRequest2.predicate = NSPredicate(format: "hwModel == %d", user.hwModelId) + let fetchedHardware2 = (try? context.fetch(fetchRequest2)) ?? [] + if let hardwareEntity = fetchedHardware2.first { + user.hwDisplayName = hardwareEntity.displayName + } + } + } + } else if packet.hopStart != 0 && packet.hopLimit <= packet.hopStart { + fetchedNode[0].hopsAway = Int32(packet.hopStart - packet.hopLimit) + } + if fetchedNode[0].user == nil { + do { + let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context) + fetchedNode[0].user = newUser + } catch CoreDataError.invalidInput(let message) { + Logger.data.error("Error Creating a new Core Data UserEntity on an existing node (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)") + } catch { + Logger.data.error("Error Creating a new Core Data UserEntity on an existing node from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") + } + } + do { + try context.save() + Logger.data.info("💾 [NodeInfoEntity] Updated from Node Info App Packet For: \(fetchedNode[0].num.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [NodeInfoEntity] Error Saving from NODEINFO_APP \(nsError, privacy: .public)") + } + } + } catch { + Logger.data.error("💥 [NodeInfoEntity] fetch data error for NODEINFO_APP") + } + } + + func upsertPositionPacket (packet: MeshPacket) async { + let context = self.backgroundContext + await context.perform { + self.upsertPositionPacket(packet: packet, context: context) + } + } + + nonisolated func upsertPositionPacket (packet: MeshPacket, context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("[Position] received from node: %@".localized, String(packet.from)) + Logger.mesh.info("📍 \(logString, privacy: .public)") + + let fetchNodePositionRequest = NodeInfoEntity.fetchRequest() + fetchNodePositionRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + + do { + + if let positionMessage = try? Position(serializedBytes: packet.decoded.payload) { + + /// Don't save empty position packets from null island or apple park + if (positionMessage.longitudeI != 0 && positionMessage.latitudeI != 0) && (positionMessage.latitudeI != 373346000 && positionMessage.longitudeI != -1220090000) { + let fetchedNode = try context.fetch(fetchNodePositionRequest) + if fetchedNode.count == 1 { + + // Unset the current latest position for this node + let fetchCurrentLatestPositionsRequest = PositionEntity.fetchRequest() + fetchCurrentLatestPositionsRequest.predicate = NSPredicate(format: "nodePosition.num == %lld && latest = true", Int64(packet.from)) + + let fetchedPositions = try context.fetch(fetchCurrentLatestPositionsRequest) + if fetchedPositions.count > 0 { + for position in fetchedPositions { + position.latest = false + } + } + let position = PositionEntity(context: context) + position.latest = true + position.snr = packet.rxSnr + position.rssi = packet.rxRssi + position.seqNo = Int32(positionMessage.seqNumber) + position.latitudeI = positionMessage.latitudeI + position.longitudeI = positionMessage.longitudeI + position.altitude = positionMessage.altitude + position.satsInView = Int32(positionMessage.satsInView) + position.speed = Int32(positionMessage.groundSpeed) + let heading = Int32(positionMessage.groundTrack) + // Throw out bad haeadings from the device + if heading >= 0 && heading <= 360 { + position.heading = Int32(positionMessage.groundTrack) + } + position.precisionBits = Int32(positionMessage.precisionBits) + if positionMessage.timestamp != 0 { + position.time = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.timestamp))) + } else { + position.time = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.time))) + } + guard let mutablePositions = fetchedNode[0].positions?.mutableCopy() as? NSMutableOrderedSet else { + return + } + /// Don't save nearly the same position over and over. If the next position is less than 10 meters from the new position, delete the previous position and save the new one. + if mutablePositions.count > 0 && (position.precisionBits == 32 || position.precisionBits == 0) { + if let mostRecent = mutablePositions.lastObject as? PositionEntity, mostRecent.coordinate.distance(from: position.coordinate) < 9.0 { + mutablePositions.remove(mostRecent) + } + } else if mutablePositions.count > 0 { + /// Don't store any history for reduced accuracy positions, we will just show a circle + mutablePositions.removeAllObjects() + } + mutablePositions.add(position) + + fetchedNode[0].channel = Int32(packet.channel) + fetchedNode[0].positions = mutablePositions.copy() as? NSOrderedSet + + do { + try context.save() + Logger.data.info("💾 [Position] Saved from Position App Packet For: \(fetchedNode[0].num.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 Error Saving NodeInfoEntity from POSITION_APP \(nsError, privacy: .public)") + } + } + } else { + Logger.data.error("💥 Empty POSITION_APP Packet: \((try? packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + } + } + } catch { + Logger.data.error("💥 Error Deserializing POSITION_APP packet.") + } + } + + func upsertBluetoothConfigPacket(config: Config.BluetoothConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertBluetoothConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertBluetoothConfigPacket(config: Config.BluetoothConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Bluetooth config received: %@".localized, String(nodeNum)) + Logger.mesh.info("📶 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Device Config + if !fetchedNode.isEmpty { + if fetchedNode[0].bluetoothConfig == nil { + let newBluetoothConfig = BluetoothConfigEntity(context: context) + newBluetoothConfig.enabled = config.enabled + newBluetoothConfig.mode = Int32(config.mode.rawValue) + newBluetoothConfig.fixedPin = Int32(config.fixedPin) + fetchedNode[0].bluetoothConfig = newBluetoothConfig + } else { + fetchedNode[0].bluetoothConfig?.enabled = config.enabled + fetchedNode[0].bluetoothConfig?.mode = Int32(config.mode.rawValue) + fetchedNode[0].bluetoothConfig?.fixedPin = Int32(config.fixedPin) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [BluetoothConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [BluetoothConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [BluetoothConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Bluetooth Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [BluetoothConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertDeviceConfigPacket(config: Config.DeviceConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertDeviceConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertDeviceConfigPacket(config: Config.DeviceConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Device config received: %@".localized, String(nodeNum)) + Logger.mesh.info("📟 \(logString, privacy: .public)") + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Device Config + if !fetchedNode.isEmpty { + if fetchedNode[0].deviceConfig == nil { + let newDeviceConfig = DeviceConfigEntity(context: context) + newDeviceConfig.role = Int32(config.role.rawValue) + newDeviceConfig.buttonGpio = Int32(config.buttonGpio) + newDeviceConfig.buzzerGpio = Int32(config.buzzerGpio) + newDeviceConfig.rebroadcastMode = Int32(config.rebroadcastMode.rawValue) + newDeviceConfig.nodeInfoBroadcastSecs = Int32(truncating: config.nodeInfoBroadcastSecs as NSNumber) + newDeviceConfig.doubleTapAsButtonPress = config.doubleTapAsButtonPress + newDeviceConfig.tripleClickAsAdHocPing = !config.disableTripleClick + newDeviceConfig.ledHeartbeatEnabled = !config.ledHeartbeatDisabled + newDeviceConfig.isManaged = config.isManaged + newDeviceConfig.tzdef = config.tzdef + fetchedNode[0].deviceConfig = newDeviceConfig + } else { + fetchedNode[0].deviceConfig?.role = Int32(config.role.rawValue) + fetchedNode[0].deviceConfig?.buttonGpio = Int32(config.buttonGpio) + fetchedNode[0].deviceConfig?.buzzerGpio = Int32(config.buzzerGpio) + fetchedNode[0].deviceConfig?.rebroadcastMode = Int32(config.rebroadcastMode.rawValue) + fetchedNode[0].deviceConfig?.nodeInfoBroadcastSecs = Int32(truncating: config.nodeInfoBroadcastSecs as NSNumber) + fetchedNode[0].deviceConfig?.doubleTapAsButtonPress = config.doubleTapAsButtonPress + fetchedNode[0].deviceConfig?.tripleClickAsAdHocPing = !config.disableTripleClick + fetchedNode[0].deviceConfig?.ledHeartbeatEnabled = !config.ledHeartbeatDisabled + fetchedNode[0].deviceConfig?.isManaged = config.isManaged + fetchedNode[0].deviceConfig?.tzdef = config.tzdef + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [DeviceConfigEntity] Updated Device Config for node number: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [DeviceConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [DeviceConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertDisplayConfigPacket(config: Config.DisplayConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertDisplayConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertDisplayConfigPacket(config: Config.DisplayConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Display config received: %@".localized, nodeNum.toHex()) + Logger.data.info("🖥️ \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Device Config + if !fetchedNode.isEmpty { + + if fetchedNode[0].displayConfig == nil { + + let newDisplayConfig = DisplayConfigEntity(context: context) + newDisplayConfig.screenOnSeconds = Int32(truncatingIfNeeded: config.screenOnSecs) + newDisplayConfig.screenCarouselInterval = Int32(truncatingIfNeeded: config.autoScreenCarouselSecs) + newDisplayConfig.compassNorthTop = config.compassNorthTop + newDisplayConfig.flipScreen = config.flipScreen + newDisplayConfig.oledType = Int32(config.oled.rawValue) + newDisplayConfig.displayMode = Int32(config.displaymode.rawValue) + newDisplayConfig.units = Int32(config.units.rawValue) + newDisplayConfig.headingBold = config.headingBold + newDisplayConfig.use12HClock = config.use12HClock + fetchedNode[0].displayConfig = newDisplayConfig + } else { + fetchedNode[0].displayConfig?.screenOnSeconds = Int32(truncatingIfNeeded: config.screenOnSecs) + fetchedNode[0].displayConfig?.screenCarouselInterval = Int32(truncatingIfNeeded: config.autoScreenCarouselSecs) + fetchedNode[0].displayConfig?.compassNorthTop = config.compassNorthTop + fetchedNode[0].displayConfig?.flipScreen = config.flipScreen + fetchedNode[0].displayConfig?.oledType = Int32(config.oled.rawValue) + fetchedNode[0].displayConfig?.displayMode = Int32(config.displaymode.rawValue) + fetchedNode[0].displayConfig?.units = Int32(config.units.rawValue) + fetchedNode[0].displayConfig?.headingBold = config.headingBold + fetchedNode[0].displayConfig?.use12HClock = config.use12HClock + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + + try context.save() + Logger.data.info("💾 [DisplayConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [DisplayConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [DisplayConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Display Config") + } + + } catch { + let nsError = error as NSError + Logger.data.error("💥 [DisplayConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertLoRaConfigPacket(config: Config.LoRaConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertLoRaConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertLoRaConfigPacket(config: Config.LoRaConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("LoRa config received: %@".localized, nodeNum.toHex()) + Logger.data.info("📻 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", nodeNum) + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save LoRa Config + if fetchedNode.count > 0 { + if fetchedNode[0].loRaConfig == nil { + // No lora config for node, save a new lora config + let newLoRaConfig = LoRaConfigEntity(context: context) + newLoRaConfig.regionCode = Int32(config.region.rawValue) + newLoRaConfig.usePreset = config.usePreset + newLoRaConfig.modemPreset = Int32(config.modemPreset.rawValue) + newLoRaConfig.bandwidth = Int32(config.bandwidth) + newLoRaConfig.spreadFactor = Int32(config.spreadFactor) + newLoRaConfig.codingRate = Int32(config.codingRate) + newLoRaConfig.frequencyOffset = config.frequencyOffset + newLoRaConfig.overrideFrequency = config.overrideFrequency + newLoRaConfig.overrideDutyCycle = config.overrideDutyCycle + newLoRaConfig.hopLimit = Int32(config.hopLimit) + newLoRaConfig.txPower = Int32(config.txPower) + newLoRaConfig.txEnabled = config.txEnabled + newLoRaConfig.channelNum = Int32(config.channelNum) + newLoRaConfig.sx126xRxBoostedGain = config.sx126XRxBoostedGain + newLoRaConfig.ignoreMqtt = config.ignoreMqtt + newLoRaConfig.okToMqtt = config.configOkToMqtt + fetchedNode[0].loRaConfig = newLoRaConfig + } else { + fetchedNode[0].loRaConfig?.regionCode = Int32(config.region.rawValue) + fetchedNode[0].loRaConfig?.usePreset = config.usePreset + fetchedNode[0].loRaConfig?.modemPreset = Int32(config.modemPreset.rawValue) + fetchedNode[0].loRaConfig?.bandwidth = Int32(config.bandwidth) + fetchedNode[0].loRaConfig?.spreadFactor = Int32(config.spreadFactor) + fetchedNode[0].loRaConfig?.codingRate = Int32(config.codingRate) + fetchedNode[0].loRaConfig?.frequencyOffset = config.frequencyOffset + fetchedNode[0].loRaConfig?.overrideFrequency = config.overrideFrequency + fetchedNode[0].loRaConfig?.overrideDutyCycle = config.overrideDutyCycle + fetchedNode[0].loRaConfig?.hopLimit = Int32(config.hopLimit) + fetchedNode[0].loRaConfig?.txPower = Int32(config.txPower) + fetchedNode[0].loRaConfig?.txEnabled = config.txEnabled + fetchedNode[0].loRaConfig?.channelNum = Int32(config.channelNum) + fetchedNode[0].loRaConfig?.sx126xRxBoostedGain = config.sx126XRxBoostedGain + fetchedNode[0].loRaConfig?.ignoreMqtt = config.ignoreMqtt + fetchedNode[0].loRaConfig?.okToMqtt = config.configOkToMqtt + fetchedNode[0].loRaConfig?.sx126xRxBoostedGain = config.sx126XRxBoostedGain + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [LoRaConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [LoRaConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [LoRaConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Lora Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [LoRaConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertNetworkConfigPacket(config: Config.NetworkConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertNetworkConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertNetworkConfigPacket(config: Config.NetworkConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Network config received: %@".localized, String(nodeNum)) + Logger.data.info("🌐 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save WiFi Config + if !fetchedNode.isEmpty { + if fetchedNode[0].networkConfig == nil { + let newNetworkConfig = NetworkConfigEntity(context: context) + newNetworkConfig.wifiEnabled = config.wifiEnabled + newNetworkConfig.wifiSsid = config.wifiSsid + newNetworkConfig.wifiPsk = config.wifiPsk + newNetworkConfig.ethEnabled = config.ethEnabled + newNetworkConfig.enabledProtocols = Int32(config.enabledProtocols) + fetchedNode[0].networkConfig = newNetworkConfig + } else { + fetchedNode[0].networkConfig?.ethEnabled = config.ethEnabled + fetchedNode[0].networkConfig?.wifiEnabled = config.wifiEnabled + fetchedNode[0].networkConfig?.wifiSsid = config.wifiSsid + fetchedNode[0].networkConfig?.wifiPsk = config.wifiPsk + fetchedNode[0].networkConfig?.enabledProtocols = Int32(config.enabledProtocols) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [NetworkConfigEntity] Updated Network Config for node: \(nodeNum.toHex(), privacy: .public)") + + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [NetworkConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [NetworkConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Network Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [NetworkConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertPositionConfigPacket(config: Config.PositionConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertPositionConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertPositionConfigPacket(config: Config.PositionConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Position config received: %@".localized, String(nodeNum)) + Logger.data.info("🗺️ \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save LoRa Config + if !fetchedNode.isEmpty { + if fetchedNode[0].positionConfig == nil { + let newPositionConfig = PositionConfigEntity(context: context) + newPositionConfig.smartPositionEnabled = config.positionBroadcastSmartEnabled + newPositionConfig.deviceGpsEnabled = config.gpsEnabled + newPositionConfig.gpsMode = Int32(truncatingIfNeeded: config.gpsMode.rawValue) + newPositionConfig.rxGpio = Int32(truncatingIfNeeded: config.rxGpio) + newPositionConfig.txGpio = Int32(truncatingIfNeeded: config.txGpio) + newPositionConfig.gpsEnGpio = Int32(truncatingIfNeeded: config.gpsEnGpio) + newPositionConfig.fixedPosition = config.fixedPosition + newPositionConfig.positionBroadcastSeconds = Int32(truncatingIfNeeded: config.positionBroadcastSecs) + newPositionConfig.broadcastSmartMinimumIntervalSecs = Int32(truncatingIfNeeded: config.broadcastSmartMinimumIntervalSecs) + newPositionConfig.broadcastSmartMinimumDistance = Int32(truncatingIfNeeded: config.broadcastSmartMinimumDistance) + newPositionConfig.positionFlags = Int32(truncatingIfNeeded: config.positionFlags) + newPositionConfig.gpsAttemptTime = 900 + newPositionConfig.gpsUpdateInterval = Int32(truncatingIfNeeded: config.gpsUpdateInterval) + fetchedNode[0].positionConfig = newPositionConfig + } else { + fetchedNode[0].positionConfig?.smartPositionEnabled = config.positionBroadcastSmartEnabled + fetchedNode[0].positionConfig?.deviceGpsEnabled = config.gpsEnabled + fetchedNode[0].positionConfig?.gpsMode = Int32(truncatingIfNeeded: config.gpsMode.rawValue) + fetchedNode[0].positionConfig?.rxGpio = Int32(truncatingIfNeeded: config.rxGpio) + fetchedNode[0].positionConfig?.txGpio = Int32(truncatingIfNeeded: config.txGpio) + fetchedNode[0].positionConfig?.gpsEnGpio = Int32(truncatingIfNeeded: config.gpsEnGpio) + fetchedNode[0].positionConfig?.fixedPosition = config.fixedPosition + fetchedNode[0].positionConfig?.positionBroadcastSeconds = Int32(truncatingIfNeeded: config.positionBroadcastSecs) + fetchedNode[0].positionConfig?.broadcastSmartMinimumIntervalSecs = Int32(truncatingIfNeeded: config.broadcastSmartMinimumIntervalSecs) + fetchedNode[0].positionConfig?.broadcastSmartMinimumDistance = Int32(truncatingIfNeeded: config.broadcastSmartMinimumDistance) + fetchedNode[0].positionConfig?.gpsAttemptTime = 900 + fetchedNode[0].positionConfig?.gpsUpdateInterval = Int32(truncatingIfNeeded: config.gpsUpdateInterval) + fetchedNode[0].positionConfig?.positionFlags = Int32(truncatingIfNeeded: config.positionFlags) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [PositionConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [PositionConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [PositionConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Position Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [PositionConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertPowerConfigPacket(config: Config.PowerConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertPowerConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertPowerConfigPacket(config: Config.PowerConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + let logString = String.localizedStringWithFormat("Power config received: %@".localized, String(nodeNum)) + Logger.data.info("🗺️ \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Power Config + if !fetchedNode.isEmpty { + if fetchedNode[0].powerConfig == nil { + let newPowerConfig = PowerConfigEntity(context: context) + newPowerConfig.adcMultiplierOverride = config.adcMultiplierOverride + newPowerConfig.deviceBatteryInaAddress = Int32(config.deviceBatteryInaAddress) + newPowerConfig.isPowerSaving = config.isPowerSaving + newPowerConfig.lsSecs = Int32(truncatingIfNeeded: config.lsSecs) + newPowerConfig.minWakeSecs = Int32(truncatingIfNeeded: config.minWakeSecs) + newPowerConfig.onBatteryShutdownAfterSecs = Int32(truncatingIfNeeded: config.onBatteryShutdownAfterSecs) + newPowerConfig.waitBluetoothSecs = Int32(truncatingIfNeeded: config.waitBluetoothSecs) + fetchedNode[0].powerConfig = newPowerConfig + } else { + fetchedNode[0].powerConfig?.adcMultiplierOverride = config.adcMultiplierOverride + fetchedNode[0].powerConfig?.deviceBatteryInaAddress = Int32(config.deviceBatteryInaAddress) + fetchedNode[0].powerConfig?.isPowerSaving = config.isPowerSaving + fetchedNode[0].powerConfig?.lsSecs = Int32(truncatingIfNeeded: config.lsSecs) + fetchedNode[0].powerConfig?.minWakeSecs = Int32(truncatingIfNeeded: config.minWakeSecs) + fetchedNode[0].powerConfig?.onBatteryShutdownAfterSecs = Int32(truncatingIfNeeded: config.onBatteryShutdownAfterSecs) + fetchedNode[0].powerConfig?.waitBluetoothSecs = Int32(truncatingIfNeeded: config.waitBluetoothSecs) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [PowerConfigEntity] Updated Power Config for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [PowerConfigEntity] Error Updating Core Data PowerConfigEntity: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [PowerConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Power Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [PowerConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertSecurityConfigPacket(config: Config.SecurityConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertSecurityConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertSecurityConfigPacket(config: Config.SecurityConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("mesh.log.security.config %@".localized, String(nodeNum)) + Logger.data.info("🛡️ \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Security Config + if !fetchedNode.isEmpty { + if fetchedNode[0].securityConfig == nil { + let newSecurityConfig = SecurityConfigEntity(context: context) + newSecurityConfig.publicKey = config.publicKey + newSecurityConfig.privateKey = config.privateKey + if config.adminKey.count > 0 { + newSecurityConfig.adminKey = config.adminKey[0] + } + newSecurityConfig.isManaged = config.isManaged + newSecurityConfig.serialEnabled = config.serialEnabled + newSecurityConfig.debugLogApiEnabled = config.debugLogApiEnabled + newSecurityConfig.adminChannelEnabled = config.adminChannelEnabled + fetchedNode[0].securityConfig = newSecurityConfig + } else { + fetchedNode[0].securityConfig?.publicKey = config.publicKey + fetchedNode[0].securityConfig?.privateKey = config.privateKey + if config.adminKey.count > 0 { + fetchedNode[0].securityConfig?.adminKey = config.adminKey[0] + if config.adminKey.count > 1 { + fetchedNode[0].securityConfig?.adminKey2 = config.adminKey[1] + } + if config.adminKey.count > 2 { + fetchedNode[0].securityConfig?.adminKey3 = config.adminKey[2] + } + } + fetchedNode[0].securityConfig?.isManaged = config.isManaged + fetchedNode[0].securityConfig?.serialEnabled = config.serialEnabled + fetchedNode[0].securityConfig?.debugLogApiEnabled = config.debugLogApiEnabled + fetchedNode[0].securityConfig?.adminChannelEnabled = config.adminChannelEnabled + } + if sessionPasskey?.count != 0 { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [SecurityConfigEntity] Updated Security Config for node: \(nodeNum.toHex(), privacy: .public)") + + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [SecurityConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [SecurityConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Security Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [SecurityConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertAmbientLightingModuleConfigPacket(config: ModuleConfig.AmbientLightingConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertAmbientLightingModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertAmbientLightingModuleConfigPacket(config: ModuleConfig.AmbientLightingConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Ambient Lighting module config received: %@".localized, String(nodeNum)) + Logger.data.info("🏮 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Ambient Lighting Config + if !fetchedNode.isEmpty { + + if fetchedNode[0].cannedMessageConfig == nil { + let newAmbientLightingConfig = AmbientLightingConfigEntity(context: context) + newAmbientLightingConfig.ledState = config.ledState + newAmbientLightingConfig.current = Int32(config.current) + newAmbientLightingConfig.red = Int32(config.red) + newAmbientLightingConfig.green = Int32(config.green) + newAmbientLightingConfig.blue = Int32(config.blue) + fetchedNode[0].ambientLightingConfig = newAmbientLightingConfig + } else { + + if fetchedNode[0].ambientLightingConfig == nil { + fetchedNode[0].ambientLightingConfig = AmbientLightingConfigEntity(context: context) + } + fetchedNode[0].ambientLightingConfig?.ledState = config.ledState + fetchedNode[0].ambientLightingConfig?.current = Int32(config.current) + fetchedNode[0].ambientLightingConfig?.red = Int32(config.red) + fetchedNode[0].ambientLightingConfig?.green = Int32(config.green) + fetchedNode[0].ambientLightingConfig?.blue = Int32(config.blue) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [AmbientLightingConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [AmbientLightingConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [AmbientLightingConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Ambient Lighting Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [AmbientLightingConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertCannedMessagesModuleConfigPacket(config: ModuleConfig.CannedMessageConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertCannedMessagesModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertCannedMessagesModuleConfigPacket(config: ModuleConfig.CannedMessageConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Canned Message module config received: %@".localized, String(nodeNum)) + Logger.data.info("🥫 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Canned Message Config + if !fetchedNode.isEmpty { + + if fetchedNode[0].cannedMessageConfig == nil { + let newCannedMessageConfig = CannedMessageConfigEntity(context: context) + newCannedMessageConfig.enabled = config.enabled + newCannedMessageConfig.sendBell = config.sendBell + newCannedMessageConfig.rotary1Enabled = config.rotary1Enabled + newCannedMessageConfig.updown1Enabled = config.updown1Enabled + newCannedMessageConfig.inputbrokerPinA = Int32(config.inputbrokerPinA) + newCannedMessageConfig.inputbrokerPinB = Int32(config.inputbrokerPinB) + newCannedMessageConfig.inputbrokerPinPress = Int32(config.inputbrokerPinPress) + newCannedMessageConfig.inputbrokerEventCw = Int32(config.inputbrokerEventCw.rawValue) + newCannedMessageConfig.inputbrokerEventCcw = Int32(config.inputbrokerEventCcw.rawValue) + newCannedMessageConfig.inputbrokerEventPress = Int32(config.inputbrokerEventPress.rawValue) + fetchedNode[0].cannedMessageConfig = newCannedMessageConfig + } else { + fetchedNode[0].cannedMessageConfig?.enabled = config.enabled + fetchedNode[0].cannedMessageConfig?.sendBell = config.sendBell + fetchedNode[0].cannedMessageConfig?.rotary1Enabled = config.rotary1Enabled + fetchedNode[0].cannedMessageConfig?.updown1Enabled = config.updown1Enabled + fetchedNode[0].cannedMessageConfig?.inputbrokerPinA = Int32(config.inputbrokerPinA) + fetchedNode[0].cannedMessageConfig?.inputbrokerPinB = Int32(config.inputbrokerPinB) + fetchedNode[0].cannedMessageConfig?.inputbrokerPinPress = Int32(config.inputbrokerPinPress) + fetchedNode[0].cannedMessageConfig?.inputbrokerEventCw = Int32(config.inputbrokerEventCw.rawValue) + fetchedNode[0].cannedMessageConfig?.inputbrokerEventCcw = Int32(config.inputbrokerEventCcw.rawValue) + fetchedNode[0].cannedMessageConfig?.inputbrokerEventPress = Int32(config.inputbrokerEventPress.rawValue) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [CannedMessageConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [CannedMessageConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [CannedMessageConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Canned Message Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [CannedMessageConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertDetectionSensorModuleConfigPacket(config: ModuleConfig.DetectionSensorConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertDetectionSensorModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertDetectionSensorModuleConfigPacket(config: ModuleConfig.DetectionSensorConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Detection Sensor module config received: %@".localized, String(nodeNum)) + Logger.data.info("🕵️ \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Detection Sensor Config + if !fetchedNode.isEmpty { + if fetchedNode[0].detectionSensorConfig == nil { + let newConfig = DetectionSensorConfigEntity(context: context) + newConfig.enabled = config.enabled + newConfig.sendBell = config.sendBell + newConfig.name = config.name + newConfig.monitorPin = Int32(config.monitorPin) + newConfig.triggerType = Int32(config.detectionTriggerType.rawValue) + newConfig.usePullup = config.usePullup + newConfig.minimumBroadcastSecs = Int32(truncatingIfNeeded: config.minimumBroadcastSecs) + newConfig.stateBroadcastSecs = Int32(truncatingIfNeeded: config.stateBroadcastSecs) + fetchedNode[0].detectionSensorConfig = newConfig + } else { + fetchedNode[0].detectionSensorConfig?.enabled = config.enabled + fetchedNode[0].detectionSensorConfig?.sendBell = config.sendBell + fetchedNode[0].detectionSensorConfig?.name = config.name + fetchedNode[0].detectionSensorConfig?.monitorPin = Int32(config.monitorPin) + fetchedNode[0].detectionSensorConfig?.usePullup = config.usePullup + fetchedNode[0].detectionSensorConfig?.triggerType = Int32(config.detectionTriggerType.rawValue) + fetchedNode[0].detectionSensorConfig?.minimumBroadcastSecs = Int32(truncatingIfNeeded: config.minimumBroadcastSecs) + fetchedNode[0].detectionSensorConfig?.stateBroadcastSecs = Int32(truncatingIfNeeded: config.stateBroadcastSecs) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [DetectionSensorConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [DetectionSensorConfigEntity] Error Updating Core Data : \(nsError, privacy: .public)") + } + + } else { + Logger.data.error("💥 [DetectionSensorConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Detection Sensor Module Config") + } + + } catch { + let nsError = error as NSError + Logger.data.error("💥 [DetectionSensorConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertExternalNotificationModuleConfigPacket(config: ModuleConfig.ExternalNotificationConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertExternalNotificationModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertExternalNotificationModuleConfigPacket(config: ModuleConfig.ExternalNotificationConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("External Notification module config received: %@".localized, String(nodeNum)) + Logger.data.info("📣 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save External Notificaitone Config + if !fetchedNode.isEmpty { + + if fetchedNode[0].externalNotificationConfig == nil { + let newExternalNotificationConfig = ExternalNotificationConfigEntity(context: context) + newExternalNotificationConfig.enabled = config.enabled + newExternalNotificationConfig.usePWM = config.usePwm + newExternalNotificationConfig.alertBell = config.alertBell + newExternalNotificationConfig.alertBellBuzzer = config.alertBellBuzzer + newExternalNotificationConfig.alertBellVibra = config.alertBellVibra + newExternalNotificationConfig.alertMessage = config.alertMessage + newExternalNotificationConfig.alertMessageBuzzer = config.alertMessageBuzzer + newExternalNotificationConfig.alertMessageVibra = config.alertMessageVibra + newExternalNotificationConfig.active = config.active + newExternalNotificationConfig.output = Int32(config.output) + newExternalNotificationConfig.outputBuzzer = Int32(config.outputBuzzer) + newExternalNotificationConfig.outputVibra = Int32(config.outputVibra) + newExternalNotificationConfig.outputMilliseconds = Int32(config.outputMs) + newExternalNotificationConfig.nagTimeout = Int32(config.nagTimeout) + newExternalNotificationConfig.useI2SAsBuzzer = config.useI2SAsBuzzer + fetchedNode[0].externalNotificationConfig = newExternalNotificationConfig + } else { + fetchedNode[0].externalNotificationConfig?.enabled = config.enabled + fetchedNode[0].externalNotificationConfig?.usePWM = config.usePwm + fetchedNode[0].externalNotificationConfig?.alertBell = config.alertBell + fetchedNode[0].externalNotificationConfig?.alertBellBuzzer = config.alertBellBuzzer + fetchedNode[0].externalNotificationConfig?.alertBellVibra = config.alertBellVibra + fetchedNode[0].externalNotificationConfig?.alertMessage = config.alertMessage + fetchedNode[0].externalNotificationConfig?.alertMessageBuzzer = config.alertMessageBuzzer + fetchedNode[0].externalNotificationConfig?.alertMessageVibra = config.alertMessageVibra + fetchedNode[0].externalNotificationConfig?.active = config.active + fetchedNode[0].externalNotificationConfig?.output = Int32(config.output) + fetchedNode[0].externalNotificationConfig?.outputBuzzer = Int32(config.outputBuzzer) + fetchedNode[0].externalNotificationConfig?.outputVibra = Int32(config.outputVibra) + fetchedNode[0].externalNotificationConfig?.outputMilliseconds = Int32(config.outputMs) + fetchedNode[0].externalNotificationConfig?.nagTimeout = Int32(config.nagTimeout) + fetchedNode[0].externalNotificationConfig?.useI2SAsBuzzer = config.useI2SAsBuzzer + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [ExternalNotificationConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [ExternalNotificationConfigEntity] Error Updating Core Data : \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [ExternalNotificationConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save External Notification Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [ExternalNotificationConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertPaxCounterModuleConfigPacket(config: ModuleConfig.PaxcounterConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertPaxCounterModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertPaxCounterModuleConfigPacket(config: ModuleConfig.PaxcounterConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("PAX Counter config received: %@".localized, String(nodeNum)) + Logger.data.info("🧑‍🤝‍🧑 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save PAX Counter Config + if !fetchedNode.isEmpty { + if fetchedNode[0].paxCounterConfig == nil { + let newPaxCounterConfig = PaxCounterConfigEntity(context: context) + newPaxCounterConfig.enabled = config.enabled + newPaxCounterConfig.updateInterval = Int32(config.paxcounterUpdateInterval) + fetchedNode[0].paxCounterConfig = newPaxCounterConfig + } else { + fetchedNode[0].paxCounterConfig?.enabled = config.enabled + fetchedNode[0].paxCounterConfig?.updateInterval = Int32(config.paxcounterUpdateInterval) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [PaxCounterConfigEntity] Updated for node number: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [PaxCounterConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [PaxCounterConfigEntity] No Nodes found in local database matching node number \(nodeNum.toHex(), privacy: .public) unable to save PAX Counter Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [PaxCounterConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertRtttlConfigPacket(ringtone: String, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertRtttlConfigPacket(ringtone: ringtone, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertRtttlConfigPacket(ringtone: String, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("RTTTL Ringtone config received: %@".localized, String(nodeNum)) + Logger.data.info("⛰️ \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save RTTTL Config + if !fetchedNode.isEmpty { + if fetchedNode[0].rtttlConfig == nil { + let newRtttlConfig = RTTTLConfigEntity(context: context) + newRtttlConfig.ringtone = ringtone + fetchedNode[0].rtttlConfig = newRtttlConfig + } else { + fetchedNode[0].rtttlConfig?.ringtone = ringtone + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [RtttlConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [RtttlConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [RtttlConfigEntity] No nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save RTTTL Ringtone Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [RtttlConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertMqttModuleConfigPacket(config: ModuleConfig.MQTTConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertMqttModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertMqttModuleConfigPacket(config: ModuleConfig.MQTTConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("MQTT module config received: %@".localized, String(nodeNum)) + Logger.data.info("🌉 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save MQTT Config + if !fetchedNode.isEmpty { + if fetchedNode[0].mqttConfig == nil { + let newMQTTConfig = MQTTConfigEntity(context: context) + newMQTTConfig.enabled = config.enabled + newMQTTConfig.proxyToClientEnabled = config.proxyToClientEnabled + newMQTTConfig.address = config.address + newMQTTConfig.username = config.username + newMQTTConfig.password = config.password + newMQTTConfig.root = config.root + newMQTTConfig.encryptionEnabled = config.encryptionEnabled + newMQTTConfig.jsonEnabled = config.jsonEnabled + newMQTTConfig.tlsEnabled = config.tlsEnabled + newMQTTConfig.mapReportingEnabled = config.mapReportingEnabled + newMQTTConfig.mapReportingShouldReportLocation = config.mapReportSettings.shouldReportLocation + newMQTTConfig.mapPositionPrecision = Int32(config.mapReportSettings.positionPrecision) + newMQTTConfig.mapPublishIntervalSecs = Int32(config.mapReportSettings.publishIntervalSecs) + fetchedNode[0].mqttConfig = newMQTTConfig + } else { + fetchedNode[0].mqttConfig?.enabled = config.enabled + fetchedNode[0].mqttConfig?.proxyToClientEnabled = config.proxyToClientEnabled + fetchedNode[0].mqttConfig?.address = config.address + fetchedNode[0].mqttConfig?.username = config.username + fetchedNode[0].mqttConfig?.password = config.password + fetchedNode[0].mqttConfig?.root = config.root + fetchedNode[0].mqttConfig?.encryptionEnabled = config.encryptionEnabled + fetchedNode[0].mqttConfig?.jsonEnabled = config.jsonEnabled + fetchedNode[0].mqttConfig?.tlsEnabled = config.tlsEnabled + fetchedNode[0].mqttConfig?.mapReportingEnabled = config.mapReportingEnabled + fetchedNode[0].mqttConfig?.mapPositionPrecision = Int32(config.mapReportSettings.positionPrecision) + fetchedNode[0].mqttConfig?.mapPublishIntervalSecs = Int32(config.mapReportSettings.publishIntervalSecs) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [MQTTConfigEntity] Updated for node number: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [MQTTConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [MQTTConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save MQTT Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [MQTTConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertRangeTestModuleConfigPacket(config: ModuleConfig.RangeTestConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertRangeTestModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertRangeTestModuleConfigPacket(config: ModuleConfig.RangeTestConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Range Test module config received: %@".localized, String(nodeNum)) + Logger.data.info("⛰️ \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Device Config + if !fetchedNode.isEmpty { + if fetchedNode[0].rangeTestConfig == nil { + let newRangeTestConfig = RangeTestConfigEntity(context: context) + newRangeTestConfig.sender = Int32(config.sender) + newRangeTestConfig.enabled = config.enabled + newRangeTestConfig.save = config.save + fetchedNode[0].rangeTestConfig = newRangeTestConfig + } else { + fetchedNode[0].rangeTestConfig?.sender = Int32(config.sender) + fetchedNode[0].rangeTestConfig?.enabled = config.enabled + fetchedNode[0].rangeTestConfig?.save = config.save + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [RangeTestConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [RangeTestConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [RangeTestConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Range Test Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [RangeTestConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertSerialModuleConfigPacket(config: ModuleConfig.SerialConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertSerialModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertSerialModuleConfigPacket(config: ModuleConfig.SerialConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Serial module config received: %@".localized, String(nodeNum)) + Logger.data.info("🤖 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Device Config + if !fetchedNode.isEmpty { + if fetchedNode[0].serialConfig == nil { + let newSerialConfig = SerialConfigEntity(context: context) + newSerialConfig.enabled = config.enabled + newSerialConfig.echo = config.echo + newSerialConfig.rxd = Int32(config.rxd) + newSerialConfig.txd = Int32(config.txd) + newSerialConfig.baudRate = Int32(config.baud.rawValue) + newSerialConfig.timeout = Int32(config.timeout) + newSerialConfig.mode = Int32(config.mode.rawValue) + fetchedNode[0].serialConfig = newSerialConfig + } else { + fetchedNode[0].serialConfig?.enabled = config.enabled + fetchedNode[0].serialConfig?.echo = config.echo + fetchedNode[0].serialConfig?.rxd = Int32(config.rxd) + fetchedNode[0].serialConfig?.txd = Int32(config.txd) + fetchedNode[0].serialConfig?.baudRate = Int32(config.baud.rawValue) + fetchedNode[0].serialConfig?.timeout = Int32(config.timeout) + fetchedNode[0].serialConfig?.mode = Int32(config.mode.rawValue) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [SerialConfigEntity]Updated Serial Module Config for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + + context.rollback() + + let nsError = error as NSError + Logger.data.error("💥 [SerialConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [SerialConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Serial Module Config") + } + } catch { + + let nsError = error as NSError + Logger.data.error("💥 [SerialConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertStoreForwardModuleConfigPacket(config: ModuleConfig.StoreForwardConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertStoreForwardModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertStoreForwardModuleConfigPacket(config: ModuleConfig.StoreForwardConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Store & Forward module config received: %@".localized, String(nodeNum)) + Logger.data.info("📬 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Store & Forward Sensor Config + if !fetchedNode.isEmpty { + if fetchedNode[0].storeForwardConfig == nil { + let newConfig = StoreForwardConfigEntity(context: context) + newConfig.enabled = config.enabled + newConfig.heartbeat = config.heartbeat + newConfig.records = Int32(config.records) + newConfig.historyReturnMax = Int32(config.historyReturnMax) + newConfig.historyReturnWindow = Int32(config.historyReturnWindow) + newConfig.isRouter = config.isServer + fetchedNode[0].storeForwardConfig = newConfig + } else { + fetchedNode[0].storeForwardConfig?.enabled = config.enabled + fetchedNode[0].storeForwardConfig?.heartbeat = config.heartbeat + fetchedNode[0].storeForwardConfig?.records = Int32(config.records) + fetchedNode[0].storeForwardConfig?.historyReturnMax = Int32(config.historyReturnMax) + fetchedNode[0].storeForwardConfig?.historyReturnWindow = Int32(config.historyReturnWindow) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [StoreForwardConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [StoreForwardConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [StoreForwardConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Store & Forward Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [StoreForwardConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertTelemetryModuleConfigPacket(config: ModuleConfig.TelemetryConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertTelemetryModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertTelemetryModuleConfigPacket(config: ModuleConfig.TelemetryConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Telemetry module config received: %@".localized, String(nodeNum)) + Logger.data.info("📈 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Telemetry Config + if !fetchedNode.isEmpty { + if fetchedNode[0].telemetryConfig == nil { + let newTelemetryConfig = TelemetryConfigEntity(context: context) + newTelemetryConfig.deviceUpdateInterval = Int32(truncatingIfNeeded: config.deviceUpdateInterval) + newTelemetryConfig.deviceTelemetryEnabled = config.deviceTelemetryEnabled + newTelemetryConfig.environmentUpdateInterval = Int32(truncatingIfNeeded: config.environmentUpdateInterval) + newTelemetryConfig.environmentMeasurementEnabled = config.environmentMeasurementEnabled + newTelemetryConfig.environmentScreenEnabled = config.environmentScreenEnabled + newTelemetryConfig.environmentDisplayFahrenheit = config.environmentDisplayFahrenheit + newTelemetryConfig.powerMeasurementEnabled = config.powerMeasurementEnabled + newTelemetryConfig.powerUpdateInterval = Int32(truncatingIfNeeded: config.powerUpdateInterval) + newTelemetryConfig.powerScreenEnabled = config.powerScreenEnabled + fetchedNode[0].telemetryConfig = newTelemetryConfig + } else { + fetchedNode[0].telemetryConfig?.deviceUpdateInterval = Int32(truncatingIfNeeded: config.deviceUpdateInterval) + fetchedNode[0].telemetryConfig?.deviceTelemetryEnabled = config.deviceTelemetryEnabled + fetchedNode[0].telemetryConfig?.environmentUpdateInterval = Int32(truncatingIfNeeded: config.environmentUpdateInterval) + fetchedNode[0].telemetryConfig?.environmentMeasurementEnabled = config.environmentMeasurementEnabled + fetchedNode[0].telemetryConfig?.environmentScreenEnabled = config.environmentScreenEnabled + fetchedNode[0].telemetryConfig?.environmentDisplayFahrenheit = config.environmentDisplayFahrenheit + fetchedNode[0].telemetryConfig?.powerMeasurementEnabled = config.powerMeasurementEnabled + fetchedNode[0].telemetryConfig?.powerUpdateInterval = Int32(truncatingIfNeeded: config.powerUpdateInterval) + fetchedNode[0].telemetryConfig?.powerScreenEnabled = config.powerScreenEnabled + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [TelemetryConfigEntity] Updated Telemetry Module Config for node: \(nodeNum.toHex(), privacy: .public)") + + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [TelemetryConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + + } else { + Logger.data.error("💥 [TelemetryConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Telemetry Module Config") + } + + } catch { + let nsError = error as NSError + Logger.data.error("💥 [TelemetryConfigEntity] Fetching node for core data TelemetryConfigEntity failed: \(nsError, privacy: .public)") + } + } + + func upsertTAKModuleConfigPacket(config: ModuleConfig.TAKConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertTAKModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertTAKModuleConfigPacket(config: ModuleConfig.TAKConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("TAK module config received: %@".localized, String(nodeNum)) + Logger.data.info("🎯 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + if !fetchedNode.isEmpty { + if fetchedNode[0].takConfig == nil { + let newTAKConfig = TAKConfigEntity(context: context) + newTAKConfig.team = Int32(config.team.rawValue) + newTAKConfig.role = Int32(config.role.rawValue) + fetchedNode[0].takConfig = newTAKConfig + } else { + fetchedNode[0].takConfig?.team = Int32(config.team.rawValue) + fetchedNode[0].takConfig?.role = Int32(config.role.rawValue) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [TAKConfigEntity] Updated TAK Module Config for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [TAKConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [TAKConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save TAK Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [TAKConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } +}.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 10/3/22. + +import CoreData +import MeshtasticProtobufs +import OSLog + +extension MeshPackets { + public func clearStaleNodes(nodeExpireDays: Int) async -> Bool { + let context = self.backgroundContext + return await context.perform { + return self.clearStaleNodes(nodeExpireDays: nodeExpireDays, context: context) + } + } + + nonisolated public func clearStaleNodes(nodeExpireDays: Int, context: NSManagedObjectContext) -> Bool { + var nodeExpireTime: TimeInterval { + return TimeInterval(-nodeExpireDays * 86400) + } + var nodePKIExpireTime: TimeInterval { + return TimeInterval((nodeExpireDays < 7 ? -7 : -nodeExpireDays) * 86400) + } + + if nodeExpireDays == 0 { + // Purge Disabled + Logger.data.info("💾 [NodeInfoEntity] Skip clearing stale nodes") + return false + } + let fetchRequest = NSFetchRequest(entityName: "NodeInfoEntity") + fetchRequest.predicate = NSPredicate(format: "favorite == false AND ignored == false AND ((user.pkiEncrypted == NO AND lastHeard < %@) OR (user.pkiEncrypted == YES AND lastHeard < %@))", + NSDate(timeIntervalSinceNow: nodeExpireTime), NSDate(timeIntervalSinceNow: nodePKIExpireTime)) + let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + batchDeleteRequest.resultType = .resultTypeCount + + do { + Logger.data.info("💾 [NodeInfoEntity] Clearing nodes older than \(nodeExpireDays) days") + if let batchDeleteResult = try context.execute(batchDeleteRequest) as? NSBatchDeleteResult { + try context.save() + let deletedNodes = batchDeleteResult.result as? Int ?? 0 + Logger.data.info("💾 [NodeInfoEntity] Cleared \(deletedNodes) stale nodes") + if deletedNodes > 0 { + return true + } + } else { + Logger.data.error("💥 [NodeInfoEntity] bad delete results") + } + } catch { + context.rollback() + Logger.data.error("💥 [NodeInfoEntity] Error deleting stale nodes") + } + return false + } + + func clearPax(destNum: Int64) async -> Bool { + let context = self.backgroundContext + return await context.perform { + return self.clearPax(destNum: destNum, context: context) + } + } + + nonisolated public func clearPax(destNum: Int64, context: NSManagedObjectContext) -> Bool { + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(destNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + let newPax = [PaxCounterLog]() + fetchedNode[0].pax? = NSOrderedSet(array: newPax) + do { + try context.save() + return true + + } catch { + context.rollback() + return false + } + } catch { + Logger.data.error("💥 [NodeInfoEntity] fetch data error") + return false + } + } + + public func clearPositions(destNum: Int64) async -> Bool { + let context = self.backgroundContext + return await context.perform { + return self.clearPositions(destNum: destNum, context: context) + } + } + + nonisolated public func clearPositions(destNum: Int64, context: NSManagedObjectContext) -> Bool { + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(destNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + let newPostions = [PositionEntity]() + fetchedNode[0].positions? = NSOrderedSet(array: newPostions) + do { + try context.save() + return true + + } catch { + context.rollback() + return false + } + } catch { + Logger.data.error("💥 [NodeInfoEntity] fetch data error") + return false + } + } + + public func clearTelemetry(destNum: Int64, metricsType: Int32) async -> Bool { + let context = self.backgroundContext + return await context.perform { + return self.clearTelemetry(destNum: destNum, metricsType: metricsType, context: context) + } + } + + nonisolated public func clearTelemetry(destNum: Int64, metricsType: Int32, context: NSManagedObjectContext) -> Bool { + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(destNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + let emptyTelemetry = [TelemetryEntity]() + fetchedNode[0].telemetries? = NSOrderedSet(array: emptyTelemetry) + do { + try context.save() + return true + + } catch { + context.rollback() + return false + } + } catch { + Logger.data.error("💥 [NodeInfoEntity] fetch data error") + return false + } + } + + public func deleteChannelMessages(channel: ChannelEntity) async { + let context = self.backgroundContext + let objectId = channel.objectID + await context.perform { + if let channelObject = context.object(with: objectId) as? ChannelEntity { + self.deleteChannelMessages(channel: channelObject, context: context) + } + } + } + + nonisolated public func deleteChannelMessages(channel: ChannelEntity, context: NSManagedObjectContext) { + do { + // Copied logic from ChannelEntity.allPrivateMessages, which is always on the MainActor + // But this code may not be on the MainActor. + let fetchRequest = MessageEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "channel == %ld AND toUser == nil AND isEmoji == false", channel.index) + let objects = (try? context.fetch(fetchRequest)) ?? [MessageEntity]() + + for object in objects { + context.delete(object) + } + + try context.save() + } catch let error as NSError { + Logger.data.error("\(error.localizedDescription, privacy: .public)") + } + } + + public func deleteUserMessages(user: UserEntity) async { + let context = self.backgroundContext + let objectId = user.objectID + await context.perform { + if let userObject = context.object(with: objectId) as? UserEntity { + self.deleteUserMessages(user: userObject, context: context) + } + } + } + + nonisolated public func deleteUserMessages(user: UserEntity, context: NSManagedObjectContext) { + do { + // Fetch messages using the same context that will perform the deletes. + // user.messageList fetches from viewContext, which would cause a context-mismatch + // crash when this method is called with a background context. + let fetchRequest = MessageEntity.fetchRequest() + fetchRequest.predicate = user.messageFetchRequest.predicate + let objects = (try? context.fetch(fetchRequest)) ?? [] for object in objects { context.delete(object) } diff --git a/Meshtastic/Views/Connect/Connect.swift b/Meshtastic/Views/Connect/Connect.swift index 84e90c6e..d72cc5c0 100644 --- a/Meshtastic/Views/Connect/Connect.swift +++ b/Meshtastic/Views/Connect/Connect.swift @@ -25,7 +25,10 @@ struct Connect: View { @State var node: NodeInfoEntity? @State var isUnsetRegion = false @State var invalidFirmwareVersion = false + @State var showSecurityVersionNag = false +#if !targetEnvironment(macCatalyst) @State var liveActivityStarted = false +#endif @ObservedObject var manualConnections = ManualConnectionList.shared var body: some View { @@ -347,6 +350,16 @@ struct Connect: View { // .onChange(of: accessoryManager) { // invalidFirmwareVersion = self.bleManager.invalidVersion // } + .sheet(isPresented: $invalidFirmwareVersion) { + InvalidVersion(minimumVersion: accessoryManager.minimumVersion, version: accessoryManager.activeConnection?.device.firmwareVersion ?? "?.?.?") + .presentationDetents([.large]) + .presentationDragIndicator(.automatic) + } + .sheet(isPresented: $showSecurityVersionNag) { + SecurityVersionNag(minimumSecureVersion: accessoryManager.securityVersion, version: accessoryManager.activeConnection?.device.firmwareVersion ?? "?.?.?") + .presentationDetents([.large]) + .presentationDragIndicator(.automatic) + } .onChange(of: self.accessoryManager.state) { _, state in if let deviceNum = accessoryManager.activeDeviceNum, UserDefaults.preferredPeripheralId.count > 0 && state == .subscribed { @@ -364,6 +377,11 @@ struct Connect: View { } catch { Logger.data.error("💥 Error fetching node info: \(error.localizedDescription, privacy: .public)") } + // Check firmware version on connection + let meetsMinimumVersion = accessoryManager.checkIsVersionSupported(forVersion: accessoryManager.minimumVersion) + let meetsSecurityVersion = accessoryManager.checkIsVersionSupported(forVersion: accessoryManager.securityVersion) + invalidFirmwareVersion = !meetsMinimumVersion + showSecurityVersionNag = meetsMinimumVersion && !meetsSecurityVersion } } } @@ -376,7 +394,7 @@ struct Connect: View { let localStats = node?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4")) let mostRecent = localStats?.lastObject as? TelemetryEntity - let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName?.addingVariationSelectors ?? "unknown") + let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName?.addingVariationSelectors ?? "unknown", shortName: node?.user?.shortName ?? "?") let future = Date(timeIntervalSinceNow: Double(timerSeconds)) let initialContentState = MeshActivityAttributes.ContentState(uptimeSeconds: UInt32(mostRecent?.uptimeSeconds ?? 0), diff --git a/Meshtastic/Views/Connect/InvalidVersion.swift b/Meshtastic/Views/Connect/InvalidVersion.swift index d6030139..2b1227c1 100644 --- a/Meshtastic/Views/Connect/InvalidVersion.swift +++ b/Meshtastic/Views/Connect/InvalidVersion.swift @@ -10,55 +10,97 @@ struct InvalidVersion: View { @Environment(\.dismiss) private var dismiss - @State var minimumVersion = "" - @State var version = "" + let minimumVersion: String + let version: String var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 20) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 60)) + .foregroundColor(.orange) + .padding(.top, 40) - VStack { - - Text("Update Your Firmware") - .font(.largeTitle) - .foregroundColor(.orange) - - Divider() - VStack { - Text("The Meshtastic Apple apps support firmware version \(minimumVersion) and above.") - .font(.title2) - .padding(.bottom) - Link("Firmware update docs", destination: URL(string: "https://meshtastic.org/docs/getting-started/flashing-firmware/")!) - .font(.title) - .padding() - Link("Additional help", destination: URL(string: "https://meshtastic.org/docs/faq")!) - .font(.title) - .padding() - } - .padding() - Divider() - .padding(.top) - VStack { - Text("🦕 End of life Version 🦖 ☄️") - .font(.title3) - .foregroundColor(.orange) - .padding(.bottom) - Text("Version \(minimumVersion) includes substantial network optimizations and extensive changes to devices and client apps. Only nodes version \(minimumVersion) and above are supported.") - .font(.callout) - .padding([.leading, .trailing, .bottom]) - - #if targetEnvironment(macCatalyst) - Button { - dismiss() - } label: { - Label("Close", systemImage: "xmark") + Text("Firmware Update Required") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + VStack(spacing: 8) { + if !version.isEmpty { + Label { + Text("Connected firmware: **\(version)**") + } icon: { + Image(systemName: "wifi.slash") + .foregroundColor(.red) + } + .font(.body) + } + Label { + Text("Minimum required: **\(minimumVersion)**") + } icon: { + Image(systemName: "checkmark.shield.fill") + .foregroundColor(.green) + } + .font(.body) } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.large) .padding() - #endif + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) - }.padding() + Text("The Meshtastic Apple app requires firmware version \(minimumVersion) or later. Older firmware versions are no longer supported and may have compatibility issues or missing features.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal) + + VStack(alignment: .leading, spacing: 12) { + Text("How to Update") + .font(.headline) + Link(destination: URL(string: "https://flasher.meshtastic.org")!) { + Label("Open Web Flasher", systemImage: "bolt.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .buttonBorderShape(.capsule) + Link(destination: URL(string: "https://meshtastic.org/docs/getting-started/flashing-firmware/")!) { + Label("Firmware Update Docs", systemImage: "book.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.regular) + .buttonBorderShape(.capsule) + Link(destination: URL(string: "https://meshtastic.org/docs/faq")!) { + Label("Additional Help", systemImage: "questionmark.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.regular) + .buttonBorderShape(.capsule) + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + .padding(.horizontal) + } + .padding(.bottom, 20) + } + + #if targetEnvironment(macCatalyst) + Button { + dismiss() + } label: { + Label("Close", systemImage: "xmark") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + #endif } } } diff --git a/Meshtastic/Views/Connect/SecurityVersionNag.swift b/Meshtastic/Views/Connect/SecurityVersionNag.swift new file mode 100644 index 00000000..1327b674 --- /dev/null +++ b/Meshtastic/Views/Connect/SecurityVersionNag.swift @@ -0,0 +1,103 @@ +// +// SecurityVersionNag.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 2024. +// +import SwiftUI + +struct SecurityVersionNag: View { + + @Environment(\.dismiss) private var dismiss + + let minimumSecureVersion: String + let version: String + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 20) { + Image(systemName: "shield.slash.fill") + .font(.system(size: 60)) + .foregroundColor(.red) + .padding(.top, 40) + + Text("Security Update Recommended") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + VStack(spacing: 8) { + if !version.isEmpty { + Label { + Text("Connected firmware: **\(version)**") + } icon: { + Image(systemName: "wifi.exclamationmark") + .foregroundColor(.orange) + } + .font(.body) + } + Label { + Text("Recommended secure version: **\(minimumSecureVersion)**") + } icon: { + Image(systemName: "checkmark.shield.fill") + .foregroundColor(.green) + } + .font(.body) + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + + VStack(alignment: .leading, spacing: 12) { + Text("Security Advisory") + .font(.headline) + Text("Your connected device is running firmware older than **\(minimumSecureVersion)**, which contains known security vulnerabilities. Updating your firmware is strongly recommended to protect your device and mesh network.") + .font(.body) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + .padding(.horizontal) + + VStack(alignment: .leading, spacing: 12) { + Text("How to Update") + .font(.headline) + Link(destination: URL(string: "https://flasher.meshtastic.org")!) { + Label("Open Web Flasher", systemImage: "bolt.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .buttonBorderShape(.capsule) + Link(destination: URL(string: "https://meshtastic.org/docs/getting-started/flashing-firmware/")!) { + Label("Firmware Update Docs", systemImage: "book.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.regular) + .buttonBorderShape(.capsule) + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + .padding(.horizontal) + } + .padding(.bottom, 20) + } + + Button { + dismiss() + } label: { + Text("Dismiss") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + } + } +} diff --git a/Meshtastic/Views/Helpers/CompassView.swift b/Meshtastic/Views/Helpers/CompassView.swift index b51ae84f..d0e63939 100644 --- a/Meshtastic/Views/Helpers/CompassView.swift +++ b/Meshtastic/Views/Helpers/CompassView.swift @@ -15,7 +15,6 @@ struct CompassView: View { let waypointLocation: CLLocationCoordinate2D? let waypointLongName: String? let waypointShortName: String? - var waypointName: String? { waypointLongName } let color: Color @ObservedObject private var locationsHandler = LocationsHandler.shared @@ -83,7 +82,7 @@ struct CompassView: View { if waypointLongName != nil || waypointLocation != nil { Spacer() VStack(spacing: 4) { - Text(waypointLongName ?? waypointName ?? "Waypoint") + Text(waypointLongName ?? "Waypoint") .font(.largeTitle) if let bearing = bearingToWaypoint() { diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift index 39e42baa..ed6e71d7 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift @@ -40,7 +40,7 @@ struct MeshMapContent: MapContent { @AppStorage("mapOverlaysEnabled") private var showMapOverlays = false @Binding var enabledOverlayConfigs: Set - @FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .easeIn) + @FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .none) var positions: FetchedResults @FetchRequest(fetchRequest: WaypointEntity.allWaypointssFetchRequest(), animation: .none) @@ -184,10 +184,13 @@ struct MeshMapContent: MapContent { @MapContentBuilder var meshMap: some MapContent { - let loraNodes = positions.filter { $0.nodePosition?.viaMqtt ?? true == false } - let loraCoords = Array(loraNodes).compactMap({(position) -> CLLocationCoordinate2D in - return position.nodeCoordinate ?? LocationsHandler.DefaultLocation - }) + // Only compute LoRa node coordinates when the convex hull is actually displayed. + // The filter scans the entire positions array on every render, so guard it. + let loraCoords: [CLLocationCoordinate2D] = showConvexHull + ? positions + .filter { !($0.nodePosition?.viaMqtt ?? true) } + .compactMap { $0.nodeCoordinate ?? LocationsHandler.DefaultLocation } + : [] /// Convex Hull if showConvexHull { if loraCoords.count > 0 { @@ -214,8 +217,10 @@ struct MeshMapContent: MapContent { let allStyledFeatures = GeoJSONOverlayManager.shared.loadStyledFeaturesForConfigs(enabledOverlayConfigs) return Group { - ForEach(0.. UIImage { - // Render to UIImage once so we don't have to do a ton of vector operations and layers when there are thousands of history points. + if let cached = NodeMapContent.circleImageCache[node.num] { return cached } let content = Circle() .fill(fill) .strokeBorder(stroke, lineWidth: 2) .frame(width: 12, height: 12) let renderer = ImageRenderer(content: content) renderer.scale = UIScreen.main.scale - return renderer.uiImage! + let image = renderer.uiImage! + NodeMapContent.circleImageCache[node.num] = image + return image } private func prerenderHistoryPointArrow(fill: Color, stroke: Color) -> UIImage { - // Render to UIImage once so we don't have to do a ton of vector operations and layers when there are thousands of history points. + if let cached = NodeMapContent.arrowImageCache[node.num] { return cached } let content = Image(systemName: "location.north.circle") .resizable() .scaledToFit() @@ -181,6 +189,8 @@ struct NodeMapContent: MapContent { .frame(width: 16, height: 16) let renderer = ImageRenderer(content: content) renderer.scale = UIScreen.main.scale - return renderer.uiImage! + let image = renderer.uiImage! + NodeMapContent.arrowImageCache[node.num] = image + return image } } diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift index fd142007..2d04e861 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift @@ -61,6 +61,18 @@ struct MapLegend: View { .navigationTitle("Map Legend") .navigationBarTitleDisplayMode(.inline) } +#if targetEnvironment(macCatalyst) + Spacer() + Button { + dismiss() + } label: { + Label("Close", systemImage: "xmark") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) +#endif } // MARK: - Sections diff --git a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift index f0c9f1f1..66eb6e3c 100644 --- a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift +++ b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift @@ -1,4 +1,5 @@ import CoreBluetooth +import Intents import OSLog import SwiftUI import Foundation @@ -8,11 +9,14 @@ struct DeviceOnboarding: View { enum SetupGuide: Hashable { case notifications case location + case backgroundActivity case localNetwork case bluetooth + case siri } @EnvironmentObject var accessoryManager: AccessoryManager + @ObservedObject private var locationsHandler: LocationsHandler = .shared @State var navigationPath: [SetupGuide] = [] @State var locationStatus = LocationsHandler.shared.manager.authorizationStatus @AppStorage("provideLocation") private var provideLocation: Bool = false @@ -21,25 +25,20 @@ struct DeviceOnboarding: View { /// The Title View var title: some View { VStack { - Text("Welcome to") - .font(.title2.bold()) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - Text("Meshtastic") - .font(.largeTitle.bold()) + Text("Welcome to Meshtastic") + .font(.title.bold()) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) } } var welcomeView: some View { - VStack { + VStack(spacing: 0) { ScrollView(.vertical) { VStack { // Title title .padding(.top) - // Onboarding VStack(alignment: .leading, spacing: 16) { makeRow( icon: "antenna.radiowaves.left.and.right", @@ -59,14 +58,34 @@ struct DeviceOnboarding: View { makeRow( icon: "person.2.shield", title: String(localized: "User Privacy"), - subtitle: String(localized: "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.") + subtitle: String(localized: "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app.") + ) + makeRow( + icon: "bell.badge", + title: String(localized: "Message Notifications"), + subtitle: String(localized: "Receive notifications for incoming messages and critical alerts even when the app is in the background.") + ) + makeRow( + icon: "custom.bluetooth", + title: String(localized: "Bluetooth Connectivity"), + subtitle: String(localized: "Connect to your Meshtastic node via Bluetooth Low Energy for the best messaging experience.") + ) + makeRow( + icon: "network", + title: String(localized: "Local Network Access"), + subtitle: String(localized: "Connect to nodes on your local Wi-Fi network.") + ) + makeRow( + icon: "car.fill", + title: String(localized: "Siri & CarPlay"), + subtitle: String(localized: "Send and receive Meshtastic messages hands-free using Siri and CarPlay.") ) } - .padding() + .padding(.horizontal) + .padding(.bottom) } - .interactiveDismissDisabled() } - Spacer() + .interactiveDismissDisabled() Button { Task { await goToNextStep(after: nil) @@ -75,10 +94,7 @@ struct DeviceOnboarding: View { Text("Get started") .frame(maxWidth: .infinity) } - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding() - .buttonStyle(.borderedProminent) + .capsuleButtonStyle() } } @@ -133,10 +149,7 @@ struct DeviceOnboarding: View { Text("Configure notification permissions") .frame(maxWidth: .infinity) } - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding() - .buttonStyle(.borderedProminent) + .capsuleButtonStyle() } } @@ -202,10 +215,64 @@ struct DeviceOnboarding: View { .frame(maxWidth: .infinity) } .padding() - .buttonBorderShape(.capsule) - .controlSize(.large) + .capsuleButtonStyle() + } + } + + var backgroundActivityView: some View { + VStack { + ScrollView(.vertical) { + VStack { + Text("Background Activity") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + VStack(alignment: .leading, spacing: 16) { + Text(createBackgroundActivityString()) + .font(.body.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + makeRow( + icon: "location.fill", + title: String(localized: "Continuous Location Updates"), + subtitle: String(localized: "Keep the mesh map updated and send your position to the mesh even while using other apps.") + ) + makeRow( + icon: "antenna.radiowaves.left.and.right", + title: String(localized: "Background Mesh Tracking"), + subtitle: String(localized: "Receive position updates from other nodes and maintain an accurate picture of the mesh while in the background.") + ) + makeRow( + icon: "battery.100.bolt", + title: String(localized: "Battery Usage"), + subtitle: String(localized: "Enabling background activity may increase battery usage. You can toggle this at any time in the app settings.") + ) + Toggle(isOn: $locationsHandler.backgroundActivity) { + Label { + Text("Enable Background Activity") + } icon: { + Image(systemName: "location.circle") + } + } + .fixedSize() + .scaleEffect(0.85) + .padding(.leading, 52) + .tint(.accentColor) + } + .padding() + } + Spacer() + Button { + Task { + await goToNextStep(after: .backgroundActivity) + } + } label: { + Text("Continue") + .frame(maxWidth: .infinity) + } .padding() - .buttonStyle(.borderedProminent) + .capsuleButtonStyle() } } @@ -252,10 +319,7 @@ struct DeviceOnboarding: View { .frame(maxWidth: .infinity) } .padding() - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding() - .buttonStyle(.borderedProminent) + .capsuleButtonStyle() } } @@ -297,10 +361,64 @@ struct DeviceOnboarding: View { .frame(maxWidth: .infinity) } .padding() - .buttonBorderShape(.capsule) - .controlSize(.large) + .capsuleButtonStyle() + } + } + + var siriView: some View { + VStack { + ScrollView(.vertical) { + VStack { + Text("Siri, Shortcuts & CarPlay") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + VStack(alignment: .leading, spacing: 16) { + Text(createSiriString()) + .font(.body.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + makeRow( + icon: "car.fill", + title: String(localized: "CarPlay Messaging"), + subtitle: String(localized: "Read and reply to Meshtastic channel and direct messages directly from your car's display using CarPlay.") + ) + makeRow( + icon: "message", + title: String(localized: "Send a Group Message"), + subtitle: String(localized: "\"Send a Meshtastic group message\" — send a message to a mesh channel.") + ) + makeRow( + icon: "bubble", + title: String(localized: "Send a Direct Message"), + subtitle: String(localized: "\"Send a Meshtastic direct message\" — send a private message to a node.") + ) + makeRow( + icon: "power", + title: String(localized: "Shut Down / Restart Node"), + subtitle: String(localized: "\"Shut down my Meshtastic node\" or \"Restart my Meshtastic node\".") + ) + makeRow( + icon: "antenna.radiowaves.left.and.right.slash", + title: String(localized: "Disconnect Node"), + subtitle: String(localized: "\"Disconnect Meshtastic\" — disconnect from the connected BLE node.") + ) + } + .padding() + } + Spacer() + Button { + Task { + await requestSiriPermissions() + await goToNextStep(after: .siri) + } + } label: { + Text("Configure Siri & Shortcuts") + .frame(maxWidth: .infinity) + } .padding() - .buttonStyle(.borderedProminent) + .capsuleButtonStyle() } } @@ -313,16 +431,50 @@ struct DeviceOnboarding: View { notificationView case .location: locationView + case .backgroundActivity: + backgroundActivityView case .bluetooth: bluetoothView case .localNetwork: localNetworkView + case .siri: + siriView } } } .toolbar(.hidden) } - + + @ViewBuilder + func makeCompactRow(icon: String, title: String, subtitle: String) -> some View { + HStack(alignment: .center, spacing: 12) { + Group { + if icon.starts(with: "custom.") { + Image(icon) + .resizable() + .symbolRenderingMode(.multicolor) + } else { + Image(systemName: icon) + .resizable() + .symbolRenderingMode(.multicolor) + } + } + .aspectRatio(contentMode: .fit) + .frame(width: 28, height: 28) + .padding(.leading, 4) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.footnote.weight(.semibold)) + .foregroundColor(.primary) + Text(subtitle) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .accessibilityElement(children: .combine) + } + @ViewBuilder func makeRow( icon: String, @@ -351,11 +503,11 @@ struct DeviceOnboarding: View { } VStack(alignment: .leading) { Text(title) - .font(.subheadline.weight(.semibold)) + .font(.footnote.weight(.semibold)) .foregroundColor(.primary) .fixedSize(horizontal: false, vertical: true) Text(subtitle) - .font(.subheadline) + .font(.footnote) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) }.multilineTextAlignment(.leading) @@ -381,18 +533,31 @@ struct DeviceOnboarding: View { } case .location: locationStatus = LocationsHandler.shared.manager.authorizationStatus - if locationStatus != .notDetermined && locationStatus != .restricted { - navigationPath.append(.localNetwork) + if locationStatus == .authorizedWhenInUse || locationStatus == .authorizedAlways { + navigationPath.append(.backgroundActivity) } + case .backgroundActivity: + navigationPath.append(.localNetwork) case .localNetwork: navigationPath.append(.bluetooth) case .bluetooth: + navigationPath.append(.siri) + case .siri: dismiss() } } // MARK: Formatting + func createBackgroundActivityString() -> AttributedString { + var fullText = AttributedString("Meshtastic can track your location in the background to keep the mesh map updated and send your position to the mesh even when the app is not in the foreground. You can update this setting at any time from settings.") + if let range = fullText.range(of: "settings") { + fullText[range].link = URL(string: UIApplication.openSettingsURLString)! + fullText[range].foregroundColor = .blue + } + return fullText + } + func createLocationString() -> AttributedString { var fullText = AttributedString(localized: "Meshtastic uses your phone's location to enable a number of features. You can update your location permissions at any time from settings.") if let range = fullText.range(of: String(localized: "settings")) { @@ -420,6 +585,15 @@ struct DeviceOnboarding: View { return fullText } + func createSiriString() -> AttributedString { + var fullText = AttributedString("Meshtastic supports Siri, Shortcuts, and CarPlay so you can send and receive messages hands-free. You can update Siri permissions at any time from settings.") + if let range = fullText.range(of: "settings") { + fullText[range].link = URL(string: UIApplication.openSettingsURLString)! + fullText[range].foregroundColor = .blue + } + return fullText + } + // MARK: Permission Checks func requestNotificationsPermissions() async { let center = UNUserNotificationCenter.current() @@ -452,6 +626,22 @@ struct DeviceOnboarding: View { func requestBluetoothPermissions() async { _ = await BluetoothAuthorizationHelper.requestBluetoothAuthorization() } + + func requestSiriPermissions() async { + await withCheckedContinuation { continuation in + INPreferences.requestSiriAuthorization { status in + switch status { + case .authorized: + Logger.services.info("Siri permissions are enabled") + case .denied: + Logger.services.info("Siri permissions denied") + default: + Logger.services.info("Siri permissions status: \(status.rawValue)") + } + continuation.resume() + } + } + } } diff --git a/Meshtastic/Views/Settings/Firmware.swift b/Meshtastic/Views/Settings/Firmware.swift index 3869d145..d909d956 100644 --- a/Meshtastic/Views/Settings/Firmware.swift +++ b/Meshtastic/Views/Settings/Firmware.swift @@ -13,14 +13,13 @@ struct Firmware: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var accessoryManager: AccessoryManager var node: NodeInfoEntity? - @State var minimumVersion = "2.5.4" @State var version = "" @State private var currentDevice: DeviceHardware? @State private var latestStable: FirmwareRelease? @State private var latestAlpha: FirmwareRelease? var body: some View { - let supportedVersion = accessoryManager.checkIsVersionSupported(forVersion: minimumVersion) + let supportedVersion = accessoryManager.checkIsVersionSupported(forVersion: accessoryManager.minimumVersion) let connectedVersion = accessoryManager.activeConnection?.device.firmwareVersion ?? "Unknown" ScrollView { VStack(alignment: .leading) { @@ -63,7 +62,7 @@ struct Firmware: View { .foregroundStyle(.red) .font(.title2) .padding(.bottom) - Text("Current Firmware Version: \(connectedVersion), Latest Firmware Version: \(minimumVersion)") + Text("Current Firmware Version: \(connectedVersion), Minimum Required Version: \(accessoryManager.minimumVersion)") .fixedSize(horizontal: false, vertical: true) .font(.title3) .padding(.bottom) diff --git a/Meshtastic/da.lproj/AppIntentVocabulary.plist b/Meshtastic/da.lproj/AppIntentVocabulary.plist new file mode 100644 index 00000000..0857f1c7 --- /dev/null +++ b/Meshtastic/da.lproj/AppIntentVocabulary.plist @@ -0,0 +1,36 @@ + + + + + IntentPhrases + + + IntentName + INSendMessageIntent + IntentExamples + + Send en besked på Meshtastic + Send en Meshtastic-besked til Lars + + + + IntentName + INSearchForMessagesIntent + IntentExamples + + Søg efter Meshtastic-beskeder + Find beskeder på Meshtastic + + + + IntentName + INSetMessageAttributeIntent + IntentExamples + + Markér Meshtastic-besked som læst + Markér Meshtastic-beskeder som læst + + + + + diff --git a/Meshtastic/de.lproj/AppIntentVocabulary.plist b/Meshtastic/de.lproj/AppIntentVocabulary.plist new file mode 100644 index 00000000..93be827c --- /dev/null +++ b/Meshtastic/de.lproj/AppIntentVocabulary.plist @@ -0,0 +1,36 @@ + + + + + IntentPhrases + + + IntentName + INSendMessageIntent + IntentExamples + + Sende eine Nachricht über Meshtastic + Sende eine Meshtastic-Nachricht an Hans + + + + IntentName + INSearchForMessagesIntent + IntentExamples + + Suche Meshtastic-Nachrichten + Finde Nachrichten auf Meshtastic + + + + IntentName + INSetMessageAttributeIntent + IntentExamples + + Markiere Meshtastic-Nachricht als gelesen + Markiere Meshtastic-Nachrichten als gelesen + + + + + diff --git a/Meshtastic/en.lproj/AppIntentVocabulary.plist b/Meshtastic/en.lproj/AppIntentVocabulary.plist new file mode 100644 index 00000000..2cc6149d --- /dev/null +++ b/Meshtastic/en.lproj/AppIntentVocabulary.plist @@ -0,0 +1,36 @@ + + + + + IntentPhrases + + + IntentName + INSendMessageIntent + IntentExamples + + Send a message on Meshtastic + Send a Meshtastic message to John + + + + IntentName + INSearchForMessagesIntent + IntentExamples + + Search Meshtastic messages + Find messages on Meshtastic + + + + IntentName + INSetMessageAttributeIntent + IntentExamples + + Mark Meshtastic message as read + Mark Meshtastic messages as read + + + + + diff --git a/Meshtastic/es.lproj/AppIntentVocabulary.plist b/Meshtastic/es.lproj/AppIntentVocabulary.plist new file mode 100644 index 00000000..e913fae1 --- /dev/null +++ b/Meshtastic/es.lproj/AppIntentVocabulary.plist @@ -0,0 +1,36 @@ + + + + + IntentPhrases + + + IntentName + INSendMessageIntent + IntentExamples + + Enviar un mensaje en Meshtastic + Enviar un mensaje de Meshtastic a Juan + + + + IntentName + INSearchForMessagesIntent + IntentExamples + + Buscar mensajes de Meshtastic + Encontrar mensajes en Meshtastic + + + + IntentName + INSetMessageAttributeIntent + IntentExamples + + Marcar mensaje de Meshtastic como leído + Marcar mensajes de Meshtastic como leídos + + + + + diff --git a/Meshtastic/fr.lproj/AppIntentVocabulary.plist b/Meshtastic/fr.lproj/AppIntentVocabulary.plist new file mode 100644 index 00000000..1605dc44 --- /dev/null +++ b/Meshtastic/fr.lproj/AppIntentVocabulary.plist @@ -0,0 +1,36 @@ + + + + + IntentPhrases + + + IntentName + INSendMessageIntent + IntentExamples + + Envoyer un message sur Meshtastic + Envoyer un message Meshtastic à Pierre + + + + IntentName + INSearchForMessagesIntent + IntentExamples + + Chercher des messages Meshtastic + Trouver des messages sur Meshtastic + + + + IntentName + INSetMessageAttributeIntent + IntentExamples + + Marquer le message Meshtastic comme lu + Marquer les messages Meshtastic comme lus + + + + + diff --git a/Meshtastic/he.lproj/AppIntentVocabulary.plist b/Meshtastic/he.lproj/AppIntentVocabulary.plist new file mode 100644 index 00000000..655291fb --- /dev/null +++ b/Meshtastic/he.lproj/AppIntentVocabulary.plist @@ -0,0 +1,36 @@ + + + + + IntentPhrases + + + IntentName + INSendMessageIntent + IntentExamples + + שלח הודעה ב-Meshtastic + שלח הודעת Meshtastic לדוד + + + + IntentName + INSearchForMessagesIntent + IntentExamples + + חפש הודעות Meshtastic + מצא הודעות ב-Meshtastic + + + + IntentName + INSetMessageAttributeIntent + IntentExamples + + סמן הודעת Meshtastic כנקראה + סמן הודעות Meshtastic כנקראו + + + + + diff --git a/Meshtastic/it.lproj/AppIntentVocabulary.plist b/Meshtastic/it.lproj/AppIntentVocabulary.plist new file mode 100644 index 00000000..52a3d8ad --- /dev/null +++ b/Meshtastic/it.lproj/AppIntentVocabulary.plist @@ -0,0 +1,36 @@ + + + + + IntentPhrases + + + IntentName + INSendMessageIntent + IntentExamples + + Invia un messaggio su Meshtastic + Invia un messaggio Meshtastic a Marco + + + + IntentName + INSearchForMessagesIntent + IntentExamples + + Cerca messaggi su Meshtastic + Trova messaggi su Meshtastic + + + + IntentName + INSetMessageAttributeIntent + IntentExamples + + Segna il messaggio Meshtastic come letto + Segna i messaggi Meshtastic come letti + + + + + diff --git a/Meshtastic/ja.lproj/AppIntentVocabulary.plist b/Meshtastic/ja.lproj/AppIntentVocabulary.plist new file mode 100644 index 00000000..e8e894eb --- /dev/null +++ b/Meshtastic/ja.lproj/AppIntentVocabulary.plist @@ -0,0 +1,36 @@ + + + + + IntentPhrases + + + IntentName + INSendMessageIntent + IntentExamples + + Meshtasticでメッセージを送信 + Meshtasticで太郎にメッセージを送って + + + + IntentName + INSearchForMessagesIntent + IntentExamples + + Meshtasticのメッセージを検索 + Meshtasticでメッセージを探して + + + + IntentName + INSetMessageAttributeIntent + IntentExamples + + Meshtasticのメッセージを既読にして + Meshtasticのメッセージを既読にする + + + + + diff --git a/Meshtastic/pl.lproj/AppIntentVocabulary.plist b/Meshtastic/pl.lproj/AppIntentVocabulary.plist new file mode 100644 index 00000000..c3429a7c --- /dev/null +++ b/Meshtastic/pl.lproj/AppIntentVocabulary.plist @@ -0,0 +1,36 @@ + + + + + IntentPhrases + + + IntentName + INSendMessageIntent + IntentExamples + + Wyślij wiadomość przez Meshtastic + Wyślij wiadomość Meshtastic do Jana + + + + IntentName + INSearchForMessagesIntent + IntentExamples + + Szukaj wiadomości Meshtastic + Znajdź wiadomości w Meshtastic + + + + IntentName + INSetMessageAttributeIntent + IntentExamples + + Oznacz wiadomość Meshtastic jako przeczytaną + Oznacz wiadomości Meshtastic jako przeczytane + + + + + diff --git a/Meshtastic/ru.lproj/AppIntentVocabulary.plist b/Meshtastic/ru.lproj/AppIntentVocabulary.plist new file mode 100644 index 00000000..7f4048dd --- /dev/null +++ b/Meshtastic/ru.lproj/AppIntentVocabulary.plist @@ -0,0 +1,36 @@ + + + + + IntentPhrases + + + IntentName + INSendMessageIntent + IntentExamples + + Отправить сообщение через Meshtastic + Отправить сообщение Meshtastic Ивану + + + + IntentName + INSearchForMessagesIntent + IntentExamples + + Найти сообщения в Meshtastic + Поиск сообщений Meshtastic + + + + IntentName + INSetMessageAttributeIntent + IntentExamples + + Отметить сообщение Meshtastic как прочитанное + Отметить сообщения Meshtastic как прочитанные + + + + + diff --git a/Meshtastic/se.lproj/AppIntentVocabulary.plist b/Meshtastic/se.lproj/AppIntentVocabulary.plist new file mode 100644 index 00000000..4bbbb822 --- /dev/null +++ b/Meshtastic/se.lproj/AppIntentVocabulary.plist @@ -0,0 +1,36 @@ + + + + + IntentPhrases + + + IntentName + INSendMessageIntent + IntentExamples + + Skicka ett meddelande på Meshtastic + Skicka ett Meshtastic-meddelande till Erik + + + + IntentName + INSearchForMessagesIntent + IntentExamples + + Sök Meshtastic-meddelanden + Hitta meddelanden på Meshtastic + + + + IntentName + INSetMessageAttributeIntent + IntentExamples + + Markera Meshtastic-meddelande som läst + Markera Meshtastic-meddelanden som lästa + + + + + diff --git a/Meshtastic/sr.lproj/AppIntentVocabulary.plist b/Meshtastic/sr.lproj/AppIntentVocabulary.plist new file mode 100644 index 00000000..54ff1c6a --- /dev/null +++ b/Meshtastic/sr.lproj/AppIntentVocabulary.plist @@ -0,0 +1,36 @@ + + + + + IntentPhrases + + + IntentName + INSendMessageIntent + IntentExamples + + Пошаљи поруку преко Meshtastic + Пошаљи Meshtastic поруку Марку + + + + IntentName + INSearchForMessagesIntent + IntentExamples + + Претражи Meshtastic поруке + Пронађи поруке на Meshtastic + + + + IntentName + INSetMessageAttributeIntent + IntentExamples + + Означи Meshtastic поруку као прочитану + Означи Meshtastic поруке као прочитане + + + + + diff --git a/Meshtastic/zh-Hans.lproj/AppIntentVocabulary.plist b/Meshtastic/zh-Hans.lproj/AppIntentVocabulary.plist new file mode 100644 index 00000000..39a13428 --- /dev/null +++ b/Meshtastic/zh-Hans.lproj/AppIntentVocabulary.plist @@ -0,0 +1,36 @@ + + + + + IntentPhrases + + + IntentName + INSendMessageIntent + IntentExamples + + 通过Meshtastic发送消息 + 用Meshtastic给小明发消息 + + + + IntentName + INSearchForMessagesIntent + IntentExamples + + 搜索Meshtastic消息 + 查找Meshtastic消息 + + + + IntentName + INSetMessageAttributeIntent + IntentExamples + + 将Meshtastic消息标记为已读 + 标记Meshtastic消息为已读 + + + + + diff --git a/Meshtastic/zh-Hant-TW.lproj/AppIntentVocabulary.plist b/Meshtastic/zh-Hant-TW.lproj/AppIntentVocabulary.plist new file mode 100644 index 00000000..47bfc4e2 --- /dev/null +++ b/Meshtastic/zh-Hant-TW.lproj/AppIntentVocabulary.plist @@ -0,0 +1,36 @@ + + + + + IntentPhrases + + + IntentName + INSendMessageIntent + IntentExamples + + 透過Meshtastic傳送訊息 + 用Meshtastic傳訊息給小明 + + + + IntentName + INSearchForMessagesIntent + IntentExamples + + 搜尋Meshtastic訊息 + 尋找Meshtastic訊息 + + + + IntentName + INSetMessageAttributeIntent + IntentExamples + + 將Meshtastic訊息標記為已讀 + 標記Meshtastic訊息為已讀 + + + + + diff --git a/MeshtasticTests/CarPlayTests.swift b/MeshtasticTests/CarPlayTests.swift new file mode 100644 index 00000000..786e4136 --- /dev/null +++ b/MeshtasticTests/CarPlayTests.swift @@ -0,0 +1,176 @@ +// +// CarPlayTests.swift +// MeshtasticTests +// +// Copyright(c) Garth Vander Houwen 4/16/26. +// + +import CarPlay +import CoreData +import Foundation +import Intents +import Testing + +@testable import Meshtastic + +// MARK: - CarPlaySceneDelegate Tests + +@Suite("CarPlaySceneDelegate") +struct CarPlaySceneDelegateTests { + + @Test func initialState() { + let delegate = CarPlaySceneDelegate() + #expect(delegate.interfaceController == nil) + } + + @Test func disconnectClearsInterfaceController() { + let delegate = CarPlaySceneDelegate() + // Simulate that interface controller was set during connect + delegate.interfaceController = nil + #expect(delegate.interfaceController == nil) + } +} + +// MARK: - CarPlayIntentDonation Tests + +@Suite("CarPlayIntentDonation") +struct CarPlayIntentDonationTests { + + // MARK: - channelDisplayName + + @Test func channelDisplayNamePrimary() { + let name = CarPlayIntentDonation.testChannelDisplayName(for: 0) + #expect(name == "Primary Channel") + } + + @Test func channelDisplayNameSecondary() { + let name = CarPlayIntentDonation.testChannelDisplayName(for: 1) + #expect(name == "Channel 1") + } + + @Test func channelDisplayNameHighIndex() { + let name = CarPlayIntentDonation.testChannelDisplayName(for: 7) + #expect(name == "Channel 7") + } + + // MARK: - mePerson + + @Test func mePersonIsMe() { + let me = CarPlayIntentDonation.testMePerson() + #expect(me.isMe) + #expect(me.displayName == "Me") + #expect(me.personHandle?.value == "me") + } + + // MARK: - Outgoing DM Intent Structure + + @Test func outgoingDMIntentHasCorrectConversationId() { + let intent = CarPlayIntentDonation.testBuildOutgoingIntent( + content: "Hello mesh", + toUserNum: 1234567890, + channel: 0 + ) + #expect(intent.conversationIdentifier == "dm-1234567890") + #expect(intent.serviceName == "Meshtastic") + #expect(intent.content == "Hello mesh") + #expect(intent.recipients?.count == 1) + #expect(intent.speakableGroupName == nil) + } + + @Test func outgoingChannelIntentHasCorrectConversationId() { + let intent = CarPlayIntentDonation.testBuildOutgoingIntent( + content: "Channel message", + toUserNum: 0, + channel: 2 + ) + #expect(intent.conversationIdentifier == "channel-2") + #expect(intent.serviceName == "Meshtastic") + #expect(intent.content == "Channel message") + #expect(intent.recipients == nil) + #expect(intent.speakableGroupName?.spokenPhrase == "Channel 2") + } + + @Test func outgoingPrimaryChannelIntentName() { + let intent = CarPlayIntentDonation.testBuildOutgoingIntent( + content: "Test", + toUserNum: 0, + channel: 0 + ) + #expect(intent.speakableGroupName?.spokenPhrase == "Primary Channel") + } + + // MARK: - Interaction Direction + + @Test func outgoingInteractionDirection() { + let interaction = CarPlayIntentDonation.testBuildOutgoingInteraction( + content: "Test", + toUserNum: 999, + channel: 0 + ) + #expect(interaction.direction == .outgoing) + } +} + +// MARK: - Test Helpers Extension + +extension CarPlayIntentDonation { + + /// Exposes channelDisplayName for testing + static func testChannelDisplayName(for index: Int32) -> String { + channelDisplayName(for: index) + } + + /// Exposes mePerson for testing + static func testMePerson() -> INPerson { + mePerson() + } + + /// Builds an outgoing INSendMessageIntent without donating + static func testBuildOutgoingIntent(content: String, toUserNum: Int64, channel: Int32) -> INSendMessageIntent { + let me = mePerson() + + if toUserNum != 0 { + let handleValue = "\(toUserNum)@meshtastic.local" + let recipientHandle = INPersonHandle(value: handleValue, type: .emailAddress) + let recipient = INPerson( + personHandle: recipientHandle, + nameComponents: nil, + displayName: "Node \(toUserNum.toHex())", + image: nil, + contactIdentifier: String(toUserNum), + customIdentifier: String(toUserNum) + ) + return INSendMessageIntent( + recipients: [recipient], + outgoingMessageType: .outgoingMessageText, + content: content, + speakableGroupName: nil, + conversationIdentifier: "dm-\(toUserNum)", + serviceName: "Meshtastic", + sender: me, + attachments: nil + ) + } else { + let channelName = channelDisplayName(for: channel) + let groupName = INSpeakableString(spokenPhrase: channelName) + return INSendMessageIntent( + recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: content, + speakableGroupName: groupName, + conversationIdentifier: "channel-\(channel)", + serviceName: "Meshtastic", + sender: me, + attachments: nil + ) + } + } + + /// Builds an outgoing INInteraction without donating + static func testBuildOutgoingInteraction(content: String, toUserNum: Int64, channel: Int32) -> INInteraction { + let intent = testBuildOutgoingIntent(content: content, toUserNum: toUserNum, channel: channel) + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .outgoing + return interaction + } +} diff --git a/MeshtasticTests/ConnectViewTests.swift b/MeshtasticTests/ConnectViewTests.swift index 450591ad..c2d1d5e1 100644 --- a/MeshtasticTests/ConnectViewTests.swift +++ b/MeshtasticTests/ConnectViewTests.swift @@ -364,7 +364,7 @@ struct InvalidVersionTests { } @Test func viewCreationWithEmptyVersions() { - let view = InvalidVersion() + let view = InvalidVersion(minimumVersion: "", version: "") #expect(view.minimumVersion == "") #expect(view.version == "") } diff --git a/MeshtasticTests/DeviceOnboardingTests.swift b/MeshtasticTests/DeviceOnboardingTests.swift new file mode 100644 index 00000000..4831c87c --- /dev/null +++ b/MeshtasticTests/DeviceOnboardingTests.swift @@ -0,0 +1,181 @@ +// +// DeviceOnboardingTests.swift +// MeshtasticTests +// +// Copyright(c) Garth Vander Houwen 2026. +// + +import Foundation +import Testing +@testable import Meshtastic + +// MARK: - SetupGuide Enum + +@Suite("DeviceOnboarding.SetupGuide") +struct SetupGuideTests { + + @Test func allCasesExist() { + let cases: [DeviceOnboarding.SetupGuide] = [ + .notifications, .location, .backgroundActivity, + .localNetwork, .bluetooth, .siri + ] + #expect(cases.count == 6) + } + + @Test func isHashable() { + var seen = Set() + seen.insert(.notifications) + seen.insert(.notifications) // duplicate should not grow set + seen.insert(.siri) + #expect(seen.count == 2) + } + + @Test func equality() { + #expect(DeviceOnboarding.SetupGuide.bluetooth == .bluetooth) + #expect(DeviceOnboarding.SetupGuide.notifications != .siri) + #expect(DeviceOnboarding.SetupGuide.location != .backgroundActivity) + } + + @Test func allCasesAreUnique() { + let cases: [DeviceOnboarding.SetupGuide] = [ + .notifications, .location, .backgroundActivity, + .localNetwork, .bluetooth, .siri + ] + let unique = Set(cases) + #expect(unique.count == cases.count) + } +} + +// MARK: - Attributed String Formatters + +@Suite("DeviceOnboarding string formatters") +struct OnboardingStringFormatterTests { + + let view = DeviceOnboarding() + + // Helpers + private func hasSettingsLink(_ string: AttributedString) -> Bool { + guard let range = string.range(of: "settings") else { return false } + return string[range].link != nil + } + + private func settingsLinkURL(_ string: AttributedString) -> URL? { + guard let range = string.range(of: "settings") else { return nil } + return string[range].link + } + + @Test func backgroundActivityStringContainsText() { + let string = view.createBackgroundActivityString() + #expect(string.description.contains("background")) + #expect(string.description.contains("settings")) + } + + @Test func backgroundActivityStringHasSettingsLink() { + let string = view.createBackgroundActivityString() + #expect(hasSettingsLink(string)) + } + + @Test func backgroundActivitySettingsLinkIsAppSettings() { + let string = view.createBackgroundActivityString() + let url = settingsLinkURL(string) + #expect(url?.scheme == "app-settings" || url?.absoluteString.contains("settings") == true) + } + + @Test func locationStringContainsText() { + let string = view.createLocationString() + #expect(string.description.contains("location")) + #expect(string.description.contains("settings")) + } + + @Test func locationStringHasSettingsLink() { + let string = view.createLocationString() + #expect(hasSettingsLink(string)) + } + + @Test func localNetworkStringContainsText() { + let string = view.createLocalNetworkString() + #expect(string.description.contains("local network") || string.description.contains("TCP")) + #expect(string.description.contains("settings")) + } + + @Test func localNetworkStringHasSettingsLink() { + let string = view.createLocalNetworkString() + #expect(hasSettingsLink(string)) + } + + @Test func bluetoothStringContainsText() { + let string = view.createBluetoothString() + #expect(string.description.contains("Bluetooth") || string.description.contains("BLE")) + #expect(string.description.contains("settings")) + } + + @Test func bluetoothStringHasSettingsLink() { + let string = view.createBluetoothString() + #expect(hasSettingsLink(string)) + } + + @Test func siriStringContainsCarPlay() { + let string = view.createSiriString() + #expect(string.description.contains("CarPlay")) + } + + @Test func siriStringContainsSiri() { + let string = view.createSiriString() + #expect(string.description.contains("Siri")) + } + + @Test func siriStringHasSettingsLink() { + let string = view.createSiriString() + #expect(hasSettingsLink(string)) + } + + @Test func allStringsHaveSettingsLinks() { + let strings = [ + view.createBackgroundActivityString(), + view.createLocationString(), + view.createLocalNetworkString(), + view.createBluetoothString(), + view.createSiriString() + ] + for string in strings { + #expect(hasSettingsLink(string), "Expected 'settings' link in: \(string)") + } + } +} + +// MARK: - Navigation Flow + +@Suite("DeviceOnboarding navigation") +struct OnboardingNavigationTests { + + @Test func backgroundActivityAlwaysGoesToLocalNetwork() async { + let view = DeviceOnboarding() + await view.goToNextStep(after: .backgroundActivity) + #expect(view.navigationPath == [.localNetwork]) + } + + @Test func localNetworkAlwaysGoesToBluetooth() async { + let view = DeviceOnboarding() + await view.goToNextStep(after: .localNetwork) + #expect(view.navigationPath == [.bluetooth]) + } + + @Test func bluetoothAlwaysGoesToSiri() async { + let view = DeviceOnboarding() + await view.goToNextStep(after: .bluetooth) + #expect(view.navigationPath == [.siri]) + } + + @Test func navigationPathStartsEmpty() { + let view = DeviceOnboarding() + #expect(view.navigationPath.isEmpty) + } + + @Test func deterministicStepsAppendInOrder() async { + let view = DeviceOnboarding() + await view.goToNextStep(after: .backgroundActivity) + await view.goToNextStep(after: .localNetwork) + await view.goToNextStep(after: .bluetooth) + #expect(view.navigationPath == [.localNetwork, .bluetooth, .siri]) + } +} diff --git a/README.md b/README.md index 6e838ea3..afa87698 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,28 @@ Each settings item has an associated deep link. No parameters are supported for | `meshtastic:///settings/appFiles` | App Files | | `meshtastic:///settings/firmwareUpdates` | Firmware Updates | +## CarPlay + +The app supports CarPlay for hands-free mesh messaging while driving. The CarPlay interface shows connection status, favorite contacts, and channels. + +### Siri Voice Commands + +Use these Siri voice commands on CarPlay to interact with Meshtastic: + +| Intent | Example Phrase | +| --- | --- | +| `INSendMessageIntent` | "Send a message on Meshtastic" | +| `INSearchForMessagesIntent` | "Search Meshtastic messages" | +| `INSetMessageAttributeIntent` | "Mark Meshtastic message as read" | + +### Features + +- **Connection Status** — Shows whether a Meshtastic device is connected and the device name +- **Favorite Contacts** — Lists nodes marked as favorites with unread message counts; tap to view contact detail with a native Siri compose button +- **Channels** — Lists configured channels with unread counts; tap to start a channel message via Siri +- **Incoming Message Notifications** — Siri announces incoming Meshtastic messages when Announce Notifications is enabled +- **Conversation History** — Sent and received messages appear in CarPlay Messages for quick access + ## Release Process For more information on how a new release of Meshtastic is managed, please refer to [RELEASING.md](./RELEASING.md) diff --git a/Widgets/MeshActivityAttributes.swift b/Widgets/MeshActivityAttributes.swift index 8d7ea9af..7d2bd5a8 100644 --- a/Widgets/MeshActivityAttributes.swift +++ b/Widgets/MeshActivityAttributes.swift @@ -34,6 +34,7 @@ struct MeshActivityAttributes: ActivityAttributes { // Fixed non-changing properties about your activity go here! var nodeNum: Int var name: String + var shortName: String } #endif #endif diff --git a/Widgets/WidgetsLiveActivity.swift b/Widgets/WidgetsLiveActivity.swift index 5f1e6d29..525cf10b 100644 --- a/Widgets/WidgetsLiveActivity.swift +++ b/Widgets/WidgetsLiveActivity.swift @@ -14,7 +14,7 @@ struct WidgetsLiveActivity: Widget { ActivityConfiguration(for: MeshActivityAttributes.self) { context in LiveActivityView(nodeName: context.attributes.name, - uptimeSeconds: 0, // context.attributes.uptimeSeconds, + uptimeSeconds: context.state.uptimeSeconds, channelUtilization: context.state.channelUtilization, airtime: context.state.airtime, sentPackets: context.state.sentPackets, @@ -31,18 +31,16 @@ struct WidgetsLiveActivity: Widget { } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { - if context.state.totalNodes > 0 { - Text(" \(context.state.nodesOnline) online") - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize() - } else { - Text(" ") - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize() - } - Text("Ch. Util: \(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%") + Text(context.attributes.shortName) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .fixedSize() + Text("Sent: \(context.state.sentPackets)") + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize() + Text("ChUtil: \(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%") .font(.caption2) .foregroundStyle(.secondary) .fixedSize() @@ -50,10 +48,6 @@ struct WidgetsLiveActivity: Widget { .font(.caption2) .foregroundStyle(.secondary) .fixedSize() - Text("Sent: \(context.state.sentPackets)") - .font(.caption2) - .foregroundStyle(.secondary) - .fixedSize() Text("Received: \(context.state.receivedPackets)") .font(.caption2) .foregroundStyle(.secondary) @@ -64,52 +58,95 @@ struct WidgetsLiveActivity: Widget { .tint(Color("LightIndigo")) } DynamicIslandExpandedRegion(.trailing, priority: 1) { - Spacer() + if context.state.totalNodes > 0 { + HStack(spacing: 3) { + Image(systemName: "person.2.fill") + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(context.state.nodesOnline)/\(context.state.totalNodes)") + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.primary) + } + .fixedSize() + } Text("Bad: \(context.state.badReceivedPackets)") - .font(.caption) + .font(.caption2) .foregroundStyle(.secondary) .fixedSize() Text("Dupe: \(context.state.dupeReceivedPackets)") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize() - Text("Relayed: \(context.state.packetsSentRelay)") - .font(.caption) + .font(.caption2) .foregroundStyle(.secondary) .fixedSize() - Text("Relay Cancel: \(context.state.packetsCanceledRelay)") - .font(.caption) + Text("Relayed: \(context.state.packetsSentRelay)") + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize() + Text("Rly Cancel: \(context.state.packetsCanceledRelay)") + .font(.caption2) .foregroundStyle(.secondary) .fixedSize() } DynamicIslandExpandedRegion(.bottom) { - Text("Last Heard: \(Date().formatted())") - .font(.caption2) - .fontWeight(.medium) - .foregroundStyle(.tint) - .fixedSize() + HStack(spacing: 4) { + if let uptime = context.state.uptimeSeconds, uptime > 0 { + Text("UPTIME:") + .font(.caption2) + .foregroundStyle(.tint) + Text(uptime >= 3600 ? "\(uptime / 3600)h \((uptime % 3600) / 60)m" : "\((uptime % 3600) / 60)m") + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.tint) + Text("•") + .font(.caption2) + .foregroundStyle(.tint) + } + Text("UPDATED:") + .font(.caption2) + .foregroundStyle(.tint) + Text("\(Date().formatted(date: .omitted, time: .shortened))") + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.tint) + } } } compactLeading: { - Image("m-logo-black") - .resizable() - .frame(width: 25) - .padding(4) - .background(.green.gradient, in: ContainerRelativeShape()) + HStack(spacing: 2) { + Image(systemName: "person.2.fill") + .font(.system(size: 9)) + .foregroundStyle(.green) + if context.state.totalNodes > 0 { + Text("\(context.state.nodesOnline)") + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(.primary) + } + } + .fixedSize() } compactTrailing: { - Text(timerInterval: context.state.timerRange, countsDown: true) - .monospacedDigit() - .foregroundColor(Color("LightIndigo")) - .frame(width: 40) + Text("\(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(1))) ?? "--")%") + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.primary) + .fixedSize() } minimal: { - Image("m-logo-black") - .resizable() - .frame(width: 24.0) - .padding(4) - .background(.green.gradient, in: ContainerRelativeShape()) + ZStack { + Image(systemName: "person.2.fill") + .font(.system(size: 10)) + .foregroundStyle(.green) + if context.state.totalNodes > 0 { + Text("\(context.state.nodesOnline)") + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(.white) + .offset(y: 6) + } + } } - .contentMargins(.trailing, 32, for: .expanded) - .contentMargins([.leading, .top, .bottom], 6, for: .compactLeading) + .contentMargins(.leading, 16, for: .expanded) + .contentMargins(.trailing, 16, for: .expanded) + .contentMargins(.all, 6, for: .compactLeading) + .contentMargins(.all, 6, for: .compactTrailing) .contentMargins(.all, 6, for: .minimal) .widgetURL(URL(string: "meshtastic:///connect")) } @@ -117,7 +154,7 @@ struct WidgetsLiveActivity: Widget { } struct WidgetsLiveActivity_Previews: PreviewProvider { - static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G") + static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G", shortName: "8E6G") static let state = MeshActivityAttributes.ContentState(uptimeSeconds: 600, channelUtilization: 1.2, airtime: 3.5, sentPackets: 12587, receivedPackets: 12555, badReceivedPackets: 800, dupeReceivedPackets: 100, packetsSentRelay: 250, packetsCanceledRelay: 372, nodesOnline: 99, totalNodes: 100, timerRange: Date.now...Date(timeIntervalSinceNow: 300)) static var previews: some View { @@ -154,108 +191,122 @@ struct LiveActivityView: View { var timerRange: ClosedRange var body: some View { - HStack { - Spacer() - Image(colorScheme == .light ? "m-logo-black" : "m-logo-white") - .resizable() - .clipShape(ContainerRelativeShape()) - .opacity(isLuminanceReduced ? 0.5 : 1.0) - .aspectRatio(contentMode: .fit) - .frame(minWidth: 25, idealWidth: 45, maxWidth: 55) - Spacer() - NodeInfoView(isLuminanceReduced: _isLuminanceReduced, nodeName: nodeName, uptimeSeconds: uptimeSeconds, channelUtilization: channelUtilization, airtime: airtime, sentPackets: sentPackets, receivedPackets: receivedPackets, badReceivedPackets: badReceivedPackets, - dupeReceivedPackets: dupeReceivedPackets, packetsSentRelay: packetsSentRelay, packetsCanceledRelay: packetsCanceledRelay, nodesOnline: nodesOnline, timerRange: timerRange) - Spacer() - } - .tint(.primary) - .padding([.leading, .top, .bottom]) - .padding(.trailing, 25) - .activityBackgroundTint(colorScheme == .light ? Color("LiveActivityBackground") : Color("AccentColorDimmed")) - .activitySystemActionForegroundColor(.primary) - } -} + let errorRate = receivedPackets > 0 + ? (Double(badReceivedPackets) / Double(receivedPackets)) * 100 + : 0.0 + let now = Date() -struct NodeInfoView: View { - @Environment(\.isLuminanceReduced) var isLuminanceReduced + VStack(alignment: .leading, spacing: 4) { + // Header row: logo + node name + nodes online + HStack(spacing: 6) { + Image("m-logo-white") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .clipShape(RoundedRectangle(cornerRadius: 6)) + Text(nodeName) + .font(.callout) + .fontWeight(.semibold) + .foregroundStyle(.tint) + .lineLimit(1) + Spacer() + if totalNodes > 0 { + HStack(spacing: 3) { + Image(systemName: "person.2.fill") + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(nodesOnline)/\(totalNodes)") + .font(.caption2) + .foregroundStyle(.secondary) + } + .fixedSize() + } + } - var nodeName: String - var uptimeSeconds: UInt32? - var channelUtilization: Float? - var airtime: Float? - var sentPackets: UInt32 - var receivedPackets: UInt32 - var badReceivedPackets: UInt32 - var dupeReceivedPackets: UInt32 - var packetsSentRelay: UInt32 - var packetsCanceledRelay: UInt32 - var nodesOnline: UInt32 - var timerRange: ClosedRange + // Stats grid — two columns + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + StatRow(label: "Ch. Utilization", value: "\(channelUtilization?.formatted(.number.precision(.fractionLength(1))) ?? "--")%") + StatRow(label: "Airtime", value: "\(airtime?.formatted(.number.precision(.fractionLength(1))) ?? "--")%") + StatRow(label: "Sent", value: "\(sentPackets)") + StatRow(label: "Received", value: "\(receivedPackets)") + } + VStack(alignment: .leading, spacing: 2) { + StatRow(label: "Error Rate", value: "\(errorRate.formatted(.number.precision(.fractionLength(1))))%") + StatRow(label: "Relayed", value: "\(packetsSentRelay)") + StatRow(label: "Relay Canceled", value: "\(packetsCanceledRelay)") + StatRow(label: "Duplicate", value: "\(dupeReceivedPackets)") + } + } + .fixedSize(horizontal: true, vertical: false) + .opacity(isLuminanceReduced ? 0.8 : 1.0) - var body: some View { - let errorRate = (Double(badReceivedPackets) / Double(receivedPackets)) * 100 - VStack(alignment: .leading, spacing: 0) { - Text(nodeName) - .font(nodeName.count > 14 ? .callout : .title3) - .fontWeight(.semibold) - .foregroundStyle(.tint) - // Text("\(channelUtilization.map { String(format: "Ch. Util: %.2f", $0 ) } ?? "--")% \(airtime.map { String(format: "Airtime: %.2f", $0) } ?? "--")%") - Text("Ch. Util: \(channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() - Text("Packets: Sent \(sentPackets) Rec. \(receivedPackets)") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() - Text("Bad: \(badReceivedPackets) Error Rate: \(errorRate.formatted(.number.precision(.fractionLength(2))))%") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() - - Text("Connected: \(nodesOnline) nodes online") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() - - let now = Date() - Text("Last Heard: \(now.formatted())") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() + // Footer: uptime + timer HStack { - - if timerRange.upperBound >= now { - Text("Next Update:") - .font(.caption) - .fontWeight(.medium) + Spacer(minLength: 0) + if let uptimeSeconds, uptimeSeconds > 0 { + Text("Uptime:") + .font(.caption2) + .foregroundStyle(.secondary) + Text(uptimeText(uptimeSeconds)) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.tint) + Text("•") + .font(.caption2) + .foregroundStyle(.secondary) + } + if timerRange.upperBound >= now { + Text("Update in:") + .font(.caption2) .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() Text(timerInterval: timerRange, countsDown: true) .monospacedDigit() - .multilineTextAlignment(.leading) - .font(.caption) + .font(.caption2) .fontWeight(.medium) .foregroundStyle(.tint) } else { Text("Not Connected") - .multilineTextAlignment(.leading) - .font(.caption) + .font(.caption2) .fontWeight(.semibold) .foregroundStyle(.tint) } + Spacer(minLength: 0) } + .fixedSize(horizontal: false, vertical: true) } + .tint(.primary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .activityBackgroundTint(colorScheme == .light ? Color("LiveActivityBackground") : Color("AccentColorDimmed")) + .activitySystemActionForegroundColor(.primary) + } + + private func uptimeText(_ seconds: UInt32) -> String { + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 + if hours > 0 { + return "\(hours)h \(minutes)m" + } + return "\(minutes)m" + } +} + +struct StatRow: View { + var label: String + var value: String + + var body: some View { + HStack(spacing: 4) { + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + Text(value) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.primary) + } + .fixedSize() } } @@ -265,27 +316,26 @@ struct TimerView: View { var timerRange: ClosedRange var body: some View { - VStack(alignment: .center) { + VStack(alignment: .center, spacing: 2) { Text("UPDATE IN") .font(.caption2) - .allowsTightening(/*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/) + .allowsTightening(true) .fontWeight(.medium) .foregroundStyle(.secondary) .opacity(isLuminanceReduced ? 0.5 : 1.0) Text(timerInterval: timerRange, countsDown: true) .monospacedDigit() .multilineTextAlignment(.center) - .frame(width: 80) - .font(.callout) + .frame(width: 60) + .font(.caption) .fontWeight(.semibold) .foregroundStyle(.tint) Image(systemName: "timer") .symbolRenderingMode(.multicolor) .resizable() .foregroundStyle(.secondary) - .frame(width: 30, height: 30) + .frame(width: 20, height: 20) .opacity(isLuminanceReduced ? 0.5 : 1.0) - .offset(y: -5) } } }