diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index b01e0ee0..2ad60579 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -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 = ""; }; DD2DC2BF29BCD8AB003B383C /* HardwareModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareModels.swift; sourceTree = ""; }; DD3501882852FC3B000FC853 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; + DD3619132B1EE20700C41C8C /* MeshtasticDataModelV21.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV21.xcdatamodel; sourceTree = ""; }; + DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsHandler.swift; sourceTree = ""; }; DD3CC6B428E33FD100FA9159 /* ShareChannels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareChannels.swift; sourceTree = ""; }; DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModel.xcdatamodel; sourceTree = ""; }; DD3CC6BD28E4CD9800FA9159 /* BatteryGauge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryGauge.swift; sourceTree = ""; }; @@ -254,6 +261,7 @@ DD457BC4295D5E35004BCE4D /* MeshtasticDataModelV5.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV5.xcdatamodel; sourceTree = ""; }; DD46401F2AFF10F4002A5ECB /* WaypointForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaypointForm.swift; sourceTree = ""; }; DD47E3D526F17ED900029299 /* CircleText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleText.swift; sourceTree = ""; }; + DD4975A42B147BA90026544E /* AmbientLightingConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmbientLightingConfig.swift; sourceTree = ""; }; DD4A911D2708C65400501B7E /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; DD4F23CC28779A3C001D37CB /* EnvironmentMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentMetricsLog.swift; sourceTree = ""; }; DD5394FD276BA0EF00AD86B1 /* PositionEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionEntityExtension.swift; sourceTree = ""; }; @@ -401,6 +409,9 @@ DDDE5A1229AFEAB900490C6C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; DDDEE5E229DBE43E00A8E078 /* MeshtasticDataModelV11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV11.xcdatamodel; sourceTree = ""; }; DDE0F7C4295F77B700B8AAB3 /* AppSettingsEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsEnums.swift; sourceTree = ""; }; + DDE5B4032B2279A700FCDD05 /* TraceRouteLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraceRouteLog.swift; sourceTree = ""; }; + DDE5B4052B227E3200FCDD05 /* TraceRouteEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraceRouteEntityExtension.swift; sourceTree = ""; }; + DDE9659B2B1C3B6A00531070 /* RouteRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteRecorder.swift; sourceTree = ""; }; DDEE03EC29544A1000FCAD57 /* MeshtasticDataModelV4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV4.xcdatamodel; sourceTree = ""; }; DDF6B2462A9AEB9E00BA6931 /* MeshtasticDataModelV17.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV17.xcdatamodel; sourceTree = ""; }; DDF6B2472A9AEBF500BA6931 /* StoreForward.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreForward.swift; sourceTree = ""; }; @@ -478,6 +489,7 @@ DDD9E4E3284B208E003777C5 /* UserEntityExtension.swift */, DD964FC1297272AE007C176F /* WaypointEntityExtension.swift */, DDAB580E2B0DAFBC00147258 /* LocationEntityExtension.swift */, + DDE5B4052B227E3200FCDD05 /* TraceRouteEntityExtension.swift */, ); path = CoreData; sourceTree = ""; @@ -508,6 +520,7 @@ DD73FD1028750779000852D6 /* PositionLog.swift */, DD4F23CC28779A3C001D37CB /* EnvironmentMetricsLog.swift */, 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */, + DDE5B4032B2279A700FCDD05 /* TraceRouteLog.swift */, ); path = Nodes; sourceTree = ""; @@ -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 = ""; @@ -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 = ""; diff --git a/Meshtastic/Enums/DeviceEnums.swift b/Meshtastic/Enums/DeviceEnums.swift index 052607ab..25938a50 100644 --- a/Meshtastic/Enums/DeviceEnums.swift +++ b/Meshtastic/Enums/DeviceEnums.swift @@ -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 } } } diff --git a/Meshtastic/Enums/PositionConfigEnums.swift b/Meshtastic/Enums/PositionConfigEnums.swift index 69e155ba..49f74cd6 100644 --- a/Meshtastic/Enums/PositionConfigEnums.swift +++ b/Meshtastic/Enums/PositionConfigEnums.swift @@ -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 - } - } -} diff --git a/Meshtastic/Extensions/CoreData/TraceRouteEntityExtension.swift b/Meshtastic/Extensions/CoreData/TraceRouteEntityExtension.swift new file mode 100644 index 00000000..4e7cdb60 --- /dev/null +++ b/Meshtastic/Extensions/CoreData/TraceRouteEntityExtension.swift @@ -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 + } + } +} diff --git a/Meshtastic/Extensions/UserDefaults.swift b/Meshtastic/Extensions/UserDefaults.swift index 1c9e21fd..4ea8d2be 100644 --- a/Meshtastic/Extensions/UserDefaults.swift +++ b/Meshtastic/Extensions/UserDefaults.swift @@ -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") + } + } } diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index f49dcc78..8f38858b 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -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).. = 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).. 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).. 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!") - // } } diff --git a/Meshtastic/Helpers/LocationHelper.swift b/Meshtastic/Helpers/LocationHelper.swift index 118623b8..8d0100c0 100644 --- a/Meshtastic/Helpers/LocationHelper.swift +++ b/Meshtastic/Helpers/LocationHelper.swift @@ -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)") diff --git a/Meshtastic/Helpers/LocationsHandler.swift b/Meshtastic/Helpers/LocationsHandler.swift new file mode 100644 index 00000000..9c5626c6 --- /dev/null +++ b/Meshtastic/Helpers/LocationsHandler.swift @@ -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 + } +} diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index f11e57b0..b8f48d7e 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -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 diff --git a/Meshtastic/Info.plist b/Meshtastic/Info.plist index c41afa05..48ae0608 100644 --- a/Meshtastic/Info.plist +++ b/Meshtastic/Info.plist @@ -55,6 +55,10 @@ We use your location to display it on the mesh map as well as to have GPS coordinatess to send to the connected device. NSLocationWhenInUseUsageDescription We use your location to display it on the mesh map as well as to have GPS coordinatess to send to the connected device. + NSLocationAlwaysUsageDescription + We use your location to display it on the mesh map as well as to have GPS coordinatess to send to the connected device. + NSLocationAlwaysAndWhenInUseUsageDescription + 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. NSSupportsLiveActivities Privacy – Bluetooth Always Usage Description diff --git a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion index fbcba258..12116f95 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion +++ b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - MeshtasticDataModelV20.xcdatamodel + MeshtasticDataModelV21.xcdatamodel diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV20.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV20.xcdatamodel/contents index fc942300..88f217f5 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV20.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV20.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -116,10 +116,12 @@ + + diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV21.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV21.xcdatamodel/contents new file mode 100644 index 00000000..d30a2970 --- /dev/null +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV21.xcdatamodel/contents @@ -0,0 +1,404 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index 9a43be75..852b2504 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -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") + } } } }) diff --git a/Meshtastic/MeshtasticAppDelegate.swift b/Meshtastic/MeshtasticAppDelegate.swift index 0918f9ea..df9fe7ac 100644 --- a/Meshtastic/MeshtasticAppDelegate.swift +++ b/Meshtastic/MeshtasticAppDelegate.swift @@ -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) { diff --git a/Meshtastic/Persistence/QueryCoreData.swift b/Meshtastic/Persistence/QueryCoreData.swift index fa200d8a..502aa61a 100644 --- a/Meshtastic/Persistence/QueryCoreData.swift +++ b/Meshtastic/Persistence/QueryCoreData.swift @@ -46,6 +46,23 @@ public func getStoreAndForwardMessageIds(seconds: Int, context: NSManagedObjectC return [] } +public func getTraceRoute(id: Int64, context: NSManagedObjectContext) -> TraceRouteEntity? { + + let fetchTraceRouteRequest: NSFetchRequest = 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 = 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 [] - } -} diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index a02837c3..198226bc 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -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 = 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)) diff --git a/Meshtastic/Protobufs/meshtastic/config.pb.swift b/Meshtastic/Protobufs/meshtastic/config.pb.swift index d04e9003..ef91027c 100644 --- a/Meshtastic/Protobufs/meshtastic/config.pb.swift +++ b/Meshtastic/Protobufs/meshtastic/config.pb.swift @@ -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"), ] } diff --git a/Meshtastic/Protobufs/meshtastic/mesh.pb.swift b/Meshtastic/Protobufs/meshtastic/mesh.pb.swift index cc2388a8..b8ebf212 100644 --- a/Meshtastic/Protobufs/meshtastic/mesh.pb.swift +++ b/Meshtastic/Protobufs/meshtastic/mesh.pb.swift @@ -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(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(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(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 } diff --git a/Meshtastic/Views/MapKitMap/Custom/LocalMBTileOverlay.swift b/Meshtastic/Views/MapKitMap/Custom/LocalMBTileOverlay.swift index 5409dc2b..4c9dc828 100644 --- a/Meshtastic/Views/MapKitMap/Custom/LocalMBTileOverlay.swift +++ b/Meshtastic/Views/MapKitMap/Custom/LocalMBTileOverlay.swift @@ -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 } diff --git a/Meshtastic/Views/Messages/UserList.swift b/Meshtastic/Views/Messages/UserList.swift index d35f6592..f102b6bd 100644 --- a/Meshtastic/Views/Messages/UserList.swift +++ b/Meshtastic/Views/Messages/UserList.swift @@ -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, diff --git a/Meshtastic/Views/Nodes/DetectionSensorLog.swift b/Meshtastic/Views/Nodes/DetectionSensorLog.swift index b6d13c99..c59fd318 100644 --- a/Meshtastic/Views/Nodes/DetectionSensorLog.swift +++ b/Meshtastic/Views/Nodes/DetectionSensorLog.swift @@ -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 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) } diff --git a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift index 48ff4ba1..850c4e7e 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift @@ -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 { diff --git a/Meshtastic/Views/Nodes/Helpers/Map/PositionAltitudeChart.swift b/Meshtastic/Views/Nodes/Helpers/Map/PositionAltitudeChart.swift index eba287e7..4f69bd2a 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/PositionAltitudeChart.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/PositionAltitudeChart.swift @@ -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) { diff --git a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift index d92d4b35..ea7a550c 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift @@ -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) diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index b644f2b6..ab5765dc 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -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 { diff --git a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift index a5b5a3db..2fb63b53 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift @@ -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) + } } } } diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 7e6f30ec..c0df0167 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -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(), diff --git a/Meshtastic/Views/Nodes/TraceRouteLog.swift b/Meshtastic/Views/Nodes/TraceRouteLog.swift new file mode 100644 index 00000000..7b525932 --- /dev/null +++ b/Meshtastic/Views/Nodes/TraceRouteLog.swift @@ -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 + } + } + } +} diff --git a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift new file mode 100644 index 00000000..bd59c988 --- /dev/null +++ b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift @@ -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 + } +} diff --git a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift index f79c90b6..42a83ed3 100644 --- a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift @@ -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.") diff --git a/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift b/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift index 35ab9b0e..64e98f2a 100644 --- a/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift @@ -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) diff --git a/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift b/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift index 3b99f7f0..0c27ee33 100644 --- a/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift @@ -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: { diff --git a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift index 1dd1f6cb..001ef1f7 100644 --- a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift @@ -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: { diff --git a/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift b/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift index 7e716166..4386b3ca 100644 --- a/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift @@ -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.") diff --git a/Meshtastic/Views/Settings/Config/Module/StoreForward.swift b/Meshtastic/Views/Settings/Config/Module/StoreForward.swift index d1b22798..cec01a74 100644 --- a/Meshtastic/Views/Settings/Config/Module/StoreForward.swift +++ b/Meshtastic/Views/Settings/Config/Module/StoreForward.swift @@ -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: { diff --git a/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift b/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift index 560331e2..f3cdb5ef 100644 --- a/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift @@ -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 { diff --git a/Meshtastic/Views/Settings/Config/PositionConfig.swift b/Meshtastic/Views/Settings/Config/PositionConfig.swift index 8ad8397c..746926dd 100644 --- a/Meshtastic/Views/Settings/Config/PositionConfig.swift +++ b/Meshtastic/Views/Settings/Config/PositionConfig.swift @@ -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) diff --git a/Meshtastic/Views/Settings/RouteRecorder.swift b/Meshtastic/Views/Settings/RouteRecorder.swift new file mode 100644 index 00000000..3a168866 --- /dev/null +++ b/Meshtastic/Views/Settings/RouteRecorder.swift @@ -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) + } + } + } + } +} diff --git a/Meshtastic/Views/Settings/Routes.swift b/Meshtastic/Views/Settings/Routes.swift index 1c3d52c7..5039f724 100644 --- a/Meshtastic/Views/Settings/Routes.swift +++ b/Meshtastic/Views/Settings/Routes.swift @@ -23,8 +23,7 @@ struct Routes: View { var routes: FetchedResults 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] ?? [] diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 7fb37da8..69a8cc96 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -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: { diff --git a/de.lproj/Localizable.strings b/de.lproj/Localizable.strings index 60d9ff6b..54c5d6f3 100644 --- a/de.lproj/Localizable.strings +++ b/de.lproj/Localizable.strings @@ -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: %@"; diff --git a/en.lproj/Localizable.strings b/en.lproj/Localizable.strings index 23c19d6a..355a9c26 100644 --- a/en.lproj/Localizable.strings +++ b/en.lproj/Localizable.strings @@ -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: %@"; diff --git a/pl.lproj/Localizable.strings b/pl.lproj/Localizable.strings index 489db06a..95ccadb5 100644 --- a/pl.lproj/Localizable.strings +++ b/pl.lproj/Localizable.strings @@ -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: %@"; diff --git a/zh-Hans.lproj/Localizable.strings b/zh-Hans.lproj/Localizable.strings index cd4a522b..202a0871 100644 --- a/zh-Hans.lproj/Localizable.strings +++ b/zh-Hans.lproj/Localizable.strings @@ -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: %@";