diff --git a/Localizable.xcstrings b/Localizable.xcstrings index f1806ddf..05c2bbb8 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -109,7 +109,7 @@ "%@ Channels?" : { }, - "%@ config data was requested over the admin channel but no response has been returned from the remote node. You can check the status of admin message requests in the admin message log." : { + "%@ config data was requested over the admin channel but no response has been returned from the remote node." : { }, "%@ dB" : { @@ -140,6 +140,16 @@ }, "%@%%" : { + }, + "%@%% %@%%" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@%% %2$@%%" + } + } + } }, "%@°F" : { @@ -1166,6 +1176,9 @@ }, "Are you sure you want to delete this message?" : { + }, + "Are you sure you want to factory reset the node?" : { + }, "are.you.sure" : { "localizations" : { @@ -1415,6 +1428,9 @@ }, "Bad" : { + }, + "Bad Packets: %d" : { + }, "Bandwidth" : { @@ -1849,9 +1865,6 @@ } } } - }, - "Bluetooth Logs" : { - }, "bluetooth.config" : { "localizations" : { @@ -4568,9 +4581,6 @@ }, "Configure" : { - }, - "Configuring Node" : { - }, "Connect to a Node" : { @@ -4895,15 +4905,8 @@ } } }, - "Coordinates: %@, %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Coordinates: %1$@, %2$@" - } - } - } + "Coordinates:" : { + }, "copy" : { "localizations" : { @@ -5061,6 +5064,9 @@ }, "Debug Log" : { + }, + "Debug Logs" : { + }, "Debug Logs%@" : { @@ -5352,9 +5358,6 @@ } } } - }, - "Developer" : { - }, "Developers" : { @@ -5420,10 +5423,7 @@ "Device GPS" : { }, - "Device is managed by a mesh administrator." : { - - }, - "Device Logging Enabled" : { + "Device is managed by a mesh administrator, the user is unable to access any of the device settings." : { }, "Device Metrics" : { @@ -6325,7 +6325,7 @@ "Direct Message Help" : { }, - "Direct messages are using the new public key infrastructure for encryption. Reguires firmware version 2.5 or greater." : { + "Direct messages are using the new public key infrastructure for encryption. Requires firmware version 2.5 or greater." : { }, "Direct messages are using the shared key for the channel." : { @@ -6712,6 +6712,9 @@ }, "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." : { + }, + "Drop Pin in Maps" : { + }, "echo" : { "localizations" : { @@ -6917,9 +6920,6 @@ }, "Enabling Ethernet will disable the bluetooth connection to the app." : { - }, - "Enabling Managed mode will restrict access to all radio configurations, such as short/long names, regions, channels, modules, etc. and will only be accessible through the Admin channel. To avoid being locked out, make sure the Admin channel is working properly before enabling it." : { - }, "Enabling WiFi will disable the bluetooth connection to the app." : { @@ -7227,12 +7227,12 @@ }, "Favorites" : { - }, - "Fetch the latest position of a cetain node" : { - }, "Favorites and nodes with recent messages show up at the top of the contact list." : { - + + }, + "Fetch the latest position of a cetain node" : { + }, "Fifteen Minutes" : { @@ -8148,6 +8148,9 @@ } } } + }, + "Group Message" : { + }, "Gusts %@" : { @@ -10912,6 +10915,9 @@ }, "Location" : { + }, + "Location:" : { + }, "Location: %@" : { @@ -14565,9 +14571,10 @@ }, "Message content exceeds 228 bytes." : { + }, "Message Status Options" : { - + }, "message.details" : { "localizations" : { @@ -16057,7 +16064,7 @@ "Other data sources" : { }, - "Output live debug logging over serial." : { + "Output live debug logging over serial, view and export position-redacted device logs over Bluetooth." : { }, "Output pin buzzer GPIO " : { @@ -16071,6 +16078,12 @@ }, "Override automatic OLED screen detection." : { + }, + "Packets Received: %d" : { + + }, + "Packets Sent: %d" : { + }, "password" : { "localizations" : { @@ -16319,6 +16332,9 @@ } } } + }, + "Perform a factory reset on the node you are connected to" : { + }, "phone.gps" : { "localizations" : { @@ -17024,6 +17040,9 @@ } } } + }, + "Reboot Node?" : { + }, "reboot.node" : { "localizations" : { @@ -17386,9 +17405,18 @@ }, "Requires that there be an accelerometer on your device." : { + }, + "Reset App Settings" : { + }, "Reset NodeDB" : { + }, + "Restart" : { + + }, + "Restart to the node you are connected to" : { + }, "restore" : { @@ -19131,13 +19159,16 @@ "Send" : { }, - "Send a channel message" : { + "Send a Group Message" : { }, "Send a message to a certain meshtastic channel" : { }, - "Send a waypoint" : { + "Send a shutdown to the node you are connected to" : { + + }, + "Send a Waypoint" : { }, "Send ASCII bell with alert message. Useful for triggering external notification on bell." : { @@ -19233,9 +19264,6 @@ }, "Serial Console over the Stream API." : { - }, - "Serial Debug Logs" : { - }, "serial.config" : { "localizations" : { @@ -19876,6 +19904,12 @@ }, "Show Weather" : { + }, + "Shut Down" : { + + }, + "Shut Down Node?" : { + }, "Shutdown Node?" : { @@ -21072,6 +21106,9 @@ }, "The minimum distance change in meters to be considered for a smart position broadcast." : { + }, + "The most recent public key for this node does not match the previously recorded key. You can delete the node and let it exchange keys again, but this also may indicate a more serious security problem. Contact the user through another trusted channel to determine if the key change was due to a factory reset or other intentional action." : { + }, "The public key authorized to send admin messages to this node." : { @@ -22241,7 +22278,7 @@ } } }, - "Updated Device Metrics Data." : { + "Updated Node Stats Data." : { }, "Updated: %@" : { @@ -22459,12 +22496,6 @@ }, "Via Mqtt" : { - }, - "View and export position-redacted device logs over Bluetooth" : { - - }, - "View Logs" : { - }, "voltage" : { "localizations" : { @@ -22678,7 +22709,7 @@ "Your Firmware is up to date" : { }, - "Your MQTT Server must support TLS." : { + "Your MQTT Server must support TLS. Not available via the public mqtt server." : { }, "Your position has been sent with a request for a response with their position. You will receive a notification when a position is returned." : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 6b02e8f1..1e63593b 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -36,6 +36,10 @@ BCB613832C672A2600485544 /* MessageChannelIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613822C672A2600485544 /* MessageChannelIntent.swift */; }; BCB613852C68703800485544 /* NodePositionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613842C68703800485544 /* NodePositionIntent.swift */; }; BCB613872C69A0FB00485544 /* AppIntentErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613862C69A0FB00485544 /* AppIntentErrors.swift */; }; + BCE2D3C32C7ADF42008E6199 /* ShutDownNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE2D3C22C7ADF42008E6199 /* ShutDownNodeIntent.swift */; }; + BCE2D3C52C7AE369008E6199 /* RestartNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE2D3C42C7AE369008E6199 /* RestartNodeIntent.swift */; }; + BCE2D3C72C7B0D0A008E6199 /* ShortcutsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE2D3C62C7B0D0A008E6199 /* ShortcutsProvider.swift */; }; + BCE2D3C92C7C377F008E6199 /* FactoryResetNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE2D3C82C7C377F008E6199 /* FactoryResetNodeIntent.swift */; }; C9697FA527933B8C00250207 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = C9697FA427933B8C00250207 /* SQLite */; }; D93068D32B8129510066FBC8 /* MessageContextMenuItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */; }; D93068D52B812B700066FBC8 /* MessageDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D42B812B700066FBC8 /* MessageDestination.swift */; }; @@ -275,6 +279,10 @@ BCB613822C672A2600485544 /* MessageChannelIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageChannelIntent.swift; sourceTree = ""; }; BCB613842C68703800485544 /* NodePositionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodePositionIntent.swift; sourceTree = ""; }; BCB613862C69A0FB00485544 /* AppIntentErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentErrors.swift; sourceTree = ""; }; + BCE2D3C22C7ADF42008E6199 /* ShutDownNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShutDownNodeIntent.swift; sourceTree = ""; }; + BCE2D3C42C7AE369008E6199 /* RestartNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartNodeIntent.swift; sourceTree = ""; }; + BCE2D3C62C7B0D0A008E6199 /* ShortcutsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsProvider.swift; sourceTree = ""; }; + BCE2D3C82C7C377F008E6199 /* FactoryResetNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactoryResetNodeIntent.swift; sourceTree = ""; }; D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageContextMenuItems.swift; sourceTree = ""; }; D93068D42B812B700066FBC8 /* MessageDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDestination.swift; sourceTree = ""; }; D93068D62B8146690066FBC8 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = ""; }; @@ -368,6 +376,7 @@ DD77093C2AA1AFA3007A8BF0 /* ChannelTips.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTips.swift; sourceTree = ""; }; DD77093E2AA1B146007A8BF0 /* UIColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; DD798B062915928D005217CD /* ChannelMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMessageList.swift; sourceTree = ""; }; + DD7E235F2C7AA3E50078ACDF /* MeshtasticDataModelV 43.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 43.xcdatamodel"; sourceTree = ""; }; DD8169F8271F1A6100F4AB02 /* MeshLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshLogger.swift; sourceTree = ""; }; DD8169FA271F1F3A00F4AB02 /* MeshLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshLog.swift; sourceTree = ""; }; DD8169FE272476C700F4AB02 /* LogDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogDocument.swift; sourceTree = ""; }; @@ -573,6 +582,10 @@ BCB613822C672A2600485544 /* MessageChannelIntent.swift */, BCB613842C68703800485544 /* NodePositionIntent.swift */, BCB613862C69A0FB00485544 /* AppIntentErrors.swift */, + BCE2D3C22C7ADF42008E6199 /* ShutDownNodeIntent.swift */, + BCE2D3C42C7AE369008E6199 /* RestartNodeIntent.swift */, + BCE2D3C82C7C377F008E6199 /* FactoryResetNodeIntent.swift */, + BCE2D3C62C7B0D0A008E6199 /* ShortcutsProvider.swift */, ); path = AppIntents; sourceTree = ""; @@ -706,8 +719,8 @@ DD41582528582E9B009B0E59 /* DeviceConfig.swift */, DD8EBF42285058FA00426DCA /* DisplayConfig.swift */, DD2553562855B02500E55709 /* LoRaConfig.swift */, - DD2553582855B52700E55709 /* PositionConfig.swift */, DD8ED9C42898D51F00B3B0AB /* NetworkConfig.swift */, + DD2553582855B52700E55709 /* PositionConfig.swift */, D93068DA2B81C85E0066FBC8 /* PowerConfig.swift */, DD1BD0F22C63C65E008C0C70 /* SecurityConfig.swift */, DD61937B2863877A00E59241 /* Module */, @@ -1257,6 +1270,7 @@ 25F26B1F2C2F611300C9CD9D /* AppData.swift in Sources */, 25F26B1E2C2F610D00C9CD9D /* Logger.swift in Sources */, 259792252C2F114500AD1659 /* ChannelEntityExtension.swift in Sources */, + BCE2D3C52C7AE369008E6199 /* RestartNodeIntent.swift in Sources */, 259792262C2F114500AD1659 /* PositionEntityExtension.swift in Sources */, 259792272C2F114500AD1659 /* TraceRouteEntityExtension.swift in Sources */, DDDB444829F8A9C900EE2349 /* String.swift in Sources */, @@ -1361,6 +1375,7 @@ D93068D32B8129510066FBC8 /* MessageContextMenuItems.swift in Sources */, DD8EBF43285058FA00426DCA /* DisplayConfig.swift in Sources */, DD964FC42974767D007C176F /* MapViewFitExtension.swift in Sources */, + BCE2D3C72C7B0D0A008E6199 /* ShortcutsProvider.swift in Sources */, DD47E3D626F17ED900029299 /* CircleText.swift in Sources */, DDC2E18F26CE25FE0042C5E4 /* ContentView.swift in Sources */, DD2553572855B02500E55709 /* LoRaConfig.swift in Sources */, @@ -1421,6 +1436,7 @@ BCB613872C69A0FB00485544 /* AppIntentErrors.swift in Sources */, DD73FD1128750779000852D6 /* PositionLog.swift in Sources */, DD15E4F52B8BFC8E00654F61 /* PaxCounterLog.swift in Sources */, + BCE2D3C32C7ADF42008E6199 /* ShutDownNodeIntent.swift in Sources */, 25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */, DD3CC6C028E7A60700FA9159 /* MessagingEnums.swift in Sources */, DD6F657B2C6EC2900053C113 /* LockLegend.swift in Sources */, @@ -1436,6 +1452,7 @@ DDDB26442AAC0206003AFCB7 /* NodeDetail.swift in Sources */, DD77093F2AA1B146007A8BF0 /* UIColor.swift in Sources */, DDF6B2482A9AEBF500BA6931 /* StoreForwardConfig.swift in Sources */, + BCE2D3C92C7C377F008E6199 /* FactoryResetNodeIntent.swift in Sources */, DD8169F9271F1A6100F4AB02 /* MeshLogger.swift in Sources */, DD93800B2BA3F968008BEC06 /* NodeMapContent.swift in Sources */, DD41582A28585C32009B0E59 /* RangeTestConfig.swift in Sources */, @@ -1669,7 +1686,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5.2; + MARKETING_VERSION = 2.5.4; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1704,7 +1721,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5.2; + MARKETING_VERSION = 2.5.4; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1736,7 +1753,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.5.2; + MARKETING_VERSION = 2.5.4; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1769,7 +1786,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.5.2; + MARKETING_VERSION = 2.5.4; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1881,6 +1898,7 @@ DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + DD7E235F2C7AA3E50078ACDF /* MeshtasticDataModelV 43.xcdatamodel */, DD1BD0F12C61D3AD008C0C70 /* MeshtasticDataModelV 42.xcdatamodel */, DD2984A82C5AEF7500B1268D /* MeshtasticDataModelV 41.xcdatamodel */, DD68BAE72C417A74004C01A0 /* MeshtasticDataModelV 40.xcdatamodel */, @@ -1924,7 +1942,7 @@ DD5D0A9A2931AD6B00F7EA61 /* MeshtasticDataModelV2.xcdatamodel */, DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */, ); - currentVersion = DD1BD0F12C61D3AD008C0C70 /* MeshtasticDataModelV 42.xcdatamodel */; + currentVersion = DD7E235F2C7AA3E50078ACDF /* MeshtasticDataModelV 43.xcdatamodel */; name = Meshtastic.xcdatamodeld; path = Meshtastic/Meshtastic.xcdatamodeld; sourceTree = ""; diff --git a/Meshtastic/AppIntents/AppIntentErrors.swift b/Meshtastic/AppIntents/AppIntentErrors.swift index c20ead7c..8e80b6b6 100644 --- a/Meshtastic/AppIntents/AppIntentErrors.swift +++ b/Meshtastic/AppIntents/AppIntentErrors.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog class AppIntentErrors { enum AppIntentError: Swift.Error, CustomLocalizedStringResourceConvertible { @@ -14,8 +15,12 @@ class AppIntentErrors { var localizedStringResource: LocalizedStringResource { switch self { - case let .message(message): return "Error: \(message)" - case .notConnected: return "No Connected Node" + case let .message(message): + Logger.services.error("App Intent: \(message)") + return "Error: \(message)" + case .notConnected: + Logger.services.error("App Intent: No Connected Node") + return "No Connected Node" } } } diff --git a/Meshtastic/AppIntents/FactoryResetNodeIntent.swift b/Meshtastic/AppIntents/FactoryResetNodeIntent.swift new file mode 100644 index 00000000..7ff2bd92 --- /dev/null +++ b/Meshtastic/AppIntents/FactoryResetNodeIntent.swift @@ -0,0 +1,41 @@ +// +// FactoryResetNodeIntent.swift +// Meshtastic +// +// Created by Benjamin Faershtein on 8/25/24. +// + +import Foundation +import AppIntents + +struct FactoryResetNodeIntent: AppIntent { + static var title: LocalizedStringResource = "Factory Reset" + static var description: IntentDescription = "Perform a factory reset on the node you are connected to" + + func perform() async throws -> some IntentResult { + // Request user confirmation before performing the factory reset + try await requestConfirmation(result: .result(dialog: "Are you sure you want to factory reset the node?"),confirmationActionName: ConfirmationActionName + .custom(acceptLabel: "Factory Reset", acceptAlternatives: [], denyLabel: "Cancel", denyAlternatives: [], destructive: true)) + + // Ensure the node is connected + if !BLEManager.shared.isConnected { + throw AppIntentErrors.AppIntentError.notConnected + } + + // Safely unwrap the connected node information + if let connectedPeripheralNum = BLEManager.shared.connectedPeripheral?.num, + let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: PersistenceController.shared.container.viewContext), + let fromUser = connectedNode.user, + let toUser = connectedNode.user { + + // Attempt to send a factory reset command, throw an error if it fails + if !BLEManager.shared.sendFactoryReset(fromUser: fromUser, toUser: toUser) { + throw AppIntentErrors.AppIntentError.message("Failed to perform factory reset") + } + } else { + throw AppIntentErrors.AppIntentError.message("Failed to retrieve connected node or required data") + } +// + return .result() + } +} diff --git a/Meshtastic/AppIntents/MessageChannelIntent.swift b/Meshtastic/AppIntents/MessageChannelIntent.swift index 53e202a6..29d0a6c2 100644 --- a/Meshtastic/AppIntents/MessageChannelIntent.swift +++ b/Meshtastic/AppIntents/MessageChannelIntent.swift @@ -9,7 +9,7 @@ import Foundation import AppIntents struct MessageChannelIntent: AppIntent { - static var title: LocalizedStringResource = "Send a channel message" + static var title: LocalizedStringResource = "Send a Group Message" static var description: IntentDescription = "Send a message to a certain meshtastic channel" diff --git a/Meshtastic/AppIntents/NodePositionIntent.swift b/Meshtastic/AppIntents/NodePositionIntent.swift index 496ca3e3..a173df0d 100644 --- a/Meshtastic/AppIntents/NodePositionIntent.swift +++ b/Meshtastic/AppIntents/NodePositionIntent.swift @@ -31,6 +31,7 @@ struct NodePositionIntent: AppIntent { } let nodeInfo = fetchedNode[0] + nodeInfo.latestEnvironmentMetrics?.batteryLevel if let latitude = nodeInfo.latestPosition?.coordinate.latitude, let longitude = nodeInfo.latestPosition?.coordinate.longitude { let nodeLocation = CLLocation(latitude: latitude, longitude: longitude) diff --git a/Meshtastic/AppIntents/RestartNodeIntent.swift b/Meshtastic/AppIntents/RestartNodeIntent.swift new file mode 100644 index 00000000..7ae8095a --- /dev/null +++ b/Meshtastic/AppIntents/RestartNodeIntent.swift @@ -0,0 +1,39 @@ +// +// RestartNodeIntent.swift +// Meshtastic +// +// Created by Benjamin Faershtein on 8/24/24. +// + +import Foundation +import AppIntents + +struct RestartNodeIntent: AppIntent { + static var title: LocalizedStringResource = "Restart" + + static var description: IntentDescription = "Restart to the node you are connected to" + + func perform() async throws -> some IntentResult { + + try await requestConfirmation(result: .result(dialog: "Reboot Node?")) + + if !BLEManager.shared.isConnected { + throw AppIntentErrors.AppIntentError.notConnected + } + // Safely unwrap the connectedNode using if let + if let connectedPeripheralNum = BLEManager.shared.connectedPeripheral?.num, + let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: PersistenceController.shared.container.viewContext), + let fromUser = connectedNode.user, + let toUser = connectedNode.user, + let adminIndex = connectedNode.myInfo?.adminIndex { + + // Attempt to send shutdown, throw an error if it fails + if !BLEManager.shared.sendReboot(fromUser: fromUser, toUser: toUser, adminIndex: adminIndex) { + throw AppIntentErrors.AppIntentError.message("Failed to restart") + } + } else { + throw AppIntentErrors.AppIntentError.message("Failed to retrieve connected node or required data") + } + return .result() + } +} diff --git a/Meshtastic/AppIntents/SendWaypointIntent.swift b/Meshtastic/AppIntents/SendWaypointIntent.swift index a94e8b7d..392d232a 100644 --- a/Meshtastic/AppIntents/SendWaypointIntent.swift +++ b/Meshtastic/AppIntents/SendWaypointIntent.swift @@ -12,7 +12,7 @@ import MeshtasticProtobufs struct SendWaypointIntent: AppIntent { - static var title = LocalizedStringResource("Send a waypoint") + static var title = LocalizedStringResource("Send a Waypoint") @Parameter(title: "Name", default: "Dropped Pin") var nameParameter: String? diff --git a/Meshtastic/AppIntents/ShortcutsProvider.swift b/Meshtastic/AppIntents/ShortcutsProvider.swift new file mode 100644 index 00000000..b21c7e7d --- /dev/null +++ b/Meshtastic/AppIntents/ShortcutsProvider.swift @@ -0,0 +1,36 @@ +// +// ShortcutsProvider.swift +// Meshtastic +// +// Created by Benjamin Faershtein on 8/24/24. +// + +import Foundation +import AppIntents + +struct ShortcutsProvider: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut(intent: ShutDownNodeIntent(), + phrases: ["Shut down \(.applicationName) node", + "Shut down my \(.applicationName) node", + "Turn off \(.applicationName) node", + "Power down \(.applicationName) node", + "Deactivate \(.applicationName) node"], + shortTitle: "Shut Down", + systemImageName: "power") + + AppShortcut(intent: RestartNodeIntent(), + phrases: ["Restart \(.applicationName) node", + "Restart my \(.applicationName) node", + "Reboot \(.applicationName) node", + "Reboot my \(.applicationName) node"], + shortTitle: "Restart", + systemImageName: "arrow.circlepath") + + AppShortcut(intent: MessageChannelIntent(), + phrases: ["Message a \(.applicationName) channel", + "Send a \(.applicationName) group message"], + shortTitle: "Group Message", + systemImageName: "message") + } +} diff --git a/Meshtastic/AppIntents/ShutDownNodeIntent.swift b/Meshtastic/AppIntents/ShutDownNodeIntent.swift new file mode 100644 index 00000000..dcb43f3c --- /dev/null +++ b/Meshtastic/AppIntents/ShutDownNodeIntent.swift @@ -0,0 +1,39 @@ +// +// ShutDownNodeIntent.swift +// Meshtastic +// +// Created by Benjamin Faershtein on 8/24/24. +// + +import Foundation +import AppIntents + +struct ShutDownNodeIntent: AppIntent { + static var title: LocalizedStringResource = "Shut Down" + + static var description: IntentDescription = "Send a shutdown to the node you are connected to" + + func perform() async throws -> some IntentResult { + try await requestConfirmation(result: .result(dialog: "Shut Down Node?")) + + if !BLEManager.shared.isConnected { + throw AppIntentErrors.AppIntentError.notConnected + } + + // Safely unwrap the connectedNode using if let + if let connectedPeripheralNum = BLEManager.shared.connectedPeripheral?.num, + let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: PersistenceController.shared.container.viewContext), + let fromUser = connectedNode.user, + let toUser = connectedNode.user, + let adminIndex = connectedNode.myInfo?.adminIndex { + + // Attempt to send shutdown, throw an error if it fails + if !BLEManager.shared.sendShutdown(fromUser: fromUser, toUser: toUser, adminIndex: adminIndex) { + throw AppIntentErrors.AppIntentError.message("Failed to shut down") + } + } else { + throw AppIntentErrors.AppIntentError.message("Failed to retrieve connected node or required data") + } + return .result() + } +} diff --git a/Meshtastic/Enums/RoutingError.swift b/Meshtastic/Enums/RoutingError.swift index 108fedd2..8b50de89 100644 --- a/Meshtastic/Enums/RoutingError.swift +++ b/Meshtastic/Enums/RoutingError.swift @@ -95,7 +95,7 @@ enum RoutingError: Int, CaseIterable, Identifiable { case .notAuthorized: return true case .pkiFailed: - return false + return true case .pkiUnknownPubkey: return false } diff --git a/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift b/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift index 07ee9117..c1bd5bec 100644 --- a/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/NodeInfoEntityExtension.swift @@ -52,8 +52,8 @@ extension NodeInfoEntity { } var isOnline: Bool { - let fifteenMinutesAgo = Calendar.current.date(byAdding: .minute, value: -15, to: Date()) - if lastHeard?.compare(fifteenMinutesAgo!) == .orderedDescending { + let twoHoursAgo = Calendar.current.date(byAdding: .minute, value: -120, to: Date()) + if lastHeard?.compare(twoHoursAgo!) == .orderedDescending { return true } return false diff --git a/Meshtastic/Extensions/View.swift b/Meshtastic/Extensions/View.swift index 7349f36d..cec5b003 100644 --- a/Meshtastic/Extensions/View.swift +++ b/Meshtastic/Extensions/View.swift @@ -8,13 +8,13 @@ import SwiftUI public extension View { - func onFirstAppear(_ action: @escaping () -> ()) -> some View { + func onFirstAppear(_ action: @escaping () -> Void) -> some View { modifier(FirstAppear(action: action)) } } private struct FirstAppear: ViewModifier { - let action: () -> () + let action: () -> Void // Use this to only fire your block one time @State private var hasAppeared = false diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index 2d8726ed..87551c4f 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -1153,7 +1153,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate } @MainActor - public func getPositionFromPhoneGPS(destNum: Int64) -> Position? { + public func getPositionFromPhoneGPS(destNum: Int64, fixedPosition: Bool) -> Position? { var positionPacket = Position() if #available(iOS 17.0, macOS 14.0, *) { @@ -1172,7 +1172,6 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate positionPacket.timestamp = UInt32(timestamp.timeIntervalSince1970) positionPacket.altitude = Int32(lastLocation.altitude) positionPacket.satsInView = UInt32(LocationsHandler.satsInView) - let currentSpeed = lastLocation.speed if currentSpeed > 0 && (!currentSpeed.isNaN || !currentSpeed.isInfinite) { positionPacket.groundSpeed = UInt32(currentSpeed) @@ -1181,6 +1180,14 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate if (currentHeading > 0 && currentHeading <= 360) && (!currentHeading.isNaN || !currentHeading.isInfinite) { positionPacket.groundTrack = UInt32(currentHeading) } + /// Set location source for time + if !fixedPosition { + /// From GPS treat time as good + positionPacket.locationSource = Position.LocSource.locExternal + } else { + /// From GPS, but time can be old and have drifted + positionPacket.locationSource = Position.LocSource.locManual + } } else { @@ -1199,6 +1206,14 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate if (currentHeading > 0 && currentHeading <= 360) && (!currentHeading.isNaN || !currentHeading.isInfinite) { positionPacket.groundTrack = UInt32(currentHeading) } + /// Set location source for time + if !fixedPosition { + /// From GPS treat time as good + positionPacket.locationSource = Position.LocSource.locExternal + } else { + /// From GPS, but time can be old and have drifted + positionPacket.locationSource = Position.LocSource.locManual + } } return positionPacket } @@ -1206,7 +1221,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate @MainActor public func setFixedPosition(fromUser: UserEntity, channel: Int32) -> Bool { var adminPacket = AdminMessage() - guard let positionPacket = getPositionFromPhoneGPS(destNum: fromUser.num) else { + guard let positionPacket = getPositionFromPhoneGPS(destNum: fromUser.num, fixedPosition: true) else { return false } adminPacket.setFixedPosition = positionPacket @@ -1261,7 +1276,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate @MainActor public func sendPosition(channel: Int32, destNum: Int64, wantResponse: Bool) -> Bool { let fromNodeNum = connectedPeripheral.num - guard let positionPacket = getPositionFromPhoneGPS(destNum: destNum) else { + guard let positionPacket = getPositionFromPhoneGPS(destNum: destNum, fixedPosition: false) else { Logger.services.error("Unable to get position data from device GPS to send to node") return false } @@ -1314,7 +1329,8 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate var adminPacket = AdminMessage() adminPacket.setTimeOnly = UInt32(Date().timeIntervalSince1970) var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = 0 + meshPacket.to = UInt32(self.connectedPeripheral.num) + meshPacket.from = UInt32(self.connectedPeripheral.num) meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. String { func localConfig (config: Config, context: NSManagedObjectContext, nodeNum: Int64, nodeLongName: String) { + let remote = nodeNum != UserDefaults.preferredPeripheralNum if config.payloadVariant == Config.OneOf_PayloadVariant.bluetooth(config.bluetooth) { upsertBluetoothConfigPacket(config: config.bluetooth, nodeNum: nodeNum, context: context) } else if config.payloadVariant == Config.OneOf_PayloadVariant.device(config.device) { @@ -365,7 +366,7 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje fetchedNode[0].user = UserEntity(context: context) } // Set the public key for a user if it is empty, don't update - if fetchedNode[0].user?.publicKey?.isEmpty == nil && !nodeInfo.user.publicKey.isEmpty { + if fetchedNode[0].user?.publicKey == nil && !nodeInfo.user.publicKey.isEmpty { fetchedNode[0].user?.pkiEncrypted = true fetchedNode[0].user?.publicKey = nodeInfo.user.publicKey } @@ -496,19 +497,19 @@ func adminAppPacket (packet: MeshPacket, context: NSManagedObjectContext) { } else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getConfigResponse(adminMessage.getConfigResponse) { let config = adminMessage.getConfigResponse if config.payloadVariant == Config.OneOf_PayloadVariant.bluetooth(config.bluetooth) { - upsertBluetoothConfigPacket(config: config.bluetooth, nodeNum: Int64(packet.from), context: context) + upsertBluetoothConfigPacket(config: config.bluetooth, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) } else if config.payloadVariant == Config.OneOf_PayloadVariant.device(config.device) { - upsertDeviceConfigPacket(config: config.device, nodeNum: Int64(packet.from), context: context) + upsertDeviceConfigPacket(config: config.device, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) } else if config.payloadVariant == Config.OneOf_PayloadVariant.display(config.display) { - upsertDisplayConfigPacket(config: config.display, nodeNum: Int64(packet.from), context: context) + upsertDisplayConfigPacket(config: config.display, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) } else if config.payloadVariant == Config.OneOf_PayloadVariant.lora(config.lora) { - upsertLoRaConfigPacket(config: config.lora, nodeNum: Int64(packet.from), context: context) + upsertLoRaConfigPacket(config: config.lora, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) } else if config.payloadVariant == Config.OneOf_PayloadVariant.network(config.network) { - upsertNetworkConfigPacket(config: config.network, nodeNum: Int64(packet.from), context: context) + upsertNetworkConfigPacket(config: config.network, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) } else if config.payloadVariant == Config.OneOf_PayloadVariant.position(config.position) { - upsertPositionConfigPacket(config: config.position, nodeNum: Int64(packet.from), context: context) + upsertPositionConfigPacket(config: config.position, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) } else if config.payloadVariant == Config.OneOf_PayloadVariant.power(config.power) { - upsertPowerConfigPacket(config: config.power, nodeNum: Int64(packet.from), context: context) + upsertPowerConfigPacket(config: config.power, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) } else if config.payloadVariant == Config.OneOf_PayloadVariant.security(config.security) { upsertSecurityConfigPacket(config: config.security, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) } @@ -632,11 +633,6 @@ func routingPacket (packet: MeshPacket, connectedNodeNum: Int64, context: NSMana } } fetchedMessage[0].ackError = Int32(routingMessage.errorReason.rawValue) - if routingError == RoutingError.pkiFailed { - fetchedMessage[0].toUser?.keyMatch = false - fetchedMessage[0].toUser?.newPublicKey = fetchedMessage[0].publicKey - } - if routingMessage.errorReason == Routing.Error.none { fetchedMessage[0].receivedACK = true @@ -681,14 +677,10 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage if let telemetryMessage = try? Telemetry(serializedData: packet.decoded.payload) { - // Only log telemetry from the mesh not the connected device - if connectedNode != Int64(packet.from) { - let logString = String.localizedStringWithFormat("mesh.log.telemetry.received %@".localized, String(packet.from)) - MeshLogger.log("📈 \(logString)") - } else { - // If it is the connected node - } - if telemetryMessage.variant != Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) { + let logString = String.localizedStringWithFormat("mesh.log.telemetry.received %@".localized, String(packet.from)) + MeshLogger.log("📈 \(logString)") + + if telemetryMessage.variant != Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) { /// Other unhandled telemetry packets return } @@ -727,6 +719,18 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage telemetry.windLull = telemetryMessage.environmentMetrics.windLull telemetry.windDirection = Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.windDirection) telemetry.metricsType = 1 + } else if telemetryMessage.variant == Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) { + // Local Stats for Live activity + telemetry.uptimeSeconds = Int32(telemetryMessage.localStats.uptimeSeconds) + telemetry.channelUtilization = telemetryMessage.localStats.channelUtilization + telemetry.airUtilTx = telemetryMessage.localStats.airUtilTx + telemetry.numPacketsTx = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsTx) + telemetry.numPacketsRx = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsRx) + telemetry.numPacketsRxBad = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsRxBad) + telemetry.numOnlineNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numOnlineNodes) + telemetry.numTotalNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTotalNodes) + telemetry.metricsType = 6 + Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.localStats.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.localStats.airUtilTx, privacy: .public) Packets Sent: \(telemetryMessage.localStats.numPacketsTx, privacy: .public) Packets Received: \(telemetryMessage.localStats.numPacketsRx, privacy: .public) Bad Packets Received: \(telemetryMessage.localStats.numPacketsRxBad, privacy: .public) Nodes Online: \(telemetryMessage.localStats.numOnlineNodes, privacy: .public) of \(telemetryMessage.localStats.numTotalNodes, privacy: .public) nodes for Node: \(packet.from.toHex(), privacy: .public)") } telemetry.snr = packet.rxSnr telemetry.rssi = packet.rxRssi @@ -743,34 +747,45 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage fetchedNode[0].telemetries = mutableTelemetries.copy() as? NSOrderedSet } try context.save() - // Only log telemetry from the mesh not the connected device - if connectedNode != Int64(packet.from) { - Logger.data.info("💾 [TelemetryEntity] Saved for Node: \(packet.from.toHex())") - } else if telemetry.metricsType == 0 { + + Logger.data.info("💾 [TelemetryEntity] Saved for Node: \(packet.from.toHex())") + if telemetry.metricsType == 0 { // Connected Device Metrics // ------------------------ // Low Battery notification - if UserDefaults.lowBatteryNotifications && telemetry.batteryLevel > 0 && telemetry.batteryLevel < 4 { - let manager = LocalNotificationManager() - manager.notifications = [ - Notification( - id: ("notification.id.\(UUID().uuidString)"), - title: "Critically Low Battery!", - subtitle: "AKA \(telemetry.nodeTelemetry?.user?.shortName ?? "UNK")", - content: "Time to charge your radio, there is \(telemetry.batteryLevel)% battery remaining.", - target: "nodes", - path: "meshtastic:///nodes?nodenum=\(telemetry.nodeTelemetry?.num ?? 0)" - ) - ] - manager.schedule() + if connectedNode == Int64(packet.from) { + if UserDefaults.lowBatteryNotifications && telemetry.batteryLevel > 0 && telemetry.batteryLevel < 4 { + let manager = LocalNotificationManager() + manager.notifications = [ + Notification( + id: ("notification.id.\(UUID().uuidString)"), + title: "Critically Low Battery!", + subtitle: "AKA \(telemetry.nodeTelemetry?.user?.shortName ?? "UNK")", + content: "Time to charge your radio, there is \(telemetry.batteryLevel)% battery remaining.", + target: "nodes", + path: "meshtastic:///nodes?nodenum=\(telemetry.nodeTelemetry?.num ?? 0)" + ) + ] + manager.schedule() + } } + } else if telemetry.metricsType == 6 { // Update our live activity if there is one running, not available on mac iOS >= 16.2 #if !targetEnvironment(macCatalyst) - let oneMinuteLater = Calendar.current.date(byAdding: .minute, value: (Int(1) ), to: Date())! - let date = Date.now...oneMinuteLater - let updatedMeshStatus = MeshActivityAttributes.MeshActivityStatus(timerRange: date, connected: true, channelUtilization: telemetry.channelUtilization, airtime: telemetry.airUtilTx, batteryLevel: UInt32(telemetry.batteryLevel), nodes: 17, nodesOnline: 9) - let alertConfiguration = AlertConfiguration(title: "Mesh activity update", body: "Updated Device Metrics Data.", sound: .default) + let fifteenMinutesLater = Calendar.current.date(byAdding: .minute, value: (Int(15) ), to: Date())! + let date = Date.now...fifteenMinutesLater + let updatedMeshStatus = MeshActivityAttributes.MeshActivityStatus(uptimeSeconds: UInt32(telemetry.uptimeSeconds), + channelUtilization: telemetry.channelUtilization, + airtime: telemetry.airUtilTx, + sentPackets: UInt32(telemetry.numPacketsTx), + receivedPackets: UInt32(telemetry.numPacketsRx), + badReceivedPackets: UInt32(telemetry.numPacketsRxBad), + nodesOnline: UInt32(telemetry.numOnlineNodes), + totalNodes: UInt32(telemetry.numTotalNodes), + timerRange: date) + + let alertConfiguration = AlertConfiguration(title: "Mesh activity update", body: "Updated Node Stats Data.", sound: .default) let updatedContent = ActivityContent(state: updatedMeshStatus, staleDate: nil) let meshActivity = Activity.activities.first(where: { $0.attributes.nodeNum == connectedNode }) diff --git a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion index 3ddf90f8..04d3cc1a 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion +++ b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - MeshtasticDataModelV 42.xcdatamodel + MeshtasticDataModelV 43.xcdatamodel diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 43.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 43.xcdatamodel/contents new file mode 100644 index 00000000..544d44c1 --- /dev/null +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 43.xcdatamodel/contents @@ -0,0 +1,475 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index 7c43448b..624dbb20 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -209,6 +209,8 @@ func upsertNodeInfoPacket (packet: MeshPacket, context: NSManagedObjectContext) } else { if packet.from > Constants.minimumNodeNum { let newUser = createUser(num: Int64(packet.from), context: context) + newNode.user?.pkiEncrypted = packet.pkiEncrypted + newNode.user?.publicKey = packet.publicKey newNode.user = newUser } } @@ -269,6 +271,11 @@ func upsertNodeInfoPacket (packet: MeshPacket, context: NSManagedObjectContext) 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) + + if !packet.publicKey.isEmpty { + fetchedNode[0].user!.pkiEncrypted = packet.pkiEncrypted + fetchedNode[0].user!.publicKey = packet.publicKey + } Task { Api().loadDeviceHardwareData { (hw) in let dh = hw.first(where: { $0.hwModel == fetchedNode[0].user?.hwModelId ?? 0 }) @@ -409,13 +416,11 @@ func upsertBluetoothConfigPacket(config: Config.BluetoothConfig, nodeNum: Int64, newBluetoothConfig.enabled = config.enabled newBluetoothConfig.mode = Int32(config.mode.rawValue) newBluetoothConfig.fixedPin = Int32(config.fixedPin) - newBluetoothConfig.deviceLoggingEnabled = config.deviceLoggingEnabled 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) - fetchedNode[0].bluetoothConfig?.deviceLoggingEnabled = config.deviceLoggingEnabled } if sessionPasskey != nil { fetchedNode[0].sessionPasskey = sessionPasskey diff --git a/Meshtastic/Router/NavigationState.swift b/Meshtastic/Router/NavigationState.swift index 0ff61108..0a85f4ab 100644 --- a/Meshtastic/Router/NavigationState.swift +++ b/Meshtastic/Router/NavigationState.swift @@ -55,17 +55,7 @@ enum SettingsNavigationState: String { case firmwareUpdates } -enum NavigationState: Hashable { - case messages(MessagesNavigationState? = nil) - case bluetooth - case nodes(selectedNodeNum: Int64? = nil) - case map(MapNavigationState? = nil) - case settings(SettingsNavigationState? = nil) -} - -// MARK: Tab Bar - -extension NavigationState { +struct NavigationState: Hashable { enum Tab: String, Hashable { case messages case bluetooth @@ -74,34 +64,9 @@ extension NavigationState { case settings } - var tab: Tab { - get { - switch self { - case .messages: - .messages - case .bluetooth: - .bluetooth - case .nodes: - .nodes - case .map: - .map - case .settings: - .settings - } - } - set { - self = switch newValue { - case .messages: - .messages() - case .bluetooth: - .bluetooth - case .nodes: - .nodes() - case .map: - .map() - case .settings: - .settings() - } - } - } + var selectedTab: Tab = .bluetooth + var messages: MessagesNavigationState? + var nodeListSelectedNodeNum: Int64? + var map: MapNavigationState? + var settings: SettingsNavigationState? } diff --git a/Meshtastic/Router/Router.swift b/Meshtastic/Router/Router.swift index 51803ed4..718c71b1 100644 --- a/Meshtastic/Router/Router.swift +++ b/Meshtastic/Router/Router.swift @@ -12,7 +12,9 @@ class Router: ObservableObject { private var cancellables: Set = [] init( - navigationState: NavigationState = .bluetooth + navigationState: NavigationState = NavigationState( + selectedTab: .bluetooth + ) ) { self.navigationState = navigationState @@ -21,10 +23,6 @@ class Router: ObservableObject { }.store(in: &cancellables) } - func route(to destination: NavigationState) { - navigationState = destination - } - func route(url: URL) { guard url.scheme == "meshtastic" else { Logger.services.error("🛣 Received routing URL \(url, privacy: .public) with invalid scheme. Ignoring route.") @@ -38,7 +36,7 @@ class Router: ObservableObject { if components.path == "/messages" { routeMessages(components) } else if components.path == "/bluetooth" { - route(to: .bluetooth) + navigationState.selectedTab = .bluetooth } else if components.path == "/nodes" { routeNodes(components) } else if components.path == "/map" { @@ -75,7 +73,8 @@ class Router: ObservableObject { } else { nil } - route(to: .messages(state)) + navigationState.selectedTab = .messages + navigationState.messages = state } private func routeNodes(_ components: URLComponents) { @@ -83,7 +82,9 @@ class Router: ObservableObject { .first(where: { $0.name == "nodenum" })? .value .flatMap(Int64.init) - route(to: .nodes(selectedNodeNum: nodeId)) + + navigationState.selectedTab = .nodes + navigationState.nodeListSelectedNodeNum = nodeId } private func routeMap(_ components: URLComponents) { @@ -95,12 +96,14 @@ class Router: ObservableObject { .first(where: { $0.name == "waypointId" })? .value .flatMap(Int64.init) - if let nodeId { - route(to: .map(.selectedNode(nodeId))) + + navigationState.selectedTab = .map + navigationState.map = if let nodeId { + .selectedNode(nodeId) } else if let waypointId { - route(to: .map(.waypoint(waypointId))) + .waypoint(waypointId) } else { - route(to: .map()) + nil } } @@ -112,6 +115,7 @@ class Router: ObservableObject { .flatMap(String.init) .flatMap(SettingsNavigationState.init(rawValue:)) - route(to: .settings(settingFromPath)) + navigationState.selectedTab = .settings + navigationState.settings = settingFromPath } } diff --git a/Meshtastic/Views/Bluetooth/Connect.swift b/Meshtastic/Views/Bluetooth/Connect.swift index 0048e430..2a1eea84 100644 --- a/Meshtastic/Views/Bluetooth/Connect.swift +++ b/Meshtastic/Views/Bluetooth/Connect.swift @@ -52,38 +52,80 @@ struct Connect: View { if #available(iOS 17.0, macOS 14.0, *) { TipView(BluetoothConnectionTip(), arrowEdge: .bottom) } - HStack { - VStack(alignment: .center) { - CircleText(text: node?.user?.shortName ?? "?", color: Color(UIColor(hex: UInt32(node?.num ?? 0))), circleSize: 90) - } - .padding(.trailing) - VStack(alignment: .leading) { - if node != nil { - Text(connectedPeripheral.longName).font(.title2) + VStack(alignment: .leading) { + HStack { + VStack(alignment: .center) { + CircleText(text: node?.user?.shortName ?? "?", color: Color(UIColor(hex: UInt32(node?.num ?? 0))), circleSize: 90) + .padding(.trailing, 5) + if node?.latestDeviceMetrics != nil { + BatteryCompact(batteryLevel: node?.latestDeviceMetrics?.batteryLevel ?? 0, font: .caption, iconFont: .callout, color: .accentColor) + .padding(.trailing, 5) + } } - Text("ble.name").font(.callout)+Text(": \(bleManager.connectedPeripheral?.peripheral.name ?? "unknown".localized)") - .font(.callout).foregroundColor(Color.gray) - if node != nil { - Text("firmware.version").font(.callout)+Text(": \(node?.metadata?.firmwareVersion ?? "unknown".localized)") + .padding(.trailing) + VStack(alignment: .leading) { + if node != nil { + Text(connectedPeripheral.longName).font(.title2) + } + Text("ble.name").font(.callout)+Text(": \(bleManager.connectedPeripheral?.peripheral.name ?? "unknown".localized)") .font(.callout).foregroundColor(Color.gray) - } - if bleManager.isSubscribed { - Text("subscribed").font(.callout) - .foregroundColor(.green) - } else { - - HStack { - if #available(iOS 17.0, macOS 14.0, *) { - Image(systemName: "square.stack.3d.down.forward") - .symbolRenderingMode(.multicolor) - .symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3)) + if node != nil { + Text("firmware.version").font(.callout)+Text(": \(node?.metadata?.firmwareVersion ?? "unknown".localized)") + .font(.callout).foregroundColor(Color.gray) + } + if bleManager.isSubscribed { + Text("subscribed").font(.callout) + .foregroundColor(.green) + } else { + HStack { + if #available(iOS 17.0, macOS 14.0, *) { + Image(systemName: "square.stack.3d.down.forward") + .symbolRenderingMode(.multicolor) + .symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3)) + .foregroundColor(.orange) + } + Text("communicating").font(.callout) .foregroundColor(.orange) } - Text("communicating").font(.callout) - .foregroundColor(.orange) } } } + VStack { + let localStats = node?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 6")).lastObject as? TelemetryEntity + if localStats != nil { + Divider() + if localStats?.numTotalNodes ?? 0 >= 100 { + Text("\(String(format: "Connected: %d nodes online", localStats?.numOnlineNodes ?? 0))") + .font(.callout) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .fixedSize() + } else { + Text("\(String(format: "Connected: %d of %d nodes online", localStats?.numOnlineNodes ?? 0, localStats?.numTotalNodes ?? 0))") + .font(.callout) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .fixedSize() + } + Text("\(String(format: "Ch. Util: %.2f", localStats?.channelUtilization ?? 0))% \(String(format: "Airtime: %.2f", localStats?.airUtilTx ?? 0))%") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + Text("Packets Sent: \(localStats?.numPacketsTx ?? 0)") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + Text("Packets Received: \(localStats?.numPacketsRx ?? 0)") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + Text("Bad Packets: \(localStats?.numPacketsRxBad ?? 0)") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .fixedSize() + } + } } .font(.caption) .foregroundColor(Color.gray) @@ -327,17 +369,25 @@ struct Connect: View { #if canImport(ActivityKit) func startNodeActivity() { liveActivityStarted = true - let timerSeconds = 60 - let deviceMetrics = node?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0")) - let mostRecent = deviceMetrics?.lastObject as? TelemetryEntity + // 15 Minutes Local Stats Interval + let timerSeconds = 900 + let localStats = node?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 6")) + let mostRecent = localStats?.lastObject as? TelemetryEntity let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName ?? "unknown") let future = Date(timeIntervalSinceNow: Double(timerSeconds)) + let initialContentState = 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), + nodesOnline: UInt32(mostRecent?.numOnlineNodes ?? 0), + totalNodes: UInt32(mostRecent?.numTotalNodes ?? 0), + timerRange: Date.now...future) - let initialContentState = MeshActivityAttributes.ContentState(timerRange: Date.now...future, connected: true, channelUtilization: mostRecent?.channelUtilization ?? 0.0, airtime: mostRecent?.airUtilTx ?? 0.0, batteryLevel: UInt32(mostRecent?.batteryLevel ?? 0), nodes: 17, nodesOnline: 9) - - let activityContent = ActivityContent(state: initialContentState, staleDate: Calendar.current.date(byAdding: .minute, value: 2, to: Date())!) + let activityContent = ActivityContent(state: initialContentState, staleDate: Calendar.current.date(byAdding: .minute, value: 15, to: Date())!) do { let myActivity = try Activity.request(attributes: activityAttributes, content: activityContent, diff --git a/Meshtastic/Views/ContentView.swift b/Meshtastic/Views/ContentView.swift index e8384c35..23b25f0c 100644 --- a/Meshtastic/Views/ContentView.swift +++ b/Meshtastic/Views/ContentView.swift @@ -13,14 +13,7 @@ struct ContentView: View { var router: Router var body: some View { - TabView(selection: Binding( - get: { - appState.router.navigationState.tab - }, - set: { newValue in - appState.router.navigationState.tab = newValue - } - )) { + TabView(selection: $appState.router.navigationState.selectedTab) { Messages( router: appState.router, unreadChannelMessages: $appState.unreadChannelMessages, diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index 91a922c9..36cc5592 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -98,9 +98,6 @@ struct ChannelMessageList: View { Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true) .foregroundStyle(ackErrorVal?.color ?? Color.red) .font(.caption2) - } else { - let messageDate = message.timestamp - Text(" \(messageDate.formattedDate(format: MessageText.dateFormatString))").font(.caption2).foregroundColor(.gray) } } } diff --git a/Meshtastic/Views/Messages/Messages.swift b/Meshtastic/Views/Messages/Messages.swift index 88a6f2a2..09b8d1af 100644 --- a/Meshtastic/Views/Messages/Messages.swift +++ b/Meshtastic/Views/Messages/Messages.swift @@ -26,21 +26,6 @@ struct Messages: View { @Binding var unreadDirectMessages: Int - // Aliases the navigation state for the NavigationSplitView sidebar selection - private var messagesSelection: Binding { - Binding( - get: { - guard case .messages(let state) = router.navigationState else { - return nil - } - return state - }, - set: { newValue in - router.navigationState = .messages(newValue) - } - ) - } - @State var node: NodeInfoEntity? @State private var userSelection: UserEntity? // Nothing selected by default. @State private var channelSelection: ChannelEntity? // Nothing selected by default. @@ -49,7 +34,7 @@ struct Messages: View { var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { - List(selection: messagesSelection) { + List(selection: $router.navigationState.messages) { NavigationLink(value: MessagesNavigationState.channels()) { Label { Text("channels") @@ -88,11 +73,12 @@ struct Messages: View { .navigationBarTitleDisplayMode(.large) .navigationBarItems(leading: MeshtasticLogo()) } content: { - if case .messages(.channels) = router.navigationState { + switch router.navigationState.messages { + case .channels(let channelId, let messageId): ChannelList(node: $node, channelSelection: $channelSelection) - } else if case .messages(.directMessages) = router.navigationState { + case .directMessages(let userNum, let messageId): UserList(node: $node, userSelection: $userSelection) - } else if case .messages(nil) = router.navigationState { + case nil: Text("Select a conversation type") } } detail: { @@ -100,9 +86,9 @@ struct Messages: View { ChannelMessageList(myInfo: myInfo, channel: channelSelection) } else if let userSelection { UserMessageList(user: userSelection) - } else if case .messages(.channels) = router.navigationState { + } else if case .channels = router.navigationState.messages { Text("Select a channel") - } else if case .messages(.directMessages) = router.navigationState { + } else if case .directMessages = router.navigationState.messages { Text("Select a conversation") } }.onChange(of: router.navigationState) { _ in @@ -116,11 +102,7 @@ struct Messages: View { node = getNodeInfo(id: nodeId, context: context) } - guard case .messages(let state) = router.navigationState else { - return - } - - guard let state else { + guard let state = router.navigationState.messages else { channelSelection = nil userSelection = nil return diff --git a/Meshtastic/Views/Messages/UserList.swift b/Meshtastic/Views/Messages/UserList.swift index bf953d5b..4db57180 100644 --- a/Meshtastic/Views/Messages/UserList.swift +++ b/Meshtastic/Views/Messages/UserList.swift @@ -35,7 +35,6 @@ struct UserList: View { var boolFilters: [Bool] {[ isFavorite, isOnline, - isPkiEncrypted, isEnvironment, distanceFilter, roleFilter @@ -45,10 +44,12 @@ struct UserList: View { sortDescriptors: [NSSortDescriptor(key: "lastMessage", ascending: false), NSSortDescriptor(key: "userNode.favorite", ascending: false), NSSortDescriptor(key: "pkiEncrypted", ascending: false), + NSSortDescriptor(key: "userNode.lastHeard", ascending: false), NSSortDescriptor(key: "longName", ascending: true)], + predicate: NSPredicate(format: "longName != ''"), animation: .default ) - private var users: FetchedResults + var users: FetchedResults @Binding var node: NodeInfoEntity? @Binding var userSelection: UserEntity? @@ -201,34 +202,55 @@ struct UserList: View { DirectMessagesHelp() } .onChange(of: searchText) { _ in - searchUserList() + Task { + await searchUserList() + } } .onChange(of: viaLora) { _ in if !viaLora && !viaMqtt { viaMqtt = true } - searchUserList() + Task { + await searchUserList() + } } .onChange(of: viaMqtt) { _ in if !viaLora && !viaMqtt { viaLora = true } - searchUserList() + Task { + await searchUserList() + } } .onChange(of: [deviceRoles]) { _ in - searchUserList() + Task { + await searchUserList() + } } .onChange(of: hopsAway) { _ in - searchUserList() + Task { + await searchUserList() + } } .onChange(of: [boolFilters]) { _ in - searchUserList() + Task { + await searchUserList() + } } .onChange(of: maxDistance) { _ in - searchUserList() + Task { + await searchUserList() + } + } + .onChange(of: isPkiEncrypted) { _ in + Task { + await searchUserList() + } } .onAppear { - searchUserList() + Task { + await searchUserList() + } } .safeAreaInset(edge: .bottom, alignment: .leading) { HStack { @@ -266,8 +288,7 @@ struct UserList: View { .scrollDismissesKeyboard(.immediately) } } - - private func searchUserList() { + private func searchUserList() async { /// Case Insensitive Search Text Predicates let searchPredicates = ["userId", "numString", "hwModel", "hwDisplayName", "longName", "shortName"].map { property in @@ -307,7 +328,7 @@ struct UserList: View { } /// Online if isOnline { - let isOnlinePredicate = NSPredicate(format: "userNode.lastHeard >= %@", Calendar.current.date(byAdding: .minute, value: -15, to: Date())! as NSDate) + let isOnlinePredicate = NSPredicate(format: "userNode.lastHeard >= %@", Calendar.current.date(byAdding: .minute, value: -120, to: Date())! as NSDate) predicates.append(isOnlinePredicate) } /// Encrypted diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index a53f788d..cb483745 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -84,6 +84,7 @@ struct UserMessageList: View { } else if currentUser && message.ackError > 0 { Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true) .foregroundStyle(ackErrorVal?.color ?? Color.red) + .font(.caption2) } } } diff --git a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift index 7906f8b5..340117ac 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift @@ -130,7 +130,9 @@ struct NodeMapSwiftUI: View { if node.positions?.count ?? 0 > 1 { position = .automatic } else { - position = .camera(MapCamera(centerCoordinate: mostRecent!.coordinate, distance: 8000, heading: 0, pitch: 60)) + if let mrCoord = mostRecent?.coordinate { + position = .camera(MapCamera(centerCoordinate: mrCoord, distance: 8000, heading: 0, pitch: 60)) + } } if self.scene == nil { Task { diff --git a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift index 17573745..35916eb6 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift @@ -28,6 +28,8 @@ struct WaypointForm: View { @State private var expire: Date = Date.now.addingTimeInterval(60 * 480) // 1 minute * 480 = 8 Hours @State private var locked: Bool = false @State private var lockedTo: Int64 = 0 + @State private var detents: Set = [.medium, .fraction(0.85)] + @State private var selectedDetent: PresentationDetent = .medium var body: some View { NavigationStack { @@ -39,9 +41,12 @@ struct WaypointForm: View { let distance = CLLocation(latitude: LocationHelper.currentLocation.latitude, longitude: LocationHelper.currentLocation.longitude).distance(from: CLLocation(latitude: waypoint.coordinate.latitude, longitude: waypoint.coordinate.longitude )) Section(header: Text("Coordinate") ) { HStack { - Text("Location: \(String(format: "%.5f", waypoint.coordinate.latitude) + "," + String(format: "%.5f", waypoint.coordinate.longitude))") + Text("Location:") + .foregroundColor(.secondary) + Text("\(String(format: "%.5f", waypoint.coordinate.latitude) + "," + String(format: "%.5f", waypoint.coordinate.longitude))") .textSelection(.enabled) - .foregroundColor(Color.gray) + .foregroundColor(.secondary) + .font(.caption) } HStack { if waypoint.coordinate.latitude != 0 && waypoint.coordinate.longitude != 0 { @@ -124,6 +129,7 @@ struct WaypointForm: View { .toggleStyle(SwitchToggleStyle(tint: .accentColor)) } } + .scrollDismissesKeyboard(.immediately) HStack { Button { /// Send a new or exiting waypoint @@ -239,7 +245,7 @@ struct WaypointForm: View { } else { VStack { HStack { - CircleText(text: String(UnicodeScalar(Int(waypoint.icon)) ?? "📍"), color: Color.orange, circleSize: 65) + CircleText(text: String(UnicodeScalar(Int(waypoint.icon)) ?? "📍"), color: Color.orange, circleSize: 50) Spacer() Text(waypoint.name ?? "?") .font(.largeTitle) @@ -250,6 +256,7 @@ struct WaypointForm: View { } else { Button { editMode = true + selectedDetent = .fraction(0.85) } label: { Image(systemName: "square.and.pencil" ) .font(.largeTitle) @@ -269,22 +276,30 @@ struct WaypointForm: View { .fixedSize(horizontal: false, vertical: true) } icon: { Image(systemName: "doc.plaintext") - .symbolRenderingMode(.hierarchical) - .frame(width: 35) } - .padding(.bottom, 5) + .padding(.bottom) } /// Coordinate Label { - Text("Coordinates: \(String(format: "%.6f", waypoint.coordinate.latitude)), \(String(format: "%.6f", waypoint.coordinate.longitude))") - .textSelection(.enabled) + Text("Coordinates:") .foregroundColor(.primary) + Text("\(String(format: "%.6f", waypoint.coordinate.latitude)), \(String(format: "%.6f", waypoint.coordinate.longitude))") + .textSelection(.enabled) + .foregroundColor(.secondary) + .font(.caption2) } icon: { - Image(systemName: "mappin.and.ellipse") - .symbolRenderingMode(.hierarchical) - .frame(width: 35) + Image(systemName: "mappin.circle") } - .padding(.bottom, 5) + .padding(.bottom) + // Drop Maps Pin + Button(action: { + if let url = URL(string: "http://maps.apple.com/?ll=\(waypoint.coordinate.latitude),\(waypoint.coordinate.longitude)&q=\(waypoint.name ?? "Dropped Pin")") { + UIApplication.shared.open(url) + } + }) { + Label("Drop Pin in Maps", systemImage: "mappin.and.ellipse") + } + .padding(.bottom) /// Created Label { Text("Created: \(waypoint.created?.formatted() ?? "?")") @@ -292,9 +307,8 @@ struct WaypointForm: View { } icon: { Image(systemName: "clock.badge.checkmark") .symbolRenderingMode(.hierarchical) - .frame(width: 35) } - .padding(.bottom, 5) + .padding(.bottom) /// Updated if waypoint.lastUpdated != nil { Label { @@ -303,9 +317,8 @@ struct WaypointForm: View { } icon: { Image(systemName: "clock.arrow.circlepath") .symbolRenderingMode(.hierarchical) - .frame(width: 35) } - .padding(.bottom, 5) + .padding(.bottom) } /// Expires if waypoint.expire != nil { @@ -378,7 +391,8 @@ struct WaypointForm: View { longitude = waypoint.coordinate.longitude } } - .presentationDetents([.fraction(0.75)]) + .presentationDetents(detents, selection: $selectedDetent) + .presentationBackgroundInteraction(.enabled(upThrough: .fraction(0.85))) .presentationDragIndicator(.visible) } } diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index 6724e805..11defc79 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -51,9 +51,9 @@ struct NodeDetail: View { Text("Public Key Mismatch") .font(.title3) .foregroundStyle(.red) - Text("The public key does not match the recorded key. You may delete the node and let it exchange keys again, but this may indicate a more serious security problem. Contact the user through another trusted channel, to determine if the key change was due to a factory reset or other intentional action.") - .font(.caption) - .foregroundStyle(.red) + Text("The most recent public key for this node does not match the previously recorded key. You can delete the node and let it exchange keys again, but this also may indicate a more serious security problem. Contact the user through another trusted channel to determine if the key change was due to a factory reset or other intentional action.") + .foregroundStyle(.secondary) + .font(.callout) } } icon: { Image(systemName: "key.slash.fill") diff --git a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift index 63aff338..481666a1 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift @@ -30,8 +30,22 @@ struct NodeListItem: View { } VStack(alignment: .leading) { HStack { + if node.user?.pkiEncrypted ?? false { + if !(node.user?.keyMatch ?? false) { + /// Public Key on the User and the Public Key on the Last Message don't match + Image(systemName: "key.slash") + .foregroundColor(.red) + } else { + Image(systemName: "lock.fill") + .foregroundColor(.green) + } + } else { + Image(systemName: "lock.open.fill") + .foregroundColor(.yellow) + } Text(node.user?.longName ?? "unknown".localized) .font(.headline) + .fontWeight(.regular) .allowsTightening(true) if node.favorite { Spacer() diff --git a/Meshtastic/Views/Nodes/MeshMap.swift b/Meshtastic/Views/Nodes/MeshMap.swift index 9e09a0c0..595abb15 100644 --- a/Meshtastic/Views/Nodes/MeshMap.swift +++ b/Meshtastic/Views/Nodes/MeshMap.swift @@ -123,10 +123,10 @@ struct MeshMap: View { MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap) } .onChange(of: router.navigationState) { - guard case .map(let selectedNodeNum) = router.navigationState else { return } + guard case .map = router.navigationState.selectedTab else { return } // TODO: handle deep link for waypoints } - .onChange(of: (selectedMapLayer)) { newMapLayer in + .onChange(of: selectedMapLayer) { newMapLayer in switch selectedMapLayer { case .standard: UserDefaults.mapLayer = newMapLayer diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 67636328..f81a34cf 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -335,11 +335,8 @@ struct NodeList: View { } } .onChange(of: router.navigationState) { _ in - // Handle deep link routing - if case .nodes(let selected) = router.navigationState { - self.selectedNode = selected.flatMap { - getNodeInfo(id: $0, context: context) - } + if let selected = router.navigationState.nodeListSelectedNodeNum { + self.selectedNode = getNodeInfo(id: selected, context: context) } else { self.selectedNode = nil } @@ -390,7 +387,7 @@ struct NodeList: View { } /// Online if isOnline { - let isOnlinePredicate = NSPredicate(format: "lastHeard >= %@", Calendar.current.date(byAdding: .minute, value: -15, to: Date())! as NSDate) + let isOnlinePredicate = NSPredicate(format: "lastHeard >= %@", Calendar.current.date(byAdding: .minute, value: -120, to: Date())! as NSDate) predicates.append(isOnlinePredicate) } /// Encrypted diff --git a/Meshtastic/Views/Settings/AppSettings.swift b/Meshtastic/Views/Settings/AppSettings.swift index 02286655..f641b77b 100644 --- a/Meshtastic/Views/Settings/AppSettings.swift +++ b/Meshtastic/Views/Settings/AppSettings.swift @@ -76,9 +76,14 @@ struct AppSettings: View { } clearCoreDataDatabase(context: context, includeRoutes: true) context.refreshAllObjects() - UserDefaults.standard.reset() } } + Button { + UserDefaults.standard.reset() + } label: { + Label("Reset App Settings", systemImage: "arrow.counterclockwise.circle") + .foregroundColor(.red) + } } if totalDownloadedTileSize != "0MB" { Section(header: Text("Map Tile Data")) { diff --git a/Meshtastic/Views/Settings/Channels.swift b/Meshtastic/Views/Settings/Channels.swift index ce48b658..ca55b95d 100644 --- a/Meshtastic/Views/Settings/Channels.swift +++ b/Meshtastic/Views/Settings/Channels.swift @@ -92,13 +92,21 @@ struct Channels: View { positionPrecision = 32 preciseLocation = true positionsEnabled = true - + if channelKey == "AQ==" { + positionPrecision = 14 + preciseLocation = false + } } else if !supportedVersion && channelRole == 2 { positionPrecision = 0 preciseLocation = false positionsEnabled = false } else { - if positionPrecision == 32 { + if channelKey == "AQ==" { + preciseLocation = false + if (positionPrecision > 0 && positionPrecision < 11) || positionPrecision > 14 { + positionPrecision = 14 + } + } else if positionPrecision == 32 { preciseLocation = true positionsEnabled = true } else { @@ -217,7 +225,7 @@ struct Channels: View { } label: { Label("save", systemImage: "square.and.arrow.down") } - .disabled(bleManager.connectedPeripheral == nil || !hasChanges || !hasValidKey) + .disabled(bleManager.connectedPeripheral == nil)// || !hasChanges)// !hasValidKey) .buttonStyle(.bordered) .buttonBorderShape(.capsule) .controlSize(.large) @@ -253,7 +261,6 @@ struct Channels: View { positionPrecision = 0 uplink = false downlink = false - hasChanges = true let newChannel = ChannelEntity(context: context) newChannel.id = channelIndex @@ -265,6 +272,7 @@ struct Channels: View { newChannel.psk = Data(base64Encoded: channelKey) ?? Data() newChannel.positionPrecision = Int32(positionPrecision) selectedChannel = newChannel + hasChanges = true } label: { Label("Add Channel", systemImage: "plus.square") diff --git a/Meshtastic/Views/Settings/Channels/ChannelForm.swift b/Meshtastic/Views/Settings/Channels/ChannelForm.swift index 780a19b4..e4930b7a 100644 --- a/Meshtastic/Views/Settings/Channels/ChannelForm.swift +++ b/Meshtastic/Views/Settings/Channels/ChannelForm.swift @@ -130,7 +130,6 @@ struct ChannelForm: View { } Section(header: Text("position")) { - VStack(alignment: .leading) { Toggle(isOn: $positionsEnabled) { Label(channelRole == 1 ? "Positions Enabled" : "Allow Position Requests", systemImage: positionsEnabled ? "mappin" : "mappin.slash") @@ -140,24 +139,26 @@ struct ChannelForm: View { } if positionsEnabled { - VStack(alignment: .leading) { - Toggle(isOn: $preciseLocation) { - Label("Precise Location", systemImage: "scope") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - .disabled(!supportedVersion) - .listRowSeparator(.visible) - .onChange(of: preciseLocation) { pl in - if pl == false { - positionPrecision = 13 + if channelKey != "AQ==" && channelRole > 0 { + VStack(alignment: .leading) { + Toggle(isOn: $preciseLocation) { + Label("Precise Location", systemImage: "scope") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .disabled(!supportedVersion) + .listRowSeparator(.visible) + .onChange(of: preciseLocation) { pl in + if pl == false { + positionPrecision = 14 + } } } } - if !preciseLocation { VStack(alignment: .leading) { Label("Approximate Location", systemImage: "location.slash.circle.fill") - Slider(value: $positionPrecision, in: 10...19, step: 1) { + + Slider(value: $positionPrecision, in: 11...14, step: 1) { } minimumValueLabel: { Image(systemName: "minus") } maximumValueLabel: { @@ -199,11 +200,24 @@ struct ChannelForm: View { .onChange(of: channelKey) { _ in hasChanges = true } + .onChange(of: channelKeySize) { _ in + if channelKeySize == -1 { + if channelRole == 0 { + preciseLocation = false + } + channelKey = "AQ==" + } + } .onChange(of: channelRole) { _ in hasChanges = true } .onChange(of: preciseLocation) { loc in if loc == true { + if channelKey == "AQ==" { + preciseLocation = false + } else { + positionPrecision = 32 + } positionPrecision = 32 } else { positionPrecision = 14 @@ -216,7 +230,7 @@ struct ChannelForm: View { .onChange(of: positionsEnabled) { pe in if pe { if positionPrecision == 0 { - positionPrecision = 32 + positionPrecision = 14 } } else { positionPrecision = 0 @@ -229,7 +243,7 @@ struct ChannelForm: View { .onChange(of: downlink) { _ in hasChanges = true } - .onAppear { + .onFirstAppear { let tempKey = Data(base64Encoded: channelKey) ?? Data() if tempKey.count == channelKeySize || channelKeySize == -1 { hasValidKey = true diff --git a/Meshtastic/Views/Settings/Config/BluetoothConfig.swift b/Meshtastic/Views/Settings/Config/BluetoothConfig.swift index 75132df1..46211dac 100644 --- a/Meshtastic/Views/Settings/Config/BluetoothConfig.swift +++ b/Meshtastic/Views/Settings/Config/BluetoothConfig.swift @@ -19,7 +19,6 @@ struct BluetoothConfig: View { @State var mode = 0 @State var fixedPin = "123456" @State var shortPin = false - @State var deviceLoggingEnabled = false var pinLength: Int = 6 let numberFormatter: NumberFormatter = { let formatter = NumberFormatter() @@ -70,10 +69,6 @@ struct BluetoothConfig: View { .foregroundColor(.red) } } - Toggle(isOn: $deviceLoggingEnabled) { - Label("Device Logging Enabled", systemImage: "ladybug") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) } } .disabled(self.bleManager.connectedPeripheral == nil || node?.bluetoothConfig == nil) @@ -85,7 +80,6 @@ struct BluetoothConfig: View { bc.enabled = enabled bc.mode = BluetoothModes(rawValue: mode)?.protoEnumValue() ?? Config.BluetoothConfig.PairingMode.randomPin bc.fixedPin = UInt32(fixedPin) ?? 123456 - bc.deviceLoggingEnabled = deviceLoggingEnabled let adminMessageId = bleManager.saveBluetoothConfig(config: bc, fromUser: connectedNode.user!, toUser: node!.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) if adminMessageId > 0 { // Should show a saved successfully alert once I know that to be true @@ -106,13 +100,24 @@ struct BluetoothConfig: View { ) } ) - .onAppear { + .onFirstAppear { // Need to request a BluetoothConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node, node.bluetoothConfig == nil { + if let connectedPeripheral = bleManager.connectedPeripheral, let node { Logger.mesh.info("empty bluetooth config") let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) if let connectedNode { - _ = bleManager.requestBluetoothConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + if node.num != connectedNode.num { + if UserDefaults.enableAdministration { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.bluetoothConfig == nil { + _ = bleManager.requestBluetoothConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestBluetoothConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } } } } @@ -125,15 +130,11 @@ struct BluetoothConfig: View { .onChange(of: fixedPin) { newFixedPin in if newFixedPin != String(node?.bluetoothConfig?.fixedPin ?? -1) { hasChanges = true } } - .onChange(of: deviceLoggingEnabled) { - if $0 != node?.bluetoothConfig?.deviceLoggingEnabled { hasChanges = true } - } } func setBluetoothValues() { self.enabled = node?.bluetoothConfig?.enabled ?? true self.mode = Int(node?.bluetoothConfig?.mode ?? 0) self.fixedPin = String(node?.bluetoothConfig?.fixedPin ?? 123456) - self.deviceLoggingEnabled = node?.bluetoothConfig?.deviceLoggingEnabled ?? false self.hasChanges = false } } diff --git a/Meshtastic/Views/Settings/Config/ConfigHeader.swift b/Meshtastic/Views/Settings/Config/ConfigHeader.swift index 3f59a01a..d1af3a6a 100644 --- a/Meshtastic/Views/Settings/Config/ConfigHeader.swift +++ b/Meshtastic/Views/Settings/Config/ConfigHeader.swift @@ -17,8 +17,9 @@ struct ConfigHeader: View { } else if node != nil && node?.num ?? 0 != bleManager.connectedPeripheral?.num ?? 0 { // Let users know what is going on if they are using remote admin and don't have the config yet - if node?[keyPath: config] == nil { - Text("\(title) config data was requested over the admin channel but no response has been returned from the remote node. You can check the status of admin message requests in the admin message log.") + let expiration = node?.sessionExpiration ?? Date() + if node?[keyPath: config] == nil || expiration < node?.sessionExpiration ?? Date() { + Text("\(title) config data was requested over the admin channel but no response has been returned from the remote node.") .font(.callout) .foregroundColor(.orange) } else { diff --git a/Meshtastic/Views/Settings/Config/DeviceConfig.swift b/Meshtastic/Views/Settings/Config/DeviceConfig.swift index 13d3aa83..dc17446e 100644 --- a/Meshtastic/Views/Settings/Config/DeviceConfig.swift +++ b/Meshtastic/Views/Settings/Config/DeviceConfig.swift @@ -29,7 +29,6 @@ struct DeviceConfig: View { @State var nodeInfoBroadcastSecs = 10800 @State var doubleTapAsButtonPress = false @State var ledHeartbeatEnabled = true - @State var isManaged = false @State var tzdef = "" var body: some View { @@ -62,12 +61,6 @@ struct DeviceConfig: View { } .pickerStyle(DefaultPickerStyle()) - Toggle(isOn: $isManaged) { - Label("Managed Device", systemImage: "gearshape.arrow.triangle.2.circlepath") - Text("Enabling Managed mode will restrict access to all radio configurations, such as short/long names, regions, channels, modules, etc. and will only be accessible through the Admin channel. To avoid being locked out, make sure the Admin channel is working properly before enabling it.") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - Picker("Node Info Broadcast Interval", selection: $nodeInfoBroadcastSecs ) { ForEach(UpdateIntervals.allCases) { ui in if ui.rawValue >= 3600 { @@ -213,13 +206,8 @@ struct DeviceConfig: View { dc.rebroadcastMode = RebroadcastModes(rawValue: rebroadcastMode)?.protoEnumValue() ?? RebroadcastModes.all.protoEnumValue() dc.nodeInfoBroadcastSecs = UInt32(nodeInfoBroadcastSecs) dc.doubleTapAsButtonPress = doubleTapAsButtonPress - dc.isManaged = isManaged dc.tzdef = tzdef dc.ledHeartbeatDisabled = !ledHeartbeatEnabled - if isManaged { - serialEnabled = false - debugLogEnabled = false - } let adminMessageId = bleManager.saveDeviceConfig(config: dc, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) if adminMessageId > 0 { // Should show a saved successfully alert once I know that to be true @@ -242,13 +230,26 @@ struct DeviceConfig: View { ) } ) - .onAppear { + .onFirstAppear { // Need to request a DeviceConfig from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.deviceConfig == nil { + if let connectedPeripheral = bleManager.connectedPeripheral, let node { Logger.mesh.info("empty device config") - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? -1, context: context) - if node != nil && connectedNode != nil && connectedNode?.user != nil { - _ = bleManager.requestDeviceConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.deviceConfig == nil { + _ = bleManager.requestDeviceConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + if node.deviceConfig == nil { + /// Legacy Administration + _ = bleManager.requestDeviceConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } + } } } } @@ -276,9 +277,6 @@ struct DeviceConfig: View { .onChange(of: doubleTapAsButtonPress) { if $0 != node?.deviceConfig?.doubleTapAsButtonPress { hasChanges = true } } - .onChange(of: isManaged) { - if $0 != node?.deviceConfig?.isManaged { hasChanges = true } - } .onChange(of: tzdef) { newTzdef in if newTzdef != node?.deviceConfig?.tzdef { hasChanges = true } } @@ -301,7 +299,6 @@ struct DeviceConfig: View { } self.doubleTapAsButtonPress = node?.deviceConfig?.doubleTapAsButtonPress ?? false self.ledHeartbeatEnabled = node?.deviceConfig?.ledHeartbeatEnabled ?? true - self.isManaged = node?.deviceConfig?.isManaged ?? false self.tzdef = node?.deviceConfig?.tzdef ?? "" if self.tzdef.isEmpty { self.tzdef = TimeZone.current.posixDescription diff --git a/Meshtastic/Views/Settings/Config/DisplayConfig.swift b/Meshtastic/Views/Settings/Config/DisplayConfig.swift index 1f3f6de4..1e9781a6 100644 --- a/Meshtastic/Views/Settings/Config/DisplayConfig.swift +++ b/Meshtastic/Views/Settings/Config/DisplayConfig.swift @@ -163,13 +163,24 @@ struct DisplayConfig: View { ) } ) - .onAppear { - // Need to request a LoRaConfig from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.displayConfig == nil { + .onFirstAppear { + // Need to request a DisplayConfig from the remote node before allowing changes + if let connectedPeripheral = bleManager.connectedPeripheral, let node { Logger.mesh.info("empty display config") - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? 0, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestDisplayConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.displayConfig == nil { + _ = bleManager.requestDisplayConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestDisplayConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } } } } diff --git a/Meshtastic/Views/Settings/Config/LoRaConfig.swift b/Meshtastic/Views/Settings/Config/LoRaConfig.swift index dad7c1a4..43729f86 100644 --- a/Meshtastic/Views/Settings/Config/LoRaConfig.swift +++ b/Meshtastic/Views/Settings/Config/LoRaConfig.swift @@ -232,13 +232,24 @@ struct LoRaConfig: View { ) } ) - .onAppear { + .onFirstAppear { // Need to request a LoRaConfig from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.loRaConfig == nil { + if let connectedPeripheral = bleManager.connectedPeripheral, let node { Logger.mesh.info("empty lora config") - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestLoRaConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.loRaConfig == nil { + _ = bleManager.requestLoRaConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestLoRaConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } } } } diff --git a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift index 76c98752..fe3faa51 100644 --- a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift @@ -6,6 +6,7 @@ // import MeshtasticProtobufs import SwiftUI +import OSLog @available(iOS 17.0, macOS 14.0, *) struct AmbientLightingConfig: View { @@ -85,12 +86,24 @@ struct AmbientLightingConfig: View { ) } ) - .onAppear { + .onFirstAppear { // Need to request a Ambient Lighting Config from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.ambientLightingConfig == nil { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestAmbientLightingConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + if let connectedPeripheral = bleManager.connectedPeripheral, let node { + Logger.mesh.info("empty ambient lighting config") + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.ambientLightingConfig == nil { + _ = bleManager.requestAmbientLightingConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestAmbientLightingConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } } } } diff --git a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift index b4ec0b63..5b94e8c3 100644 --- a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift @@ -233,13 +233,24 @@ struct CannedMessagesConfig: View { ) } ) - .onAppear { + .onFirstAppear { // Need to request a CannedMessagesModuleConfig from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.cannedMessageConfig == nil { - Logger.mesh.info("empty canned messages module config") - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestCannedMessagesModuleConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + if let connectedPeripheral = bleManager.connectedPeripheral, let node { + Logger.mesh.info("empty canned message config") + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration && node.num != connectedNode.num { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.cannedMessageConfig == nil { + _ = bleManager.requestCannedMessagesModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestCannedMessagesModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } } } } diff --git a/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift b/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift index 1ff1ed86..e67898eb 100644 --- a/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift @@ -189,13 +189,24 @@ struct DetectionSensorConfig: View { ) } ) - .onAppear { - // Need to request a Detection Sensor Module Config from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.detectionSensorConfig == nil { - Logger.mesh.info("empty detection sensor module config") - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestDetectionSensorModuleConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + .onFirstAppear { + // Need to request a DetectionSensorModuleConfig from the remote node before allowing changes + if let connectedPeripheral = bleManager.connectedPeripheral, let node { + Logger.mesh.info("empty detection sensor config") + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration && node.num != connectedNode.num { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.detectionSensorConfig == nil { + _ = bleManager.requestDetectionSensorModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestDetectionSensorModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } } } } diff --git a/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift b/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift index 32cacbd3..57c3a672 100644 --- a/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift @@ -199,13 +199,24 @@ struct ExternalNotificationConfig: View { ) } ) - .onAppear { - // Need to request a TelemetryModuleConfig from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.externalNotificationConfig == nil { - Logger.mesh.info("empty external notification module config") - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestExternalNotificationModuleConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + .onFirstAppear { + // Need to request a ExternalNotificationModuleConfig from the remote node before allowing changes + if let connectedPeripheral = bleManager.connectedPeripheral, let node { + Logger.mesh.info("empty external notificaiton module config") + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration && node.num != connectedNode.num { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.externalNotificationConfig == nil { + _ = bleManager.requestExternalNotificationModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestExternalNotificationModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } } } } @@ -279,7 +290,7 @@ struct ExternalNotificationConfig: View { if newNagTimeout != node!.externalNotificationConfig!.nagTimeout { hasChanges = true } } } - .onChange(of: useI2SAsBuzzer) { + .onChange(of: useI2SAsBuzzer) { if let val = node?.externalNotificationConfig?.useI2SAsBuzzer { hasChanges = $0 != val } diff --git a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift index d6176526..1996c744 100644 --- a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift @@ -24,7 +24,7 @@ struct MQTTConfig: View { @State var password = "" @State var encryptionEnabled = true @State var jsonEnabled = false - @State var tlsEnabled = true + @State var tlsEnabled = false @State var root = "msh" @State var selectedTopic = "" @State var mqttConnected: Bool = false @@ -32,8 +32,7 @@ struct MQTTConfig: View { @State var nearbyTopics = [String]() @State var mapReportingEnabled = false @State var mapPublishIntervalSecs = 3600 - @State var preciseLocation: Bool = false - @State var mapPositionPrecision: Double = 13.0 + @State var mapPositionPrecision: Double = 14.0 let locale = Locale.current @@ -105,35 +104,17 @@ struct MQTTConfig: View { } } .pickerStyle(DefaultPickerStyle()) - VStack(alignment: .leading) { - Toggle(isOn: $preciseLocation) { - Label("Precise Location", systemImage: "scope") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - .listRowSeparator(.visible) - .onChange(of: preciseLocation) { pl in - if pl == false { - mapPositionPrecision = 12 - } else { - mapPositionPrecision = 32 - } - } - } - - if !preciseLocation { - VStack(alignment: .leading) { - Label("Approximate Location", systemImage: "location.slash.circle.fill") - Slider(value: $mapPositionPrecision, in: 11...16, step: 1) { - } minimumValueLabel: { - Image(systemName: "minus") - } maximumValueLabel: { - Image(systemName: "plus") - } - Text(PositionPrecision(rawValue: Int(mapPositionPrecision))?.description ?? "") - .foregroundColor(.gray) - .font(.callout) + Label("Approximate Location", systemImage: "location.slash.circle.fill") + Slider(value: $mapPositionPrecision, in: 11...14, step: 1) { + } minimumValueLabel: { + Image(systemName: "minus") + } maximumValueLabel: { + Image(systemName: "plus") } + Text(PositionPrecision(rawValue: Int(mapPositionPrecision))?.description ?? "") + .foregroundColor(.gray) + .font(.callout) } } } @@ -234,7 +215,7 @@ struct MQTTConfig: View { .listRowSeparator(/*@START_MENU_TOKEN@*/.visible/*@END_MENU_TOKEN@*/) Toggle(isOn: $tlsEnabled) { Label("TLS Enabled", systemImage: "checkmark.shield.fill") - Text("Your MQTT Server must support TLS.") + Text("Your MQTT Server must support TLS. Not available via the public mqtt server.") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) } @@ -288,9 +269,6 @@ struct MQTTConfig: View { jsonEnabled = false } if newProxyToClientEnabled != node?.mqttConfig?.proxyToClientEnabled { hasChanges = true } - if newProxyToClientEnabled { - jsonEnabled = false - } } .onChange(of: address) { newAddress in if node != nil && node?.mqttConfig != nil { @@ -324,8 +302,12 @@ struct MQTTConfig: View { } if newJsonEnabled != node?.mqttConfig?.jsonEnabled { hasChanges = true } } - .onChange(of: tlsEnabled) { - if $0 != node?.mqttConfig?.tlsEnabled { hasChanges = true } + .onChange(of: tlsEnabled) { newTlsEnabled in + if address.lowercased() == "mqtt.meshtastic.org" { + tlsEnabled = false + } else { + if newTlsEnabled != node?.mqttConfig?.tlsEnabled { hasChanges = true } + } } .onChange(of: mqttConnected) { newMqttConnected in if newMqttConnected == false { @@ -346,13 +328,24 @@ struct MQTTConfig: View { if newMapPublishIntervalSecs != node!.mqttConfig!.mapPublishIntervalSecs { hasChanges = true } } } - .onAppear { - // Need to request a TelemetryModuleConfig from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.mqttConfig == nil { + .onFirstAppear { + // Need to request a MqttModuleConfig from the remote node before allowing changes + if let connectedPeripheral = bleManager.connectedPeripheral, let node { Logger.mesh.info("empty mqtt module config") - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestMqttModuleConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration && node.num != connectedNode.num { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.mqttConfig == nil { + _ = bleManager.requestMqttModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestMqttModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } } } } @@ -417,11 +410,12 @@ struct MQTTConfig: View { self.mqttConnected = bleManager.mqttProxyConnected self.mapReportingEnabled = node?.mqttConfig?.mapReportingEnabled ?? false self.mapPublishIntervalSecs = Int(node?.mqttConfig?.mapPublishIntervalSecs ?? 3600) - self.mapPositionPrecision = Double(node?.mqttConfig?.mapPositionPrecision ?? 12) - if mapPositionPrecision == 0.0 { - self.mapPositionPrecision = 12 + self.mapPositionPrecision = Double(node?.mqttConfig?.mapPositionPrecision ?? 14) + if mapPositionPrecision < 11 || mapPositionPrecision > 14 { + self.mapPositionPrecision = 14 + self.hasChanges = true + } else { + self.hasChanges = false } - self.preciseLocation = mapPositionPrecision == 32 - self.hasChanges = false } } diff --git a/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift b/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift index 6e7bef08..1141bc7d 100644 --- a/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift @@ -7,6 +7,7 @@ import MeshtasticProtobufs import SwiftUI +import OSLog struct PaxCounterConfig: View { @Environment(\.managedObjectContext) private var context @@ -57,12 +58,24 @@ struct PaxCounterConfig: View { name: "\(bleManager.connectedPeripheral?.shortName ?? "?")" ) }) - .onAppear { - // Need to request a PAX Counter module config from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.paxCounterConfig == nil { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? 0, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestPaxCounterModuleConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + .onFirstAppear { + // Need to request a PaxCounterModuleConfig from the remote node before allowing changes + if let connectedPeripheral = bleManager.connectedPeripheral, let node { + Logger.mesh.info("empty pax counter module config") + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration && node.num != connectedNode.num { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.paxCounterConfig == nil { + _ = bleManager.requestPaxCounterModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestPaxCounterModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } } } } diff --git a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift index 001ad2b1..872294d8 100644 --- a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift @@ -81,13 +81,24 @@ struct RangeTestConfig: View { ) } ) - .onAppear { - // Need to request a RangeTestModule Config from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.rangeTestConfig == nil { - Logger.mesh.debug("empty range test module config") - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestRangeTestModuleConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + .onFirstAppear { + // Need to request a RangeTestModuleConfig from the remote node before allowing changes + if let connectedPeripheral = bleManager.connectedPeripheral, let node { + Logger.mesh.info("empty range test module config") + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration && node.num != connectedNode.num { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.rangeTestConfig == nil { + _ = bleManager.requestRangeTestModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestRangeTestModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } } } } diff --git a/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift b/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift index 95f237a3..46b02848 100644 --- a/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift @@ -6,6 +6,7 @@ // import SwiftUI +import OSLog struct RtttlConfig: View { @Environment(\.managedObjectContext) var context @@ -71,12 +72,24 @@ struct RtttlConfig: View { ) } ) - .onAppear { - // Need to request a Rtttl Config from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && (node?.rtttlConfig == nil || node?.rtttlConfig?.ringtone?.count ?? 0 == 0) { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestRtttlConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + .onFirstAppear { + // Need to request a RtttlConfig from the remote node before allowing changes + if let connectedPeripheral = bleManager.connectedPeripheral, let node { + Logger.mesh.info("empty range test module config") + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration && node.num != connectedNode.num { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.rtttlConfig == nil { + _ = bleManager.requestRtttlConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestRtttlConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } } } } diff --git a/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift b/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift index c7243a87..893ddca1 100644 --- a/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift @@ -136,20 +136,31 @@ struct SerialConfig: View { ) } ) - .onAppear { + .onFirstAppear { // Need to request a SerialModuleConfig from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.serialConfig == nil { - Logger.mesh.debug("empty serial module config") - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestSerialModuleConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + if let connectedPeripheral = bleManager.connectedPeripheral, let node { + Logger.mesh.info("empty serial module config") + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration && node.num != connectedNode.num { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.serialConfig == nil { + _ = bleManager.requestSerialModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestSerialModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } } } } .onChange(of: enabled) { if $0 != node?.serialConfig?.enabled { hasChanges = true } } - .onChange(of: echo) { + .onChange(of: echo) { if $0 != node?.serialConfig?.echo { hasChanges = true } } .onChange(of: rxd) { newRxd in diff --git a/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift b/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift index 4829a5fa..8fff8979 100644 --- a/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift @@ -146,13 +146,24 @@ struct StoreForwardConfig: View { ) } ) - .onAppear { - // Need to request a Detection Sensor Module Config from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.storeForwardConfig == nil { - Logger.mesh.debug("empty store and forward module config") - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestStoreAndForwardModuleConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + .onFirstAppear { + // Need to request a StoreForwardModuleConfig from the remote node before allowing changes + if let connectedPeripheral = bleManager.connectedPeripheral, let node { + Logger.mesh.info("empty store & forward module config") + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration && node.num != connectedNode.num { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.storeForwardConfig == nil { + _ = bleManager.requestStoreAndForwardModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestStoreAndForwardModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } } } } diff --git a/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift b/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift index df811677..afef5727 100644 --- a/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift @@ -134,13 +134,24 @@ struct TelemetryConfig: View { ) } ) - .onAppear { + .onFirstAppear { // Need to request a TelemetryModuleConfig from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.telemetryConfig == nil { + if let connectedPeripheral = bleManager.connectedPeripheral, let node { Logger.mesh.info("empty telemetry module config") - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestTelemetryModuleConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration && node.num != connectedNode.num { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.telemetryConfig == nil { + _ = bleManager.requestTelemetryModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestTelemetryModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } } } } diff --git a/Meshtastic/Views/Settings/Config/NetworkConfig.swift b/Meshtastic/Views/Settings/Config/NetworkConfig.swift index 0554c766..928bad57 100644 --- a/Meshtastic/Views/Settings/Config/NetworkConfig.swift +++ b/Meshtastic/Views/Settings/Config/NetworkConfig.swift @@ -128,6 +128,27 @@ struct NetworkConfig: View { } } } + .onFirstAppear { + // Need to request a NetworkConfig from the remote node before allowing changes + if let connectedPeripheral = bleManager.connectedPeripheral, let node { + Logger.mesh.info("empty network config") + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.networkConfig == nil { + _ = bleManager.requestNetworkConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestNetworkConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } + } + } + } .onChange(of: wifiEnabled) { if $0 != node?.networkConfig?.wifiEnabled { hasChanges = true } } diff --git a/Meshtastic/Views/Settings/Config/PositionConfig.swift b/Meshtastic/Views/Settings/Config/PositionConfig.swift index 0f71b379..4497c1f7 100644 --- a/Meshtastic/Views/Settings/Config/PositionConfig.swift +++ b/Meshtastic/Views/Settings/Config/PositionConfig.swift @@ -376,18 +376,25 @@ struct PositionConfig: View { ) } ) - .onAppear { + .onFirstAppear { supportedVersion = bleManager.connectedVersion == "0.0.0" || self.minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedAscending || minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedSame - // Need to request a PositionConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, node?.positionConfig == nil { + // Need to request a NetworkConfig from the remote node before allowing changes + if let connectedPeripheral = bleManager.connectedPeripheral, let node { Logger.mesh.info("empty position config") let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) - if let node, let connectedNode { - _ = bleManager.requestPositionConfig( - fromUser: connectedNode.user!, - toUser: node.user!, - adminIndex: connectedNode.myInfo?.adminIndex ?? 0 - ) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.positionConfig == nil { + _ = bleManager.requestPositionConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestPositionConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } } } } diff --git a/Meshtastic/Views/Settings/Config/PowerConfig.swift b/Meshtastic/Views/Settings/Config/PowerConfig.swift index 42b6a534..9ba0fe0a 100644 --- a/Meshtastic/Views/Settings/Config/PowerConfig.swift +++ b/Meshtastic/Views/Settings/Config/PowerConfig.swift @@ -1,5 +1,6 @@ import SwiftUI import MeshtasticProtobufs +import OSLog struct PowerConfig: View { @Environment(\.managedObjectContext) private var context @@ -117,7 +118,7 @@ struct PowerConfig: View { .font(.subheadline) } } - .onAppear { + .onFirstAppear { Api().loadDeviceHardwareData { (hw) in for device in hw { let currentHardware = node?.user?.hwModel ?? "UNSET" @@ -127,11 +128,23 @@ struct PowerConfig: View { } } } - // Need to request a Power config from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.powerConfig == nil { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? 0, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestPowerConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + // Need to request a NetworkConfig from the remote node before allowing changes + if let connectedPeripheral = bleManager.connectedPeripheral, let node { + Logger.mesh.info("empty power config") + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.powerConfig == nil { + _ = bleManager.requestPowerConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + /// Legacy Administration + _ = bleManager.requestPowerConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } } } } diff --git a/Meshtastic/Views/Settings/Config/SecurityConfig.swift b/Meshtastic/Views/Settings/Config/SecurityConfig.swift index e07ed945..e58de058 100644 --- a/Meshtastic/Views/Settings/Config/SecurityConfig.swift +++ b/Meshtastic/Views/Settings/Config/SecurityConfig.swift @@ -30,7 +30,6 @@ struct SecurityConfig: View { @State var isManaged = false @State var serialEnabled = false @State var debugLogApiEnabled = false - @State var bluetoothLoggingEnabled = false @State var adminChannelEnabled = false var body: some View { @@ -71,10 +70,14 @@ struct SecurityConfig: View { } } Section(header: Text("Logs")) { - Toggle(isOn: $bluetoothLoggingEnabled) { - Label("Bluetooth Logs", systemImage: "dot.radiowaves.right") - Text("View and export position-redacted device logs over Bluetooth") - Link("View Logs", destination: URL(string: "meshtastic:///settings/debugLogs")!) + Toggle(isOn: $serialEnabled) { + Label("Serial Console", systemImage: "terminal") + Text("Serial Console over the Stream API.") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + Toggle(isOn: $debugLogApiEnabled) { + Label("Debug Logs", systemImage: "ant.fill") + Text("Output live debug logging over serial, view and export position-redacted device logs over Bluetooth.") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) } @@ -82,7 +85,7 @@ struct SecurityConfig: View { if adminKey.length > 0 || adminChannelEnabled { Toggle(isOn: $isManaged) { Label("Managed Device", systemImage: "gearshape.arrow.triangle.2.circlepath") - Text("Device is managed by a mesh administrator.") + Text("Device is managed by a mesh administrator, the user is unable to access any of the device settings.") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) } @@ -92,20 +95,6 @@ struct SecurityConfig: View { } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) } - Section(header: Text("Developer")) { - Toggle(isOn: $serialEnabled) { - Label("Serial Console", systemImage: "terminal") - Text("Serial Console over the Stream API.") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - if serialEnabled { - Toggle(isOn: $debugLogApiEnabled) { - Label("Serial Debug Logs", systemImage: "ant.fill") - Text("Output live debug logging over serial.") - } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - } - } } } .scrollDismissesKeyboard(.immediately) @@ -126,9 +115,6 @@ struct SecurityConfig: View { .onChange(of: debugLogApiEnabled) { if $0 != node?.securityConfig?.debugLogApiEnabled { hasChanges = true } } - .onChange(of: bluetoothLoggingEnabled) { - if $0 != node?.securityConfig?.bluetoothLoggingEnabled { hasChanges = true } - } .onChange(of: adminChannelEnabled) { if $0 != node?.securityConfig?.adminChannelEnabled { hasChanges = true } } @@ -162,11 +148,25 @@ struct SecurityConfig: View { hasChanges = true } .onFirstAppear { - // Need to request a Power config from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.securityConfig == nil { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? 0, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestSecurityConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + // Need to request a DeviceConfig from the remote node before allowing changes + if let connectedPeripheral = bleManager.connectedPeripheral, let node { + Logger.mesh.info("empty security config") + let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let connectedNode { + if node.num != connectedNode.num { + if UserDefaults.enableAdministration { + /// 2.5 Administration with session passkey + let expiration = node.sessionExpiration ?? Date() + if expiration < Date() || node.securityConfig == nil { + _ = bleManager.requestSecurityConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } else { + if node.deviceConfig == nil { + /// Legacy Administration + _ = bleManager.requestSecurityConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } + } + } } } } @@ -190,7 +190,6 @@ struct SecurityConfig: View { config.isManaged = isManaged config.serialEnabled = serialEnabled config.debugLogApiEnabled = debugLogApiEnabled - config.bluetoothLoggingEnabled = bluetoothLoggingEnabled config.adminChannelEnabled = adminChannelEnabled let adminMessageId = bleManager.saveSecurityConfig( @@ -215,7 +214,6 @@ struct SecurityConfig: View { self.isManaged = node?.securityConfig?.isManaged ?? false self.serialEnabled = node?.securityConfig?.serialEnabled ?? false self.debugLogApiEnabled = node?.securityConfig?.debugLogApiEnabled ?? false - self.bluetoothLoggingEnabled = node?.securityConfig?.bluetoothLoggingEnabled ?? false self.adminChannelEnabled = node?.securityConfig?.adminChannelEnabled ?? false self.hasChanges = false } diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 814f3ab9..481fb29d 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -299,13 +299,10 @@ struct Settings: View { NavigationStack( path: Binding<[SettingsNavigationState]>( get: { - guard case .settings(let route) = router.navigationState, let setting = route else { - return [] - } - return [setting] + [router.navigationState.settings].compactMap { $0 } }, set: { newPath in - router.navigationState = .settings(newPath.first) + router.navigationState.settings = newPath.first } ) ) { @@ -349,7 +346,7 @@ struct Settings: View { if bleManager.connectedPeripheral != nil { Section("Configure") { if node?.canRemoteAdmin ?? false { - Picker("Configuring Node", selection: $selectedNode) { + Picker("Node", selection: $selectedNode) { if selectedNode == 0 { Text("Connect to a Node").tag(0) } @@ -368,6 +365,7 @@ struct Settings: View { } icon: { Image(systemName: "av.remote") } + .font(.caption2) .tag(Int(node.num)) } else if !UserDefaults.enableAdministration && node.metadata != nil { /// Nodes using the old admin system Label { @@ -393,8 +391,7 @@ struct Settings: View { } } } - .pickerStyle(.automatic) - .labelsHidden() + .pickerStyle(.navigationLink) .onChange(of: selectedNode) { newValue in if selectedNode > 0 { let node = nodes.first(where: { $0.num == newValue }) diff --git a/MeshtasticTests/RouterTests.swift b/MeshtasticTests/RouterTests.swift index 81b07276..a3ecee6d 100644 --- a/MeshtasticTests/RouterTests.swift +++ b/MeshtasticTests/RouterTests.swift @@ -5,53 +5,144 @@ import XCTest final class RouterTests: XCTestCase { - func testInitialState() throws { - XCTAssertEqual(Router().navigationState, .bluetooth) + func testInitialState() async throws { + let router = await Router() + let tab = await router.navigationState.selectedTab + XCTAssertEqual(tab, .bluetooth) } - func testRouteTo() throws { - let router = Router(navigationState: .bluetooth) - router.route(to: .settings(.about)) - XCTAssertEqual(router.navigationState, .settings(.about)) + func testRouteMessages() async throws { + try await assertRoute( + router: Router(), + "meshtastic:///messages", + NavigationState(selectedTab: .messages) + ) } - func testRouteURL() throws { - // Messages - try assertRoute("meshtastic:///messages", .messages()) - try assertRoute( + func testRouteMessagesWithChannelIdAndMessageId() async throws { + try await assertRoute( + router: Router(), "meshtastic:///messages?channelId=0&messageId=1122334455", - .messages(.channels(channelId: 0, messageId: 1122334455)) + NavigationState( + selectedTab: .messages, + messages: .channels( + channelId: 0, + messageId: 1122334455 + ) + ) ) - try assertRoute( + } + + func testRouteMessagesWithUserNumAndMessageId() async throws { + try await assertRoute( + router: Router(), "meshtastic:///messages?userNum=123456789&messageId=9876543210", - .messages(.directMessages(userNum: 123456789, messageId: 9876543210)) + NavigationState( + selectedTab: .messages, + messages: .directMessages( + userNum: 123456789, + messageId: 9876543210 + ) + ) ) + } - // Bluetooth - try assertRoute("meshtastic:///bluetooth", .bluetooth) + func testRouteBluetooth() async throws { + try await assertRoute( + router: Router(), + "meshtastic:///bluetooth", + NavigationState(selectedTab: .bluetooth) + ) + } - // Nodes - try assertRoute("meshtastic:///nodes", .nodes()) - try assertRoute("meshtastic:///nodes?nodenum=1234567890", .nodes(selectedNodeNum: 1234567890)) + func testRouteNodes() async throws { + try await assertRoute( + router: Router(), + "meshtastic:///nodes", + NavigationState(selectedTab: .nodes) + ) + } - // Map - try assertRoute("meshtastic:///map", .map()) - try assertRoute("meshtastic:///map?waypointId=123456", .map(.waypoint(123456))) - try assertRoute("meshtastic:///map?nodenum=1234567890", .map(.selectedNode(1234567890))) + func testRouteNodesWithNodeNum() async throws { + try await assertRoute( + router: Router(), + "meshtastic:///nodes?nodenum=1234567890", + NavigationState( + selectedTab: .nodes, + nodeListSelectedNodeNum: 1234567890 + ) + ) + } - // Settings - try assertRoute("meshtastic:///settings", .settings()) - try assertRoute("meshtastic:///settings/about", .settings(.about)) - try assertRoute("meshtastic:///settings/invalidSetting", .settings()) + func testRouteMap() async throws { + try await assertRoute( + router: Router(), + "meshtastic:///map", + NavigationState(selectedTab: .map) + ) + } + + func testRouteMapWithWaypointId() async throws { + try await assertRoute( + router: Router(), + "meshtastic:///map?waypointId=123456", + NavigationState( + selectedTab: .map, + map: .waypoint(123456) + ) + ) + } + + func testRouteMapWithNodeNum() async throws { + try await assertRoute( + router: Router(), + "meshtastic:///map?nodenum=1234567890", + NavigationState( + selectedTab: .map, + map: .selectedNode(1234567890) + ) + ) + } + + func testRouteSettings() async throws { + try await assertRoute( + router: Router(), + "meshtastic:///settings", + NavigationState( + selectedTab: .settings + ) + ) + } + + func testRouteSettingsAbout() async throws { + try await assertRoute( + router: Router(), + "meshtastic:///settings/about", + NavigationState( + selectedTab: .settings, + settings: .about + ) + ) + } + + func testRouteSettingsInvalidSetting() async throws { + try await assertRoute( + router: Router(), + "meshtastic:///settings/invalidSetting", + NavigationState( + selectedTab: .settings + ) + ) } private func assertRoute( - router: Router = Router(), + router: Router, _ urlString: String, _ destination: NavigationState - ) throws { + ) async throws { let url = try XCTUnwrap(URL(string: urlString)) - router.route(url: url) - XCTAssertEqual(router.navigationState, destination) + await router.route(url: url) + let state = await router.navigationState + XCTAssertEqual(state, destination) } } diff --git a/Widgets/MeshActivityAttributes.swift b/Widgets/MeshActivityAttributes.swift index 916377c9..a2abdbba 100644 --- a/Widgets/MeshActivityAttributes.swift +++ b/Widgets/MeshActivityAttributes.swift @@ -15,13 +15,15 @@ struct MeshActivityAttributes: ActivityAttributes { public typealias MeshActivityStatus = ContentState public struct ContentState: Codable, Hashable { // Dynamic stateful properties about your activity go here! - var timerRange: ClosedRange - var connected: Bool + var uptimeSeconds: UInt32 var channelUtilization: Float var airtime: Float - var batteryLevel: UInt32 - var nodes: Int - var nodesOnline: Int + var sentPackets: UInt32 + var receivedPackets: UInt32 + var badReceivedPackets: UInt32 + var nodesOnline: UInt32 + var totalNodes: UInt32 + var timerRange: ClosedRange } // Fixed non-changing properties about your activity go here! diff --git a/Widgets/WidgetsLiveActivity.swift b/Widgets/WidgetsLiveActivity.swift index 396aaac9..e6177129 100644 --- a/Widgets/WidgetsLiveActivity.swift +++ b/Widgets/WidgetsLiveActivity.swift @@ -13,64 +13,84 @@ struct WidgetsLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: MeshActivityAttributes.self) { context in - LiveActivityView(nodeName: context.attributes.name, channelUtilization: context.state.channelUtilization, airtime: context.state.airtime, batteryLevel: context.state.batteryLevel, nodes: 17, nodesOnline: 7, timerRange: context.state.timerRange) - .widgetURL(URL(string: "meshtastic:///node/\(context.attributes.name)")) + LiveActivityView(nodeName: context.attributes.name, + uptimeSeconds: 0, // context.attributes.uptimeSeconds, + channelUtilization: context.state.channelUtilization, + airtime: context.state.airtime, + sentPackets: context.state.sentPackets, + receivedPackets: context.state.receivedPackets, + badReceivedPackets: context.state.badReceivedPackets, + nodesOnline: context.state.nodesOnline, + totalNodes: context.state.totalNodes, + timerRange: context.state.timerRange) + .widgetURL(URL(string: "meshtastic:///bluetooth")) } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { - Text("Network") - .font(.headline) - .fontWeight(.bold) - .foregroundStyle(.secondary) - .fixedSize() - .padding(.top, 10) + HStack(alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/) { + Spacer() + Text("Mesh") + .font(.callout) + .fontWeight(.medium) + .foregroundStyle(.primary) + .padding(.bottom, 10) + .fixedSize() + Spacer() + } + if context.state.nodesOnline >= 100 { + Text("100+ online") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize() + } else { + Text("\(context.state.nodesOnline) of \(context.state.totalNodes) online") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize() + } Text("\(String(format: "Ch. Util: %.2f", context.state.channelUtilization))%") - .font(.headline) - .fontWeight(.medium) + .font(.caption) .foregroundStyle(.secondary) .fixedSize() Text("\(String(format: "Airtime: %.2f", context.state.airtime))%") - .font(.headline) - .fontWeight(.medium) + .font(.caption) .foregroundStyle(.secondary) .fixedSize() - Spacer() } DynamicIslandExpandedRegion(.center) { - VStack(alignment: .center, spacing: 0) { - BatteryIcon(batteryLevel: Int32(context.state.batteryLevel), font: .title, color: .accentColor) - if context.state.batteryLevel == 0 { - Text("< 1%") - .font(.title3) - .foregroundColor(.gray) - .fixedSize() - } else if context.state.batteryLevel < 101 { - Text(String(context.state.batteryLevel) + "%") - .font(.title3) - .foregroundColor(.gray) - .fixedSize() - } else { - Text("PWD") - .font(.title3) - .foregroundColor(.gray) - } - } - } - DynamicIslandExpandedRegion(.trailing, priority: 1) { TimerView(timerRange: context.state.timerRange) .tint(Color("LightIndigo")) - + } + DynamicIslandExpandedRegion(.trailing, priority: 1) { + HStack(alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/) { + Spacer() + Text("Packets") + .font(.callout) + .fontWeight(.medium) + .foregroundStyle(.primary) + .padding(.bottom, 10) + .fixedSize() + Spacer() + } + Text("Sent: \(context.state.sentPackets)") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize() + Text("Received: \(context.state.receivedPackets)") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize() + Text("Bad \(context.state.badReceivedPackets)") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize() } DynamicIslandExpandedRegion(.bottom) { - Text(context.attributes.name) - .font(context.attributes.name.count > 14 ? .callout : .title3) - .fontWeight(.semibold) - .foregroundStyle(.tint) Text("Last Heard: \(Date().formatted())") .font(.caption) .fontWeight(.medium) - .foregroundStyle(.secondary) + .foregroundStyle(.tint) .fixedSize() } @@ -95,85 +115,63 @@ struct WidgetsLiveActivity: Widget { .contentMargins(.trailing, 32, for: .expanded) .contentMargins([.leading, .top, .bottom], 6, for: .compactLeading) .contentMargins(.all, 6, for: .minimal) - .widgetURL(URL(string: "meshtastic:///node/\(context.attributes.name)")) + .widgetURL(URL(string: "meshtastic:///bluetooth")) } } } -struct WidgetsLiveActivity_Previews: PreviewProvider { - static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G") - static let state = MeshActivityAttributes.ContentState( - timerRange: Date.now...Date(timeIntervalSinceNow: 60), connected: true, channelUtilization: 25.84, airtime: 10.01, batteryLevel: 39, nodes: 17, nodesOnline: 9) - - static var previews: some View { - attributes - .previewContext(state, viewKind: .dynamicIsland(.compact)) - .previewDisplayName("Compact") - attributes - .previewContext(state, viewKind: .dynamicIsland(.minimal)) - .previewDisplayName("Minimal") - attributes - .previewContext(state, viewKind: .dynamicIsland(.expanded)) - .previewDisplayName("Expanded") - attributes - .previewContext(state, viewKind: .content) - .previewDisplayName("Notification") - } -} +//struct WidgetsLiveActivity_Previews: PreviewProvider { +// static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G") +// static let state = MeshActivityAttributes.ContentState( +// timerRange: Date.now...Date(timeIntervalSinceNow: 60), connected: true, channelUtilization: 25.84, airtime: 10.01, batteryLevel: 39, nodes: 17, nodesOnline: 9) +// +// static var previews: some View { +// attributes +// .previewContext(state, viewKind: .dynamicIsland(.compact)) +// .previewDisplayName("Compact") +// attributes +// .previewContext(state, viewKind: .dynamicIsland(.minimal)) +// .previewDisplayName("Minimal") +// attributes +// .previewContext(state, viewKind: .dynamicIsland(.expanded)) +// .previewDisplayName("Expanded") +// attributes +// .previewContext(state, viewKind: .content) +// .previewDisplayName("Notification") +// } +//} struct LiveActivityView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.isLuminanceReduced) var isLuminanceReduced var nodeName: String - // var connected: Bool + var uptimeSeconds: UInt32 var channelUtilization: Float var airtime: Float - var batteryLevel: UInt32 - var nodes: Int - var nodesOnline: Int + var sentPackets: UInt32 + var receivedPackets: UInt32 + var badReceivedPackets: UInt32 + var nodesOnline: UInt32 + var totalNodes: UInt32 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(width: 65) + .frame(minWidth: 25, idealWidth: 45, maxWidth: 55) Spacer() - NodeInfoView(nodeName: nodeName, timerRange: timerRange, channelUtilization: channelUtilization, airtime: airtime, batteryLevel: batteryLevel, nodes: nodes, nodesOnline: nodesOnline) + NodeInfoView(isLuminanceReduced: _isLuminanceReduced, nodeName: nodeName, uptimeSeconds: uptimeSeconds, channelUtilization: channelUtilization, airtime: airtime, sentPackets: sentPackets, receivedPackets: receivedPackets, badReceivedPackets: badReceivedPackets, nodesOnline: nodesOnline, totalNodes: totalNodes, timerRange: timerRange) Spacer() - VStack { - BatteryIcon(batteryLevel: Int32(batteryLevel), font: .title, color: .secondary) - if batteryLevel == 0 { - Text("< 1%") - .font(.headline) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() - } else if batteryLevel < 101 { - Text(String(batteryLevel) + "%") - .font(.headline) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() - } else { - Text("Plugged In") - .font(.headline) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .opacity(isLuminanceReduced ? 0.8 : 1.0) - .fixedSize() - } - } } .tint(.primary) .padding([.leading, .top, .bottom]) - .padding(.trailing, 32) + .padding(.trailing, 25) .activityBackgroundTint(colorScheme == .light ? Color("LiveActivityBackground") : Color("AccentColorDimmed")) .activitySystemActionForegroundColor(.primary) } @@ -183,12 +181,15 @@ struct NodeInfoView: View { @Environment(\.isLuminanceReduced) var isLuminanceReduced var nodeName: String - var timerRange: ClosedRange + var uptimeSeconds: UInt32 var channelUtilization: Float var airtime: Float - var batteryLevel: UInt32 - var nodes: Int - var nodesOnline: Int + var sentPackets: UInt32 + var receivedPackets: UInt32 + var badReceivedPackets: UInt32 + var nodesOnline: UInt32 + var totalNodes: UInt32 + var timerRange: ClosedRange var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -196,24 +197,45 @@ struct NodeInfoView: View { .font(nodeName.count > 14 ? .callout : .title3) .fontWeight(.semibold) .foregroundStyle(.tint) - Text("\(String(format: "Ch. Util: %.2f", channelUtilization))%") - .font(.headline) + Text("\(String(format: "Ch. Util: %.2f", channelUtilization))% \(String(format: "Airtime: %.2f", airtime))%") + .font(.caption) .fontWeight(.medium) .foregroundStyle(.secondary) .opacity(isLuminanceReduced ? 0.8 : 1.0) .fixedSize() - Text("\(String(format: "Airtime: %.2f", airtime))%") - .font(.headline) + Text("Packets Sent: \(sentPackets)") + .font(.caption) .fontWeight(.medium) .foregroundStyle(.secondary) .opacity(isLuminanceReduced ? 0.8 : 1.0) .fixedSize() -// Text("\(String(format: "Connected: %d of %d online", nodesOnline, nodes))") -// .font(.callout) -// .fontWeight(.medium) -// .foregroundStyle(.secondary) -// .opacity(isLuminanceReduced ? 0.8 : 1.0) -// .fixedSize() + Text("Packets Received: \(receivedPackets)") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .opacity(isLuminanceReduced ? 0.8 : 1.0) + .fixedSize() + Text("Bad Packets: \(badReceivedPackets)") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .opacity(isLuminanceReduced ? 0.8 : 1.0) + .fixedSize() + if totalNodes >= 100 { + Text("\(String(format: "Connected: %d nodes online", nodesOnline))") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .opacity(isLuminanceReduced ? 0.8 : 1.0) + .fixedSize() + } else { + Text("\(String(format: "Connected: %d of %d nodes online", nodesOnline, totalNodes))") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .opacity(isLuminanceReduced ? 0.8 : 1.0) + .fixedSize() + } let now = Date() Text("Last Heard: \(now.formatted())") .font(.caption) @@ -255,8 +277,9 @@ struct TimerView: View { var body: some View { VStack(alignment: .center) { - Text("NEXT UPDATE") - .font(.caption) + Text("UPDATE IN") + .font(.caption2) + .allowsTightening(/*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/) .fontWeight(.medium) .foregroundStyle(.secondary) .opacity(isLuminanceReduced ? 0.5 : 1.0) @@ -268,10 +291,12 @@ struct TimerView: View { .fontWeight(.semibold) .foregroundStyle(.tint) Image(systemName: "timer") + .symbolRenderingMode(.multicolor) .resizable() .foregroundStyle(.secondary) .frame(width: 30, height: 30) .opacity(isLuminanceReduced ? 0.5 : 1.0) + .offset(y: -5) } } }