Merge branch 'main'

This commit is contained in:
Garth Vander Houwen 2024-09-09 11:14:18 -07:00
commit 9bc56f8097
59 changed files with 1725 additions and 649 deletions

View file

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

View file

@ -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 = "<group>"; };
BCB613842C68703800485544 /* NodePositionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodePositionIntent.swift; sourceTree = "<group>"; };
BCB613862C69A0FB00485544 /* AppIntentErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentErrors.swift; sourceTree = "<group>"; };
BCE2D3C22C7ADF42008E6199 /* ShutDownNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShutDownNodeIntent.swift; sourceTree = "<group>"; };
BCE2D3C42C7AE369008E6199 /* RestartNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartNodeIntent.swift; sourceTree = "<group>"; };
BCE2D3C62C7B0D0A008E6199 /* ShortcutsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsProvider.swift; sourceTree = "<group>"; };
BCE2D3C82C7C377F008E6199 /* FactoryResetNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactoryResetNodeIntent.swift; sourceTree = "<group>"; };
D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageContextMenuItems.swift; sourceTree = "<group>"; };
D93068D42B812B700066FBC8 /* MessageDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDestination.swift; sourceTree = "<group>"; };
D93068D62B8146690066FBC8 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = "<group>"; };
@ -368,6 +376,7 @@
DD77093C2AA1AFA3007A8BF0 /* ChannelTips.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTips.swift; sourceTree = "<group>"; };
DD77093E2AA1B146007A8BF0 /* UIColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = "<group>"; };
DD798B062915928D005217CD /* ChannelMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMessageList.swift; sourceTree = "<group>"; };
DD7E235F2C7AA3E50078ACDF /* MeshtasticDataModelV 43.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 43.xcdatamodel"; sourceTree = "<group>"; };
DD8169F8271F1A6100F4AB02 /* MeshLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshLogger.swift; sourceTree = "<group>"; };
DD8169FA271F1F3A00F4AB02 /* MeshLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshLog.swift; sourceTree = "<group>"; };
DD8169FE272476C700F4AB02 /* LogDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogDocument.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 = "<group>";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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?

View file

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

View file

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

View file

@ -95,7 +95,7 @@ enum RoutingError: Int, CaseIterable, Identifiable {
case .notAuthorized:
return true
case .pkiFailed:
return false
return true
case .pkiUnknownPubkey:
return false
}

View file

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

View file

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

View file

@ -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)..<UInt32.max)
meshPacket.priority = MeshPacket.Priority.reliable
meshPacket.wantAck = true
@ -2548,6 +2564,9 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
var adminPacket = AdminMessage()
adminPacket.getConfigRequest = AdminMessage.ConfigType.bluetoothConfig
if UserDefaults.enableAdministration {
adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data()
}
var meshPacket: MeshPacket = MeshPacket()
meshPacket.to = UInt32(toUser.num)
meshPacket.from = UInt32(fromUser.num)

View file

@ -50,6 +50,7 @@ func generateMessageMarkdown (message: String) -> 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<MeshActivityAttributes>.activities.first(where: { $0.attributes.nodeNum == connectedNode })

View file

@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>MeshtasticDataModelV 42.xcdatamodel</string>
<string>MeshtasticDataModelV 43.xcdatamodel</string>
</dict>
</plist>

View file

@ -0,0 +1,475 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23G5075b" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="AmbientLightingConfigEntity" representedClassName="AmbientLightingConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="blue" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="current" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="green" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ledState" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="red" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="ambientLightingConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="ambientLightingConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="BluetoothConfigEntity" representedClassName="BluetoothConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="deviceLoggingEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="fixedPin" optional="YES" attributeType="Integer 32" defaultValueString="123456" usesScalarValueType="YES"/>
<attribute name="mode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="bluetoothConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="bluetoothConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="CannedMessageConfigEntity" representedClassName="CannedMessageConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="inputbrokerEventCcw" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="inputbrokerEventCw" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="inputbrokerEventPress" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="inputbrokerPinA" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="inputbrokerPinB" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<attribute name="inputbrokerPinPress" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<attribute name="messages" optional="YES" attributeType="String" minValueString="0" maxValueString="198"/>
<attribute name="rotary1Enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="sendBell" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="updown1Enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="cannedMessagesConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="cannedMessageConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="ChannelEntity" representedClassName="ChannelEntity" syncable="YES" codeGenerationType="class">
<attribute name="downlinkEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="index" attributeType="Integer 32" minValueString="0" maxValueString="13" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="mute" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="positionPrecision" optional="YES" attributeType="Integer 32" defaultValueString="32" usesScalarValueType="YES"/>
<attribute name="psk" optional="YES" attributeType="Binary"/>
<attribute name="role" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="uplinkEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="myInfoChannel" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MyInfoEntity" inverseName="channels" inverseEntity="MyInfoEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="index"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="DetectionSensorConfigEntity" representedClassName="DetectionSensorConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="detectionTriggeredHigh" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="minimumBroadcastSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="monitorPin" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="sendBell" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="stateBroadcastSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="usePullup" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="detectionSensorConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="detectionSensorConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="DeviceConfigEntity" representedClassName="DeviceConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="buttonGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="buzzerGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="debugLogEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="disableTripleClick" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="doubleTapAsButtonPress" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="isManaged" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="ledHeartbeatEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="nodeInfoBroadcastSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rebroadcastMode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="role" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="serialEnabled" optional="YES" attributeType="Boolean" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="tzdef" optional="YES" attributeType="String"/>
<relationship name="deviceConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="deviceConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="DeviceMetadataEntity" representedClassName="DeviceMetadataEntity" syncable="YES" codeGenerationType="class">
<attribute name="canShutdown" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="deviceStateVersion" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="firmwareVersion" optional="YES" attributeType="String"/>
<attribute name="hasBluetooth" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="hasEthernet" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="hasWifi" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="hwModel" optional="YES" attributeType="String"/>
<attribute name="positionFlags" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="role" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="metadataNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="metadata" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="DisplayConfigEntity" representedClassName="DisplayConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="compassNorthTop" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="displayMode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="flipScreen" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="gpsFormat" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="headingBold" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="oledType" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="screenCarouselInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="screenOnSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="units" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="wakeOnTapOrMotion" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="displayConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="displayConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="ExternalNotificationConfigEntity" representedClassName="ExternalNotificationConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="active" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertBell" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertBellBuzzer" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertBellVibra" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertMessage" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertMessageBuzzer" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="alertMessageVibra" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="nagTimeout" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="output" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="outputBuzzer" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="outputMilliseconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="outputVibra" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="useI2SAsBuzzer" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="usePWM" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<relationship name="externalNotificationConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="externalNotificationConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="LocationEntity" representedClassName="LocationEntity" syncable="YES" codeGenerationType="class">
<attribute name="altitude" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="heading" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<attribute name="latitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="longitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="speed" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="routeLocation" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RouteEntity" inverseName="locations" inverseEntity="RouteEntity"/>
</entity>
<entity name="LoRaConfigEntity" representedClassName="LoRaConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="bandwidth" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="channelNum" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="codingRate" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="frequencyOffset" optional="YES" attributeType="Float" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="hopLimit" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ignoreMqtt" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="modemPreset" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="overrideDutyCycle" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="overrideFrequency" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="regionCode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="spreadFactor" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sx126xRxBoostedGain" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="txEnabled" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="txPower" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="usePreset" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<relationship name="loRaConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="loRaConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="MessageEntity" representedClassName="MessageEntity" syncable="YES" codeGenerationType="class">
<attribute name="ackError" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ackSNR" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="ackTimestamp" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="admin" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="adminDescription" optional="YES" attributeType="String"/>
<attribute name="channel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isEmoji" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="messageId" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="messagePayload" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="messagePayloadMarkdown" optional="YES" attributeType="String"/>
<attribute name="messageTimestamp" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="pkiEncrypted" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="portNum" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="publicKey" optional="YES" attributeType="Binary"/>
<attribute name="read" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="realACK" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="receivedACK" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="replyID" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<relationship name="fromUser" optional="YES" maxCount="1" deletionRule="Nullify" ordered="YES" destinationEntity="UserEntity" inverseName="sentMessages" inverseEntity="UserEntity"/>
<relationship name="toUser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserEntity" inverseName="receivedMessages" inverseEntity="UserEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="messageId"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="MQTTConfigEntity" representedClassName="MQTTConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="address" optional="YES" attributeType="String"/>
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="encryptionEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="jsonEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="mapPositionPrecision" optional="YES" attributeType="Integer 32" defaultValueString="13" usesScalarValueType="YES"/>
<attribute name="mapPublishIntervalSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="mapReportingEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="password" optional="YES" attributeType="String" maxValueString="30"/>
<attribute name="proxyToClientEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="root" optional="YES" attributeType="String" defaultValueString="msh"/>
<attribute name="tlsEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="username" optional="YES" attributeType="String" maxValueString="30"/>
<relationship name="mqttConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="mqttConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="MyInfoEntity" representedClassName="MyInfoEntity" syncable="YES" codeGenerationType="class">
<attribute name="adminIndex" optional="YES" attributeType="Integer 32" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="bleName" optional="YES" attributeType="String"/>
<attribute name="minAppVersion" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="myNodeNum" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="peripheralId" optional="YES" attributeType="String"/>
<attribute name="rebootCount" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="channels" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="ChannelEntity" inverseName="myInfoChannel" inverseEntity="ChannelEntity"/>
<relationship name="myInfoNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="myInfo" inverseEntity="NodeInfoEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="myNodeNum"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="NetworkConfigEntity" representedClassName="NetworkConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="dns" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ethEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="gateway" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ip" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="ntpServer" optional="YES" attributeType="String"/>
<attribute name="subnet" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="wifiEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="wifiMode" optional="YES" attributeType="Integer 32" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="wifiPsk" optional="YES" attributeType="String" minValueString="0" maxValueString="60"/>
<attribute name="wifiSsid" optional="YES" attributeType="String" minValueString="0" maxValueString="30"/>
<relationship name="networkConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="networkConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="NodeInfoEntity" representedClassName="NodeInfoEntity" syncable="YES" codeGenerationType="class">
<attribute name="bleName" optional="YES" attributeType="String"/>
<attribute name="channel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="favorite" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="firstHeard" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="hopsAway" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="lastHeard" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="num" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="peripheralId" optional="YES" attributeType="String"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sessionExpiration" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="sessionPasskey" optional="YES" attributeType="Binary"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="viaMqtt" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="ambientLightingConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="AmbientLightingConfigEntity" inverseName="ambientLightingConfigNode" inverseEntity="AmbientLightingConfigEntity"/>
<relationship name="bluetoothConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="BluetoothConfigEntity" inverseName="bluetoothConfigNode" inverseEntity="BluetoothConfigEntity"/>
<relationship name="cannedMessageConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="CannedMessageConfigEntity" inverseName="cannedMessagesConfigNode" inverseEntity="CannedMessageConfigEntity"/>
<relationship name="detectionSensorConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="DetectionSensorConfigEntity" inverseName="detectionSensorConfigNode" inverseEntity="DetectionSensorConfigEntity"/>
<relationship name="deviceConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="DeviceConfigEntity" inverseName="deviceConfigNode" inverseEntity="DeviceConfigEntity"/>
<relationship name="displayConfig" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="DisplayConfigEntity" inverseName="displayConfigNode" inverseEntity="DisplayConfigEntity"/>
<relationship name="externalNotificationConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ExternalNotificationConfigEntity" inverseName="externalNotificationConfigNode" inverseEntity="ExternalNotificationConfigEntity"/>
<relationship name="loRaConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LoRaConfigEntity" inverseName="loRaConfigNode" inverseEntity="LoRaConfigEntity"/>
<relationship name="metadata" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="DeviceMetadataEntity" inverseName="metadataNode" inverseEntity="DeviceMetadataEntity"/>
<relationship name="mqttConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MQTTConfigEntity" inverseName="mqttConfigNode" inverseEntity="MQTTConfigEntity"/>
<relationship name="myInfo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MyInfoEntity" inverseName="myInfoNode" inverseEntity="MyInfoEntity"/>
<relationship name="networkConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NetworkConfigEntity" inverseName="networkConfigNode" inverseEntity="NetworkConfigEntity"/>
<relationship name="pax" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="PaxCounterEntity" inverseName="paxNode" inverseEntity="PaxCounterEntity"/>
<relationship name="paxCounterConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PaxCounterConfigEntity" inverseName="paxCounterConfigNode" inverseEntity="PaxCounterConfigEntity"/>
<relationship name="positionConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PositionConfigEntity" inverseName="positionConfigNode" inverseEntity="PositionConfigEntity"/>
<relationship name="positions" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="PositionEntity" inverseName="nodePosition" inverseEntity="PositionEntity"/>
<relationship name="powerConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PowerConfigEntity" inverseName="powerConfigNode" inverseEntity="PowerConfigEntity"/>
<relationship name="rangeTestConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RangeTestConfigEntity" inverseName="rangeTestConfigNode" inverseEntity="RangeTestConfigEntity"/>
<relationship name="rtttlConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="RTTTLConfigEntity" inverseName="rtttlConfigNode" inverseEntity="RTTTLConfigEntity"/>
<relationship name="securityConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SecurityConfigEntity" inverseName="securityConfigNode" inverseEntity="SecurityConfigEntity"/>
<relationship name="serialConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SerialConfigEntity" inverseName="serialConfigNode" inverseEntity="SerialConfigEntity"/>
<relationship name="storeForwardConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="StoreForwardConfigEntity" inverseName="storeForwardConfigNode" inverseEntity="StoreForwardConfigEntity"/>
<relationship name="telemetries" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="TelemetryEntity" inverseName="nodeTelemetry" inverseEntity="TelemetryEntity"/>
<relationship name="telemetryConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="TelemetryConfigEntity" inverseName="telemetryConfigNode" inverseEntity="TelemetryConfigEntity"/>
<relationship name="traceRoutes" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="TraceRouteEntity" inverseName="node" inverseEntity="TraceRouteEntity"/>
<relationship name="user" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserEntity" inverseName="userNode" inverseEntity="UserEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="num"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="PaxCounterConfigEntity" representedClassName="PaxCounterConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="bleThreshold" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="updateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="wifiThreshold" optional="YES" attributeType="Integer 32" defaultValueString="-80" usesScalarValueType="YES"/>
<relationship name="paxCounterConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="paxCounterConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="PaxCounterEntity" representedClassName="PaxCounterEntity" syncable="YES" codeGenerationType="class">
<attribute name="ble" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="uptime" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="wifi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="paxNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="pax" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="PositionConfigEntity" representedClassName="PositionConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="broadcastSmartMinimumDistance" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="broadcastSmartMinimumIntervalSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="deviceGpsEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="fixedPosition" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="gpsAttemptTime" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="gpsEnGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="gpsMode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="gpsUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="positionBroadcastSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="positionFlags" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rxGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="smartPositionEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="txGpio" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="positionConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="positionConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="PositionEntity" representedClassName="PositionEntity" syncable="YES" codeGenerationType="class">
<attribute name="altitude" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="heading" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="latest" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="latitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="longitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="precisionBits" optional="YES" attributeType="Integer 32" defaultValueString="32" usesScalarValueType="YES"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="satsInView" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="seqNo" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="speed" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="nodePosition" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="positions" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="PowerConfigEntity" representedClassName="PowerConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="adcMultiplierOverride" optional="YES" attributeType="Float" usesScalarValueType="YES"/>
<attribute name="deviceBatteryInaAddress" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isPowerSaving" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="lsSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="minWakeSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="onBatteryShutdownAfterSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="waitBluetoothSecs" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="powerConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="powerConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="RangeTestConfigEntity" representedClassName="RangeTestConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="save" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="sender" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
<relationship name="rangeTestConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="rangeTestConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="RouteEntity" representedClassName="RouteEntity" syncable="YES" codeGenerationType="class">
<attribute name="color" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="distance" optional="YES" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="elevationGain" optional="YES" attributeType="Double" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="endDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="notes" optional="YES" attributeType="String"/>
<relationship name="locations" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="LocationEntity" inverseName="routeLocation" inverseEntity="LocationEntity"/>
</entity>
<entity name="RTTTLConfigEntity" representedClassName="RTTTLConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="ringtone" optional="YES" attributeType="String" maxValueString="228" defaultValueString=""/>
<relationship name="rtttlConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="rtttlConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="SecurityConfigEntity" representedClassName="SecurityConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="adminChannelEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="adminKey" optional="YES" attributeType="Binary"/>
<attribute name="bluetoothLoggingEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="debugLogApiEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="isManaged" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="privateKey" optional="YES" attributeType="Binary"/>
<attribute name="publicKey" optional="YES" attributeType="Binary"/>
<attribute name="serialEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<relationship name="securityConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="securityConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="SerialConfigEntity" representedClassName="SerialConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="baudRate" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="echo" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="enabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="mode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="overrideConsoleSerialPort" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="rxd" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="timeout" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="txd" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="serialConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="serialConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="StoreForwardConfigEntity" representedClassName="StoreForwardConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="enabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="heartbeat" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="historyReturnMax" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="historyReturnWindow" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isRouter" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="lastHeartbeat" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="lastRequest" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="records" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="storeForwardConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="storeForwardConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TelemetryConfigEntity" representedClassName="TelemetryConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="deviceUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="environmentDisplayFahrenheit" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="environmentMeasurementEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="environmentScreenEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="environmentUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="powerMeasurementEnabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="powerScreenEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="powerUpdateInterval" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="telemetryConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="telemetryConfig" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TelemetryEntity" representedClassName="TelemetryEntity" syncable="YES" codeGenerationType="class">
<attribute name="airUtilTx" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="barometricPressure" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="batteryLevel" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="channelUtilization" optional="YES" attributeType="Float" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="current" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="gasResistance" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="iaq" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="metricsType" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numOnlineNodes" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numPacketsRx" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numPacketsRxBad" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numPacketsTx" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numTotalNodes" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="relativeHumidity" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="temperature" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="uptimeSeconds" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="voltage" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="weight" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="windDirection" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="windGust" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="windLull" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="windSpeed" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<relationship name="nodeTelemetry" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="telemetries" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TraceRouteEntity" representedClassName="TraceRouteEntity" syncable="YES" codeGenerationType="class">
<attribute name="altitude" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="hasPositions" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="latitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="longitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="num" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="response" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="route" optional="YES" attributeType="Transformable" customClassName="[UInt32]"/>
<attribute name="routeText" optional="YES" attributeType="String"/>
<attribute name="time" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="hops" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="TraceRouteHopEntity" inverseName="traceRoute" inverseEntity="TraceRouteHopEntity"/>
<relationship name="node" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="traceRoutes" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="TraceRouteHopEntity" representedClassName="TraceRouteHopEntity" syncable="YES" codeGenerationType="class">
<attribute name="altitude" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="latitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="longitudeI" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="num" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="time" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="traceRoute" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="TraceRouteEntity" inverseName="hops" inverseEntity="TraceRouteEntity"/>
</entity>
<entity name="UserEntity" representedClassName="UserEntity" syncable="YES" codeGenerationType="class">
<attribute name="hwDisplayName" optional="YES" attributeType="String"/>
<attribute name="hwModel" attributeType="String"/>
<attribute name="hwModelId" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="isLicensed" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="keyMatch" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="lastMessage" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="longName" attributeType="String"/>
<attribute name="mute" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="newPublicKey" optional="YES" attributeType="Binary"/>
<attribute name="num" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="numString" optional="YES" attributeType="String"/>
<attribute name="pkiEncrypted" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="publicKey" optional="YES" attributeType="Binary"/>
<attribute name="role" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="shortName" attributeType="String"/>
<attribute name="userId" attributeType="String"/>
<relationship name="receivedMessages" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="MessageEntity" inverseName="toUser" inverseEntity="MessageEntity"/>
<relationship name="sentMessages" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="MessageEntity" inverseName="fromUser" inverseEntity="MessageEntity"/>
<relationship name="userNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="user" inverseEntity="NodeInfoEntity"/>
</entity>
<entity name="WaypointEntity" representedClassName="WaypointEntity" syncable="YES" codeGenerationType="class">
<attribute name="created" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="expire" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="icon" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="latitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="locked" attributeType="Integer 64" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="longDescription" optional="YES" attributeType="String" maxValueString="100"/>
<attribute name="longitudeI" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="name" attributeType="String" minValueString="1" maxValueString="30"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
</model>

View file

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

View file

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

View file

@ -12,7 +12,9 @@ class Router: ObservableObject {
private var cancellables: Set<AnyCancellable> = []
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
}
}

View file

@ -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<MeshActivityAttributes>.request(attributes: activityAttributes, content: activityContent,

View file

@ -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,

View file

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

View file

@ -26,21 +26,6 @@ struct Messages: View {
@Binding
var unreadDirectMessages: Int
// Aliases the navigation state for the NavigationSplitView sidebar selection
private var messagesSelection: Binding<MessagesNavigationState?> {
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

View file

@ -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<UserEntity>
var users: FetchedResults<UserEntity>
@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

View file

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

View file

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

View file

@ -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<PresentationDetent> = [.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)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,8 +17,9 @@ struct ConfigHeader<T>: 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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Date>
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<Date>
}
// Fixed non-changing properties about your activity go here!

View file

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