Merge pull request #429 from meshtastic/2.2.14_Working_Changes

2.2.14 working changes
This commit is contained in:
Garth Vander Houwen 2023-12-12 23:03:00 -08:00 committed by GitHub
commit 1fb0d781f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 2428 additions and 1018 deletions

View file

@ -29,6 +29,7 @@
DD2AD8A8296D2DF9001FF0E7 /* MapViewSwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2AD8A7296D2DF9001FF0E7 /* MapViewSwiftUI.swift */; };
DD2DC2C029BCD8AB003B383C /* HardwareModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2DC2BF29BCD8AB003B383C /* HardwareModels.swift */; };
DD3501892852FC3B000FC853 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3501882852FC3B000FC853 /* Settings.swift */; };
DD3619152B1EF9F900C41C8C /* LocationsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */; };
DD3CC6B528E33FD100FA9159 /* ShareChannels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3CC6B428E33FD100FA9159 /* ShareChannels.swift */; };
DD3CC6BC28E366DF00FA9159 /* Meshtastic.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */; };
DD3CC6BE28E4CD9800FA9159 /* BatteryGauge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3CC6BD28E4CD9800FA9159 /* BatteryGauge.swift */; };
@ -41,6 +42,7 @@
DD457188293C7E63000C49FB /* BLESignalStrengthIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD457187293C7E63000C49FB /* BLESignalStrengthIndicator.swift */; };
DD4640202AFF10F4002A5ECB /* WaypointForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD46401F2AFF10F4002A5ECB /* WaypointForm.swift */; };
DD47E3D626F17ED900029299 /* CircleText.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD47E3D526F17ED900029299 /* CircleText.swift */; };
DD4975A52B147BA90026544E /* AmbientLightingConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4975A42B147BA90026544E /* AmbientLightingConfig.swift */; };
DD4A911E2708C65400501B7E /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4A911D2708C65400501B7E /* AppSettings.swift */; };
DD4F23CD28779A3C001D37CB /* EnvironmentMetricsLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4F23CC28779A3C001D37CB /* EnvironmentMetricsLog.swift */; };
DD5394FC276993AD00AD86B1 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = DD5394FB276993AD00AD86B1 /* SwiftProtobuf */; };
@ -171,6 +173,9 @@
DDDE5A1329AFEAB900490C6C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDDE5A1229AFEAB900490C6C /* Assets.xcassets */; };
DDDE5A1429AFEAB900490C6C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDDE5A1229AFEAB900490C6C /* Assets.xcassets */; };
DDE0F7C5295F77B700B8AAB3 /* AppSettingsEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE0F7C4295F77B700B8AAB3 /* AppSettingsEnums.swift */; };
DDE5B4042B2279A700FCDD05 /* TraceRouteLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE5B4032B2279A700FCDD05 /* TraceRouteLog.swift */; };
DDE5B4062B227E3200FCDD05 /* TraceRouteEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE5B4052B227E3200FCDD05 /* TraceRouteEntityExtension.swift */; };
DDE9659C2B1C3B6A00531070 /* RouteRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE9659B2B1C3B6A00531070 /* RouteRecorder.swift */; };
DDF6B2482A9AEBF500BA6931 /* StoreForward.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF6B2472A9AEBF500BA6931 /* StoreForward.swift */; };
DDF924CA26FBB953009FE055 /* ConnectedDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF924C926FBB953009FE055 /* ConnectedDevice.swift */; };
DDFEB3BB29900C1200EE7472 /* CurrentConditionsCompact.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFEB3BA29900C1200EE7472 /* CurrentConditionsCompact.swift */; };
@ -239,6 +244,8 @@
DD2CC2E52ABFE04E00EDFDA7 /* MeshtasticDataModelV19.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV19.xcdatamodel; sourceTree = "<group>"; };
DD2DC2BF29BCD8AB003B383C /* HardwareModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareModels.swift; sourceTree = "<group>"; };
DD3501882852FC3B000FC853 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
DD3619132B1EE20700C41C8C /* MeshtasticDataModelV21.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV21.xcdatamodel; sourceTree = "<group>"; };
DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsHandler.swift; sourceTree = "<group>"; };
DD3CC6B428E33FD100FA9159 /* ShareChannels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareChannels.swift; sourceTree = "<group>"; };
DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModel.xcdatamodel; sourceTree = "<group>"; };
DD3CC6BD28E4CD9800FA9159 /* BatteryGauge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryGauge.swift; sourceTree = "<group>"; };
@ -254,6 +261,7 @@
DD457BC4295D5E35004BCE4D /* MeshtasticDataModelV5.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV5.xcdatamodel; sourceTree = "<group>"; };
DD46401F2AFF10F4002A5ECB /* WaypointForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaypointForm.swift; sourceTree = "<group>"; };
DD47E3D526F17ED900029299 /* CircleText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleText.swift; sourceTree = "<group>"; };
DD4975A42B147BA90026544E /* AmbientLightingConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmbientLightingConfig.swift; sourceTree = "<group>"; };
DD4A911D2708C65400501B7E /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
DD4F23CC28779A3C001D37CB /* EnvironmentMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentMetricsLog.swift; sourceTree = "<group>"; };
DD5394FD276BA0EF00AD86B1 /* PositionEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionEntityExtension.swift; sourceTree = "<group>"; };
@ -401,6 +409,9 @@
DDDE5A1229AFEAB900490C6C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
DDDEE5E229DBE43E00A8E078 /* MeshtasticDataModelV11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV11.xcdatamodel; sourceTree = "<group>"; };
DDE0F7C4295F77B700B8AAB3 /* AppSettingsEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsEnums.swift; sourceTree = "<group>"; };
DDE5B4032B2279A700FCDD05 /* TraceRouteLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraceRouteLog.swift; sourceTree = "<group>"; };
DDE5B4052B227E3200FCDD05 /* TraceRouteEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraceRouteEntityExtension.swift; sourceTree = "<group>"; };
DDE9659B2B1C3B6A00531070 /* RouteRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteRecorder.swift; sourceTree = "<group>"; };
DDEE03EC29544A1000FCAD57 /* MeshtasticDataModelV4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV4.xcdatamodel; sourceTree = "<group>"; };
DDF6B2462A9AEB9E00BA6931 /* MeshtasticDataModelV17.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV17.xcdatamodel; sourceTree = "<group>"; };
DDF6B2472A9AEBF500BA6931 /* StoreForward.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreForward.swift; sourceTree = "<group>"; };
@ -478,6 +489,7 @@
DDD9E4E3284B208E003777C5 /* UserEntityExtension.swift */,
DD964FC1297272AE007C176F /* WaypointEntityExtension.swift */,
DDAB580E2B0DAFBC00147258 /* LocationEntityExtension.swift */,
DDE5B4052B227E3200FCDD05 /* TraceRouteEntityExtension.swift */,
);
path = CoreData;
sourceTree = "<group>";
@ -508,6 +520,7 @@
DD73FD1028750779000852D6 /* PositionLog.swift */,
DD4F23CC28779A3C001D37CB /* EnvironmentMetricsLog.swift */,
6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */,
DDE5B4032B2279A700FCDD05 /* TraceRouteLog.swift */,
);
path = Nodes;
sourceTree = "<group>";
@ -529,6 +542,7 @@
DD0F791A28713C8A00A6FDAD /* AdminMessageList.swift */,
DD4A911D2708C65400501B7E /* AppSettings.swift */,
DDAB580C2B0DAA9E00147258 /* Routes.swift */,
DDE9659B2B1C3B6A00531070 /* RouteRecorder.swift */,
DDA0B6B1294CDC55001356EC /* Channels.swift */,
DDD6EEAE29BC024700383354 /* Firmware.swift */,
DD8169FA271F1F3A00F4AB02 /* MeshLog.swift */,
@ -592,6 +606,7 @@
DD61937B2863877A00E59241 /* Module */ = {
isa = PBXGroup;
children = (
DD4975A42B147BA90026544E /* AmbientLightingConfig.swift */,
DD6193762862F90F00E59241 /* CannedMessagesConfig.swift */,
DDC4C9FE2A8D982900CE201C /* DetectionSensorConfig.swift */,
DD6193742862F6E600E59241 /* ExternalNotificationConfig.swift */,
@ -833,6 +848,7 @@
DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */,
DD964FBC296E6B01007C176F /* EmojiOnlyTextField.swift */,
DDDB443C29F6592F00EE2349 /* NetworkManager.swift */,
DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */,
);
path = Helpers;
sourceTree = "<group>";
@ -1142,10 +1158,12 @@
DDDB26462AACC0B7003AFCB7 /* NodeInfoItem.swift in Sources */,
DD2AD8A8296D2DF9001FF0E7 /* MapViewSwiftUI.swift in Sources */,
DD5E5213298EE33B00D21B61 /* deviceonly.pb.swift in Sources */,
DDE5B4042B2279A700FCDD05 /* TraceRouteLog.swift in Sources */,
DD5E5208298EE33B00D21B61 /* rtttl.pb.swift in Sources */,
DD6193792863875F00E59241 /* SerialConfig.swift in Sources */,
DDDB263F2AABEE20003AFCB7 /* NodeList.swift in Sources */,
DDA0B6B2294CDC55001356EC /* Channels.swift in Sources */,
DDE9659C2B1C3B6A00531070 /* RouteRecorder.swift in Sources */,
DDB8F4102A9EE5B400230ECE /* Messages.swift in Sources */,
DDDB26482AACD6D1003AFCB7 /* NodeMapMapkit.swift in Sources */,
DD4A911E2708C65400501B7E /* AppSettings.swift in Sources */,
@ -1160,6 +1178,7 @@
DDF924CA26FBB953009FE055 /* ConnectedDevice.swift in Sources */,
DD3CC6BE28E4CD9800FA9159 /* BatteryGauge.swift in Sources */,
DD6193772862F90F00E59241 /* CannedMessagesConfig.swift in Sources */,
DD3619152B1EF9F900C41C8C /* LocationsHandler.swift in Sources */,
DDDB444A29F8AA3A00EE2349 /* CLLocationCoordinate2D.swift in Sources */,
DD41582628582E9B009B0E59 /* DeviceConfig.swift in Sources */,
DD007BAE2AA4E91200F5FA12 /* MyInfoEntityExtension.swift in Sources */,
@ -1216,6 +1235,7 @@
DDB75A212A12B954006ED576 /* LoRaSignalStrength.swift in Sources */,
DD6193752862F6E600E59241 /* ExternalNotificationConfig.swift in Sources */,
DDB6ABE428B13FFF00384BA1 /* DisplayEnums.swift in Sources */,
DD4975A52B147BA90026544E /* AmbientLightingConfig.swift in Sources */,
DD86D40A287F04F100BAEB7A /* InvalidVersion.swift in Sources */,
DDD94A502845C8F5004A87A0 /* DateTimeText.swift in Sources */,
DD5E5212298EE33B00D21B61 /* apponly.pb.swift in Sources */,
@ -1250,6 +1270,7 @@
DDAB580F2B0DAFBC00147258 /* LocationEntityExtension.swift in Sources */,
DD5E5211298EE33B00D21B61 /* remote_hardware.pb.swift in Sources */,
DD5E5204298EE33B00D21B61 /* xmodem.pb.swift in Sources */,
DDE5B4062B227E3200FCDD05 /* TraceRouteEntityExtension.swift in Sources */,
DDC2E15826CE248E0042C5E4 /* MeshtasticApp.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1460,7 +1481,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.2.13;
MARKETING_VERSION = 2.2.14;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
@ -1494,7 +1515,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.2.13;
MARKETING_VERSION = 2.2.14;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
@ -1616,7 +1637,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.2.13;
MARKETING_VERSION = 2.2.14;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1649,7 +1670,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.2.13;
MARKETING_VERSION = 2.2.14;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1760,6 +1781,7 @@
DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */ = {
isa = XCVersionGroup;
children = (
DD3619132B1EE20700C41C8C /* MeshtasticDataModelV21.xcdatamodel */,
DDAB580B2B0D913500147258 /* MeshtasticDataModelV20.xcdatamodel */,
DD2CC2E52ABFE04E00EDFDA7 /* MeshtasticDataModelV19.xcdatamodel */,
DDDB26492AAD743E003AFCB7 /* MeshtasticDataModelV18.xcdatamodel */,
@ -1781,7 +1803,7 @@
DD5D0A9A2931AD6B00F7EA61 /* MeshtasticDataModelV2.xcdatamodel */,
DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */,
);
currentVersion = DDAB580B2B0D913500147258 /* MeshtasticDataModelV20.xcdatamodel */;
currentVersion = DD3619132B1EE20700C41C8C /* MeshtasticDataModelV21.xcdatamodel */;
name = Meshtastic.xcdatamodeld;
path = Meshtastic/Meshtastic.xcdatamodeld;
sourceTree = "<group>";

View file

@ -17,6 +17,9 @@ enum DeviceRoles: Int, CaseIterable, Identifiable {
case repeater = 4
case tracker = 5
case sensor = 6
case tak = 7
case clientHidden = 8
case lostAndFound = 9
var id: Int { self.rawValue }
var name: String {
@ -35,6 +38,12 @@ enum DeviceRoles: Int, CaseIterable, Identifiable {
return "Tracker"
case .sensor:
return "Sensor"
case .tak:
return "TAK"
case .clientHidden:
return "Client Hidden"
case .lostAndFound:
return "Lost and Found"
}
}
var description: String {
@ -53,6 +62,12 @@ enum DeviceRoles: Int, CaseIterable, Identifiable {
return "device.role.tracker".localized
case .sensor:
return "device.role.sensor".localized
case .tak:
return "device.role.tak".localized
case .clientHidden:
return "device.role.clienthidden".localized
case .lostAndFound:
return "device.role.lostandfound".localized
}
}
func protoEnumValue() -> Config.DeviceConfig.Role {
@ -72,6 +87,12 @@ enum DeviceRoles: Int, CaseIterable, Identifiable {
return Config.DeviceConfig.Role.tracker
case .sensor:
return Config.DeviceConfig.Role.sensor
case .tak:
return Config.DeviceConfig.Role.tak
case .clientHidden:
return Config.DeviceConfig.Role.clientHidden
case .lostAndFound:
return Config.DeviceConfig.Role.lostAndFound
}
}
}
@ -81,6 +102,7 @@ enum RebroadcastModes: Int, CaseIterable, Identifiable {
case all = 0
case allSkipDecoding = 1
case localOnly = 2
case knownOnly = 3
var id: Int { self.rawValue }
@ -92,6 +114,8 @@ enum RebroadcastModes: Int, CaseIterable, Identifiable {
return "All Skip Decoding"
case .localOnly:
return "Local Only"
case .knownOnly:
return "Known Only"
}
}
var description: String {
@ -102,6 +126,8 @@ enum RebroadcastModes: Int, CaseIterable, Identifiable {
return "Same as behavior as ALL but skips packet decoding and simply rebroadcasts them. Only available in Repeater role. Setting this on any other roles will result in ALL behavior."
case .localOnly:
return "Ignores observed messages from foreign meshes that are open or those which it cannot decrypt. Only rebroadcasts message on the nodes local primary / secondary channels."
case .knownOnly:
return "Ignores observed messages from foreign meshes like Local Only, but takes it step further by also ignoring messages from nodes not already in the node's known list."
}
}
func protoEnumValue() -> Config.DeviceConfig.RebroadcastMode {
@ -113,6 +139,8 @@ enum RebroadcastModes: Int, CaseIterable, Identifiable {
return Config.DeviceConfig.RebroadcastMode.allSkipDecoding
case .localOnly:
return Config.DeviceConfig.RebroadcastMode.localOnly
case .knownOnly:
return Config.DeviceConfig.RebroadcastMode.knownOnly
}
}
}

View file

@ -52,116 +52,3 @@ enum GpsFormats: Int, CaseIterable, Identifiable {
}
}
}
enum GpsUpdateIntervals: Int, CaseIterable, Identifiable {
case fiveSeconds = 5
case tenSeconds = 10
case fifteenSeconds = 15
case twentySeconds = 20
case twentyFiveSeconds = 25
case thirtySeconds = 30
case fortyFiveSeconds = 45
case oneMinute = 60
case twoMinutes = 120
case fiveMinutes = 300
case tenMinutes = 600
case fifteenMinutes = 900
case thirtyMinutes = 1800
case oneHour = 3600
case sixHours = 21600
case twelveHours = 43200
case twentyFourHours = 86400
case maxInt32 = 2147483647
var id: Int { self.rawValue }
var description: String {
switch self {
case .fiveSeconds:
return "interval.five.seconds".localized
case .tenSeconds:
return "interval.ten.seconds".localized
case .fifteenSeconds:
return "interval.fifteen.seconds".localized
case .twentySeconds:
return "interval.twenty.seconds".localized
case .twentyFiveSeconds:
return "interval.twentyfive.seconds".localized
case .thirtySeconds:
return "interval.thirty.seconds".localized
case .fortyFiveSeconds:
return "interval.fortyfive.seconds".localized
case .oneMinute:
return "interval.one.minute".localized
case .twoMinutes:
return "interval.two.minutes".localized
case .fiveMinutes:
return "interval.five.minutes".localized
case .tenMinutes:
return "interval.ten.minutes".localized
case .fifteenMinutes:
return "interval.fifteen.minutes".localized
case .thirtyMinutes:
return "interval.thirty.minutes".localized
case .oneHour:
return "interval.one.hour".localized
case .sixHours:
return "interval.six.hours".localized
case .twelveHours:
return "interval.twelve.hours".localized
case .twentyFourHours:
return "interval.twentyfour.hours".localized
case .maxInt32:
return "on.boot"
}
}
}
enum GpsAttemptTimes: Int, CaseIterable, Identifiable {
case twoSeconds = 2
case fiveSeconds = 5
case tenSeconds = 10
case fifteenSeconds = 15
case twentySeconds = 20
case twentyFiveSeconds = 25
case thirtySeconds = 30
case fortyFiveSeconds = 45
case oneMinute = 60
case twoMinutes = 120
case fiveMinutes = 300
case tenMinutes = 600
case fifteenMinutes = 900
var id: Int { self.rawValue }
var description: String {
switch self {
case .twoSeconds:
return "interval.two.seconds".localized
case .fiveSeconds:
return "interval.five.seconds".localized
case .tenSeconds:
return "interval.ten.seconds".localized
case .fifteenSeconds:
return "interval.fifteen.seconds".localized
case .twentySeconds:
return "interval.twenty.seconds".localized
case .twentyFiveSeconds:
return "interval.twentyfive.seconds".localized
case .thirtySeconds:
return "interval.thirty.seconds".localized
case .fortyFiveSeconds:
return "interval.fortyfive.seconds".localized
case .oneMinute:
return "interval.one.minute".localized
case .twoMinutes:
return "interval.two.minutes".localized
case .fiveMinutes:
return "interval.five.minutes".localized
case .tenMinutes:
return "interval.ten.minutes".localized
case .fifteenMinutes:
return "interval.fifteen.minutes".localized
}
}
}

View file

@ -0,0 +1,71 @@
//
// TraceRouteEntityExtension.swift
// Meshtastic
//
// Copyright(c) Garth Vander Houwen 12/7/23.
//
import CoreData
import CoreLocation
import MapKit
import SwiftUI
extension TraceRouteEntity {
var latitude: Double? {
let d = Double(latitudeI)
if d == 0 {
return 0
}
return d / 1e7
}
var longitude: Double? {
let d = Double(longitudeI)
if d == 0 {
return 0
}
return d / 1e7
}
var coordinate: CLLocationCoordinate2D? {
if latitudeI != 0 && longitudeI != 0 {
let coord = CLLocationCoordinate2D(latitude: latitude!, longitude: longitude!)
return coord
} else {
return nil
}
}
}
extension TraceRouteHopEntity {
var latitude: Double? {
let d = Double(latitudeI)
if d == 0 {
return 0
}
return d / 1e7
}
var longitude: Double? {
let d = Double(longitudeI)
if d == 0 {
return 0
}
return d / 1e7
}
var coordinate: CLLocationCoordinate2D? {
if latitudeI != 0 && longitudeI != 0 {
let coord = CLLocationCoordinate2D(latitude: latitude!, longitude: longitude!)
return coord
} else {
return nil
}
}
}

View file

@ -26,6 +26,8 @@ extension UserDefaults {
case mapTileServer
case mapTilesAboveLabels
case mapUseLegacy
case enableDetectionNotifications
case detectionSensorRole
}
func reset() {
@ -190,4 +192,22 @@ extension UserDefaults {
UserDefaults.standard.set(newValue, forKey: "mapUseLegacy")
}
}
static var enableDetectionNotifications: Bool {
get {
UserDefaults.standard.bool(forKey: "enableDetectionNotifications")
}
set {
UserDefaults.standard.set(newValue, forKey: "enableDetectionNotifications")
}
}
static var detectionSensorRole: DetectionSensorRole {
get {
DetectionSensorRole(rawValue: UserDefaults.standard.string(forKey: "detectionSensorRole") ?? DetectionSensorRole.sensor.rawValue) ?? DetectionSensorRole.sensor
}
set {
UserDefaults.standard.set(newValue.rawValue, forKey: "detectionSensorRole")
}
}
}

View file

@ -20,7 +20,6 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
var context: NSManagedObjectContext?
//var userSettings: UserSettings?
private var centralManager: CBCentralManager!
private let restoreKey = "Meshtastic.BLE.Manager"
@Published var peripherals: [Peripheral] = []
@Published var connectedPeripheral: Peripheral!
@Published var lastConnectionError: String
@ -42,6 +41,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
let emptyNodeNum: UInt32 = 4294967295
let mqttManager = MqttClientProxyManager.shared
var wantRangeTestPackets = false
var wantStoreAndForwardPackets = false
/* Meshtastic Service Details */
var TORADIO_characteristic: CBCharacteristic!
var FROMRADIO_characteristic: CBCharacteristic!
@ -377,6 +377,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
let fromNodeNum = connectedPeripheral.num
let routePacket = RouteDiscovery()
var meshPacket = MeshPacket()
meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
meshPacket.to = UInt32(destNum)
meshPacket.from = UInt32(fromNodeNum)
meshPacket.wantAck = true
@ -395,8 +396,43 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse)
success = true
let logString = String.localizedStringWithFormat("mesh.log.traceroute.sent %@".localized, String(destNum))
MeshLogger.log("🪧 \(logString)")
let traceRoute = TraceRouteEntity(context: context!)
let nodes: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest.init(entityName: "NodeInfoEntity")
nodes.predicate = NSPredicate(format: "num IN %@", [destNum, self.connectedPeripheral.num])
do {
guard let fetchedNodes = try context!.fetch(nodes) as? [NodeInfoEntity] else {
return false
}
let receivingNode = fetchedNodes.first(where: { $0.num == destNum })
let connectedNode = fetchedNodes.first(where: { $0.num == self.connectedPeripheral.num })
traceRoute.id = Int64(meshPacket.id)
traceRoute.time = Date()
traceRoute.node = receivingNode
// Grab the most recent postion, within the last hour
if connectedNode?.positions?.count ?? 0 > 0 {
let mostRecent = connectedNode?.positions?.lastObject as! PositionEntity
if mostRecent.time! >= Calendar.current.date(byAdding: .minute, value: -60, to: Date())! {
traceRoute.altitude = mostRecent.altitude
traceRoute.latitudeI = mostRecent.latitudeI
traceRoute.longitudeI = mostRecent.longitudeI
}
}
do {
try context!.save()
print("💾 Saved TraceRoute sent to node: \(String(receivingNode?.user?.longName ?? "unknown".localized))")
} catch {
context!.rollback()
let nsError = error as NSError
print("💥 Error Updating Core Data BluetoothConfigEntity: \(nsError)")
}
let logString = String.localizedStringWithFormat("mesh.log.traceroute.sent %@".localized, String(destNum))
MeshLogger.log("🪧 \(logString)")
} catch {
}
}
return success
}
@ -572,13 +608,17 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
case .serialApp:
MeshLogger.log("🕸️ MESH PACKET received for Serial App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure")")
case .storeForwardApp:
storeAndForwardPacket(packet: decodedInfo.packet, connectedNodeNum: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context!)
if wantStoreAndForwardPackets {
storeAndForwardPacket(packet: decodedInfo.packet, connectedNodeNum: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context!)
} else {
MeshLogger.log("🕸️ MESH PACKET received for Store and Forward App - Store and Forward is disabled.")
}
case .rangeTestApp:
if wantRangeTestPackets && !UserDefaults.blockRangeTest {
textMessageAppPacket(packet: decodedInfo.packet, blockRangeTest: false, connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context!)
}
else {
MeshLogger.log("🕸️ MESH PACKET received for Range Test App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure")")
MeshLogger.log("🕸️ MESH PACKET received for Range Test App Range testing is disabled.")
}
case .telemetryApp:
if !invalidVersion { telemetryPacket(packet: decodedInfo.packet, connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context!) }
@ -596,16 +636,44 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
MeshLogger.log("🕸️ MESH PACKET received for Audio App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure")")
case .tracerouteApp:
if let routingMessage = try? RouteDiscovery(serializedData: decodedInfo.packet.decoded.payload) {
let traceRoute = getTraceRoute(id: Int64(decodedInfo.packet.decoded.requestID), context: context!)
traceRoute?.response = true
traceRoute?.route = routingMessage.route
if routingMessage.route.count == 0 {
let logString = String.localizedStringWithFormat("mesh.log.traceroute.received.direct %@".localized, String(decodedInfo.packet.from))
MeshLogger.log("🪧 \(logString)")
} else {
var routeString = "\(decodedInfo.packet.to) --> "
for node in routingMessage.route {
routeString += "\(node) --> "
} else {
var routeString = "You --> "
var hopNodes: [TraceRouteHopEntity] = []
// for node in routingMessage.route {
// let hopNode = getNodeInfo(id: Int64(node), context: context!)
// let traceRouteHop = TraceRouteHopEntity(context: context!)
// traceRouteHop.time = Date()
// let mostRecent = hopNode?.positions?.lastObject as! PositionEntity
// if mostRecent.time! >= Calendar.current.date(byAdding: .minute, value: -60, to: Date())! {
// traceRouteHop.altitude = mostRecent.altitude
// traceRouteHop.latitudeI = mostRecent.latitudeI
// traceRouteHop.longitudeI = mostRecent.longitudeI
// traceRouteHop.name = hopNode?.user?.longName ?? "unknown".localized
// }
// traceRouteHop.num = hopNode?.num ?? 0
// if hopNode != nil {
// hopNodes.append(traceRouteHop)
// }
// routeString += "\(hopNode?.user?.longName ?? "unknown".localized) --> "
// }
traceRoute?.routeText = routeString
traceRoute?.hops = NSOrderedSet(array: hopNodes)
do {
try context!.save()
print("💾 Saved Trace Route")
} catch {
context!.rollback()
let nsError = error as NSError
print("💥 Error Updating Core Data TraceRouteHOp: \(nsError)")
}
routeString += "\(decodedInfo.packet.from)"
let logString = String.localizedStringWithFormat("mesh.log.traceroute.received.route %@".localized, routeString)
MeshLogger.log("🪧 \(logString)")
@ -656,6 +724,9 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
if fetchedNodeInfo.count == 1 && fetchedNodeInfo[0].rangeTestConfig?.enabled == true {
wantRangeTestPackets = true;
}
if fetchedNodeInfo.count == 1 && fetchedNodeInfo[0].storeForwardConfig?.enabled == true {
wantStoreAndForwardPackets = true;
}
} catch {
print("Failed to find a node info for the connected node")
@ -874,23 +945,48 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
public func sendPosition(destNum: Int64, wantResponse: Bool) -> Bool {
var success = false
let fromNodeNum = connectedPeripheral.num
if fromNodeNum <= 0 || LocationHelper.currentLocation.distance(from: LocationHelper.DefaultLocation) == 0.0 {
return false
}
var positionPacket = Position()
positionPacket.latitudeI = Int32(LocationHelper.currentLocation.latitude * 1e7)
positionPacket.longitudeI = Int32(LocationHelper.currentLocation.longitude * 1e7)
positionPacket.time = UInt32(LocationHelper.currentTimestamp.timeIntervalSince1970)
positionPacket.timestamp = UInt32(LocationHelper.currentTimestamp.timeIntervalSince1970)
positionPacket.altitude = Int32(LocationHelper.currentAltitude)
positionPacket.satsInView = UInt32(LocationHelper.satsInView)
if LocationHelper.currentSpeed > 0 && (!LocationHelper.currentSpeed.isNaN || !LocationHelper.currentSpeed.isInfinite) {
positionPacket.groundSpeed = UInt32(LocationHelper.currentSpeed * 3.6)
}
if LocationHelper.currentHeading > 0 && (!LocationHelper.currentHeading.isNaN || !LocationHelper.currentHeading.isInfinite) {
positionPacket.groundTrack = UInt32(LocationHelper.currentHeading)
}
if #available(iOS 17.0, macOS 14.0, *) {
if fromNodeNum <= 0 {
return false
}
positionPacket.latitudeI = Int32(LocationsHandler.shared.lastLocation.coordinate.latitude * 1e7)
positionPacket.longitudeI = Int32(LocationsHandler.shared.lastLocation.coordinate.longitude * 1e7)
let timestamp = LocationsHandler.shared.lastLocation.timestamp
positionPacket.time = UInt32(timestamp.timeIntervalSince1970)
positionPacket.timestamp = UInt32(timestamp.timeIntervalSince1970)
positionPacket.altitude = Int32(LocationsHandler.shared.lastLocation.altitude)
positionPacket.satsInView = UInt32(LocationsHandler.satsInView)
let currentSpeed = LocationsHandler.shared.lastLocation.speed
if currentSpeed > 0 && (!currentSpeed.isNaN || !currentSpeed.isInfinite) {
positionPacket.groundSpeed = UInt32(currentSpeed * 3.6)
}
let currentHeading = LocationsHandler.shared.lastLocation.course
if currentHeading > 0 && (!currentHeading.isNaN || !currentHeading.isInfinite) {
positionPacket.groundTrack = UInt32(currentHeading)
}
} else {
if fromNodeNum <= 0 || LocationHelper.currentLocation.distance(from: LocationHelper.DefaultLocation) == 0.0 {
return false
}
positionPacket.latitudeI = Int32(LocationHelper.currentLocation.latitude * 1e7)
positionPacket.longitudeI = Int32(LocationHelper.currentLocation.longitude * 1e7)
let timestamp = LocationHelper.shared.locationManager.location?.timestamp ?? Date()
positionPacket.time = UInt32(timestamp.timeIntervalSince1970)
positionPacket.timestamp = UInt32(timestamp.timeIntervalSince1970)
positionPacket.altitude = Int32(LocationHelper.shared.locationManager.location?.altitude ?? 0)
positionPacket.satsInView = UInt32(LocationHelper.satsInView)
let currentSpeed = LocationHelper.shared.locationManager.location?.speed ?? 0
if currentSpeed > 0 && (!currentSpeed.isNaN || !currentSpeed.isInfinite) {
positionPacket.groundSpeed = UInt32(currentSpeed * 3.6)
}
let currentHeading = LocationHelper.shared.locationManager.location?.course ?? 0
if currentHeading > 0 && (!currentHeading.isNaN || !currentHeading.isInfinite) {
positionPacket.groundTrack = UInt32(currentHeading)
}
}
var meshPacket = MeshPacket()
meshPacket.to = UInt32(destNum)
meshPacket.from = UInt32(fromNodeNum)
@ -1359,6 +1455,33 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
return 0
}
public func saveAmbientLightingModuleConfig(config: ModuleConfig.AmbientLightingConfig, fromUser: UserEntity, toUser: UserEntity, adminIndex: Int32) -> Int64 {
var adminPacket = AdminMessage()
adminPacket.setModuleConfig.ambientLighting = config
var meshPacket: MeshPacket = MeshPacket()
meshPacket.to = UInt32(toUser.num)
meshPacket.from = UInt32(fromUser.num)
meshPacket.channel = UInt32(adminIndex)
meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
meshPacket.priority = MeshPacket.Priority.reliable
meshPacket.wantAck = true
var dataMessage = DataMessage()
dataMessage.payload = try! adminPacket.serializedData()
dataMessage.portnum = PortNum.adminApp
meshPacket.decoded = dataMessage
let messageDescription = "🛟 Saved Ambient Lighting Module Config for \(toUser.longName ?? "unknown".localized)"
if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription, fromUser: fromUser, toUser: toUser) {
upsertAmbientLightingModuleConfigPacket(config: config, nodeNum: toUser.num, context: context!)
return Int64(meshPacket.id)
}
return 0
}
public func saveCannedMessageModuleConfig(config: ModuleConfig.CannedMessageConfig, fromUser: UserEntity, toUser: UserEntity, adminIndex: Int32) -> Int64 {
var adminPacket = AdminMessage()
@ -1858,6 +1981,33 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
return false
}
public func requestAmbientLightingConfig(fromUser: UserEntity, toUser: UserEntity, adminIndex: Int32) -> Bool {
var adminPacket = AdminMessage()
adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.ambientlightingConfig
var meshPacket: MeshPacket = MeshPacket()
meshPacket.to = UInt32(toUser.num)
meshPacket.from = UInt32(fromUser.num)
meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
meshPacket.priority = MeshPacket.Priority.reliable
meshPacket.channel = UInt32(adminIndex)
meshPacket.wantAck = true
var dataMessage = DataMessage()
dataMessage.payload = try! adminPacket.serializedData()
dataMessage.portnum = PortNum.adminApp
dataMessage.wantResponse = true
meshPacket.decoded = dataMessage
let messageDescription = "🛎️ Requested Ambient Lighting Config on admin channel \(adminIndex) for node: \(toUser.longName ?? "unknown".localized)"
if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription, fromUser: fromUser, toUser: toUser) {
return true
}
return false
}
public func requestCannedMessagesModuleConfig(fromUser: UserEntity, toUser: UserEntity, adminIndex: Int32) -> Bool {
var adminPacket = AdminMessage()
@ -2121,7 +2271,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
/// send a request for ClientHistory with a time period matching the heartbeat
var sfPacket = StoreAndForward()
sfPacket.rr = StoreAndForward.RequestResponse.clientHistory
sfPacket.history.window = storeAndForwardMessage.heartbeat.period
sfPacket.history.window = 18000000 // storeAndForwardMessage.heartbeat.period
var meshPacket: MeshPacket = MeshPacket()
meshPacket.to = UInt32(packet.from)
meshPacket.from = UInt32(connectedNodeNum)
@ -2249,29 +2399,4 @@ extension BLEManager: CBCentralManagerDelegate {
let visibleDuration = Calendar.current.date(byAdding: .second, value: -5, to: today)!
self.peripherals.removeAll(where: { $0.lastUpdate < visibleDuration})
}
// func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) {
//
// guard let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] else {
// return
// }
//
// if peripherals.count > 0 {
//
// for peripheral in peripherals {
// print(peripheral)
// switch peripheral.state {
// case .connecting: // I've only seen this happen when
// // re-launching attached to Xcode.
// print("Xcode Restore")
//
// case .connected:
// connectTo(peripheral: peripheral)
// print("Restore BLE State")
// default: break
// }
// }
// }
// print("willRestoreState Hit!")
// }
}

View file

@ -14,43 +14,16 @@ class LocationHelper: NSObject, ObservableObject, CLLocationManagerDelegate {
locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
locationManager.pausesLocationUpdatesAutomatically = true
locationManager.allowsBackgroundLocationUpdates = true
locationManager.activityType = .otherNavigation
locationManager.activityType = .other
}
// Apple Park
static let DefaultLocation = CLLocationCoordinate2D(latitude: 37.3346, longitude: -122.0090)
static let DefaultAltitude = CLLocationDistance(integerLiteral: 0)
static let DefaultSpeed = CLLocationSpeed(integerLiteral: 0)
static let DefaultHeading = CLLocationDirection(integerLiteral: 0)
static var currentLocation: CLLocationCoordinate2D {
guard let location = shared.locationManager.location else {
return DefaultLocation
}
return location.coordinate
}
static var currentAltitude: CLLocationDistance {
guard let altitude = shared.locationManager.location?.altitude else {
return DefaultAltitude
}
return altitude
}
static var currentSpeed: CLLocationSpeed {
guard let speed = shared.locationManager.location?.speed else {
return DefaultSpeed
}
return speed
}
static var currentHeading: CLLocationDirection {
guard let heading = shared.locationManager.location?.course else {
return DefaultHeading
}
return heading
}
static var currentTimestamp: Date {
guard let timestamp = shared.locationManager.location?.timestamp else {
return Date.now
}
return timestamp
}
static var satsInView: Int {
// If we have a position we have a sat
var sats = 1
@ -74,9 +47,11 @@ class LocationHelper: NSObject, ObservableObject, CLLocationManagerDelegate {
}
return sats
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
switch manager.authorizationStatus {
case .authorizedAlways:
authorizationStatus = .authorizedAlways
case .authorizedWhenInUse:
authorizationStatus = .authorizedWhenInUse
locationManager.requestLocation()
@ -86,19 +61,13 @@ class LocationHelper: NSObject, ObservableObject, CLLocationManagerDelegate {
authorizationStatus = .denied
case .notDetermined:
authorizationStatus = .notDetermined
locationManager.requestWhenInUseAuthorization()
locationManager.requestAlwaysAuthorization()
default:
break
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
// locationManager.stopUpdatingLocation()
// locations.last.map {
// region = MKCoordinateRegion(
// center: $0.coordinate,
// span: .init(latitudeDelta: 0.01, longitudeDelta: 0.01)
// )
// }
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("Location manager error: \(error.localizedDescription)")

View file

@ -0,0 +1,96 @@
//
// LocationsHandler.swift
// Meshtastic
//
// Created by Garth Vander Houwen on 12/4/23.
//
import SwiftUI
import CoreLocation
// Shared state that manages the `CLLocationManager` and `CLBackgroundActivitySession`.
@available(iOS 17.0, macOS 14.0, *)
@MainActor class LocationsHandler: ObservableObject {
static let shared = LocationsHandler() // Create a single, shared instance of the object.
private let manager: CLLocationManager
private var background: CLBackgroundActivitySession?
@Published var lastLocation = CLLocation()
@Published var isStationary = false
@Published var count = 0
@Published
var updatesStarted: Bool = UserDefaults.standard.bool(forKey: "liveUpdatesStarted") {
didSet { UserDefaults.standard.set(updatesStarted, forKey: "liveUpdatesStarted") }
}
@Published
var backgroundActivity: Bool = UserDefaults.standard.bool(forKey: "BGActivitySessionStarted") {
didSet {
backgroundActivity ? self.background = CLBackgroundActivitySession() : self.background?.invalidate()
UserDefaults.standard.set(backgroundActivity, forKey: "BGActivitySessionStarted")
}
}
private init() {
self.manager = CLLocationManager() // Creating a location manager instance is safe to call here in `MainActor`.
}
func startLocationUpdates() {
if self.manager.authorizationStatus == .notDetermined {
self.manager.requestWhenInUseAuthorization()
}
print("Starting location updates")
Task() {
do {
self.updatesStarted = true
let updates = CLLocationUpdate.liveUpdates()
for try await update in updates {
if !self.updatesStarted { break } // End location updates by breaking out of the loop.
if let loc = update.location {
self.lastLocation = loc
self.isStationary = update.isStationary
self.count += 1
//print("Location \(self.count): \(self.lastLocation)")
}
}
} catch {
print("Could not start location updates")
}
return
}
}
func stopLocationUpdates() {
print("Stopping location updates")
self.updatesStarted = false
}
static let DefaultLocation = CLLocationCoordinate2D(latitude: 37.3346, longitude: -122.0090)
static var satsInView: Int {
// If we have a position we have a sat
var sats = 1
if shared.lastLocation.verticalAccuracy > 0 {
sats = 4
if 0...5 ~= shared.lastLocation.horizontalAccuracy {
sats = 12
} else if 6...15 ~= shared.lastLocation.horizontalAccuracy {
sats = 10
} else if 16...30 ~= shared.lastLocation.horizontalAccuracy {
sats = 9
} else if 31...45 ~= shared.lastLocation.horizontalAccuracy {
sats = 7
} else if 46...60 ~= shared.lastLocation.horizontalAccuracy {
sats = 5
}
} else if shared.lastLocation.verticalAccuracy < 0 && 60...300 ~= shared.lastLocation.horizontalAccuracy {
sats = 3
} else if shared.lastLocation.verticalAccuracy < 0 && shared.lastLocation.horizontalAccuracy > 300 {
sats = 2
}
return sats
}
}

View file

@ -56,7 +56,9 @@ func localConfig (config: Config, context: NSManagedObjectContext, nodeNum: Int6
func moduleConfig (config: ModuleConfig, context: NSManagedObjectContext, nodeNum: Int64, nodeLongName: String) {
if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.cannedMessage(config.cannedMessage) {
if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.ambientLighting(config.ambientLighting) {
upsertAmbientLightingModuleConfigPacket(config: config.ambientLighting, nodeNum: nodeNum, context: context)
} else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.cannedMessage(config.cannedMessage) {
upsertCannedMessagesModuleConfigPacket(config: config.cannedMessage, nodeNum: nodeNum, context: context)
} else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.detectionSensor(config.detectionSensor) {
upsertDetectionSensorModuleConfigPacket(config: config.detectionSensor, nodeNum: nodeNum, context: context)
@ -472,7 +474,9 @@ func adminAppPacket (packet: MeshPacket, context: NSManagedObjectContext) {
}
} else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getModuleConfigResponse(adminMessage.getModuleConfigResponse) {
let moduleConfig = adminMessage.getModuleConfigResponse
if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.cannedMessage(moduleConfig.cannedMessage) {
if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.ambientLighting(moduleConfig.ambientLighting) {
upsertAmbientLightingModuleConfigPacket(config: moduleConfig.ambientLighting, nodeNum: Int64(packet.from), context: context)
} else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.cannedMessage(moduleConfig.cannedMessage) {
upsertCannedMessagesModuleConfigPacket(config: moduleConfig.cannedMessage, nodeNum: Int64(packet.from), context: context)
} else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.detectionSensor(moduleConfig.detectionSensor) {
upsertDetectionSensorModuleConfigPacket(config: moduleConfig.detectionSensor, nodeNum: Int64(packet.from), context: context)
@ -726,7 +730,11 @@ func textMessageAppPacket(packet: MeshPacket, blockRangeTest: Bool, connectedNod
newMessage.isEmoji = packet.decoded.emoji == 1
newMessage.channel = Int32(packet.channel)
newMessage.portNum = Int32(packet.decoded.portnum.rawValue)
if packet.decoded.portnum == PortNum.detectionSensorApp {
if !UserDefaults.enableDetectionNotifications {
newMessage.read = true
}
}
if packet.decoded.replyID > 0 {
newMessage.replyID = Int64(packet.decoded.replyID)
}
@ -751,6 +759,10 @@ func textMessageAppPacket(packet: MeshPacket, blockRangeTest: Bool, connectedNod
messageSaved = true
if messageSaved {
if packet.decoded.portnum == PortNum.detectionSensorApp && !UserDefaults.enableDetectionNotifications {
return
}
let appState = AppState.shared
if newMessage.fromUser != nil && newMessage.toUser != nil && !(newMessage.fromUser?.mute ?? false) {
// Set Unread Message Indicators

View file

@ -55,6 +55,10 @@
<string>We use your location to display it on the mesh map as well as to have GPS coordinatess to send to the connected device.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to display it on the mesh map as well as to have GPS coordinatess to send to the connected device.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>We use your location to display it on the mesh map as well as to have GPS coordinatess to send to the connected device.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We use your location to display it on the mesh map as well as to have GPS coordinatess to send to the connected device. Route Recording uses location in the background.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>Privacy Bluetooth Always Usage Description</key>

View file

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

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22225" systemVersion="23C5047e" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22225" systemVersion="23C5055b" 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"/>
@ -116,10 +116,12 @@
<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="horizontalAccuracy" optional="YES" attributeType="Double" defaultValueString="0.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"/>
<attribute name="verticalAccuracy" optional="YES" attributeType="Double" defaultValueString="0.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">

View file

@ -0,0 +1,404 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22225" systemVersion="23C64" 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="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" optional="YES" attributeType="Boolean" 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="psk" optional="YES" attributeType="Binary"/>
<attribute name="role" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="uplinkEnabled" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="myInfoChannel" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MyInfoEntity" inverseName="channels" inverseEntity="MyInfoEntity"/>
<fetchedProperty name="allPrivateMessages" optional="YES">
<fetchRequest name="fetchedPropertyFetchRequest" entity="MessageEntity" predicateString="channel == $FETCH_SOURCE.index &amp;&amp; toUser == nil AND isEmoji == false"/>
</fetchedProperty>
<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="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"/>
<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"/>
<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="usePWM" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<relationship name="externalNotificationConfigNode" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="NodeInfoEntity" inverseName="externalNotificationConfig" inverseEntity="NodeInfoEntity"/>
<fetchedProperty name="fetchedProperty" optional="YES">
<fetchRequest name="fetchedPropertyFetchRequest" entity="ExternalNotificationConfigEntity"/>
</fetchedProperty>
</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="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="portNum" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<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="receivedTimestamp" optional="YES" attributeType="Integer 32" defaultValueString="0" 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"/>
<fetchedProperty name="tapbacks" optional="YES">
<fetchRequest name="fetchedPropertyFetchRequest" entity="MessageEntity" predicateString="replyID == $FETCH_SOURCE.messageId AND isEmoji == true"/>
</fetchedProperty>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="messageId"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="MQTTConfigEntity" representedClassName="MQTTConfigEntity" syncable="YES" codeGenerationType="class">
<attribute name="address" optional="YES" attributeType="String" maxValueString="30"/>
<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="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"/>
<fetchedProperty name="allMessages" optional="YES">
<fetchRequest name="fetchedPropertyFetchRequest" entity="MessageEntity" predicateString="toUser == nil"/>
</fetchedProperty>
<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="detection" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="environment" optional="YES" attributeType="Boolean" defaultValueString="NO" 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="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<relationship name="ambientLightingConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="AmbientLightingConfigEntity" inverseName="ambientLightingConfigNode" inverseEntity="AmbientLightingConfigEntity"/>
<relationship name="bluetoothConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="BluetoothConfigEntity" inverseName="bluetoothConfigNode" inverseEntity="BluetoothConfigEntity"/>
<relationship name="cannedMessageConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CannedMessageConfigEntity" inverseName="cannedMessagesConfigNode" inverseEntity="CannedMessageConfigEntity"/>
<relationship name="detectionSensorConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="DetectionSensorConfigEntity" inverseName="detectionSensorConfigNode" inverseEntity="DetectionSensorConfigEntity"/>
<relationship name="deviceConfig" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="DeviceConfigEntity" inverseName="deviceConfigNode" inverseEntity="DeviceConfigEntity"/>
<relationship name="displayConfig" optional="YES" maxCount="1" deletionRule="Nullify" 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="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="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="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="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="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="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="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="enabled" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<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="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="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="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"/>
<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="metricsType" 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="voltage" 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="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="hwModel" attributeType="String"/>
<attribute name="isLicensed" optional="YES" attributeType="Boolean" defaultValueString="NO" 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="num" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="shortName" attributeType="String"/>
<attribute name="userId" attributeType="String"/>
<attribute name="vip" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<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"/>
<fetchedProperty name="adminMessages" optional="YES">
<fetchRequest name="fetchedPropertyFetchRequest" entity="MessageEntity" predicateString="(fromUser.num == $FETCH_SOURCE.num) AND isEmoji == false AND admin = true"/>
</fetchedProperty>
<fetchedProperty name="allMessages" optional="YES">
<fetchRequest name="fetchedPropertyFetchRequest" entity="MessageEntity" predicateString="((toUser.num == $FETCH_SOURCE.num) OR (fromUser.num == $FETCH_SOURCE.num)) AND toUser != nil AND fromUser != nil AND isEmoji == false AND admin = false AND portNum != 10 "/>
</fetchedProperty>
<fetchedProperty name="detectionSensorMessages" optional="YES">
<fetchRequest name="fetchedPropertyFetchRequest" entity="MessageEntity" predicateString="(fromUser.num == $FETCH_SOURCE.num) AND portNum = 10"/>
</fetchedProperty>
</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"/>
</entity>
</model>

View file

@ -59,40 +59,42 @@ struct MeshtasticAppleApp: App {
saveChannels = false
print("User wants to import a MBTILES offline map file: \(self.incomingUrl?.absoluteString ?? "No Tiles link")")
}
if UserDefaults.mapUseLegacy {
/// we are expecting a .mbtiles map file that contains raster data
/// save it to the documents directory, and name it offline_map.mbtiles
let fileManager = FileManager.default
let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let destination = documentsDirectory.appendingPathComponent("offline_map.mbtiles", isDirectory: false)
// we are expecting a .mbtiles map file that contains raster data
// save it to the documents directory, and name it offline_map.mbtiles
let fileManager = FileManager.default
let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let destination = documentsDirectory.appendingPathComponent("offline_map.mbtiles", isDirectory: false)
if !self.saveChannels {
// tell the system we want the file please
guard url.startAccessingSecurityScopedResource() else {
return
}
// do we need to delete an old one?
if fileManager.fileExists(atPath: destination.path) {
print(" Found an old map file. Deleting it")
try? fileManager.removeItem(atPath: destination.path)
}
do {
try fileManager.copyItem(at: url, to: destination)
} catch {
print("Copy MB Tile file failed. Error: \(error)")
}
if fileManager.fileExists(atPath: destination.path) {
print(" Saved the map file")
// need to tell the map view that it needs to update and try loading the new overlay
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lastUpdatedLocalMapFile")
} else {
print("💥 Didn't save the map file")
if !self.saveChannels {
// tell the system we want the file please
guard url.startAccessingSecurityScopedResource() else {
return
}
// do we need to delete an old one?
if fileManager.fileExists(atPath: destination.path) {
print(" Found an old map file. Deleting it")
try? fileManager.removeItem(atPath: destination.path)
}
do {
try fileManager.copyItem(at: url, to: destination)
} catch {
print("Copy MB Tile file failed. Error: \(error)")
}
if fileManager.fileExists(atPath: destination.path) {
print(" Saved the map file")
// need to tell the map view that it needs to update and try loading the new overlay
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lastUpdatedLocalMapFile")
} else {
print("💥 Didn't save the map file")
}
}
}
})

View file

@ -7,7 +7,7 @@
import SwiftUI
class MeshtasticAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
class MeshtasticAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate, ObservableObject {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
print("🚀 Meshtstic Apple App launched!")
// Default User Default Values
@ -16,6 +16,18 @@ class MeshtasticAppDelegate: NSObject, UIApplicationDelegate, UNUserNotification
UserDefaults.standard.register(defaults: ["meshMapShowNodeHistory" : true])
UserDefaults.standard.register(defaults: ["meshMapShowRouteLines" : true])
UNUserNotificationCenter.current().delegate = self
if #available(iOS 17.0, macOS 14.0, *) {
let locationsHandler = LocationsHandler.shared
// If location updates were previously active, restart them after the background launch.
if locationsHandler.updatesStarted {
locationsHandler.startLocationUpdates()
}
// If a background activity session was previously active, reinstantiate it after the background launch.
if locationsHandler.backgroundActivity {
locationsHandler.backgroundActivity = true
}
}
return true
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {

View file

@ -46,6 +46,23 @@ public func getStoreAndForwardMessageIds(seconds: Int, context: NSManagedObjectC
return []
}
public func getTraceRoute(id: Int64, context: NSManagedObjectContext) -> TraceRouteEntity? {
let fetchTraceRouteRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest.init(entityName: "TraceRouteEntity")
fetchTraceRouteRequest.predicate = NSPredicate(format: "id == %lld", Int64(id))
do {
guard let fetchedTraceRoute = try context.fetch(fetchTraceRouteRequest) as? [TraceRouteEntity] else {
return nil
}
if fetchedTraceRoute.count == 1 {
return fetchedTraceRoute[0]
}
} catch {
return nil
}
return nil
}
public func getUser(id: Int64, context: NSManagedObjectContext) -> UserEntity {
@ -82,21 +99,3 @@ public func getWaypoint(id: Int64, context: NSManagedObjectContext) -> WaypointE
}
return WaypointEntity(context: context)
}
public func getDetectionSensorMessages(nodeNum: Int64?, context: NSManagedObjectContext) -> [MessageEntity] {
let fetchDetectionMessagesPredicate: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest.init(entityName: "MessageEntity")
fetchDetectionMessagesPredicate.predicate = NSPredicate(format: "portNum == %d", Int32(PortNum.detectionSensorApp.rawValue))
do {
let fetched = try context.fetch(fetchDetectionMessagesPredicate) as? [MessageEntity] ?? []
if nodeNum == nil {
return fetched.reversed()
}
return fetched.filter { message in
return message.fromUser?.num == nodeNum!
}.reversed()
} catch {
return []
}
}

View file

@ -624,6 +624,62 @@ func upsertPositionConfigPacket(config: Meshtastic.Config.PositionConfig, nodeNu
}
}
func upsertAmbientLightingModuleConfigPacket(config: Meshtastic.ModuleConfig.AmbientLightingConfig, nodeNum: Int64, context: NSManagedObjectContext) {
let logString = String.localizedStringWithFormat("mesh.log.ambientlighting.config %@".localized, String(nodeNum))
MeshLogger.log("🏮 \(logString)")
let fetchNodeInfoRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest.init(entityName: "NodeInfoEntity")
fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum))
do {
guard let fetchedNode = try context.fetch(fetchNodeInfoRequest) as? [NodeInfoEntity] else {
return
}
// Found a node, save Ambient Lighting Config
if !fetchedNode.isEmpty {
if fetchedNode[0].cannedMessageConfig == nil {
let newAmbientLightingConfig = AmbientLightingConfigEntity(context: context)
newAmbientLightingConfig.ledState = config.ledState
newAmbientLightingConfig.current = Int32(config.current)
newAmbientLightingConfig.red = Int32(config.red)
newAmbientLightingConfig.green = Int32(config.green)
newAmbientLightingConfig.blue = Int32(config.blue)
fetchedNode[0].ambientLightingConfig = newAmbientLightingConfig
} else {
if fetchedNode[0].ambientLightingConfig == nil {
fetchedNode[0].ambientLightingConfig = AmbientLightingConfigEntity(context: context)
}
fetchedNode[0].ambientLightingConfig?.ledState = config.ledState
fetchedNode[0].ambientLightingConfig?.current = Int32(config.current)
fetchedNode[0].ambientLightingConfig?.red = Int32(config.red)
fetchedNode[0].ambientLightingConfig?.green = Int32(config.green)
fetchedNode[0].ambientLightingConfig?.blue = Int32(config.blue)
}
do {
try context.save()
print("💾 Updated Ambient Lighting Module Config for node number: \(String(nodeNum))")
} catch {
context.rollback()
let nsError = error as NSError
print("💥 Error Updating Core Data AmbientLightingConfigEntity: \(nsError)")
}
} else {
print("💥 No Nodes found in local database matching node number \(nodeNum) unable to save Ambient Lighting Module Config")
}
} catch {
let nsError = error as NSError
print("💥 Fetching node for core data AmbientLightingConfigEntity failed: \(nsError)")
}
}
func upsertCannedMessagesModuleConfigPacket(config: Meshtastic.ModuleConfig.CannedMessageConfig, nodeNum: Int64, context: NSManagedObjectContext) {
let logString = String.localizedStringWithFormat("mesh.log.cannedmessage.config %@".localized, String(nodeNum))

View file

@ -244,6 +244,21 @@ struct Config {
/// Turns off many of the routine broadcasts to favor CoT packet stream
/// from the Meshtastic ATAK plugin -> IMeshService -> Node
case tak // = 7
///
/// Client Hidden device role
/// Used for nodes that "only speak when spoken to"
/// Turns all of the routine broadcasts but allows for ad-hoc communication
/// Still rebroadcasts, but with local only rebroadcast mode (known meshes only)
/// Can be used for clandestine operation or to dramatically reduce airtime / power consumption
case clientHidden // = 8
///
/// Lost and Found device role
/// Used to automatically send a text message to the mesh
/// with the current position of the device on a frequent interval:
/// "I'm lost! Position: lat / long"
case lostAndFound // = 9
case UNRECOGNIZED(Int)
init() {
@ -260,6 +275,8 @@ struct Config {
case 5: self = .tracker
case 6: self = .sensor
case 7: self = .tak
case 8: self = .clientHidden
case 9: self = .lostAndFound
default: self = .UNRECOGNIZED(rawValue)
}
}
@ -274,6 +291,8 @@ struct Config {
case .tracker: return 5
case .sensor: return 6
case .tak: return 7
case .clientHidden: return 8
case .lostAndFound: return 9
case .UNRECOGNIZED(let i): return i
}
}
@ -299,6 +318,11 @@ struct Config {
/// Ignores observed messages from foreign meshes that are open or those which it cannot decrypt.
/// Only rebroadcasts message on the nodes local primary / secondary channels.
case localOnly // = 2
///
/// Ignores observed messages from foreign meshes like LOCAL_ONLY,
/// but takes it step further by also ignoring messages from nodenums not in the node's known list (NodeDB)
case knownOnly // = 3
case UNRECOGNIZED(Int)
init() {
@ -310,6 +334,7 @@ struct Config {
case 0: self = .all
case 1: self = .allSkipDecoding
case 2: self = .localOnly
case 3: self = .knownOnly
default: self = .UNRECOGNIZED(rawValue)
}
}
@ -319,6 +344,7 @@ struct Config {
case .all: return 0
case .allSkipDecoding: return 1
case .localOnly: return 2
case .knownOnly: return 3
case .UNRECOGNIZED(let i): return i
}
}
@ -1295,6 +1321,8 @@ extension Config.DeviceConfig.Role: CaseIterable {
.tracker,
.sensor,
.tak,
.clientHidden,
.lostAndFound,
]
}
@ -1304,6 +1332,7 @@ extension Config.DeviceConfig.RebroadcastMode: CaseIterable {
.all,
.allSkipDecoding,
.localOnly,
.knownOnly,
]
}
@ -1703,6 +1732,8 @@ extension Config.DeviceConfig.Role: SwiftProtobuf._ProtoNameProviding {
5: .same(proto: "TRACKER"),
6: .same(proto: "SENSOR"),
7: .same(proto: "TAK"),
8: .same(proto: "CLIENT_HIDDEN"),
9: .same(proto: "LOST_AND_FOUND"),
]
}
@ -1711,6 +1742,7 @@ extension Config.DeviceConfig.RebroadcastMode: SwiftProtobuf._ProtoNameProviding
0: .same(proto: "ALL"),
1: .same(proto: "ALL_SKIP_DECODING"),
2: .same(proto: "LOCAL_ONLY"),
3: .same(proto: "KNOWN_ONLY"),
]
}

View file

@ -118,6 +118,14 @@ enum HardwareModel: SwiftProtobuf.Enum {
/// RAK11310 (RP2040 + SX1262)
case rak11310 // = 26
///
/// Makerfabs SenseLoRA Receiver (RP2040 + RFM96)
case senseloraRp2040 // = 27
///
/// Makerfabs SenseLoRA Industrial Monitor (ESP32-S3 + RFM96)
case senseloraS3 // = 28
///
/// ---------------------------------------------------------------------------
/// Less common/prototype boards listed here (needs one more byte over the air)
@ -247,6 +255,8 @@ enum HardwareModel: SwiftProtobuf.Enum {
case 19: self = .loraType
case 25: self = .stationG1
case 26: self = .rak11310
case 27: self = .senseloraRp2040
case 28: self = .senseloraS3
case 32: self = .loraRelayV1
case 33: self = .nrf52840Dk
case 34: self = .ppr
@ -299,6 +309,8 @@ enum HardwareModel: SwiftProtobuf.Enum {
case .loraType: return 19
case .stationG1: return 25
case .rak11310: return 26
case .senseloraRp2040: return 27
case .senseloraS3: return 28
case .loraRelayV1: return 32
case .nrf52840Dk: return 33
case .ppr: return 34
@ -356,6 +368,8 @@ extension HardwareModel: CaseIterable {
.loraType,
.stationG1,
.rak11310,
.senseloraRp2040,
.senseloraS3,
.loraRelayV1,
.nrf52840Dk,
.ppr,
@ -935,6 +949,10 @@ struct User {
/// Also, "long_name" should be their licence number.
var isLicensed: Bool = false
///
/// Indicates that the user's role in the mesh
var role: Config.DeviceConfig.Role = .client
var unknownFields = SwiftProtobuf.UnknownStorage()
init() {}
@ -1930,20 +1948,26 @@ struct FromRadio {
///
/// The packet id, used to allow the phone to request missing read packets from the FIFO,
/// see our bluetooth docs
var id: UInt32 = 0
var id: UInt32 {
get {return _storage._id}
set {_uniqueStorage()._id = newValue}
}
///
/// Log levels, chosen to match python logging conventions.
var payloadVariant: FromRadio.OneOf_PayloadVariant? = nil
var payloadVariant: OneOf_PayloadVariant? {
get {return _storage._payloadVariant}
set {_uniqueStorage()._payloadVariant = newValue}
}
///
/// Log levels, chosen to match python logging conventions.
var packet: MeshPacket {
get {
if case .packet(let v)? = payloadVariant {return v}
if case .packet(let v)? = _storage._payloadVariant {return v}
return MeshPacket()
}
set {payloadVariant = .packet(newValue)}
set {_uniqueStorage()._payloadVariant = .packet(newValue)}
}
///
@ -1951,10 +1975,10 @@ struct FromRadio {
/// NOTE: This ID must not change - to keep (minimal) compatibility with <1.2 version of android apps.
var myInfo: MyNodeInfo {
get {
if case .myInfo(let v)? = payloadVariant {return v}
if case .myInfo(let v)? = _storage._payloadVariant {return v}
return MyNodeInfo()
}
set {payloadVariant = .myInfo(newValue)}
set {_uniqueStorage()._payloadVariant = .myInfo(newValue)}
}
///
@ -1962,30 +1986,30 @@ struct FromRadio {
/// starts over with the first node in our DB
var nodeInfo: NodeInfo {
get {
if case .nodeInfo(let v)? = payloadVariant {return v}
if case .nodeInfo(let v)? = _storage._payloadVariant {return v}
return NodeInfo()
}
set {payloadVariant = .nodeInfo(newValue)}
set {_uniqueStorage()._payloadVariant = .nodeInfo(newValue)}
}
///
/// Include a part of the config (was: RadioConfig radio)
var config: Config {
get {
if case .config(let v)? = payloadVariant {return v}
if case .config(let v)? = _storage._payloadVariant {return v}
return Config()
}
set {payloadVariant = .config(newValue)}
set {_uniqueStorage()._payloadVariant = .config(newValue)}
}
///
/// Set to send debug console output over our protobuf stream
var logRecord: LogRecord {
get {
if case .logRecord(let v)? = payloadVariant {return v}
if case .logRecord(let v)? = _storage._payloadVariant {return v}
return LogRecord()
}
set {payloadVariant = .logRecord(newValue)}
set {_uniqueStorage()._payloadVariant = .logRecord(newValue)}
}
///
@ -1995,10 +2019,10 @@ struct FromRadio {
/// NOTE: This ID must not change - to keep (minimal) compatibility with <1.2 version of android apps.
var configCompleteID: UInt32 {
get {
if case .configCompleteID(let v)? = payloadVariant {return v}
if case .configCompleteID(let v)? = _storage._payloadVariant {return v}
return 0
}
set {payloadVariant = .configCompleteID(newValue)}
set {_uniqueStorage()._payloadVariant = .configCompleteID(newValue)}
}
///
@ -2008,70 +2032,70 @@ struct FromRadio {
/// NOTE: This ID must not change - to keep (minimal) compatibility with <1.2 version of android apps.
var rebooted: Bool {
get {
if case .rebooted(let v)? = payloadVariant {return v}
if case .rebooted(let v)? = _storage._payloadVariant {return v}
return false
}
set {payloadVariant = .rebooted(newValue)}
set {_uniqueStorage()._payloadVariant = .rebooted(newValue)}
}
///
/// Include module config
var moduleConfig: ModuleConfig {
get {
if case .moduleConfig(let v)? = payloadVariant {return v}
if case .moduleConfig(let v)? = _storage._payloadVariant {return v}
return ModuleConfig()
}
set {payloadVariant = .moduleConfig(newValue)}
set {_uniqueStorage()._payloadVariant = .moduleConfig(newValue)}
}
///
/// One packet is sent for each channel
var channel: Channel {
get {
if case .channel(let v)? = payloadVariant {return v}
if case .channel(let v)? = _storage._payloadVariant {return v}
return Channel()
}
set {payloadVariant = .channel(newValue)}
set {_uniqueStorage()._payloadVariant = .channel(newValue)}
}
///
/// Queue status info
var queueStatus: QueueStatus {
get {
if case .queueStatus(let v)? = payloadVariant {return v}
if case .queueStatus(let v)? = _storage._payloadVariant {return v}
return QueueStatus()
}
set {payloadVariant = .queueStatus(newValue)}
set {_uniqueStorage()._payloadVariant = .queueStatus(newValue)}
}
///
/// File Transfer Chunk
var xmodemPacket: XModem {
get {
if case .xmodemPacket(let v)? = payloadVariant {return v}
if case .xmodemPacket(let v)? = _storage._payloadVariant {return v}
return XModem()
}
set {payloadVariant = .xmodemPacket(newValue)}
set {_uniqueStorage()._payloadVariant = .xmodemPacket(newValue)}
}
///
/// Device metadata message
var metadata: DeviceMetadata {
get {
if case .metadata(let v)? = payloadVariant {return v}
if case .metadata(let v)? = _storage._payloadVariant {return v}
return DeviceMetadata()
}
set {payloadVariant = .metadata(newValue)}
set {_uniqueStorage()._payloadVariant = .metadata(newValue)}
}
///
/// MQTT Client Proxy Message (device sending to client / phone for publishing to MQTT)
var mqttClientProxyMessage: MqttClientProxyMessage {
get {
if case .mqttClientProxyMessage(let v)? = payloadVariant {return v}
if case .mqttClientProxyMessage(let v)? = _storage._payloadVariant {return v}
return MqttClientProxyMessage()
}
set {payloadVariant = .mqttClientProxyMessage(newValue)}
set {_uniqueStorage()._payloadVariant = .mqttClientProxyMessage(newValue)}
}
var unknownFields = SwiftProtobuf.UnknownStorage()
@ -2192,6 +2216,8 @@ struct FromRadio {
}
init() {}
fileprivate var _storage = _StorageClass.defaultInstance
}
///
@ -2519,6 +2545,8 @@ extension HardwareModel: SwiftProtobuf._ProtoNameProviding {
19: .same(proto: "LORA_TYPE"),
25: .same(proto: "STATION_G1"),
26: .same(proto: "RAK11310"),
27: .same(proto: "SENSELORA_RP2040"),
28: .same(proto: "SENSELORA_S3"),
32: .same(proto: "LORA_RELAY_V1"),
33: .same(proto: "NRF52840DK"),
34: .same(proto: "PPR"),
@ -2830,6 +2858,7 @@ extension User: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase,
4: .same(proto: "macaddr"),
5: .standard(proto: "hw_model"),
6: .standard(proto: "is_licensed"),
7: .same(proto: "role"),
]
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
@ -2844,6 +2873,7 @@ extension User: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase,
case 4: try { try decoder.decodeSingularBytesField(value: &self.macaddr) }()
case 5: try { try decoder.decodeSingularEnumField(value: &self.hwModel) }()
case 6: try { try decoder.decodeSingularBoolField(value: &self.isLicensed) }()
case 7: try { try decoder.decodeSingularEnumField(value: &self.role) }()
default: break
}
}
@ -2868,6 +2898,9 @@ extension User: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase,
if self.isLicensed != false {
try visitor.visitSingularBoolField(value: self.isLicensed, fieldNumber: 6)
}
if self.role != .client {
try visitor.visitSingularEnumField(value: self.role, fieldNumber: 7)
}
try unknownFields.traverse(visitor: &visitor)
}
@ -2878,6 +2911,7 @@ extension User: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase,
if lhs.macaddr != rhs.macaddr {return false}
if lhs.hwModel != rhs.hwModel {return false}
if lhs.isLicensed != rhs.isLicensed {return false}
if lhs.role != rhs.role {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
@ -3687,246 +3721,280 @@ extension FromRadio: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation
14: .same(proto: "mqttClientProxyMessage"),
]
fileprivate class _StorageClass {
var _id: UInt32 = 0
var _payloadVariant: FromRadio.OneOf_PayloadVariant?
static let defaultInstance = _StorageClass()
private init() {}
init(copying source: _StorageClass) {
_id = source._id
_payloadVariant = source._payloadVariant
}
}
fileprivate mutating func _uniqueStorage() -> _StorageClass {
if !isKnownUniquelyReferenced(&_storage) {
_storage = _StorageClass(copying: _storage)
}
return _storage
}
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularUInt32Field(value: &self.id) }()
case 2: try {
var v: MeshPacket?
var hadOneofValue = false
if let current = self.payloadVariant {
hadOneofValue = true
if case .packet(let m) = current {v = m}
_ = _uniqueStorage()
try withExtendedLifetime(_storage) { (_storage: _StorageClass) in
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularUInt32Field(value: &_storage._id) }()
case 2: try {
var v: MeshPacket?
var hadOneofValue = false
if let current = _storage._payloadVariant {
hadOneofValue = true
if case .packet(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
_storage._payloadVariant = .packet(v)
}
}()
case 3: try {
var v: MyNodeInfo?
var hadOneofValue = false
if let current = _storage._payloadVariant {
hadOneofValue = true
if case .myInfo(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
_storage._payloadVariant = .myInfo(v)
}
}()
case 4: try {
var v: NodeInfo?
var hadOneofValue = false
if let current = _storage._payloadVariant {
hadOneofValue = true
if case .nodeInfo(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
_storage._payloadVariant = .nodeInfo(v)
}
}()
case 5: try {
var v: Config?
var hadOneofValue = false
if let current = _storage._payloadVariant {
hadOneofValue = true
if case .config(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
_storage._payloadVariant = .config(v)
}
}()
case 6: try {
var v: LogRecord?
var hadOneofValue = false
if let current = _storage._payloadVariant {
hadOneofValue = true
if case .logRecord(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
_storage._payloadVariant = .logRecord(v)
}
}()
case 7: try {
var v: UInt32?
try decoder.decodeSingularUInt32Field(value: &v)
if let v = v {
if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()}
_storage._payloadVariant = .configCompleteID(v)
}
}()
case 8: try {
var v: Bool?
try decoder.decodeSingularBoolField(value: &v)
if let v = v {
if _storage._payloadVariant != nil {try decoder.handleConflictingOneOf()}
_storage._payloadVariant = .rebooted(v)
}
}()
case 9: try {
var v: ModuleConfig?
var hadOneofValue = false
if let current = _storage._payloadVariant {
hadOneofValue = true
if case .moduleConfig(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
_storage._payloadVariant = .moduleConfig(v)
}
}()
case 10: try {
var v: Channel?
var hadOneofValue = false
if let current = _storage._payloadVariant {
hadOneofValue = true
if case .channel(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
_storage._payloadVariant = .channel(v)
}
}()
case 11: try {
var v: QueueStatus?
var hadOneofValue = false
if let current = _storage._payloadVariant {
hadOneofValue = true
if case .queueStatus(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
_storage._payloadVariant = .queueStatus(v)
}
}()
case 12: try {
var v: XModem?
var hadOneofValue = false
if let current = _storage._payloadVariant {
hadOneofValue = true
if case .xmodemPacket(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
_storage._payloadVariant = .xmodemPacket(v)
}
}()
case 13: try {
var v: DeviceMetadata?
var hadOneofValue = false
if let current = _storage._payloadVariant {
hadOneofValue = true
if case .metadata(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
_storage._payloadVariant = .metadata(v)
}
}()
case 14: try {
var v: MqttClientProxyMessage?
var hadOneofValue = false
if let current = _storage._payloadVariant {
hadOneofValue = true
if case .mqttClientProxyMessage(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
_storage._payloadVariant = .mqttClientProxyMessage(v)
}
}()
default: break
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
self.payloadVariant = .packet(v)
}
}()
case 3: try {
var v: MyNodeInfo?
var hadOneofValue = false
if let current = self.payloadVariant {
hadOneofValue = true
if case .myInfo(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
self.payloadVariant = .myInfo(v)
}
}()
case 4: try {
var v: NodeInfo?
var hadOneofValue = false
if let current = self.payloadVariant {
hadOneofValue = true
if case .nodeInfo(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
self.payloadVariant = .nodeInfo(v)
}
}()
case 5: try {
var v: Config?
var hadOneofValue = false
if let current = self.payloadVariant {
hadOneofValue = true
if case .config(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
self.payloadVariant = .config(v)
}
}()
case 6: try {
var v: LogRecord?
var hadOneofValue = false
if let current = self.payloadVariant {
hadOneofValue = true
if case .logRecord(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
self.payloadVariant = .logRecord(v)
}
}()
case 7: try {
var v: UInt32?
try decoder.decodeSingularUInt32Field(value: &v)
if let v = v {
if self.payloadVariant != nil {try decoder.handleConflictingOneOf()}
self.payloadVariant = .configCompleteID(v)
}
}()
case 8: try {
var v: Bool?
try decoder.decodeSingularBoolField(value: &v)
if let v = v {
if self.payloadVariant != nil {try decoder.handleConflictingOneOf()}
self.payloadVariant = .rebooted(v)
}
}()
case 9: try {
var v: ModuleConfig?
var hadOneofValue = false
if let current = self.payloadVariant {
hadOneofValue = true
if case .moduleConfig(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
self.payloadVariant = .moduleConfig(v)
}
}()
case 10: try {
var v: Channel?
var hadOneofValue = false
if let current = self.payloadVariant {
hadOneofValue = true
if case .channel(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
self.payloadVariant = .channel(v)
}
}()
case 11: try {
var v: QueueStatus?
var hadOneofValue = false
if let current = self.payloadVariant {
hadOneofValue = true
if case .queueStatus(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
self.payloadVariant = .queueStatus(v)
}
}()
case 12: try {
var v: XModem?
var hadOneofValue = false
if let current = self.payloadVariant {
hadOneofValue = true
if case .xmodemPacket(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
self.payloadVariant = .xmodemPacket(v)
}
}()
case 13: try {
var v: DeviceMetadata?
var hadOneofValue = false
if let current = self.payloadVariant {
hadOneofValue = true
if case .metadata(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
self.payloadVariant = .metadata(v)
}
}()
case 14: try {
var v: MqttClientProxyMessage?
var hadOneofValue = false
if let current = self.payloadVariant {
hadOneofValue = true
if case .mqttClientProxyMessage(let m) = current {v = m}
}
try decoder.decodeSingularMessageField(value: &v)
if let v = v {
if hadOneofValue {try decoder.handleConflictingOneOf()}
self.payloadVariant = .mqttClientProxyMessage(v)
}
}()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every if/case branch local when no optimizations
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182
if self.id != 0 {
try visitor.visitSingularUInt32Field(value: self.id, fieldNumber: 1)
}
switch self.payloadVariant {
case .packet?: try {
guard case .packet(let v)? = self.payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 2)
}()
case .myInfo?: try {
guard case .myInfo(let v)? = self.payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 3)
}()
case .nodeInfo?: try {
guard case .nodeInfo(let v)? = self.payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 4)
}()
case .config?: try {
guard case .config(let v)? = self.payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 5)
}()
case .logRecord?: try {
guard case .logRecord(let v)? = self.payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 6)
}()
case .configCompleteID?: try {
guard case .configCompleteID(let v)? = self.payloadVariant else { preconditionFailure() }
try visitor.visitSingularUInt32Field(value: v, fieldNumber: 7)
}()
case .rebooted?: try {
guard case .rebooted(let v)? = self.payloadVariant else { preconditionFailure() }
try visitor.visitSingularBoolField(value: v, fieldNumber: 8)
}()
case .moduleConfig?: try {
guard case .moduleConfig(let v)? = self.payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 9)
}()
case .channel?: try {
guard case .channel(let v)? = self.payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 10)
}()
case .queueStatus?: try {
guard case .queueStatus(let v)? = self.payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 11)
}()
case .xmodemPacket?: try {
guard case .xmodemPacket(let v)? = self.payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 12)
}()
case .metadata?: try {
guard case .metadata(let v)? = self.payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 13)
}()
case .mqttClientProxyMessage?: try {
guard case .mqttClientProxyMessage(let v)? = self.payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 14)
}()
case nil: break
try withExtendedLifetime(_storage) { (_storage: _StorageClass) in
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every if/case branch local when no optimizations
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182
if _storage._id != 0 {
try visitor.visitSingularUInt32Field(value: _storage._id, fieldNumber: 1)
}
switch _storage._payloadVariant {
case .packet?: try {
guard case .packet(let v)? = _storage._payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 2)
}()
case .myInfo?: try {
guard case .myInfo(let v)? = _storage._payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 3)
}()
case .nodeInfo?: try {
guard case .nodeInfo(let v)? = _storage._payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 4)
}()
case .config?: try {
guard case .config(let v)? = _storage._payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 5)
}()
case .logRecord?: try {
guard case .logRecord(let v)? = _storage._payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 6)
}()
case .configCompleteID?: try {
guard case .configCompleteID(let v)? = _storage._payloadVariant else { preconditionFailure() }
try visitor.visitSingularUInt32Field(value: v, fieldNumber: 7)
}()
case .rebooted?: try {
guard case .rebooted(let v)? = _storage._payloadVariant else { preconditionFailure() }
try visitor.visitSingularBoolField(value: v, fieldNumber: 8)
}()
case .moduleConfig?: try {
guard case .moduleConfig(let v)? = _storage._payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 9)
}()
case .channel?: try {
guard case .channel(let v)? = _storage._payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 10)
}()
case .queueStatus?: try {
guard case .queueStatus(let v)? = _storage._payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 11)
}()
case .xmodemPacket?: try {
guard case .xmodemPacket(let v)? = _storage._payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 12)
}()
case .metadata?: try {
guard case .metadata(let v)? = _storage._payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 13)
}()
case .mqttClientProxyMessage?: try {
guard case .mqttClientProxyMessage(let v)? = _storage._payloadVariant else { preconditionFailure() }
try visitor.visitSingularMessageField(value: v, fieldNumber: 14)
}()
case nil: break
}
}
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: FromRadio, rhs: FromRadio) -> Bool {
if lhs.id != rhs.id {return false}
if lhs.payloadVariant != rhs.payloadVariant {return false}
if lhs._storage !== rhs._storage {
let storagesAreEqual: Bool = withExtendedLifetime((lhs._storage, rhs._storage)) { (_args: (_StorageClass, _StorageClass)) in
let _storage = _args.0
let rhs_storage = _args.1
if _storage._id != rhs_storage._id {return false}
if _storage._payloadVariant != rhs_storage._payloadVariant {return false}
return true
}
if !storagesAreEqual {return false}
}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}

View file

@ -60,7 +60,7 @@ class LocalMBTileOverlay: MKTileOverlay {
// make sure it's raster
let formatQuery = try mb.pluck(metadata.select(value).filter(name == "format"))
if formatQuery?[value] == nil || (formatQuery![value] != "jpg" && formatQuery![value] != "png") {
if formatQuery?[value] == nil || (formatQuery![value] != "jpeg" && formatQuery![value] != "jpg" && formatQuery![value] != "png") {
throw MapTileError.invalidFormat
}

View file

@ -33,7 +33,6 @@ struct UserList: View {
@State var node: NodeInfoEntity?
@State private var userSelection: UserEntity? // Nothing selected by default.
@State private var isPresentingDeleteUserMessagesConfirm: Bool = false
@State private var isPresentingTraceRouteSentAlert = false
var body: some View {
let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMdd", options: 0, locale: Locale.current)
@ -126,14 +125,6 @@ struct UserList: View {
} label: {
Label(user.mute ? "Show Alerts" : "Hide Alerts", systemImage: user.mute ? "bell" : "bell.slash")
}
Button {
let success = bleManager.sendTraceRouteRequest(destNum: user.num, wantResponse: true)
if success {
isPresentingTraceRouteSentAlert = true
}
} label: {
Label("Trace Route", systemImage: "signpost.right.and.left")
}
if user.messageList.count > 0 {
Button(role: .destructive) {
isPresentingDeleteUserMessagesConfirm = true
@ -143,14 +134,6 @@ struct UserList: View {
}
}
}
.alert(
"Trace Route Sent",
isPresented: $isPresentingTraceRouteSentAlert
) {
Button("OK", role: .cancel) { }
} message: {
Text("This could take a while, response will appear in the mesh log.")
}
.confirmationDialog(
"This conversation will be deleted.",
isPresented: $isPresentingDeleteUserMessagesConfirm,

View file

@ -15,22 +15,24 @@ struct DetectionSensorLog: View {
@State var isExporting = false
@State var exportString = ""
@ObservedObject var node: NodeInfoEntity
@FetchRequest(sortDescriptors: [NSSortDescriptor(key: "messageTimestamp", ascending: false)],
predicate: NSPredicate(format: "portNum == %d", Int32(PortNum.detectionSensorApp.rawValue)), animation: .none)
private var detections: FetchedResults<MessageEntity>
var body: some View {
let oneDayAgo = Calendar.current.date(byAdding: .day, value: -1, to: Date())
let detections = getDetectionSensorMessages(nodeNum: node.num, context: context)
let chartData = detections
.filter { $0.timestamp >= oneDayAgo! }
.filter { $0.timestamp >= oneDayAgo! && $0.fromUser?.num ?? -1 == node.user?.num ?? 0 }
.sorted { $0.timestamp < $1.timestamp }
VStack {
if chartData.count > 0 {
GroupBox(label: Label("\(detections.count) Total Detection Events", systemImage: "sensor")) {
GroupBox(label: Label("\(chartData.count) Total Detection Events", systemImage: "sensor")) {
Chart {
ForEach(chartData, id: \.self) { point in
Plot {
BarMark(
x: .value("x", point.timestamp),
x: .value("x", point.timestamp, unit: .hour),
y: .value("y", 1)
)
}
@ -49,12 +51,8 @@ struct DetectionSensorLog: View {
}
.chartXAxis(content: {
AxisMarks(position: .top)
// AxisMarks(position: .top, values: .stride(by: .hour)) { date in
// AxisValueLabel(format: .dateTime.hour())
// }
})
.chartXAxis(.automatic)
.chartYScale(domain: 0...20)
.chartForegroundStyleScale([
"Detection events": .green
])
@ -91,9 +89,10 @@ struct DetectionSensorLog: View {
.font(.caption)
.fontWeight(.bold)
}
ForEach(detections) { d in
ForEach(detections.filter( {$0.fromUser?.num ?? -1 == node.user?.num ?? 0})) { d in
GridRow {
Text(d.messagePayload ?? "Detected")
.font(.caption)
Text(d.timestamp.formattedDate(format: dateFormatString))
.font(.caption)
}

View file

@ -50,7 +50,7 @@ struct NodeMapSwiftUI: View {
let positionArray = node.positions?.array as? [PositionEntity] ?? []
var mostRecent = node.positions?.lastObject as? PositionEntity
let lineCoords = positionArray.compactMap({(position) -> CLLocationCoordinate2D in
return position.nodeCoordinate ?? LocationHelper.DefaultLocation
return position.nodeCoordinate ?? LocationsHandler.DefaultLocation
})
if node.hasPositions {

View file

@ -23,8 +23,10 @@ struct PositionAltitudeChart: View {
@State private var lineWidth = 2.0
var body: some View {
let fiveYearsAgo = Calendar.current.date(byAdding: .year, value: -5, to: Date())
let nodePositions = Array(node.positions!) as! [PositionEntity]
let data = nodePositions.map { PositionAltitude(time: $0.time ?? Date(), altitude: Measurement(value: Double($0.altitude), unit: .meters) ) }
let filteredPositions = nodePositions.filter({$0.time != nil && ($0.time ?? fiveYearsAgo!) > fiveYearsAgo!})
let data = filteredPositions.map { PositionAltitude(time: $0.time ?? Date(), altitude: Measurement(value: Double($0.altitude), unit: .meters) ) }
GroupBox(label: Label("Altitude", systemImage: "mountain.2")) {
Chart(data, id: \.time) {

View file

@ -8,7 +8,9 @@
import SwiftUI
import MapKit
@available(iOS 17.0, macOS 14.0, *)
struct PositionPopover: View {
@ObservedObject var locationsHandler = LocationsHandler.shared
@Environment(\.managedObjectContext) var context
@EnvironmentObject var bleManager: BLEManager
@Environment(\.dismiss) private var dismiss
@ -132,8 +134,8 @@ struct PositionPopover: View {
.padding(.bottom, 5)
/// Distance
if LocationHelper.currentLocation.distance(from: LocationHelper.DefaultLocation) > 0.0 {
let metersAway = position.coordinate.distance(from: LocationHelper.currentLocation)
if locationsHandler.lastLocation.distance(from: CLLocation(latitude: LocationsHandler.DefaultLocation.latitude, longitude: LocationsHandler.DefaultLocation.longitude)) > 0.0 {
let metersAway = position.coordinate.distance(from:CLLocationCoordinate2D(latitude: locationsHandler.lastLocation.coordinate.latitude, longitude: locationsHandler.lastLocation.coordinate.longitude))
Label {
Text("distance".localized + ": \(distanceFormatter.string(fromDistance: Double(metersAway)))")
.foregroundColor(.primary)

View file

@ -93,9 +93,21 @@ struct NodeDetail: View {
}
.disabled(!node.hasDetectionSensorMetrics)
Divider()
if #available(iOS 17.0, macOS 14.0, *) {
NavigationLink {
TraceRouteLog(node: node)
} label: {
Image(systemName: "signpost.right.and.left")
.symbolRenderingMode(.hierarchical)
.font(.title)
Text("Trace Route Log")
.font(.title3)
}
.disabled(node.traceRoutes?.count ?? 0 == 0)
Divider()
}
}
if self.bleManager.connectedPeripheral != nil && node.metadata != nil {
HStack {
if node.metadata?.canShutdown ?? false {

View file

@ -55,14 +55,28 @@ struct NodeListItem: View {
if node.positions?.count ?? 0 > 0 && connectedNode != node.num {
HStack {
let lastPostion = node.positions!.reversed()[0] as! PositionEntity
let myCoord = CLLocation(latitude: LocationHelper.currentLocation.latitude, longitude: LocationHelper.currentLocation.longitude)
if lastPostion.nodeCoordinate != nil && myCoord.coordinate.longitude != LocationHelper.DefaultLocation.longitude && myCoord.coordinate.latitude != LocationHelper.DefaultLocation.latitude {
let nodeCoord = CLLocation(latitude: lastPostion.nodeCoordinate!.latitude, longitude: lastPostion.nodeCoordinate!.longitude)
let metersAway = nodeCoord.distance(from: myCoord)
Image(systemName: "lines.measurement.horizontal")
.font(.callout)
.symbolRenderingMode(.hierarchical)
DistanceText(meters: metersAway).font(.callout)
if #available(iOS 17.0, macOS 14.0, *) {
let myCoord = CLLocation(latitude: LocationsHandler.shared.lastLocation.coordinate.latitude, longitude: LocationsHandler.shared.lastLocation.coordinate.longitude)
if lastPostion.nodeCoordinate != nil && myCoord.coordinate.longitude != LocationsHandler.DefaultLocation.longitude && myCoord.coordinate.latitude != LocationsHandler.DefaultLocation.latitude {
let nodeCoord = CLLocation(latitude: lastPostion.nodeCoordinate!.latitude, longitude: lastPostion.nodeCoordinate!.longitude)
let metersAway = nodeCoord.distance(from: myCoord)
Image(systemName: "lines.measurement.horizontal")
.font(.callout)
.symbolRenderingMode(.hierarchical)
DistanceText(meters: metersAway).font(.callout)
}
} else {
let myCoord = CLLocation(latitude: LocationHelper.currentLocation.latitude, longitude: LocationHelper.currentLocation.longitude)
if lastPostion.nodeCoordinate != nil && myCoord.coordinate.longitude != LocationHelper.DefaultLocation.longitude && myCoord.coordinate.latitude != LocationHelper.DefaultLocation.latitude {
let nodeCoord = CLLocation(latitude: lastPostion.nodeCoordinate!.latitude, longitude: lastPostion.nodeCoordinate!.longitude)
let metersAway = nodeCoord.distance(from: myCoord)
Image(systemName: "lines.measurement.horizontal")
.font(.callout)
.symbolRenderingMode(.hierarchical)
DistanceText(meters: metersAway).font(.callout)
}
}
}
}

View file

@ -11,6 +11,7 @@ struct NodeList: View {
@State private var columnVisibility = NavigationSplitViewVisibility.all
@State private var selectedNode: NodeInfoEntity?
@State private var isPresentingTraceRouteSentAlert = false
@SceneStorage("selectedDetailView") var selectedDetailView: String?
@ -72,13 +73,31 @@ struct NodeList: View {
} label: {
Label(node.user!.mute ? "Show Alerts" : "Hide Alerts", systemImage: node.user!.mute ? "bell" : "bell.slash")
}
if connectedNodeNum != node.num {
Button {
let success = bleManager.sendTraceRouteRequest(destNum: node.user?.num ?? 0, wantResponse: true)
if success {
isPresentingTraceRouteSentAlert = true
}
} label: {
Label("Trace Route", systemImage: "signpost.right.and.left")
}
}
}
}
.alert(
"Trace Route Sent",
isPresented: $isPresentingTraceRouteSentAlert
) {
Button("OK", role: .cancel) { }
} message: {
Text("This could take a while, response will appear in the trace route log for the node it was sent to.")
}
}
.searchable(text: nodesQuery, prompt: "Find a node")
.navigationTitle(String.localizedStringWithFormat("nodes %@".localized, String(nodes.count)))
.listStyle(.plain)
.navigationSplitViewColumnWidth(min: 100, ideal: 250, max: 500)
.navigationBarItems(leading:
MeshtasticLogo(),

View file

@ -0,0 +1,161 @@
//
// TraceRouteLog.swift
// Meshtastic
//
// Copyright(c) Garth Vander Houwen 12/7/23.
//
import SwiftUI
#if canImport(MapKit)
import MapKit
#endif
@available(iOS 17.0, macOS 14.0, *)
struct TraceRouteLog: View {
@ObservedObject var locationsHandler = LocationsHandler.shared
@Environment(\.managedObjectContext) var context
@EnvironmentObject var bleManager: BLEManager
@State private var isPresentingClearLogConfirm: Bool = false
@State var isExporting = false
@State var exportString = ""
@ObservedObject var node: NodeInfoEntity
@State private var selectedRoute: TraceRouteEntity?
// Map Configuration
@Namespace var mapScope
@State var mapStyle: MapStyle = MapStyle.standard(elevation: .realistic, emphasis: MapStyle.StandardEmphasis.muted ,pointsOfInterest: .all, showsTraffic: true)
@State var position = MapCameraPosition.automatic
let distanceFormatter = MKDistanceFormatter()
var body: some View {
HStack (alignment: .top) {
VStack {
VStack {
List(node.traceRoutes?.reversed() as? [TraceRouteEntity] ?? [], id: \.self, selection: $selectedRoute) { route in
Label {
Text("\(route.time?.formatted() ?? "unknown".localized) - \(route.response ? (route.hops?.count == 0 && route.response ? "Direct" : "\(route.hops?.count == 0) Hops") : "No Response")")
} icon: {
Image(systemName: route.response ? (route.hops?.count == 0 && route.response ? "person.line.dotted.person" : "point.3.connected.trianglepath.dotted") : "person.slash")
.symbolRenderingMode(.hierarchical)
}
}
.listStyle(.plain)
}
.frame(minHeight: 200, maxHeight: 230)
VStack {
if selectedRoute != nil {
if selectedRoute?.response ?? false && selectedRoute?.hops?.count ?? 0 > 0 {
Text("Received by \(selectedRoute?.node?.user?.longName ?? "unknown".localized)")
Text("Route: \(selectedRoute?.routeText ?? "unknown".localized)")
.font(.title3)
} else if selectedRoute?.response ?? false {
Label {
Text("Trace route received directly by \(selectedRoute?.node?.user?.longName ?? "unknown".localized)")
} icon: {
Image(systemName: "signpost.right.and.left")
.symbolRenderingMode(.hierarchical)
}
.font(.title3)
}
let hopsArray = selectedRoute?.hops?.array as? [TraceRouteHopEntity] ?? []
let lineCoords = hopsArray.compactMap({(hop) -> CLLocationCoordinate2D in
return hop.coordinate ?? LocationHelper.DefaultLocation
})
if selectedRoute?.response ?? false {
if selectedRoute?.coordinate != nil && (selectedRoute?.node?.positions?.count ?? 0 > 0 || false ) {
Map(position: $position, bounds: MapCameraBounds(minimumDistance: 1, maximumDistance: .infinity), scope: mapScope) {
Annotation("You", coordinate: selectedRoute?.coordinate ?? LocationHelper.DefaultLocation) {
ZStack {
Circle()
.fill(Color(.green))
.strokeBorder(.white, lineWidth: 3)
.frame(width: 15, height: 15)
}
}
.annotationTitles(.automatic)
// Direct Trace Route
if selectedRoute?.response ?? false && selectedRoute?.hops?.count ?? 0 == 0 {
if selectedRoute?.node?.positions?.count ?? 0 > 0 {
let mostRecent = selectedRoute?.node?.positions?.lastObject as! PositionEntity
var traceRouteCoords: [CLLocationCoordinate2D] = [selectedRoute?.coordinate ?? LocationsHandler.DefaultLocation, mostRecent.coordinate]
Annotation(selectedRoute?.node?.user?.shortName ?? "???", coordinate: mostRecent.nodeCoordinate ?? LocationHelper.DefaultLocation) {
ZStack {
Circle()
.fill(Color(.black))
.strokeBorder(.white, lineWidth: 3)
.frame(width: 15, height: 15)
}
}
let dashed = StrokeStyle(
lineWidth: 2,
lineCap: .round, lineJoin: .round, dash: [7, 10]
)
MapPolyline(coordinates: traceRouteCoords)
.stroke(.blue, style: dashed)
}
} else if selectedRoute?.hops?.count ?? 0 == 0 {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
VStack {
/// Distance
if selectedRoute?.node?.positions?.count ?? 0 > 0 && selectedRoute?.coordinate != nil {
let mostRecent = selectedRoute?.node?.positions?.lastObject as! PositionEntity
let startPoint = CLLocation(latitude: selectedRoute?.coordinate?.latitude ?? LocationsHandler.DefaultLocation.latitude, longitude: selectedRoute?.coordinate?.longitude ?? LocationsHandler.DefaultLocation.longitude)
if startPoint.distance(from: CLLocation(latitude: LocationsHandler.DefaultLocation.latitude, longitude: LocationsHandler.DefaultLocation.longitude)) > 0.0 {
let metersAway = selectedRoute?.coordinate?.distance(from:CLLocationCoordinate2D(latitude: mostRecent.latitude ?? LocationsHandler.DefaultLocation.latitude, longitude: mostRecent.longitude ?? LocationsHandler.DefaultLocation.longitude))
Label {
Text("distance".localized + ": \(distanceFormatter.string(fromDistance: Double(metersAway ?? 0)))")
.foregroundColor(.primary)
} icon: {
Image(systemName: "lines.measurement.horizontal")
.symbolRenderingMode(.hierarchical)
}
}
}
}
} else {
VStack {
Label {
Text("Trace route sent to \(selectedRoute?.node?.user?.longName ?? "unknown".localized)")
} icon: {
Image(systemName: "signpost.right.and.left")
.symbolRenderingMode(.hierarchical)
}
.font(.title3)
Divider()
Label {
Text("\(selectedRoute?.time?.formatted() ?? "") - No response")
} icon: {
Image(systemName: "person.slash")
.symbolRenderingMode(.hierarchical)
}
.font(.callout)
Spacer()
}
}
} else {
ContentUnavailableView("Select a Trace Route", systemImage: "signpost.right.and.left")
}
}
Spacer()
}
.navigationTitle("Trace Route Log")
}
.navigationBarItems(trailing:
ZStack {
ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?")
})
.onAppear {
if self.bleManager.context == nil {
self.bleManager.context = context
}
}
}
}

View file

@ -0,0 +1,159 @@
//
// AmbientLightingConfig.swift
// Meshtastic
//
// Copyright(c) Garth Vander Houwen 11/26/23
//
import SwiftUI
@available(iOS 17.0, macOS 14.0, *)
struct AmbientLightingConfig: View {
@Environment(\.self) var environment
@Environment(\.managedObjectContext) var context
@EnvironmentObject var bleManager: BLEManager
@Environment(\.dismiss) private var goBack
var node: NodeInfoEntity?
@State private var isPresentingSaveConfirm: Bool = false
@State var hasChanges = false
@State var ledState: Bool = false
@State var current = 10
@State var red = 0
@State var green = 0
@State var blue = 0
@State private var color = Color(red: 51, green: 199, blue: 88) // Color(.sRGB, red: 0.98, green: 0.9, blue: 0.2)
@State private var components: Color.Resolved?
var body: some View {
VStack {
Form {
if node != nil && node?.metadata == nil && node?.num ?? 0 != bleManager.connectedPeripheral?.num ?? 0 {
Text("There has been no response to a request for device metadata over the admin channel for this node.")
.font(.callout)
.foregroundColor(.orange)
} 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?.rtttlConfig == nil {
Text("Ambient Lighting 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.")
.font(.callout)
.foregroundColor(.orange)
} else {
Text("Remote administration for: \(node?.user?.longName ?? "Unknown")")
.font(.title3)
.onAppear {
setAmbientLightingConfigValue()
}
}
} else if node != nil && node?.num ?? 0 == bleManager.connectedPeripheral?.num ?? 0 {
Text("Configuration for: \(node?.user?.longName ?? "Unknown")")
.font(.title3)
} else {
Text("Please connect to a radio to configure settings.")
.font(.callout)
.foregroundColor(.orange)
}
Section(header: Text("options")) {
Toggle(isOn: $ledState) {
Label("LED State", systemImage: ledState ? "lightbulb.led.fill" : "lightbulb.led")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.listRowSeparator(.hidden)
Text("The state of the LED (on/off)")
.font(.caption)
.foregroundStyle(.gray)
HStack {
Image(systemName: "eyedropper")
.foregroundColor(.accentColor)
ColorPicker("Color", selection: $color, supportsOpacity: false)
.padding(5)
}
HStack {
Image(systemName: "directcurrent")
.foregroundColor(.accentColor)
Stepper("Current: \(current)", value: $current, in: 0...31, step: 1)
.padding(5)
}
.onChange(of: color, initial: true) {
components = color.resolve(in: environment)
hasChanges = true
}
}
}
.disabled(self.bleManager.connectedPeripheral == nil || node?.ambientLightingConfig == nil)
Button {
isPresentingSaveConfirm = true
} label: {
Label("save", systemImage: "square.and.arrow.down")
}
.disabled(self.bleManager.connectedPeripheral == nil || !hasChanges)
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding()
.confirmationDialog(
"are.you.sure",
isPresented: $isPresentingSaveConfirm,
titleVisibility: .visible
) {
let nodeName = node?.user?.longName ?? "unknown".localized
let buttonText = String.localizedStringWithFormat("save.config %@".localized, nodeName)
Button(buttonText) {
let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context)
if connectedNode != nil {
var al = ModuleConfig.AmbientLightingConfig()
al.ledState = ledState
al.current = UInt32(current)
if let components {
al.red = UInt32(components.red * 255)
al.green = UInt32(components.green * 255)
al.blue = UInt32(components.blue * 255)
}
let adminMessageId = bleManager.saveAmbientLightingModuleConfig(config: al, 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
// for now just disable the button after a successful save
hasChanges = false
goBack()
}
}
}
}
message: {
Text("config.save.confirm")
}
.navigationTitle("ambient.lighting.config")
.navigationBarItems(trailing:
ZStack {
ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?")
})
.onAppear {
self.bleManager.context = context
setAmbientLightingConfigValue()
// 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)
}
}
}
.onChange(of: ledState) { newLedState in
if node != nil && node!.ambientLightingConfig != nil {
if newLedState != node!.ambientLightingConfig!.ledState { hasChanges = true }
}
}
}
}
func setAmbientLightingConfigValue() {
self.ledState = node?.ambientLightingConfig?.ledState ?? false
self.current = Int(node?.ambientLightingConfig?.current ?? 10)
let red = Double(node?.ambientLightingConfig?.red ?? 255)
let green = Double(node?.ambientLightingConfig?.green ?? 255)
let blue = Double(node?.ambientLightingConfig?.blue ?? 255)
color = Color(red: red / 255.0, green: green / 255.0, blue: blue / 255.0)
self.hasChanges = false
}
}

View file

@ -37,7 +37,6 @@ struct CannedMessagesConfig: View {
@State var messages = ""
var body: some View {
VStack {
Form {
if node != nil && node?.metadata == nil && node?.num ?? 0 != bleManager.connectedPeripheral?.num ?? 0 {
Text("There has been no response to a request for device metadata over the admin channel for this node.")

View file

@ -6,6 +6,20 @@
//
import SwiftUI
enum DetectionSensorRole: String, CaseIterable, Equatable {
case sensor
case client
var description: String {
switch self {
case .sensor:
return "Sensor"
case .client:
return "Client"
}
}
var localized: String { self.rawValue.localized }
}
struct DetectionSensorConfig: View {
@Environment(\.managedObjectContext) var context
@ -14,8 +28,10 @@ struct DetectionSensorConfig: View {
var node: NodeInfoEntity?
@State private var isPresentingSaveConfirm: Bool = false
@State var hasChanges: Bool = false
@AppStorage("detectionSensorRole") private var role: DetectionSensorRole = .sensor
@AppStorage("enableDetectionNotifications") private var detectionNotificationsEnabled = false
/// Module Config Settings
@State var enabled = false
/// DetectionSensorModule will sends a bell character with the messages.
@State var sendBell: Bool = false
@State var name: String = ""
@State var detectionTriggeredHigh: Bool = true
@ -25,99 +41,152 @@ struct DetectionSensorConfig: View {
@State var monitorPin = 0
var body: some View {
Form {
if node != nil && node?.metadata == nil && node?.num ?? 0 != bleManager.connectedPeripheral?.num ?? 0 {
Text("There has been no response to a request for device metadata over the admin channel for this node.")
.font(.callout)
.foregroundColor(.orange)
} 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?.detectionSensorConfig == nil {
Text("Detection Sensor 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.")
VStack {
Form {
if node != nil && node?.metadata == nil && node?.num ?? 0 != bleManager.connectedPeripheral?.num ?? 0 {
Text("There has been no response to a request for device metadata over the admin channel for this node.")
.font(.callout)
.foregroundColor(.orange)
} else {
Text("Remote administration for: \(node?.user?.longName ?? "Unknown")")
} 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?.detectionSensorConfig == nil {
Text("Detection Sensor 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.")
.font(.callout)
.foregroundColor(.orange)
} else {
Text("Remote administration for: \(node?.user?.longName ?? "Unknown")")
.font(.title3)
.onAppear {
setDetectionSensorValues()
}
}
} else if node != nil && node?.num ?? 0 == bleManager.connectedPeripheral?.num ?? 0 {
Text("Configuration for: \(node?.user?.longName ?? "Unknown")")
.font(.title3)
.onAppear {
setDetectionSensorValues()
} else {
Text("Please connect to a radio to configure settings.")
.font(.callout)
.foregroundColor(.orange)
}
Section(header: Text("options")) {
Toggle(isOn: $enabled) {
Label("enabled", systemImage: "dot.radiowaves.right")
Text("Enables the detection sensor module, it needs to be enabled on both the node with the sensor, and any nodes that you want to receive detection sensor text messages or view the detection sensor log and chart.")
.font(.caption)
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.listRowSeparator(.visible)
if enabled {
HStack {
Picker(selection: $role, label: Text("Role")) {
ForEach(DetectionSensorRole.allCases, id: \.self) { r in
Text(r.description)
.tag(r)
}
}
.pickerStyle(SegmentedPickerStyle())
.padding(.top, 5)
.padding(.bottom, 5)
}
}
}
} else if node != nil && node?.num ?? 0 == bleManager.connectedPeripheral?.num ?? 0 {
Text("Configuration for: \(node?.user?.longName ?? "Unknown")")
.font(.title3)
} else {
Text("Please connect to a radio to configure settings.")
.font(.callout)
.foregroundColor(.orange)
}
Section(header: Text("options")) {
Toggle(isOn: $enabled) {
Label("enabled", systemImage: "dot.radiowaves.right")
if enabled && role == .client {
Section(header: Text("Client options")) {
Toggle(isOn: $detectionNotificationsEnabled) {
Label("Enable Notifications", systemImage: "bell.badge")
Text("Detection sensor messages are received as text messages. If you enable notifications you will recieve a notification for each detection message received and a corresponding unread message badge.")
.font(.caption)
}
.listRowSeparator(.visible)
}
}
Toggle(isOn: $sendBell) {
Label("Send Bell", systemImage: "bell")
}
TextField("Friendly name (sent for detection alerts text messages)", text: $name, axis: .vertical)
.foregroundColor(.gray)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: name, perform: { _ in
let totalBytes = name.utf8.count
// Only mess with the value if it is too big
if totalBytes > 20 {
let firstNBytes = Data(name.utf8.prefix(20))
if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) {
// Set the shortName back to the last place where it was the right size
name = maxBytesString
if enabled && role == .sensor {
Section(header: Text("Sensor options")) {
Toggle(isOn: $sendBell) {
Label("Send Bell", systemImage: "bell")
Text("Send ASCII bell with alert message. Useful for triggering external notification on bell.")
.font(.caption)
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.listRowSeparator(.visible)
HStack {
Label("Name", systemImage: "signature")
TextField("Friendly name", text: $name, axis: .vertical)
.foregroundColor(.gray)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: name, perform: { _ in
let totalBytes = name.utf8.count
// Only mess with the value if it is too big
if totalBytes > 20 {
let firstNBytes = Data(name.utf8.prefix(20))
if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) {
// Set the shortName back to the last place where it was the right size
name = maxBytesString
}
}
})
.foregroundColor(.gray)
}
.listRowSeparator(.hidden)
Text("Friendly name used to format message sent to mesh. Example: A name \"Motion\" would result in a message \"Motion detected\"")
.font(.caption)
.foregroundStyle(.gray)
.listRowSeparator(.visible)
.offset(y: -10)
Picker("GPIO Pin to monitor", selection: $monitorPin) {
ForEach(0..<46) {
if $0 == 0 {
Text("unset")
} else {
Text("Pin \($0)")
}
}
}
})
.foregroundColor(.gray)
}
Section(header: Text("Sensor option")) {
Picker("GPIO Pin to monitor", selection: $monitorPin) {
ForEach(0..<46) {
if $0 == 0 {
Text("unset")
} else {
Text("Pin \($0)")
.pickerStyle(DefaultPickerStyle())
Toggle(isOn: $detectionTriggeredHigh) {
Label("Detection trigger High", systemImage: "dial.high")
Text("Whether or not the GPIO pin state detection is triggered on HIGH (1) or LOW (0)")
.font(.caption)
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $usePullup) {
Label("Uses pullup resistor", systemImage: "arrow.up.to.line")
Text(" Whether or not use INPUT_PULLUP mode for GPIO pin. Only applicable if the board uses pull-up resistors on the pin")
.font(.caption)
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
}
Section(header: Text("update.interval")) {
Picker("Minimum time between detection broadcasts", selection: $minimumBroadcastSecs) {
ForEach(UpdateIntervals.allCases) { ui in
Text(ui.description).tag(ui.rawValue)
}
}
.pickerStyle(DefaultPickerStyle())
.listRowSeparator(.hidden)
Text("Mininum time between detection broadcasts. Default is 45 seconds.")
.font(.caption)
.foregroundStyle(.gray)
.listRowSeparator(.visible)
Picker("State Broadcast Interval", selection: $stateBroadcastSecs) {
Text("Never").tag(0)
ForEach(UpdateIntervals.allCases) { ui in
Text(ui.description).tag(ui.rawValue)
}
}
.pickerStyle(DefaultPickerStyle())
.listRowSeparator(.hidden)
Text("How often to send detection sensor state to mesh regardless of detection. Default is Never.")
.font(.caption)
.foregroundStyle(.gray)
}
}
.pickerStyle(DefaultPickerStyle())
Toggle(isOn: $detectionTriggeredHigh) {
Label("Detection trigger High", systemImage: "dial.high")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $usePullup) {
Label("Uses pullup resistor", systemImage: "arrow.up.to.line")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
}
Section(header: Text("update.interval")) {
Picker("Minimum time between detection broadcasts", selection: $minimumBroadcastSecs) {
ForEach(UpdateIntervals.allCases) { ui in
Text(ui.description).tag(ui.rawValue)
}
}
.pickerStyle(DefaultPickerStyle())
Text("Mininum time between detection broadcasts. Default is 45 seconds.")
.font(.caption)
Picker("State Broadcast Interval", selection: $stateBroadcastSecs) {
Text("Never").tag(0)
ForEach(UpdateIntervals.allCases) { ui in
Text(ui.description).tag(ui.rawValue)
}
}
.pickerStyle(DefaultPickerStyle())
Text("How often to send detection sensor state to mesh regardless of detection. Default is Never.")
.font(.caption)
}
}
.scrollDismissesKeyboard(.interactively)
@ -223,6 +292,9 @@ struct DetectionSensorConfig: View {
if newStateBroadcastSecs != node!.detectionSensorConfig!.stateBroadcastSecs { hasChanges = true }
}
}
.onChange(of: detectionNotificationsEnabled) { newDetectionNotificationsEnabled in
UserDefaults.enableDetectionNotifications = newDetectionNotificationsEnabled
}
}
func setDetectionSensorValues() {
self.enabled = (node?.detectionSensorConfig?.enabled ?? false)

View file

@ -32,137 +32,138 @@ struct ExternalNotificationConfig: View {
@State var nagTimeout = 0
var body: some View {
Form {
if node != nil && node?.metadata == nil && node?.num ?? 0 != bleManager.connectedPeripheral?.num ?? 0 {
Text("There has been no response to a request for device metadata over the admin channel for this node.")
.font(.callout)
.foregroundColor(.orange)
} 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?.externalNotificationConfig == nil {
Text("External notification 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.")
VStack {
Form {
if node != nil && node?.metadata == nil && node?.num ?? 0 != bleManager.connectedPeripheral?.num ?? 0 {
Text("There has been no response to a request for device metadata over the admin channel for this node.")
.font(.callout)
.foregroundColor(.orange)
} else {
Text("Remote administration for: \(node?.user?.longName ?? "Unknown")")
} 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?.externalNotificationConfig == nil {
Text("External notification 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.")
.font(.callout)
.foregroundColor(.orange)
} else {
Text("Remote administration for: \(node?.user?.longName ?? "Unknown")")
.font(.title3)
.onAppear {
setExternalNotificationValues()
}
}
} else if node != nil && node?.num ?? 0 == bleManager.connectedPeripheral?.num ?? 0 {
Text("Configuration for: \(node?.user?.longName ?? "Unknown")")
.font(.title3)
.onAppear {
setExternalNotificationValues()
}
} else {
Text("Please connect to a radio to configure settings.")
.font(.callout)
.foregroundColor(.orange)
}
} else if node != nil && node?.num ?? 0 == bleManager.connectedPeripheral?.num ?? 0 {
Text("Configuration for: \(node?.user?.longName ?? "Unknown")")
.font(.title3)
} else {
Text("Please connect to a radio to configure settings.")
.font(.callout)
.foregroundColor(.orange)
}
Section(header: Text("options")) {
Toggle(isOn: $enabled) {
Label("enabled", systemImage: "megaphone")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $alertBell) {
Label("Alert when receiving a bell", systemImage: "bell")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $alertMessage) {
Label("Alert when receiving a message", systemImage: "message")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $usePWM) {
Label("Use PWM Buzzer", systemImage: "light.beacon.max.fill")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Text("Use a PWM output (like the RAK Buzzer) for tunes instead of an on/off output. This will ignore the output, output duration and active settings and use the device config buzzer GPIO option instead.")
.font(.caption)
}
Section(header: Text("Advanced GPIO Options")) {
Section(header: Text("Primary GPIO")
.font(.caption)
.foregroundColor(.gray)
.textCase(.uppercase)) {
Toggle(isOn: $active) {
Label("Active", systemImage: "togglepower")
Section(header: Text("options")) {
Toggle(isOn: $enabled) {
Label("enabled", systemImage: "megaphone")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Text("If enabled, the 'output' Pin will be pulled active high, disabled means active low.")
.font(.caption)
Picker("Output pin GPIO", selection: $output) {
ForEach(0..<46) {
if $0 == 0 {
Text("unset")
} else {
Text("Pin \($0)")
}
}
Toggle(isOn: $alertBell) {
Label("Alert when receiving a bell", systemImage: "bell")
}
.pickerStyle(DefaultPickerStyle())
Picker("GPIO Output Duration", selection: $outputMilliseconds ) {
ForEach(OutputIntervals.allCases) { oi in
Text(oi.description)
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $alertMessage) {
Label("Alert when receiving a message", systemImage: "message")
}
.pickerStyle(DefaultPickerStyle())
Text("When using in GPIO mode, keep the output on for this long. ")
.font(.caption)
Picker("Nag timeout", selection: $nagTimeout ) {
ForEach(OutputIntervals.allCases) { oi in
Text(oi.description)
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $usePWM) {
Label("Use PWM Buzzer", systemImage: "light.beacon.max.fill")
}
.pickerStyle(DefaultPickerStyle())
Text("Specifies how long the monitored GPIO should output.")
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Text("Use a PWM output (like the RAK Buzzer) for tunes instead of an on/off output. This will ignore the output, output duration and active settings and use the device config buzzer GPIO option instead.")
.font(.caption)
}
Section(header: Text("Optional GPIO")
.font(.caption)
.foregroundColor(.gray)
.textCase(.uppercase)) {
Toggle(isOn: $alertBellBuzzer) {
Label("Alert GPIO buzzer when receiving a bell", systemImage: "bell")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $alertBellVibra) {
Label("Alert GPIO vibra motor when receiving a bell", systemImage: "bell")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $alertMessageBuzzer) {
Label("Alert GPIO buzzer when receiving a message", systemImage: "message")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $alertMessageBuzzer) {
Label("Alert GPIO vibra motor when receiving a message", systemImage: "message")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Picker("Output pin buzzer GPIO ", selection: $outputBuzzer) {
ForEach(0..<46) {
if $0 == 0 {
Text("unset")
} else {
Text("Pin \($0)")
Section(header: Text("Advanced GPIO Options")) {
Section(header: Text("Primary GPIO")
.font(.caption)
.foregroundColor(.gray)
.textCase(.uppercase)) {
Toggle(isOn: $active) {
Label("Active", systemImage: "togglepower")
}
}
}
.pickerStyle(DefaultPickerStyle())
Picker("Output pin vibra GPIO", selection: $outputVibra) {
ForEach(0..<46) {
if $0 == 0 {
Text("unset")
} else {
Text("Pin \($0)")
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Text("If enabled, the 'output' Pin will be pulled active high, disabled means active low.")
.font(.caption)
Picker("Output pin GPIO", selection: $output) {
ForEach(0..<46) {
if $0 == 0 {
Text("unset")
} else {
Text("Pin \($0)")
}
}
}
.pickerStyle(DefaultPickerStyle())
Picker("GPIO Output Duration", selection: $outputMilliseconds ) {
ForEach(OutputIntervals.allCases) { oi in
Text(oi.description)
}
}
.pickerStyle(DefaultPickerStyle())
Text("When using in GPIO mode, keep the output on for this long. ")
.font(.caption)
Picker("Nag timeout", selection: $nagTimeout ) {
ForEach(OutputIntervals.allCases) { oi in
Text(oi.description)
}
}
.pickerStyle(DefaultPickerStyle())
Text("Specifies how long the monitored GPIO should output.")
.font(.caption)
}
Section(header: Text("Optional GPIO")
.font(.caption)
.foregroundColor(.gray)
.textCase(.uppercase)) {
Toggle(isOn: $alertBellBuzzer) {
Label("Alert GPIO buzzer when receiving a bell", systemImage: "bell")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $alertBellVibra) {
Label("Alert GPIO vibra motor when receiving a bell", systemImage: "bell")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $alertMessageBuzzer) {
Label("Alert GPIO buzzer when receiving a message", systemImage: "message")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $alertMessageBuzzer) {
Label("Alert GPIO vibra motor when receiving a message", systemImage: "message")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Picker("Output pin buzzer GPIO ", selection: $outputBuzzer) {
ForEach(0..<46) {
if $0 == 0 {
Text("unset")
} else {
Text("Pin \($0)")
}
}
}
.pickerStyle(DefaultPickerStyle())
Picker("Output pin vibra GPIO", selection: $outputVibra) {
ForEach(0..<46) {
if $0 == 0 {
Text("unset")
} else {
Text("Pin \($0)")
}
}
}
.pickerStyle(DefaultPickerStyle())
}
}
.pickerStyle(DefaultPickerStyle())
}
}
.disabled(self.bleManager.connectedPeripheral == nil || node?.externalNotificationConfig == nil)
}
.disabled(self.bleManager.connectedPeripheral == nil || node?.externalNotificationConfig == nil)
Button {
isPresentingSaveConfirm = true
} label: {

View file

@ -25,176 +25,177 @@ struct MQTTConfig: View {
@State var root = "msh"
var body: some View {
Form {
if node != nil && node?.metadata == nil && node?.num ?? 0 != bleManager.connectedPeripheral?.num ?? 0 {
Text("There has been no response to a request for device metadata over the admin channel for this node.")
.font(.callout)
.foregroundColor(.orange)
} 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?.mqttConfig == nil {
Text("MQTT 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.")
VStack {
Form {
if node != nil && node?.metadata == nil && node?.num ?? 0 != bleManager.connectedPeripheral?.num ?? 0 {
Text("There has been no response to a request for device metadata over the admin channel for this node.")
.font(.callout)
.foregroundColor(.orange)
} else {
Text("Remote administration for: \(node?.user?.longName ?? "Unknown")")
} 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?.mqttConfig == nil {
Text("MQTT 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.")
.font(.callout)
.foregroundColor(.orange)
} else {
Text("Remote administration for: \(node?.user?.longName ?? "Unknown")")
.font(.title3)
.onAppear {
setMqttValues()
}
}
} else if node != nil && node?.num ?? 0 == bleManager.connectedPeripheral?.num ?? 0 {
Text("Configuration for: \(node?.user?.longName ?? "Unknown")")
.font(.title3)
.onAppear {
setMqttValues()
}
} else {
Text("Please connect to a radio to configure settings.")
.font(.callout)
.foregroundColor(.orange)
}
} else if node != nil && node?.num ?? 0 == bleManager.connectedPeripheral?.num ?? 0 {
Text("Configuration for: \(node?.user?.longName ?? "Unknown")")
.font(.title3)
} else {
Text("Please connect to a radio to configure settings.")
Section(header: Text("options")) {
Toggle(isOn: $enabled) {
Label("enabled", systemImage: "dot.radiowaves.right")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $proxyToClientEnabled) {
Label("mqtt.clientproxy", systemImage: "iphone.radiowaves.left.and.right")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Text("If both MQTT and the client proxy are enabled your mobile device will utalize an available network connection to connect to the specified MQTT server.")
.font(.caption2)
Toggle(isOn: $encryptionEnabled) {
Label("Encryption Enabled", systemImage: "lock.icloud")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $jsonEnabled) {
Label("JSON Enabled", systemImage: "ellipsis.curlybraces")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Text("JSON mode is a limited, unencrypted MQTT output.")
.font(.caption2)
Toggle(isOn: $tlsEnabled) {
Label("TLS Enabled", systemImage: "checkmark.shield.fill")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Text("Your MQTT Server must support TLS.")
.font(.caption2)
}
Section(header: Text("Custom Server")) {
HStack {
Label("Address", systemImage: "server.rack")
TextField("Server Address", text: $address)
.foregroundColor(.gray)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: address, perform: { _ in
let totalBytes = address.utf8.count
// Only mess with the value if it is too big
if totalBytes > 62 {
let firstNBytes = Data(username.utf8.prefix(62))
if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) {
// Set the shortName back to the last place where it was the right size
address = maxBytesString
}
}
hasChanges = true
})
.foregroundColor(.gray)
.keyboardType(.default)
}
.autocorrectionDisabled()
HStack {
Label("mqtt.username", systemImage: "person.text.rectangle")
TextField("mqtt.username", text: $username)
.foregroundColor(.gray)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: username, perform: { _ in
let totalBytes = username.utf8.count
// Only mess with the value if it is too big
if totalBytes > 62 {
let firstNBytes = Data(username.utf8.prefix(62))
if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) {
// Set the shortName back to the last place where it was the right size
username = maxBytesString
}
}
hasChanges = true
})
.foregroundColor(.gray)
}
.keyboardType(.default)
.scrollDismissesKeyboard(.interactively)
HStack {
Label("password", systemImage: "wallet.pass")
TextField("password", text: $password)
.foregroundColor(.gray)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: password, perform: { _ in
let totalBytes = password.utf8.count
// Only mess with the value if it is too big
if totalBytes > 62 {
let firstNBytes = Data(password.utf8.prefix(62))
if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) {
// Set the shortName back to the last place where it was the right size
password = maxBytesString
}
}
hasChanges = true
})
.foregroundColor(.gray)
}
.keyboardType(.default)
.scrollDismissesKeyboard(.interactively)
HStack {
Label("Root Topic", systemImage: "tree")
TextField("Root Topic", text: $root)
.foregroundColor(.gray)
.onChange(of: root, perform: { _ in
let totalBytes = root.utf8.count
// Only mess with the value if it is too big
if totalBytes > 14 {
let firstNBytes = Data(root.utf8.prefix(14))
if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) {
// Set the shortName back to the last place where it was the right size
root = maxBytesString
}
}
})
.foregroundColor(.gray)
}
.keyboardType(.asciiCapable)
.scrollDismissesKeyboard(.interactively)
.disableAutocorrection(true)
Text("The root topic to use for MQTT messages. Default is \"msh\". This is useful if you want to use a single MQTT server for multiple meshtastic networks and separate them via ACLs")
.font(.caption2)
}
Text("You can set uplink and downlink for each channel.")
.font(.callout)
.foregroundColor(.orange)
}
Section(header: Text("options")) {
Toggle(isOn: $enabled) {
Label("enabled", systemImage: "dot.radiowaves.right")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $proxyToClientEnabled) {
Label("mqtt.clientproxy", systemImage: "iphone.radiowaves.left.and.right")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Text("If both MQTT and the client proxy are enabled your mobile device will utalize an available network connection to connect to the specified MQTT server.")
.font(.caption2)
Toggle(isOn: $encryptionEnabled) {
Label("Encryption Enabled", systemImage: "lock.icloud")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Toggle(isOn: $jsonEnabled) {
Label("JSON Enabled", systemImage: "ellipsis.curlybraces")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Text("JSON mode is a limited, unencrypted MQTT output.")
.font(.caption2)
Toggle(isOn: $tlsEnabled) {
Label("TLS Enabled", systemImage: "checkmark.shield.fill")
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
Text("Your MQTT Server must support TLS.")
.font(.caption2)
}
Section(header: Text("Custom Server")) {
HStack {
Label("Address", systemImage: "server.rack")
TextField("Server Address", text: $address)
.foregroundColor(.gray)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: address, perform: { _ in
let totalBytes = address.utf8.count
// Only mess with the value if it is too big
if totalBytes > 62 {
let firstNBytes = Data(username.utf8.prefix(62))
if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) {
// Set the shortName back to the last place where it was the right size
address = maxBytesString
}
}
hasChanges = true
})
.foregroundColor(.gray)
.keyboardType(.default)
}
.autocorrectionDisabled()
HStack {
Label("mqtt.username", systemImage: "person.text.rectangle")
TextField("mqtt.username", text: $username)
.foregroundColor(.gray)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: username, perform: { _ in
let totalBytes = username.utf8.count
// Only mess with the value if it is too big
if totalBytes > 62 {
let firstNBytes = Data(username.utf8.prefix(62))
if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) {
// Set the shortName back to the last place where it was the right size
username = maxBytesString
}
}
hasChanges = true
})
.foregroundColor(.gray)
}
.keyboardType(.default)
.scrollDismissesKeyboard(.interactively)
HStack {
Label("password", systemImage: "wallet.pass")
TextField("password", text: $password)
.foregroundColor(.gray)
.autocapitalization(.none)
.disableAutocorrection(true)
.onChange(of: password, perform: { _ in
let totalBytes = password.utf8.count
// Only mess with the value if it is too big
if totalBytes > 62 {
let firstNBytes = Data(password.utf8.prefix(62))
if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) {
// Set the shortName back to the last place where it was the right size
password = maxBytesString
}
}
hasChanges = true
})
.foregroundColor(.gray)
}
.keyboardType(.default)
.scrollDismissesKeyboard(.interactively)
HStack {
Label("Root Topic", systemImage: "tree")
TextField("Root Topic", text: $root)
.foregroundColor(.gray)
.onChange(of: root, perform: { _ in
let totalBytes = root.utf8.count
// Only mess with the value if it is too big
if totalBytes > 14 {
let firstNBytes = Data(root.utf8.prefix(14))
if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) {
// Set the shortName back to the last place where it was the right size
root = maxBytesString
}
}
})
.foregroundColor(.gray)
}
.keyboardType(.asciiCapable)
.scrollDismissesKeyboard(.interactively)
.disableAutocorrection(true)
Text("The root topic to use for MQTT messages. Default is \"msh\". This is useful if you want to use a single MQTT server for multiple meshtastic networks and separate them via ACLs")
.font(.caption2)
}
Text("You can set uplink and downlink for each channel.")
.font(.callout)
.scrollDismissesKeyboard(.interactively)
.disabled(self.bleManager.connectedPeripheral == nil || node?.mqttConfig == nil)
}
.scrollDismissesKeyboard(.interactively)
.disabled(self.bleManager.connectedPeripheral == nil || node?.mqttConfig == nil)
Button {
isPresentingSaveConfirm = true
} label: {

View file

@ -26,9 +26,7 @@ struct SerialConfig: View {
@State var mode = 0
var body: some View {
VStack {
Form {
if node != nil && node?.metadata == nil && node?.num ?? 0 != bleManager.connectedPeripheral?.num ?? 0 {
Text("There has been no response to a request for device metadata over the admin channel for this node.")

View file

@ -27,72 +27,72 @@ struct StoreForwardConfig: View {
@State var historyReturnWindow = 0
var body: some View {
Form {
if node != nil && node?.metadata == nil && node?.num ?? 0 != bleManager.connectedPeripheral?.num ?? 0 {
Text("There has been no response to a request for device metadata over the admin channel for this node.")
.font(.callout)
.foregroundColor(.orange)
} 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?.storeForwardConfig == nil {
Text("Store and forward 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.")
VStack {
Form {
if node != nil && node?.metadata == nil && node?.num ?? 0 != bleManager.connectedPeripheral?.num ?? 0 {
Text("There has been no response to a request for device metadata over the admin channel for this node.")
.font(.callout)
.foregroundColor(.orange)
} else {
Text("Remote administration for: \(node?.user?.longName ?? "Unknown")")
} 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?.storeForwardConfig == nil {
Text("Store and forward 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.")
.font(.callout)
.foregroundColor(.orange)
} else {
Text("Remote administration for: \(node?.user?.longName ?? "Unknown")")
.font(.title3)
.onAppear {
setDetectionSensorValues()
}
}
} else if node != nil && node?.num ?? 0 == bleManager.connectedPeripheral?.num ?? 0 {
Text("Configuration for: \(node?.user?.longName ?? "Unknown")")
.font(.title3)
.onAppear {
setDetectionSensorValues()
}
} else {
Text("Please connect to a radio to configure settings.")
.font(.callout)
.foregroundColor(.orange)
}
Section(header: Text("options")) {
Toggle(isOn: $enabled) {
Label("enabled", systemImage: "envelope.arrow.triangle.branch")
}
Toggle(isOn: $heartbeat) {
Label("storeforward.heartbeat", systemImage: "waveform.path.ecg")
}
Picker("Number of records", selection: $records) {
Text("unset").tag(0)
Text("25").tag(25)
Text("50").tag(50)
Text("75").tag(75)
Text("100").tag(100)
}
.pickerStyle(DefaultPickerStyle())
Picker("History Return Max", selection: $historyReturnMax ) {
Text("unset").tag(0)
Text("25").tag(25)
Text("50").tag(50)
Text("75").tag(75)
Text("100").tag(100)
}
.pickerStyle(DefaultPickerStyle())
Picker("History Return Window", selection: $historyReturnWindow ) {
Text("unset").tag(0)
Text("One Minute").tag(60)
Text("Five Minutes").tag(300)
Text("Ten Minutes").tag(600)
Text("Fifteen Minutes").tag(900)
Text("Thirty Minutes").tag(1800)
Text("One Hour").tag(3600)
}
.pickerStyle(DefaultPickerStyle())
}
} else if node != nil && node?.num ?? 0 == bleManager.connectedPeripheral?.num ?? 0 {
Text("Configuration for: \(node?.user?.longName ?? "Unknown")")
.font(.title3)
} else {
Text("Please connect to a radio to configure settings.")
.font(.callout)
.foregroundColor(.orange)
}
Section(header: Text("options")) {
Toggle(isOn: $enabled) {
Label("enabled", systemImage: "envelope.arrow.triangle.branch")
}
Toggle(isOn: $heartbeat) {
Label("storeforward.heartbeat", systemImage: "waveform.path.ecg")
}
Picker("Number of records", selection: $records) {
Text("unset").tag(0)
Text("25").tag(25)
Text("50").tag(50)
Text("75").tag(75)
Text("100").tag(100)
}
.pickerStyle(DefaultPickerStyle())
Picker("History Return Max", selection: $historyReturnMax ) {
Text("unset").tag(0)
Text("25").tag(25)
Text("50").tag(50)
Text("75").tag(75)
Text("100").tag(100)
}
.pickerStyle(DefaultPickerStyle())
Picker("History Return Window", selection: $historyReturnWindow ) {
Text("unset").tag(0)
Text("One Minute").tag(60)
Text("Five Minutes").tag(300)
Text("Ten Minutes").tag(600)
Text("Fifteen Minutes").tag(900)
Text("Thirty Minutes").tag(1800)
Text("One Hour").tag(3600)
}
.pickerStyle(DefaultPickerStyle())
}
.scrollDismissesKeyboard(.interactively)
.disabled(self.bleManager.connectedPeripheral == nil || node?.storeForwardConfig == nil)
}
.scrollDismissesKeyboard(.interactively)
.disabled(self.bleManager.connectedPeripheral == nil || node?.storeForwardConfig == nil)
Button {
isPresentingSaveConfirm = true
} label: {

View file

@ -23,7 +23,6 @@ struct TelemetryConfig: View {
@State var environmentDisplayFahrenheit = false
var body: some View {
VStack {
Form {
if node != nil && node?.metadata == nil && node?.num ?? 0 != bleManager.connectedPeripheral?.num ?? 0 {

View file

@ -39,8 +39,6 @@ struct PositionConfig: View {
@State var rxGpio = 0
@State var txGpio = 0
@State var fixedPosition = false
@State var gpsUpdateInterval = 0
@State var gpsAttemptTime = 0
@State var positionBroadcastSeconds = 0
@State var broadcastSmartMinimumDistance = 0
@State var broadcastSmartMinimumIntervalSecs = 0
@ -212,19 +210,7 @@ struct PositionConfig: View {
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
if deviceGpsEnabled {
Picker("Update Interval", selection: $gpsUpdateInterval) {
ForEach(GpsUpdateIntervals.allCases) { ui in
Text(ui.description)
}
}
Text("How often should we try to get a GPS position.")
.font(.caption)
Picker("Attempt Time", selection: $gpsAttemptTime) {
ForEach(GpsAttemptTimes.allCases) { at in
Text(at.description)
}
}
.pickerStyle(DefaultPickerStyle())
Picker("GPS Receive GPIO", selection: $rxGpio) {
ForEach(0..<46) {
if $0 == 0 {
@ -286,8 +272,6 @@ struct PositionConfig: View {
pc.positionBroadcastSmartEnabled = smartPositionEnabled
pc.gpsEnabled = deviceGpsEnabled
pc.fixedPosition = fixedPosition
pc.gpsUpdateInterval = UInt32(gpsUpdateInterval)
pc.gpsAttemptTime = UInt32(gpsAttemptTime)
pc.positionBroadcastSecs = UInt32(positionBroadcastSeconds)
pc.broadcastSmartMinimumIntervalSecs = UInt32(broadcastSmartMinimumIntervalSecs)
pc.broadcastSmartMinimumDistance = UInt32(broadcastSmartMinimumDistance)
@ -354,16 +338,6 @@ struct PositionConfig: View {
if newTxGpio != node!.positionConfig!.txGpio { hasChanges = true }
}
}
.onChange(of: gpsAttemptTime) { newGpsAttemptTime in
if node != nil && node!.positionConfig != nil {
if newGpsAttemptTime != node!.positionConfig!.gpsAttemptTime { hasChanges = true }
}
}
.onChange(of: gpsUpdateInterval) { newGpsUpdateInterval in
if node != nil && node!.positionConfig != nil {
if newGpsUpdateInterval != node!.positionConfig!.gpsUpdateInterval { hasChanges = true }
}
}
.onChange(of: smartPositionEnabled) { newSmartPositionEnabled in
if node != nil && node!.positionConfig != nil {
if newSmartPositionEnabled != node!.positionConfig!.smartPositionEnabled { hasChanges = true }
@ -451,8 +425,6 @@ struct PositionConfig: View {
self.rxGpio = Int(node?.positionConfig?.rxGpio ?? 0)
self.txGpio = Int(node?.positionConfig?.txGpio ?? 0)
self.fixedPosition = node?.positionConfig?.fixedPosition ?? false
self.gpsUpdateInterval = Int(node?.positionConfig?.gpsUpdateInterval ?? 30)
self.gpsAttemptTime = Int(node?.positionConfig?.gpsAttemptTime ?? 30)
self.positionBroadcastSeconds = Int(node?.positionConfig?.positionBroadcastSeconds ?? 900)
self.broadcastSmartMinimumIntervalSecs = Int(node?.positionConfig?.broadcastSmartMinimumIntervalSecs ?? 30)
self.broadcastSmartMinimumDistance = Int(node?.positionConfig?.broadcastSmartMinimumDistance ?? 50)

View file

@ -0,0 +1,169 @@
//
// Routes.swift
// Meshtastic
//
// Created by Garth Vander Houwen on 11/21/23.
//
import SwiftUI
import CoreData
import MapKit
import CoreLocation
import CoreMotion
struct TimerDisplayObject {
var seconds: Int = 0
var minutes: Int = 0
var hours: Int = 0
var display: String {
if self.seconds == 0 {
"\(String(format: "%02d", self.hours)):\(String(format: "%02d", self.minutes)):00"
} else {
"\(String(format: "%02d", self.hours)):\(String(format: "%02d", self.minutes)):\(String(format: "%02d", self.seconds))"
}
}
var timeMinuteCalculator: Float { Float(hours*60+seconds/60+minutes) }
}
@available(iOS 17.0, macOS 14.0, *)
struct RouteRecorder: View {
@ObservedObject var locationsHandler = LocationsHandler.shared
@Environment(\.managedObjectContext) var context
@State private var position: MapCameraPosition = .userLocation(followsHeading: true, fallback: .automatic)
@State var isTimerRunning = false
@State var isShowingDetails = false
@State var timer: Timer?
@Namespace var namespace
@Namespace var routerecorderscope
@State var timeElapsed: TimerDisplayObject = TimerDisplayObject()
@State var timerDisplay = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack {
VStack {
VStack {
Map(position: $position, scope: routerecorderscope) {
UserAnnotation()
// ForEach(locations, id: \.id) { location in
// Marker(location.name, systemImage: location.icon, coordinate: location.location)
// .tint(location.colour)
// }
}
}
.mapScope(routerecorderscope)
.mapControls {
MapUserLocationButton()
MapCompass()
MapScaleView()
MapPitchToggle()
}
.mapStyle(.hybrid(elevation: .realistic, showsTraffic: true))
.transition(.slide)
.mapControlVisibility(.visible)
.safeAreaInset(edge: .bottom) {
ZStack {
VStack {
HStack(spacing: 10) {
Spacer()
if isTimerRunning {
Button {
isShowingDetails = true
isTimerRunning = false
} label: {
Image(systemName: "pause.fill")
.frame(width: 60, height: 60)
}
.buttonStyle(.bordered)
.buttonBorderShape(.circle)
.matchedGeometryEffect(id: "Pause Button", in: namespace)
} else {
Button {
isShowingDetails = true
isTimerRunning = true
timeElapsed.seconds -= 1
} label: {
Image(systemName: "play.fill")
.frame(width: 60, height: 60)
}
.buttonStyle(.bordered)
.buttonBorderShape(.circle)
.matchedGeometryEffect(id: "Play Button", in: namespace)
}
Spacer()
}
}
.onReceive(timerDisplay) { _ in
if isTimerRunning {
timeElapsed.seconds += 1
if timeElapsed.seconds == 60 {
timeElapsed.seconds = 0
timeElapsed.minutes += 1
if timeElapsed.minutes == 60 {
timeElapsed.minutes = 0
timeElapsed.hours += 1
}
}
}
}
}
.padding()
}
.sheet(isPresented: $isShowingDetails) {
NavigationStack {
VStack {
HStack {
Text(timeElapsed.display)
.font(.largeTitle)
Text("Time Elapseed")
.font(.callout)
}
.padding()
Divider()
VStack(alignment: .leading) {
let horizontalAccuracy = Measurement(value: locationsHandler.lastLocation.horizontalAccuracy, unit: UnitLength.meters)
let verticalAccuracy = Measurement(value: locationsHandler.lastLocation.verticalAccuracy, unit: UnitLength.meters)
let altitiude = Measurement(value: locationsHandler.lastLocation.altitude, unit: UnitLength.meters)
let speed = Measurement(value: locationsHandler.lastLocation.speed, unit: UnitSpeed.kilometersPerHour)
List {
Label("Coordinate \(String(format: "%.5f", locationsHandler.lastLocation.coordinate.latitude)), \(String(format: "%.5f", locationsHandler.lastLocation.coordinate.longitude))", systemImage: "mappin")
.textSelection(.enabled)
Label("Horizontal Accuracy \(horizontalAccuracy.formatted())", systemImage: "scope")
if locationsHandler.lastLocation.verticalAccuracy > 0 {
Label("Altitude \(altitiude.formatted())", systemImage: "mountain.2")
}
Label("Vertical Accuracy \(verticalAccuracy.formatted())", systemImage: "lines.measurement.vertical")
Label("Satellites Estimate \(LocationHelper.satsInView)", systemImage: "sparkles")
Label("\(locationsHandler.isStationary ? "Moving" : "Stationary")", systemImage: locationsHandler.isStationary ? "figure.walk.motion" : "figure.stand")
if locationsHandler.lastLocation.speedAccuracy > 0 {
Label("Speed \(speed.formatted())", systemImage: "speedometer")
}
if locationsHandler.lastLocation.courseAccuracy > 0 {
/// Heading
let degrees = Angle.degrees(Double(locationsHandler.lastLocation.course))
Label {
let heading = Measurement(value: degrees.degrees, unit: UnitAngle.degrees)
/// Text("Heading: \(heading.formatted())")
Text("Heading \(String(format: "%.2f", locationsHandler.lastLocation.course))°")
.foregroundColor(.primary)
} icon: {
Image(systemName: "location.circle")
.symbolRenderingMode(.hierarchical)
.frame(width: 35)
.rotationEffect(degrees)
}
}
}
.listStyle(.plain)
}
}
}
.presentationDetents([.fraction(0.6)])
.presentationDragIndicator(.visible)
}
}
}
}
}

View file

@ -23,8 +23,7 @@ struct Routes: View {
var routes: FetchedResults<RouteEntity>
var body: some View {
//NavigationSplitView(columnVisibility: $columnVisibility) {
NavigationStack {
VStack {
Button("Import Route") {
importing = true
}
@ -152,8 +151,6 @@ struct Routes: View {
.listStyle(.plain)
}
.navigationTitle("Route List")
// } detail: {
VStack {
if selectedRoute != nil {
let locationArray = selectedRoute?.locations?.array as? [LocationEntity] ?? []

View file

@ -18,6 +18,7 @@ struct Settings: View {
enum SettingsSidebar {
case appSettings
case routes
case routeRecorder
case shareChannels
case userConfig
case loraConfig
@ -27,6 +28,7 @@ struct Settings: View {
case displayConfig
case networkConfig
case positionConfig
case ambientLightingConfig
case cannedMessagesConfig
case detectionSensorConfig
case externalNotificationConfig
@ -67,6 +69,15 @@ struct Settings: View {
Text("routes")
}
.tag(SettingsSidebar.routes)
// NavigationLink {
// RouteRecorder()
// } label: {
// Image(systemName: "record.circle")
// .symbolRenderingMode(.hierarchical)
// Text("route.recorder")
// }
// .tag(SettingsSidebar.routeRecorder)
}
let node = nodes.first(where: { $0.num == preferredNodeNum })
@ -187,6 +198,16 @@ struct Settings: View {
.tag(SettingsSidebar.positionConfig)
}
Section("module.configuration") {
if #available(iOS 17.0, macOS 14.0, *) {
NavigationLink {
AmbientLightingConfig(node: nodes.first(where: { $0.num == selectedNode }))
} label: {
Image(systemName: "light.max")
.symbolRenderingMode(.hierarchical)
Text("ambient.lighting")
}
.tag(SettingsSidebar.ambientLightingConfig)
}
NavigationLink {
CannedMessagesConfig(node: nodes.first(where: { $0.num == selectedNode }))
} label: {

View file

@ -12,6 +12,8 @@
"ago"="her";
"airtime"="Airtime";
"always.on"="Immer an";
"ambient.lighting"="Ambient Lighting";
"ambient.lighting.config"="Ambient Lighting Config";
"app.settings"="App Einstellungen";
"are.you.sure"="Bist Du sicher?";
"ascii.capable"="ASCII fähig";
@ -64,6 +66,8 @@
"device.metrics.log"="Device Metrics Log";
"device.role.client"="Client (Standard) - Mit App verbundener Client.";
"device.role.clientmute"="Client Leise - Das selbe wie Client, außer das die Pakete nicht über diesen Node weitergeleitet werden. Nimmt nicht am Mesh-Routing teil.";
"device.role.lostandfound"="Used to automatically send a text message to the mesh with the current position of the device on a frequent interval: \"I'm lost! Position: lat / long\"";
"device.role.clienthidden"=" Used for nodes that \"only speak when spoken to\" Turns all of the routine broadcasts but allows for ad-hoc communication. Still rebroadcasts, but with local only rebroadcast mode (known meshes only). Can be used for private operation or to dramatically reduce airtime / power consumption.";
"device.role.router"="Router - Mesh Pakete werden bevorzugt über diesen Node gerouted. Dieser Node wird nicht von einer Client App benutzt. WLAN, Bluetooth und Display sind aus.";
"device.role.routerclient"="Router Client - Mesh Pakete werden bevorzugt über diesen Node gerouted. Der Router Client kann parallel auch von einer Client-App genutzt werden.";
"device.role.repeater"="Repeater - Mesh packets will prefer to be routed over this node. This role eliminates unnecessary overhead such as NodeInfo, DeviceTelemetry, and any other mesh packet, resulting in the device not appearing as part of the network. Please see Rebroadcast Mode for additional settings specific to this role.";
@ -149,6 +153,7 @@
"map.usertrackingmode.followwithheading"="Follow with heading";
"mesh.live.activity"="Mesh Live Activity";
"mesh.log"="Mesh Log";
"mesh.log.ambientlighting.config %@"="Ambient Lighting module config received: %@";
"mesh.log.bluetooth.config %@"="Bluetooth Konfiguration empfangen: %@";
"mesh.log.cannedmessage.config %@"="Canned Message module config received: %@";
"mesh.log.cannedmessages.messages.get %@"="Requested Canned Messages Module Messages for node: %@";

View file

@ -12,6 +12,8 @@
"ago"="ago";
"airtime"="Airtime";
"always.on"="Always On";
"ambient.lighting"="Ambient Lighting";
"ambient.lighting.config"="Ambient Lighting Config";
"app.settings"="App Settings";
"are.you.sure"="Are you sure?";
"ascii.capable"="ASCII Capable";
@ -66,11 +68,14 @@
"device.metrics.delete"="Delete all device metrics?";
"device.metrics.log"="Device Metrics Log";
"device.role.client"="Client (default) - App connected client.";
"device.role.clienthidden"=" Used for nodes that \"only speak when spoken to\" Turns all of the routine broadcasts but allows for ad-hoc communication. Still rebroadcasts, but with local only rebroadcast mode (known meshes only). Can be used for private operation or to dramatically reduce airtime / power consumption.";
"device.role.clientmute"="Client Mute - Same as a client except packets will not hop over this node, does not contribute to routing packets for mesh.";
"device.role.lostandfound"="Used to automatically send a text message to the mesh with the current position of the device on a frequent interval: \"I'm lost! Position: lat / long\"";
"device.role.router"="Router - Mesh packets will prefer to be routed over this node. Assumes device will operate in a standalone manner while placed in a location with a coverage advantage. WARNING: The BLE/Wi-Fi radios and the OLED screen will be put to sleep.";
"device.role.routerclient"="Router Client - Hybrid of the Client and Router roles. Similar to Router, except the Router Client can be used as both a Router and an app connected Client. BLE/Wi-Fi and OLED screen will not be put to sleep.";
"device.role.repeater"="Repeater - Mesh packets will prefer to be routed over this node. This role eliminates unnecessary overhead such as NodeInfo, DeviceTelemetry, and any other mesh packet, resulting in the device not appearing as part of the network. Please see Rebroadcast Mode for additional settings specific to this role.";
"device.role.tracker"="Tracker - For use with devices intended as a GPS tracker. Position packets sent from this device will be higher priority, with position broadcasting every two minutes. Smart Position Broadcast will default to off.";
"device.role.tak"="Used for nodes dedicated for connection to an ATAK EUD. Turns off many of the routine broadcasts to favor CoT packet stream from the Meshtastic ATAK plugin -> IMeshService -> Node";
"direct.messages"="Direct Messages";
"dismiss.keyboard"="Dismiss";
"display"="Display (Device Screen)";
@ -152,6 +157,7 @@
"map.usertrackingmode.none"="None";
"mesh.live.activity"="Mesh Live Activity";
"mesh.log"="Mesh Log";
"mesh.log.ambientlighting.config %@"="Ambient Lighting module config received: %@";
"mesh.log.bluetooth.config %@"="Bluetooth config received: %@";
"mesh.log.cannedmessage.config %@"="Canned Message module config received: %@";
"mesh.log.cannedmessages.messages.get %@"="Requested Canned Messages Module Messages for node: %@";

View file

@ -14,6 +14,8 @@
"ago"="temu";
"airtime"="Czas nadawania";
"always.on"="Zawsze włączone";
"ambient.lighting"="Ambient Lighting";
"ambient.lighting.config"="Ambient Lighting Config";
"app.settings"="Ustawienia aplikacji";
"are.you.sure"="Jesteś pewny?";
"ascii.capable"="Zgodny z ASCII";
@ -66,6 +68,8 @@
"device.metrics.log"="Dziennik metryk urządzenia";
"device.role.client"="Klient (domyślnie) - Klient połączony z aplikacją.";
"device.role.clientmute"="Wyciszenie klienta - To samo, co klient, z wyjątkiem pakietów, które nie przeskakują przez ten węzeł, nie przyczynia się do routingu pakietów dla siatki.";
"device.role.clienthidden"=" Used for nodes that \"only speak when spoken to\" Turns all of the routine broadcasts but allows for ad-hoc communication. Still rebroadcasts, but with local only rebroadcast mode (known meshes only). Can be used for private operation or to dramatically reduce airtime / power consumption.";
"device.role.lostandfound"="Used to automatically send a text message to the mesh with the current position of the device on a frequent interval: \"I'm lost! Position: lat / long\"";
"device.role.router"="Router - Pakiety siatki będą preferować trasowanie przez ten węzeł. Zakłada, że urządzenie będzie działać samodzielnie, umieszczone w miejscu z przewagą zasięgu. UWAGA: Radia BLE/Wi-Fi i ekran OLED zostaną uśpione.";
"device.role.routerclient"="Router Client - Hybryda ról klienta i routera. Podobnie jak w przypadku routera, z tym że Router Client może być używany zarówno jako router, jak i klient połączony z aplikacją. Radia BLE/Wi-Fi i ekran OLED nie zostaną uśpione.";
"device.role.repeater"="Przekaźnik - Pakiety siatki będą preferować trasowanie przez ten węzeł. Ta rola eliminuje niepotrzebny nadmiar, taki jak NodeInfo, DeviceTelemetry i inne pakiety siatki, skutkując tym, że urządzenie nie będzie widoczne jako część sieci. Proszę zobaczyć tryb Rebroadcast dla dodatkowych ustawień specyficznych dla tej roli.";
@ -151,6 +155,7 @@
"map.usertrackingmode.none"="Brak";
"mesh.live.activity"="Aktywność na Żywo";
"mesh.log"="Dziennik Sieci";
"mesh.log.ambientlighting.config %@"="Ambient Lighting module config received: %@";
"mesh.log.bluetooth.config %@"="Otrzymano konfigurację Bluetooth: %@";
"mesh.log.cannedmessage.config %@"="Otrzymano konfigurację modułu wiadomości gotowych: %@";
"mesh.log.cannedmessages.messages.get %@"="Zażądano Wiadomości z Modułu Wiadomości Gotowych dla węzła: %@";

View file

@ -12,6 +12,8 @@
"ago"="ago";
"airtime"="广播时间";
"always.on"="常亮";
"ambient.lighting"="Ambient Lighting";
"ambient.lighting.config"="Ambient Lighting Config";
"app.settings"="通用设置";
"are.you.sure"="是否确认?";
"ascii.capable"="ASCII Capable";
@ -64,6 +66,8 @@
"device.metrics.log"="电台指标日志";
"device.role.client"="标准模式 - App 可以连接到电台进行收发操作,并且会自动转发 Mesh 网络中其他节点的消息。";
"device.role.clientmute"="静默模式 - 与标准模式类似App 可以连接到电台进行收发操作,但不会转发 Mesh 网络中其他节点的消息。";
"device.role.clienthidden"=" Used for nodes that \"only speak when spoken to\" Turns all of the routine broadcasts but allows for ad-hoc communication. Still rebroadcasts, but with local only rebroadcast mode (known meshes only). Can be used for private operation or to dramatically reduce airtime / power consumption.";
"device.role.lostandfound"="Used to automatically send a text message to the mesh with the current position of the device on a frequent interval: \"I'm lost! Position: lat / long\"";
"device.role.router"="纯路由模式 - 自动转发 Mesh 网络中其他节点的消息中继模式下屏幕会熄灭Wi-Fi 和蓝牙将会进入睡眠模式App 将无法连接到电台进行收发操作。";
"device.role.routerclient"="路由客户端模式 - 优先转发 Mesh 网络中其他节点的消息App 也可以连接到电台进行收发操作。";
"device.role.repeater"="中继模式 - Mesh 网络数据包将优先通过此节点路由。此模式可消除不必要的开销,如 NodeInfo、DeviceTelemetry 和任何其他 Mesh 数据包,从而使设备不显示为 Mesh 网络的一部分。有关此角色的其他特定设置,请参阅转播模式。";
@ -149,6 +153,7 @@
"map.usertrackingmode.followwithheading"="Follow with heading";
"mesh.live.activity"="Mesh 实时活动";
"mesh.log"="Mesh 日志";
"mesh.log.ambientlighting.config %@"="Ambient Lighting module config received: %@";
"mesh.log.bluetooth.config %@"="Bluetooth config received: %@";
"mesh.log.cannedmessage.config %@"="Canned Message module config received: %@";
"mesh.log.cannedmessages.messages.get %@"="Requested Canned Messages Module Messages for node: %@";