diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index d28ae0a6..f3915cf1 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - C9483F6D2773017500998F6B /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9483F6C2773017500998F6B /* MapView.swift */; }; C9697F9D279336B700250207 /* LocalMBTileOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9697F9C279336B700250207 /* LocalMBTileOverlay.swift */; }; C9697FA527933B8C00250207 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = C9697FA427933B8C00250207 /* SQLite */; }; C9A7BC1027759A9600760B50 /* PositionAnnotationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A7BC0F27759A9600760B50 /* PositionAnnotationView.swift */; }; @@ -22,7 +21,10 @@ DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */; }; DD2553572855B02500E55709 /* LoRaConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2553562855B02500E55709 /* LoRaConfig.swift */; }; DD2553592855B52700E55709 /* PositionConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2553582855B52700E55709 /* PositionConfig.swift */; }; + DD2AD8A8296D2DF9001FF0E7 /* MapViewSwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2AD8A7296D2DF9001FF0E7 /* MapViewSwiftUI.swift */; }; DD2E65262767A01F00E45FC5 /* NodeDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2E65252767A01F00E45FC5 /* NodeDetail.swift */; }; + DD2F145129787595009E4638 /* xmodem.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2F144F29787595009E4638 /* xmodem.pb.swift */; }; + DD2F145229787595009E4638 /* rtttl.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2F145029787595009E4638 /* rtttl.pb.swift */; }; DD3501892852FC3B000FC853 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3501882852FC3B000FC853 /* Settings.swift */; }; DD35018B2852FC79000FC853 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD35018A2852FC79000FC853 /* UserSettings.swift */; }; DD3CC6B528E33FD100FA9159 /* ShareChannels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3CC6B428E33FD100FA9159 /* ShareChannels.swift */; }; @@ -66,6 +68,11 @@ DD8ED9C8289CE4B900B3B0AB /* RoutingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8ED9C7289CE4B900B3B0AB /* RoutingError.swift */; }; DD90860E26F69BAE00DC5189 /* NodeMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD90860D26F69BAE00DC5189 /* NodeMap.swift */; }; DD913639270DFF4C00D7ACF3 /* LocalNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD913638270DFF4C00D7ACF3 /* LocalNotificationManager.swift */; }; + DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD964FBC296E6B01007C176F /* EmojiOnlyTextField.swift */; }; + DD964FBF296E76EF007C176F /* WaypointFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD964FBE296E76EF007C176F /* WaypointFormView.swift */; }; + DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD964FC1297272AE007C176F /* WaypointEntityExtension.swift */; }; + DD964FC42974767D007C176F /* MapViewFitExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD964FC32974767D007C176F /* MapViewFitExtension.swift */; }; + DD964FC62975DBFD007C176F /* QueryCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD964FC52975DBFD007C176F /* QueryCoreData.swift */; }; DD97E96628EFD9820056DDA4 /* MeshtasticLogo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD97E96528EFD9820056DDA4 /* MeshtasticLogo.swift */; }; DD97E96828EFE9A00056DDA4 /* About.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD97E96728EFE9A00056DDA4 /* About.swift */; }; DD994B69295F88B60013760A /* IntervalEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD994B68295F88B60013760A /* IntervalEnums.swift */; }; @@ -127,7 +134,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - C9483F6C2773017500998F6B /* MapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = ""; }; + A65FA974296876BF00A97686 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; C9697F9C279336B700250207 /* LocalMBTileOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMBTileOverlay.swift; sourceTree = ""; }; C9A7BC0F27759A9600760B50 /* PositionAnnotationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionAnnotationView.swift; sourceTree = ""; }; C9A88B54278B503C00BD810A /* MapViewModule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MapViewModule.swift; sourceTree = ""; }; @@ -141,7 +148,10 @@ DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeripheralModel.swift; sourceTree = ""; }; DD2553562855B02500E55709 /* LoRaConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoRaConfig.swift; sourceTree = ""; }; DD2553582855B52700E55709 /* PositionConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PositionConfig.swift; sourceTree = ""; }; + DD2AD8A7296D2DF9001FF0E7 /* MapViewSwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewSwiftUI.swift; sourceTree = ""; }; DD2E65252767A01F00E45FC5 /* NodeDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeDetail.swift; sourceTree = ""; }; + DD2F144F29787595009E4638 /* xmodem.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = xmodem.pb.swift; sourceTree = ""; }; + DD2F145029787595009E4638 /* rtttl.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = rtttl.pb.swift; sourceTree = ""; }; DD3501882852FC3B000FC853 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; DD35018A2852FC79000FC853 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = ""; }; DD3CC6B428E33FD100FA9159 /* ShareChannels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareChannels.swift; sourceTree = ""; }; @@ -187,6 +197,12 @@ DD90860A26F645B700DC5189 /* Meshtastic.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Meshtastic.entitlements; sourceTree = ""; }; DD90860D26F69BAE00DC5189 /* NodeMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeMap.swift; sourceTree = ""; }; DD913638270DFF4C00D7ACF3 /* LocalNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalNotificationManager.swift; sourceTree = ""; }; + DD964FBC296E6B01007C176F /* EmojiOnlyTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiOnlyTextField.swift; sourceTree = ""; }; + DD964FBE296E76EF007C176F /* WaypointFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaypointFormView.swift; sourceTree = ""; }; + DD964FC029724F6D007C176F /* MeshtasticDataModelV6.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV6.xcdatamodel; sourceTree = ""; }; + DD964FC1297272AE007C176F /* WaypointEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaypointEntityExtension.swift; sourceTree = ""; }; + DD964FC32974767D007C176F /* MapViewFitExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewFitExtension.swift; sourceTree = ""; }; + DD964FC52975DBFD007C176F /* QueryCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryCoreData.swift; sourceTree = ""; }; DD97E96528EFD9820056DDA4 /* MeshtasticLogo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticLogo.swift; sourceTree = ""; }; DD97E96728EFE9A00056DDA4 /* About.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = About.swift; sourceTree = ""; }; DD994B68295F88B60013760A /* IntervalEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntervalEnums.swift; sourceTree = ""; }; @@ -270,9 +286,7 @@ isa = PBXGroup; children = ( C9A7BC0E27759A6800760B50 /* Custom */, - C9483F6C2773017500998F6B /* MapView.swift */, - C9A88B54278B503C00BD810A /* MapViewModule.swift */, - C9697F9C279336B700250207 /* LocalMBTileOverlay.swift */, + DD964FBE296E76EF007C176F /* WaypointFormView.swift */, ); path = Map; sourceTree = ""; @@ -280,6 +294,10 @@ C9A7BC0E27759A6800760B50 /* Custom */ = { isa = PBXGroup; children = ( + C9697F9C279336B700250207 /* LocalMBTileOverlay.swift */, + DD964FC32974767D007C176F /* MapViewFitExtension.swift */, + DD2AD8A7296D2DF9001FF0E7 /* MapViewSwiftUI.swift */, + C9A88B54278B503C00BD810A /* MapViewModule.swift */, C9A7BC0F27759A9600760B50 /* PositionAnnotationView.swift */, ); path = Custom; @@ -391,21 +409,23 @@ DDAF8C5626ED07740058C060 /* Protobufs */ = { isa = PBXGroup; children = ( - DDB3107128A6224100F1DE3D /* device_metadata.pb.swift */, - DDCFF600285453A7005FA625 /* localonly.pb.swift */, - DD4DED8F27AD2975004BA27E /* cannedmessages.pb.swift */, DDAF8C6126ED0A230058C060 /* admin.pb.swift */, C9A88B56278B559900BD810A /* apponly.pb.swift */, + DD4DED8F27AD2975004BA27E /* cannedmessages.pb.swift */, DDAF8C6426ED0A490058C060 /* channel.pb.swift */, DD4C158D2824AA7E0032668E /* config.pb.swift */, DDAF8C6826ED0D070058C060 /* deviceonly.pb.swift */, + DDB3107128A6224100F1DE3D /* device_metadata.pb.swift */, + DDCFF600285453A7005FA625 /* localonly.pb.swift */, DDAF8C5726ED07FD0058C060 /* mesh.pb.swift */, DD4C158B2824A91E0032668E /* module_config.pb.swift */, DDAF8C6026ED0A230058C060 /* mqtt.pb.swift */, DDAF8C5C26ED09490058C060 /* portnums.pb.swift */, DDAF8C6626ED0C8C0058C060 /* remote_hardware.pb.swift */, + DD2F145029787595009E4638 /* rtttl.pb.swift */, DD17E5DC277D49D400010EC2 /* storeforward.pb.swift */, DDB2CC6D27F3EB47009C5FCC /* telemetry.pb.swift */, + DD2F144F29787595009E4638 /* xmodem.pb.swift */, ); path = Protobufs; sourceTree = ""; @@ -546,6 +566,7 @@ DD913638270DFF4C00D7ACF3 /* LocalNotificationManager.swift */, DD8169F8271F1A6100F4AB02 /* MeshLogger.swift */, DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */, + DD964FBC296E6B01007C176F /* EmojiOnlyTextField.swift */, ); path = Helpers; sourceTree = ""; @@ -557,7 +578,9 @@ DD5394FD276BA0EF00AD86B1 /* PositionEntityExtension.swift */, DDD9E4E3284B208E003777C5 /* UserEntityExtension.swift */, DD58C5F12919AD3C00D5BEFB /* ChannelEntityExtension.swift */, + DD964FC52975DBFD007C176F /* QueryCoreData.swift */, DD3CC6C128EB9D4900FA9159 /* UpdateCoreData.swift */, + DD964FC1297272AE007C176F /* WaypointEntityExtension.swift */, ); path = Persistence; sourceTree = ""; @@ -655,6 +678,7 @@ en, de, Base, + "zh-Hans", ); mainGroup = DDC2E14B26CE248E0042C5E4; packageReferences = ( @@ -729,6 +753,7 @@ DDCFF601285453A7005FA625 /* localonly.pb.swift in Sources */, DD836AE726F6B38600ABCC23 /* Connect.swift in Sources */, DDAF8C6E26ED19040058C060 /* Extensions.swift in Sources */, + DD964FBF296E76EF007C176F /* WaypointFormView.swift in Sources */, DD3501892852FC3B000FC853 /* Settings.swift in Sources */, DD5D0A9C2931B9F200F7EA61 /* EthernetModes.swift in Sources */, DD798B072915928D005217CD /* ChannelMessageList.swift in Sources */, @@ -736,6 +761,7 @@ DDC2E1A726CEB3400042C5E4 /* LocationHelper.swift in Sources */, DD5394FE276BA0EF00AD86B1 /* PositionEntityExtension.swift in Sources */, DD913639270DFF4C00D7ACF3 /* LocalNotificationManager.swift in Sources */, + DD2F145129787595009E4638 /* xmodem.pb.swift in Sources */, DD4C158C2824A91E0032668E /* module_config.pb.swift in Sources */, DD4F23CD28779A3C001D37CB /* EnvironmentMetricsLog.swift in Sources */, DDB6ABD628AE742000384BA1 /* BluetoothConfig.swift in Sources */, @@ -743,7 +769,9 @@ DDAF8C5326EB1DF10058C060 /* BLEManager.swift in Sources */, DDC4D568275499A500A4208E /* Persistence.swift in Sources */, DD90860E26F69BAE00DC5189 /* NodeMap.swift in Sources */, + DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */, DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */, + DD2AD8A8296D2DF9001FF0E7 /* MapViewSwiftUI.swift in Sources */, DD6193792863875F00E59241 /* SerialConfig.swift in Sources */, DDA0B6B2294CDC55001356EC /* Channels.swift in Sources */, DDAF8C6926ED0D070058C060 /* deviceonly.pb.swift in Sources */, @@ -766,8 +794,10 @@ DDB6ABDB28B0AC6000384BA1 /* DistanceText.swift in Sources */, C9A7BC1027759A9600760B50 /* PositionAnnotationView.swift in Sources */, DD882F5D2772E4640005BF05 /* Contacts.swift in Sources */, + DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */, DD47E3CE26F103C600029299 /* NodeList.swift in Sources */, DD8EBF43285058FA00426DCA /* DisplayConfig.swift in Sources */, + DD964FC42974767D007C176F /* MapViewFitExtension.swift in Sources */, DD47E3D626F17ED900029299 /* CircleText.swift in Sources */, DDC2E18F26CE25FE0042C5E4 /* ContentView.swift in Sources */, DD17E5DE277D49D400010EC2 /* storeforward.pb.swift in Sources */, @@ -783,8 +813,8 @@ DD86D40C287F401000BAEB7A /* SaveChannelQRCode.swift in Sources */, DDA1C48E28DB49D3009933EC /* ChannelRoles.swift in Sources */, DD8ED9C8289CE4B900B3B0AB /* RoutingError.swift in Sources */, - C9483F6D2773017500998F6B /* MapView.swift in Sources */, DDAF8C5826ED07FD0058C060 /* mesh.pb.swift in Sources */, + DD964FC62975DBFD007C176F /* QueryCoreData.swift in Sources */, DD8169FB271F1F3A00F4AB02 /* MeshLog.swift in Sources */, DD8ED9C52898D51F00B3B0AB /* NetworkConfig.swift in Sources */, DDC3B274283F411B00AC321C /* LastHeardText.swift in Sources */, @@ -809,6 +839,7 @@ C9697F9D279336B700250207 /* LocalMBTileOverlay.swift in Sources */, DD58C5F22919AD3C00D5BEFB /* ChannelEntityExtension.swift in Sources */, DD0F791B28713C8A00A6FDAD /* AdminMessageList.swift in Sources */, + DD2F145229787595009E4638 /* rtttl.pb.swift in Sources */, DD3CC6BC28E366DF00FA9159 /* Meshtastic.xcdatamodeld in Sources */, DD8169F9271F1A6100F4AB02 /* MeshLogger.swift in Sources */, DD539502276DAA6A00AD86B1 /* MapLocation.swift in Sources */, @@ -857,6 +888,7 @@ children = ( DDCDC6CC29481FCC004C1DDA /* en */, DDCDC6CE294821AD004C1DDA /* de */, + A65FA974296876BF00A97686 /* zh-Hans */, ); name = Localizable.strings; sourceTree = ""; @@ -1006,7 +1038,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.10; + MARKETING_VERSION = 2.0.11; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1039,7 +1071,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.10; + MARKETING_VERSION = 2.0.11; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1215,13 +1247,14 @@ DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + DD964FC029724F6D007C176F /* MeshtasticDataModelV6.xcdatamodel */, DD457BC4295D5E35004BCE4D /* MeshtasticDataModelV5.xcdatamodel */, DDEE03EC29544A1000FCAD57 /* MeshtasticDataModelV4.xcdatamodel */, DDCDC69A29467643004C1DDA /* MeshtasticDataModelV3.xcdatamodel */, DD5D0A9A2931AD6B00F7EA61 /* MeshtasticDataModelV2.xcdatamodel */, DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */, ); - currentVersion = DD457BC4295D5E35004BCE4D /* MeshtasticDataModelV5.xcdatamodel */; + currentVersion = DD964FC029724F6D007C176F /* MeshtasticDataModelV6.xcdatamodel */; name = Meshtastic.xcdatamodeld; path = Meshtastic/Meshtastic.xcdatamodeld; sourceTree = ""; diff --git a/Meshtastic/Enums/AppSettingsEnums.swift b/Meshtastic/Enums/AppSettingsEnums.swift index f94a9b6c..53ce177b 100644 --- a/Meshtastic/Enums/AppSettingsEnums.swift +++ b/Meshtastic/Enums/AppSettingsEnums.swift @@ -6,6 +6,7 @@ // import Foundation +import MapKit enum KeyboardType: Int, CaseIterable, Identifiable { @@ -36,24 +37,51 @@ enum KeyboardType: Int, CaseIterable, Identifiable { enum MeshMapType: String, CaseIterable, Identifiable { - case satellite = "satellite" + case standard = "standard" + case mutedStandard = "mutedStandard" case hybrid = "hybrid" - case standard = "standard" + case hybridFlyover = "hybridFlyover" + case satellite = "satellite" + case satelliteFlyover = "satelliteFlyover" + var id: String { self.rawValue } var description: String { get { switch self { - case .satellite: - return NSLocalizedString("satellite", comment: "Satellite Map Type") case .standard: - return NSLocalizedString("standard", comment: "Standard Map Type") + return NSLocalizedString("standard", comment: "Standard") + case .mutedStandard: + return NSLocalizedString("standard.muted", comment: "Standard Muted") case .hybrid: - return NSLocalizedString("hybrid", comment: "Hybrid Map Type") + return NSLocalizedString("hybrid", comment: "Hybrid") + case .hybridFlyover: + return NSLocalizedString("hybrid.flyover", comment: "Hybrid Flyover") + case .satellite: + return NSLocalizedString("satellite", comment: "Satellite") + case .satelliteFlyover: + return NSLocalizedString("satellite.flyover", comment: "Satellite Flyover") } } } + func MKMapTypeValue() -> MKMapType { + + switch self { + case .standard: + return MKMapType.standard + case .mutedStandard: + return MKMapType.mutedStandard + case .hybrid: + return MKMapType.hybrid + case .hybridFlyover: + return MKMapType.hybridFlyover + case .satellite: + return MKMapType.satellite + case .satelliteFlyover: + return MKMapType.satelliteFlyover + } + } } enum LocationUpdateInterval: Int, CaseIterable, Identifiable { diff --git a/Meshtastic/Enums/DisplayEnums.swift b/Meshtastic/Enums/DisplayEnums.swift index 46110532..d124af5d 100644 --- a/Meshtastic/Enums/DisplayEnums.swift +++ b/Meshtastic/Enums/DisplayEnums.swift @@ -104,6 +104,7 @@ enum OledTypes: Int, CaseIterable, Identifiable { case auto = 0 case ssd1306 = 1 case sh1106 = 2 + //case sh1107 = 3 var id: Int { self.rawValue } var description: String { @@ -115,6 +116,8 @@ enum OledTypes: Int, CaseIterable, Identifiable { return "SSD 1306" case .sh1106: return "SH 1106" + //case .sh1107: + // return "SH 1107" } } } @@ -127,6 +130,46 @@ enum OledTypes: Int, CaseIterable, Identifiable { return Config.DisplayConfig.OledType.oledSsd1306 case .sh1106: return Config.DisplayConfig.OledType.oledSh1106 + //case .sh1107: + // return Config.DisplayConfig.OledType.oledSh1107 + } + } +} + +// Default of 0 is auto +enum DisplayModes: Int, CaseIterable, Identifiable { + + case defaultMode = 0 + case twoColor = 1 + case inverted = 2 + case color = 3 + + var id: Int { self.rawValue } + var description: String { + get { + switch self { + case .defaultMode: + return "Default 128x64 screen layout" + case .twoColor: + return "Optimized for 2 color displays" + case .inverted: + return "Inverted top bar for 2 Color display" + case .color: + return "TFT Full Color Displays" + } + } + } + func protoEnumValue() -> Config.DisplayConfig.DisplayMode { + + switch self { + case .defaultMode: + return Config.DisplayConfig.DisplayMode.default + case .twoColor: + return Config.DisplayConfig.DisplayMode.twocolor + case .inverted: + return Config.DisplayConfig.DisplayMode.inverted + case .color: + return Config.DisplayConfig.DisplayMode.color } } } diff --git a/Meshtastic/Export/WriteCsvFile.swift b/Meshtastic/Export/WriteCsvFile.swift index 31d08e9e..48d8d788 100644 --- a/Meshtastic/Export/WriteCsvFile.swift +++ b/Meshtastic/Export/WriteCsvFile.swift @@ -10,7 +10,7 @@ import SwiftUI func TelemetryToCsvFile(telemetry: [TelemetryEntity], metricsType: Int) -> String { var csvString: String = "" let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmma", options: 0, locale: Locale.current) - let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma") + let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma").replacingOccurrences(of: ",", with: "") if metricsType == 0 { // Create Device Metrics Header csvString = "\(NSLocalizedString("battery.level", comment: "")), \(NSLocalizedString("voltage", comment: "")), \(NSLocalizedString("channel.utilization", comment: "")), \(NSLocalizedString("airtime", comment: "")), \(NSLocalizedString("timestamp", comment: ""))" @@ -56,7 +56,7 @@ func TelemetryToCsvFile(telemetry: [TelemetryEntity], metricsType: Int) -> Strin func PositionToCsvFile(positions: [PositionEntity]) -> String { var csvString: String = "" let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmma", options: 0, locale: Locale.current) - let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma") + let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma").replacingOccurrences(of: ",", with: "") // Create Position Header csvString = "SeqNo, Latitude, Longitude, Altitude, Sats, Speed, Heading, SNR, \(NSLocalizedString("timestamp", comment: ""))" for pos in positions { diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index d51723b0..1ad90961 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -291,9 +291,9 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { } } - func requestDeviceMetadata() { + func requestDeviceMetadata(fromUser: UserEntity, toUser: UserEntity, adminIndex: Int32, context: NSManagedObjectContext) -> Int64 { - guard (connectedPeripheral!.peripheral.state == CBPeripheralState.connected) else { return } + guard (connectedPeripheral!.peripheral.state == CBPeripheralState.connected) else { return 0 } let nodeName = connectedPeripheral!.peripheral.name ?? NSLocalizedString("unknown", comment: NSLocalizedString("unknown", comment: "Unknown")) let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.devicemetadata %@", @@ -303,19 +303,20 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { adminPacket.getDeviceMetadataRequest = true var meshPacket: MeshPacket = MeshPacket() meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { @@ -504,9 +505,9 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { case .remoteHardwareApp: MeshLogger.log("🕸️ MESH PACKET received for Remote Hardware App UNHANDLED \(try! decodedInfo.packet.jsonString())") case .positionApp: - positionPacket(packet: decodedInfo.packet, context: context!) + upsertPositionPacket(packet: decodedInfo.packet, context: context!) case .waypointApp: - MeshLogger.log("🕸️ MESH PACKET received for Waypoint App UNHANDLED \(try! decodedInfo.packet.jsonString())") + waypointPacket(packet: decodedInfo.packet, context: context!) case .nodeinfoApp: if !invalidVersion { nodeInfoAppPacket(packet: decodedInfo.packet, context: context!) } case .routingApp: @@ -627,7 +628,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { if preferredPeripheral != nil && preferredPeripheral?.peripheral != nil { connectTo(peripheral: preferredPeripheral!.peripheral) } - let nodeName = connectedPeripheral!.peripheral.name ?? NSLocalizedString("unknown", comment: NSLocalizedString("unknown", comment: "Unknown")) + let nodeName = connectedPeripheral?.peripheral.name ?? NSLocalizedString("unknown", comment: NSLocalizedString("unknown", comment: "Unknown")) let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.textmessage.send.failed %@", comment: "Message Send Failed, not properly connected to %@"), nodeName) MeshLogger.log("🚫 \(logString)") @@ -727,23 +728,19 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { return success } - public func sendWaypoint(destNum: Int64, name: String, wantAck: Bool) -> Bool { + public func sendWaypoint(waypoint: Waypoint) -> Bool { - var success = false - let fromNodeNum = connectedPeripheral.num - if fromNodeNum <= 0 || (LocationHelper.currentLocation.latitude == LocationHelper.DefaultLocation.latitude && LocationHelper.currentLocation.longitude == LocationHelper.DefaultLocation.longitude) { + if waypoint.latitudeI == 373346000 && waypoint.longitudeI == -1220090000 { return false } - var waypointPacket = Waypoint() - waypointPacket.latitudeI = Int32(LocationHelper.currentLocation.latitude * 1e7) - waypointPacket.longitudeI = Int32(LocationHelper.currentLocation.longitude * 1e7) - let oneWeekFromNow = Calendar.current.date(byAdding: .day, value: 7, to: Date()) - waypointPacket.expire = UInt32(oneWeekFromNow!.timeIntervalSince1970) - waypointPacket.name = name + + var success = false + let fromNodeNum = UInt32(connectedPeripheral.num) + var waypointPacket = waypoint var meshPacket = MeshPacket() - meshPacket.to = UInt32(destNum) - meshPacket.from = 0 // Send 0 as from from phone to device to avoid warning about client trying to set node num - meshPacket.wantAck = true//wantAck + meshPacket.to = emptyNodeNum + meshPacket.from = fromNodeNum + meshPacket.wantAck = true var dataMessage = DataMessage() dataMessage.payload = try! waypointPacket.serializedData() dataMessage.portnum = PortNum.waypointApp @@ -758,6 +755,22 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { if connectedPeripheral!.peripheral.state == CBPeripheralState.connected { connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse) success = true + + let wayPointEntity = getWaypoint(id: Int64(waypoint.id), context: context!) + wayPointEntity.id = Int64(waypoint.id) + wayPointEntity.name = waypoint.name.count >= 1 ? waypointPacket.name : "Dropped Pin" + wayPointEntity.longDescription = waypoint.description_p + wayPointEntity.icon = Int64(waypoint.icon) + wayPointEntity.latitudeI = waypoint.latitudeI + wayPointEntity.longitudeI = waypoint.longitudeI + do { + try context!.save() + print("💾 Updated Waypoint from Waypoint App Packet From: \(fromNodeNum)") + } catch { + context!.rollback() + let nsError = error as NSError + print("💥 Error Saving NodeInfoEntity from WAYPOINT_APP \(nsError)") + } } return success } @@ -766,9 +779,10 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { var success = false let fromNodeNum = connectedPeripheral.num - if fromNodeNum <= 0 || (LocationHelper.currentLocation.latitude == LocationHelper.DefaultLocation.latitude && LocationHelper.currentLocation.longitude == LocationHelper.DefaultLocation.longitude) { + 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) @@ -789,9 +803,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { var dataMessage = DataMessage() dataMessage.payload = try! positionPacket.serializedData() dataMessage.portnum = PortNum.positionApp - //if destNum != emptyNodeNum { - dataMessage.wantResponse = wantResponse - //} + dataMessage.wantResponse = wantResponse meshPacket.decoded = dataMessage var toRadio: ToRadio! @@ -809,18 +821,13 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { } @objc func positionTimerFired(timer: Timer) { - // Check for connected node if connectedPeripheral != nil { - // Send a position out to the mesh if "share location with the mesh" is enabled in settings if userSettings!.provideLocation { - let success = sendPosition(destNum: connectedPeripheral.num, wantResponse: false) if !success { - - print("Failed to send positon to device") - + print("Failed to send position to device") } } } @@ -829,7 +836,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { public func sendShutdown(fromUser: UserEntity, toUser: UserEntity) -> Bool { var adminPacket = AdminMessage() - adminPacket.shutdownSeconds = 10 + adminPacket.shutdownSeconds = 5 var meshPacket: MeshPacket = MeshPacket() meshPacket.to = UInt32(toUser.num) meshPacket.from = UInt32(fromUser.num) @@ -852,7 +859,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { public func sendReboot(fromUser: UserEntity, toUser: UserEntity) -> Bool { var adminPacket = AdminMessage() - adminPacket.rebootSeconds = 10 + adminPacket.rebootSeconds = 5 var meshPacket: MeshPacket = MeshPacket() meshPacket.to = UInt32(connectedPeripheral.num) @@ -860,7 +867,6 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + public func saveUser(config: User, fromUser: UserEntity, toUser: UserEntity, adminIndex: Int32) -> Int64 { var adminPacket = AdminMessage() adminPacket.setOwner = config var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(connectedPeripheral.num) - meshPacket.from = 0 //UInt32(connectedPeripheral.num) + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.channel = UInt32(adminIndex) meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + public func saveLoRaConfig(config: Config.LoRaConfig, fromUser: UserEntity, toUser: UserEntity, adminIndex: Int32) -> Int64 { var adminPacket = AdminMessage() adminPacket.setConfig.lora = config var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(connectedPeripheral.num) - meshPacket.from = 0 //UInt32(connectedPeripheral.num) + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. 0 { + meshPacket.channel = UInt32(adminIndex) + } var dataMessage = DataMessage() dataMessage.payload = try! adminPacket.serializedData() dataMessage.portnum = PortNum.adminApp @@ -1240,7 +1248,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { return 0 } - public func saveWiFiConfig(config: Config.NetworkConfig, fromUser: UserEntity, toUser: UserEntity) -> Int64 { + public func saveNetworkConfig(config: Config.NetworkConfig, fromUser: UserEntity, toUser: UserEntity) -> Int64 { var adminPacket = AdminMessage() adminPacket.setConfig.network = config @@ -1391,6 +1399,64 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { return false } + public func requestBluetoothConfig(fromUser: UserEntity, toUser: UserEntity, adminIndex: Int32) -> Bool { + + var adminPacket = AdminMessage() + adminPacket.getConfigRequest = AdminMessage.ConfigType.bluetoothConfig + + 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() + adminPacket.getConfigRequest = AdminMessage.ConfigType.loraConfig + + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { var adminPacket = AdminMessage() @@ -1542,7 +1608,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { do { connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse) try context!.save() - print("⚙️ \(adminDescription)") + print(adminDescription) return true } catch { context!.rollback() diff --git a/Meshtastic/Helpers/EmojiOnlyTextField.swift b/Meshtastic/Helpers/EmojiOnlyTextField.swift new file mode 100644 index 00000000..ea30fb37 --- /dev/null +++ b/Meshtastic/Helpers/EmojiOnlyTextField.swift @@ -0,0 +1,74 @@ +// +// EmojiKeyboard.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 1/10/23. +// +import SwiftUI + +class SwiftUIEmojiTextField: UITextField { + + override func awakeFromNib() { + super.awakeFromNib() + } + + func setEmoji() { + _ = self.textInputMode + } + + override var textInputContextIdentifier: String? { + return "" + } + + override var textInputMode: UITextInputMode? { + for mode in UITextInputMode.activeInputModes { + if mode.primaryLanguage == "emoji" { + self.keyboardType = .default // do not remove this + return mode + } + } + return nil + } +} + +struct EmojiOnlyTextField: UIViewRepresentable { + @Binding var text: String + var placeholder: String = "" + + func makeUIView(context: Context) -> SwiftUIEmojiTextField { + let emojiTextField = SwiftUIEmojiTextField() + emojiTextField.placeholder = placeholder + emojiTextField.text = text + emojiTextField.delegate = context.coordinator + return emojiTextField + } + + func updateUIView(_ uiView: SwiftUIEmojiTextField, context: Context) { + uiView.text = text + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + class Coordinator: NSObject, UITextFieldDelegate { + var parent: EmojiOnlyTextField + init(parent: EmojiOnlyTextField) { + self.parent = parent + } + func textFieldDidChangeSelection(_ textField: UITextField) { + DispatchQueue.main.async { [weak self] in + self?.parent.text = textField.text ?? "" + } + } + } +} + +//struct EmojiContentView: View { +// +// @State private var text: String = "" +// +// var body: some View { +// EmojiTextField(text: $text, placeholder: "Enter emoji") +// } +//} diff --git a/Meshtastic/Helpers/Extensions.swift b/Meshtastic/Helpers/Extensions.swift index c1b41f99..39a2b6af 100644 --- a/Meshtastic/Helpers/Extensions.swift +++ b/Meshtastic/Helpers/Extensions.swift @@ -1,5 +1,24 @@ import Foundation import SwiftUI +import MapKit + +extension Character { + var isEmoji: Bool { + guard let scalar = unicodeScalars.first else { return false } + return scalar.properties.isEmoji && (scalar.value >= 0x203C || unicodeScalars.count > 1) + } +} + +extension CLLocationCoordinate2D { + /// Returns distance from coordianate in meters. + /// - Parameter from: coordinate which will be used as end point. + /// - Returns: Returns distance in meters. + func distance(from: CLLocationCoordinate2D) -> CLLocationDistance { + let from = CLLocation(latitude: from.latitude, longitude: from.longitude) + let to = CLLocation(latitude: self.latitude, longitude: self.longitude) + return from.distance(from: to) + } +} extension Data { var macAddressString: String { @@ -73,6 +92,10 @@ extension String { return base64url } + func onlyEmojis() -> Bool { + return count > 0 && !contains { !$0.isEmoji } + } + func image(fontSize:CGFloat = 40, bgColor:UIColor = UIColor.clear, imageSize:CGSize? = nil) -> UIImage? { let font = UIFont.systemFont(ofSize: fontSize) diff --git a/Meshtastic/Helpers/LocationHelper.swift b/Meshtastic/Helpers/LocationHelper.swift index db7ec2f8..2d1c7a85 100644 --- a/Meshtastic/Helpers/LocationHelper.swift +++ b/Meshtastic/Helpers/LocationHelper.swift @@ -6,7 +6,6 @@ class LocationHelper: NSObject, ObservableObject { // 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) @@ -81,7 +80,10 @@ class LocationHelper: NSObject, ObservableObject { super.init() locationManager.delegate = self - locationManager.desiredAccuracy = kCLLocationAccuracyBest + locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + locationManager.pausesLocationUpdatesAutomatically = true + locationManager.allowsBackgroundLocationUpdates = true + locationManager.activityType = .otherNavigation locationManager.requestWhenInUseAuthorization() locationManager.startUpdatingLocation() } diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index d07bb2ba..cbd9a56c 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -16,7 +16,7 @@ func generateMessageMarkdown (message: String) -> String { let matches = detector.matches(in: message, options: [], range: NSRange(location: 0, length: message.utf16.count)) var messageWithMarkdown = message if matches.count > 0 { - + for match in matches { guard let range = Range(match.range, in: message) else { continue } if match.resultType == .address { @@ -38,295 +38,19 @@ func generateMessageMarkdown (message: String) -> String { func localConfig (config: Config, context:NSManagedObjectContext, nodeNum: Int64, nodeLongName: String) { - // We don't care about any of the Power settings, config is available for everyting else + // We don't care about any of the Power settings, config is available for everything else if config.payloadVariant == Config.OneOf_PayloadVariant.bluetooth(config.bluetooth) { - - let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.bluetooth.config %@", comment: "Bluetooth config received: %@"), String(nodeNum)) - MeshLogger.log("📶 \(logString)") - - let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) - - do { - - let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] - // Found a node, save Device Config - if !fetchedNode.isEmpty { - if fetchedNode[0].bluetoothConfig == nil { - let newBluetoothConfig = BluetoothConfigEntity(context: context) - newBluetoothConfig.enabled = config.bluetooth.enabled - newBluetoothConfig.mode = Int32(config.bluetooth.mode.rawValue) - newBluetoothConfig.fixedPin = Int32(config.bluetooth.fixedPin) - fetchedNode[0].bluetoothConfig = newBluetoothConfig - } else { - fetchedNode[0].bluetoothConfig?.enabled = config.bluetooth.enabled - fetchedNode[0].bluetoothConfig?.mode = Int32(config.bluetooth.mode.rawValue) - fetchedNode[0].bluetoothConfig?.fixedPin = Int32(config.bluetooth.fixedPin) - } - do { - try context.save() - print("💾 Updated Bluetooth Config for node number: \(String(nodeNum))") - } catch { - context.rollback() - let nsError = error as NSError - print("💥 Error Updating Core Data BluetoothConfigEntity: \(nsError)") - } - } else { - print("💥 No Nodes found in local database matching node number \(nodeNum) unable to save Bluetooth Config") - } - } catch { - let nsError = error as NSError - print("💥 Fetching node for core data BluetoothConfigEntity failed: \(nsError)") - } - } - - if config.payloadVariant == Config.OneOf_PayloadVariant.device(config.device) { - - let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.device.config %@", comment: "Device config received: %@"), String(nodeNum)) - MeshLogger.log("📟 \(logString)") - let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) - - do { - - let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] - // Found a node, save Device Config - if !fetchedNode.isEmpty { - if fetchedNode[0].deviceConfig == nil { - let newDeviceConfig = DeviceConfigEntity(context: context) - newDeviceConfig.role = Int32(config.device.role.rawValue) - newDeviceConfig.serialEnabled = config.device.serialEnabled - newDeviceConfig.debugLogEnabled = config.device.debugLogEnabled - newDeviceConfig.buttonGpio = Int32(config.device.buttonGpio) - newDeviceConfig.buzzerGpio = Int32(config.device.buzzerGpio) - fetchedNode[0].deviceConfig = newDeviceConfig - } else { - fetchedNode[0].deviceConfig?.role = Int32(config.device.role.rawValue) - fetchedNode[0].deviceConfig?.serialEnabled = config.device.serialEnabled - fetchedNode[0].deviceConfig?.debugLogEnabled = config.device.debugLogEnabled - fetchedNode[0].deviceConfig?.buttonGpio = Int32(config.device.buttonGpio) - fetchedNode[0].deviceConfig?.buzzerGpio = Int32(config.device.buzzerGpio) - } - do { - try context.save() - print("💾 Updated Device Config for node number: \(String(nodeNum))") - } catch { - context.rollback() - let nsError = error as NSError - print("💥 Error Updating Core Data DeviceConfigEntity: \(nsError)") - } - } - } catch { - let nsError = error as NSError - print("💥 Fetching node for core data DeviceConfigEntity failed: \(nsError)") - } - } - - if config.payloadVariant == Config.OneOf_PayloadVariant.display(config.display) { - - let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.display.config %@", comment: "Display config received: %@"), String(nodeNum)) - MeshLogger.log("🖥️ \(logString)") - - let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) - - do { - - let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] - - // Found a node, save Device Config - if !fetchedNode.isEmpty { - - if fetchedNode[0].displayConfig == nil { - - let newDisplayConfig = DisplayConfigEntity(context: context) - newDisplayConfig.gpsFormat = Int32(config.display.gpsFormat.rawValue) - newDisplayConfig.screenOnSeconds = Int32(config.display.screenOnSecs) - newDisplayConfig.screenCarouselInterval = Int32(config.display.autoScreenCarouselSecs) - newDisplayConfig.compassNorthTop = config.display.compassNorthTop - newDisplayConfig.flipScreen = config.display.flipScreen - newDisplayConfig.oledType = Int32(config.display.oled.rawValue) - fetchedNode[0].displayConfig = newDisplayConfig - - } else { - - fetchedNode[0].displayConfig?.gpsFormat = Int32(config.display.gpsFormat.rawValue) - fetchedNode[0].displayConfig?.screenOnSeconds = Int32(config.display.screenOnSecs) - fetchedNode[0].displayConfig?.screenCarouselInterval = Int32(config.display.autoScreenCarouselSecs) - fetchedNode[0].displayConfig?.compassNorthTop = config.display.compassNorthTop - fetchedNode[0].displayConfig?.flipScreen = config.display.flipScreen - fetchedNode[0].displayConfig?.oledType = Int32(config.display.oled.rawValue) - } - - do { - - try context.save() - print("💾 Updated Display Config for node number: \(String(nodeNum))") - - } catch { - - context.rollback() - - let nsError = error as NSError - print("💥 Error Updating Core Data DisplayConfigEntity: \(nsError)") - } - } else { - - print("💥 No Nodes found in local database matching node number \(nodeNum) unable to save Display Config") - } - - } catch { - - let nsError = error as NSError - print("💥 Fetching node for core data DisplayConfigEntity failed: \(nsError)") - } - } - - if config.payloadVariant == Config.OneOf_PayloadVariant.lora(config.lora) { - - let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.lora.config %@", comment: "LoRa config received: %@"), String(nodeNum)) - MeshLogger.log("📻 \(logString)") - - let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) - - do { - - let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] - // Found a node, save LoRa Config - if !fetchedNode.isEmpty { - if fetchedNode[0].loRaConfig == nil { - let newLoRaConfig = LoRaConfigEntity(context: context) - newLoRaConfig.regionCode = Int32(config.lora.region.rawValue) - newLoRaConfig.usePreset = config.lora.usePreset - newLoRaConfig.modemPreset = Int32(config.lora.modemPreset.rawValue) - newLoRaConfig.bandwidth = Int32(config.lora.bandwidth) - newLoRaConfig.spreadFactor = Int32(config.lora.spreadFactor) - newLoRaConfig.codingRate = Int32(config.lora.codingRate) - newLoRaConfig.frequencyOffset = config.lora.frequencyOffset - newLoRaConfig.hopLimit = Int32(config.lora.hopLimit) - newLoRaConfig.txPower = Int32(config.lora.txPower) - newLoRaConfig.txEnabled = config.lora.txEnabled - newLoRaConfig.channelNum = Int32(config.lora.channelNum) - fetchedNode[0].loRaConfig = newLoRaConfig - } else { - fetchedNode[0].loRaConfig?.regionCode = Int32(config.lora.region.rawValue) - fetchedNode[0].loRaConfig?.usePreset = config.lora.usePreset - fetchedNode[0].loRaConfig?.modemPreset = Int32(config.lora.modemPreset.rawValue) - fetchedNode[0].loRaConfig?.bandwidth = Int32(config.lora.bandwidth) - fetchedNode[0].loRaConfig?.spreadFactor = Int32(config.lora.spreadFactor) - fetchedNode[0].loRaConfig?.codingRate = Int32(config.lora.codingRate) - fetchedNode[0].loRaConfig?.frequencyOffset = config.lora.frequencyOffset - fetchedNode[0].loRaConfig?.hopLimit = Int32(config.lora.hopLimit) - fetchedNode[0].loRaConfig?.txPower = Int32(config.lora.txPower) - fetchedNode[0].loRaConfig?.txEnabled = config.lora.txEnabled - fetchedNode[0].loRaConfig?.channelNum = Int32(config.lora.channelNum) - } - do { - try context.save() - print("💾 Updated LoRa Config for node number: \(String(nodeNum))") - } catch { - context.rollback() - let nsError = error as NSError - print("💥 Error Updating Core Data LoRaConfigEntity: \(nsError)") - } - } else { - print("💥 No Nodes found in local database matching node number \(nodeNum) unable to save Lora Config") - } - } catch { - let nsError = error as NSError - print("💥 Fetching node for core data LoRaConfigEntity failed: \(nsError)") - } - } - - if config.payloadVariant == Config.OneOf_PayloadVariant.network(config.network) { - - let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.network.config %@", comment: "Network config received: %@"), String(nodeNum)) - MeshLogger.log("🌐 \(logString)") - - let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) - - do { - - let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] - // Found a node, save WiFi Config - if !fetchedNode.isEmpty { - if fetchedNode[0].networkConfig == nil { - let newNetworkConfig = NetworkConfigEntity(context: context) - newNetworkConfig.wifiSsid = config.network.wifiSsid - newNetworkConfig.wifiPsk = config.network.wifiPsk - fetchedNode[0].networkConfig = newNetworkConfig - } else { - fetchedNode[0].networkConfig?.wifiSsid = config.network.wifiSsid - fetchedNode[0].networkConfig?.wifiPsk = config.network.wifiPsk - } - - do { - try context.save() - print("💾 Updated Network Config for node number: \(String(nodeNum))") - - } catch { - context.rollback() - let nsError = error as NSError - print("💥 Error Updating Core Data WiFiConfigEntity: \(nsError)") - } - } else { - print("💥 No Nodes found in local database matching node number \(nodeNum) unable to save Network Config") - } - } catch { - let nsError = error as NSError - print("💥 Fetching node for core data NetworkConfigEntity failed: \(nsError)") - } - } - - if config.payloadVariant == Config.OneOf_PayloadVariant.position(config.position) { - - let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.position.config %@", comment: "Positon config received: %@"), String(nodeNum)) - MeshLogger.log("🗺️ \(logString)") - - let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) - - do { - - let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] - // Found a node, save LoRa Config - if !fetchedNode.isEmpty { - if fetchedNode[0].positionConfig == nil { - let newPositionConfig = PositionConfigEntity(context: context) - newPositionConfig.smartPositionEnabled = config.position.positionBroadcastSmartEnabled - newPositionConfig.deviceGpsEnabled = config.position.gpsEnabled - newPositionConfig.fixedPosition = config.position.fixedPosition - newPositionConfig.gpsUpdateInterval = Int32(config.position.gpsUpdateInterval) - newPositionConfig.gpsAttemptTime = Int32(config.position.gpsAttemptTime) - newPositionConfig.positionBroadcastSeconds = Int32(config.position.positionBroadcastSecs) - newPositionConfig.positionFlags = Int32(config.position.positionFlags) - fetchedNode[0].positionConfig = newPositionConfig - } else { - fetchedNode[0].positionConfig?.smartPositionEnabled = config.position.positionBroadcastSmartEnabled - fetchedNode[0].positionConfig?.deviceGpsEnabled = config.position.gpsEnabled - fetchedNode[0].positionConfig?.fixedPosition = config.position.fixedPosition - fetchedNode[0].positionConfig?.gpsUpdateInterval = Int32(config.position.gpsUpdateInterval) - fetchedNode[0].positionConfig?.gpsAttemptTime = Int32(config.position.gpsAttemptTime) - fetchedNode[0].positionConfig?.positionBroadcastSeconds = Int32(config.position.positionBroadcastSecs) - fetchedNode[0].positionConfig?.positionFlags = Int32(config.position.positionFlags) - } - do { - try context.save() - print("💾 Updated Position Config for node number: \(String(nodeNum))") - } catch { - context.rollback() - let nsError = error as NSError - print("💥 Error Updating Core Data PositionConfigEntity: \(nsError)") - } - } else { - print("💥 No Nodes found in local database matching node number \(nodeNum) unable to save Position Config") - } - } catch { - let nsError = error as NSError - print("💥 Fetching node for core data PositionConfigEntity failed: \(nsError)") - } + upsertBluetoothConfigPacket(config: config, nodeNum: nodeNum, context: context) + } else if config.payloadVariant == Config.OneOf_PayloadVariant.device(config.device) { + upsertDeviceConfigPacket(config: config, nodeNum: nodeNum, context: context) + } else if config.payloadVariant == Config.OneOf_PayloadVariant.display(config.display) { + upsertDisplayConfigPacket(config: config, nodeNum: nodeNum, context: context) + } else if config.payloadVariant == Config.OneOf_PayloadVariant.lora(config.lora) { + upsertLoRaConfigPacket(config: config, nodeNum: nodeNum, context: context) + } else if config.payloadVariant == Config.OneOf_PayloadVariant.network(config.network) { + upsertNetworkConfigPacket(config: config, nodeNum: nodeNum, context: context) + } else if config.payloadVariant == Config.OneOf_PayloadVariant.position(config.position) { + upsertPositionConfigPacket(config: config, nodeNum: nodeNum, context: context) } } @@ -341,7 +65,7 @@ func moduleConfig (config: ModuleConfig, context:NSManagedObjectContext, nodeNum fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) do { - + let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] // Found a node, save Canned Message Config @@ -350,7 +74,7 @@ func moduleConfig (config: ModuleConfig, context:NSManagedObjectContext, nodeNum if fetchedNode[0].cannedMessageConfig == nil { let newCannedMessageConfig = CannedMessageConfigEntity(context: context) - + newCannedMessageConfig.enabled = config.cannedMessage.enabled newCannedMessageConfig.sendBell = config.cannedMessage.sendBell newCannedMessageConfig.rotary1Enabled = config.cannedMessage.rotary1Enabled @@ -361,7 +85,7 @@ func moduleConfig (config: ModuleConfig, context:NSManagedObjectContext, nodeNum newCannedMessageConfig.inputbrokerEventCw = Int32(config.cannedMessage.inputbrokerEventCw.rawValue) newCannedMessageConfig.inputbrokerEventCcw = Int32(config.cannedMessage.inputbrokerEventCcw.rawValue) newCannedMessageConfig.inputbrokerEventPress = Int32(config.cannedMessage.inputbrokerEventPress.rawValue) - + fetchedNode[0].cannedMessageConfig = newCannedMessageConfig } else { @@ -405,7 +129,7 @@ func moduleConfig (config: ModuleConfig, context:NSManagedObjectContext, nodeNum fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) do { - + let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] // Found a node, save External Notificaitone Config if !fetchedNode.isEmpty { @@ -471,7 +195,7 @@ func moduleConfig (config: ModuleConfig, context:NSManagedObjectContext, nodeNum fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) do { - + let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] // Found a node, save MQTT Config if !fetchedNode.isEmpty { @@ -519,7 +243,7 @@ func moduleConfig (config: ModuleConfig, context:NSManagedObjectContext, nodeNum fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) do { - + let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] // Found a node, save Device Config if !fetchedNode.isEmpty { @@ -550,7 +274,7 @@ func moduleConfig (config: ModuleConfig, context:NSManagedObjectContext, nodeNum print("💥 Fetching node for core data RangeTestConfigEntity failed: \(nsError)") } } - + if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.serial(config.serial) { let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.serial.config %@", comment: "Serial module config received: %@"), String(nodeNum)) @@ -560,7 +284,7 @@ func moduleConfig (config: ModuleConfig, context:NSManagedObjectContext, nodeNum fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) do { - + let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] // Found a node, save Device Config @@ -591,11 +315,11 @@ func moduleConfig (config: ModuleConfig, context:NSManagedObjectContext, nodeNum do { try context.save() print("💾 Updated Serial Module Config for node number: \(String(nodeNum))") - + } catch { - + context.rollback() - + let nsError = error as NSError print("💥 Error Updating Core Data SerialConfigEntity: \(nsError)") } @@ -621,7 +345,7 @@ func moduleConfig (config: ModuleConfig, context:NSManagedObjectContext, nodeNum fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) do { - + let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] // Found a node, save Telemetry Config if !fetchedNode.isEmpty { @@ -650,7 +374,7 @@ func moduleConfig (config: ModuleConfig, context:NSManagedObjectContext, nodeNum do { try context.save() print("💾 Updated Telemetry Module Config for node number: \(String(nodeNum))") - + } catch { context.rollback() let nsError = error as NSError @@ -675,7 +399,7 @@ func myInfoPacket (myInfo: MyNodeInfo, peripheralId: String, context: NSManagedO let fetchMyInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "MyInfoEntity") fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(myInfo.myNodeNum)) - + do { let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) as! [MyInfoEntity] // Not Found Insert @@ -705,7 +429,7 @@ func myInfoPacket (myInfo: MyNodeInfo, peripheralId: String, context: NSManagedO print("💥 Error Inserting New Core Data MyInfoEntity: \(nsError)") } } else { - + fetchedMyInfo[0].peripheralId = peripheralId fetchedMyInfo[0].myNodeNum = Int64(myInfo.myNodeNum) fetchedMyInfo[0].hasGps = myInfo.hasGps_p @@ -737,7 +461,7 @@ func myInfoPacket (myInfo: MyNodeInfo, peripheralId: String, context: NSManagedO func channelPacket (channel: Channel, fromNum: Int64, context: NSManagedObjectContext) { if channel.isInitialized && channel.hasSettings && channel.role != Channel.Role.disabled { - + let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.channel.received %d %@", comment: "Channel %d received from: %@"), channel.index, String(fromNum)) MeshLogger.log("🎛️ \(logString)") @@ -763,6 +487,9 @@ func channelPacket (channel: Channel, fromNum: Int64, context: NSManagedObjectCo mutableChannels.add(newChannel) } fetchedMyInfo[0].channels = mutableChannels.copy() as? NSOrderedSet + if newChannel.name?.lowercased() == "admin" { + fetchedMyInfo[0].adminIndex = newChannel.index + } do { try context.save() } catch { @@ -780,6 +507,45 @@ func channelPacket (channel: Channel, fromNum: Int64, context: NSManagedObjectCo } } +func deviceMetadataPacket (metadata: DeviceMetadata, fromNum: Int64, context: NSManagedObjectContext) { + + if metadata.isInitialized { + let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.device.metadata.received %@", comment: "Device Metadata admin message received from: %@"), String(fromNum)) + MeshLogger.log("🏷️ \(logString)") + + let fetchedNodeRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") + fetchedNodeRequest.predicate = NSPredicate(format: "num == %lld", fromNum) + + do { + + let fetchedNode = try context.fetch(fetchedNodeRequest) as! [NodeInfoEntity] + if fetchedNode.count > 0 { + let newMetadata = DeviceMetadataEntity(context: context) + newMetadata.firmwareVersion = metadata.firmwareVersion + newMetadata.deviceStateVersion = Int32(metadata.deviceStateVersion) + newMetadata.canShutdown = metadata.canShutdown + newMetadata.hasWifi = metadata.hasWifi_p + newMetadata.hasBluetooth = metadata.hasBluetooth_p + newMetadata.hasEthernet = metadata.hasEthernet_p + newMetadata.role = Int32(metadata.role.rawValue) + newMetadata.positionFlags = Int32(metadata.positionFlags) + fetchedNode[0].metadata = newMetadata + + do { + try context.save() + } catch { + print("Failed to save device metadata") + } + print("💾 Updated Device Metadata from Admin App Packet For: \(fromNum)") + } + } catch { + context.rollback() + let nsError = error as NSError + print("💥 Error Saving MyInfo Channel from ADMIN_APP \(nsError)") + } + } +} + func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObjectContext) -> NodeInfoEntity? { let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.nodeinfo.received %@", comment: "Node info received for: %@"), String(nodeInfo.num)) @@ -787,13 +553,13 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeInfo.num)) - + do { - + let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] // Not Found Insert if fetchedNode.isEmpty && nodeInfo.hasUser { - + let newNode = NodeInfoEntity(context: context) newNode.id = Int64(nodeInfo.num) newNode.num = Int64(nodeInfo.num) @@ -822,8 +588,9 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje newUser.hwModel = String(describing: nodeInfo.user.hwModel).uppercased() newNode.user = newUser } - - if nodeInfo.position.latitudeI > 0 || nodeInfo.position.longitudeI > 0 { + + if nodeInfo.position.longitudeI > 0 || nodeInfo.position.latitudeI > 0 && (nodeInfo.position.latitudeI != 373346000 && nodeInfo.position.longitudeI != -1220090000) + { let position = PositionEntity(context: context) position.seqNo = Int32(nodeInfo.position.seqNumber) position.latitudeI = nodeInfo.position.latitudeI @@ -837,13 +604,13 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje newPostions.append(position) newNode.positions? = NSOrderedSet(array: newPostions) } - + // Look for a MyInfo let fetchMyInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "MyInfoEntity") fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(nodeInfo.num)) - + do { - + let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) as! [MyInfoEntity] if fetchedMyInfo.count > 0 { newNode.myInfo = fetchedMyInfo[0] @@ -860,16 +627,15 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje print("💥 Fetch MyInfo Error") } } else if nodeInfo.hasUser && nodeInfo.num > 0 { - + fetchedNode[0].id = Int64(nodeInfo.num) fetchedNode[0].num = Int64(nodeInfo.num) fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.lastHeard))) fetchedNode[0].snr = nodeInfo.snr fetchedNode[0].channel = Int32(channel) - if nodeInfo.hasUser { - + fetchedNode[0].user!.userId = nodeInfo.user.id fetchedNode[0].user!.num = Int64(nodeInfo.num) fetchedNode[0].user!.longName = nodeInfo.user.longName @@ -877,7 +643,7 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje fetchedNode[0].user!.macaddr = nodeInfo.user.macaddr fetchedNode[0].user!.hwModel = String(describing: nodeInfo.user.hwModel).uppercased() } - + if nodeInfo.hasDeviceMetrics { let newTelemetry = TelemetryEntity(context: context) @@ -890,21 +656,26 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje } if nodeInfo.hasPosition { - - let position = PositionEntity(context: context) - position.latitudeI = nodeInfo.position.latitudeI - position.longitudeI = nodeInfo.position.longitudeI - position.altitude = nodeInfo.position.altitude - position.satsInView = Int32(nodeInfo.position.satsInView) - position.time = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.position.time))) - let mutablePositions = fetchedNode[0].positions!.mutableCopy() as! NSMutableOrderedSet - fetchedNode[0].positions = mutablePositions.copy() as? NSOrderedSet + + + if nodeInfo.position.longitudeI > 0 || nodeInfo.position.latitudeI > 0 && (nodeInfo.position.latitudeI != 373346000 && nodeInfo.position.longitudeI != -1220090000) { + + let position = PositionEntity(context: context) + position.latitudeI = nodeInfo.position.latitudeI + position.longitudeI = nodeInfo.position.longitudeI + position.altitude = nodeInfo.position.altitude + position.satsInView = Int32(nodeInfo.position.satsInView) + position.time = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.position.time))) + let mutablePositions = fetchedNode[0].positions!.mutableCopy() as! NSMutableOrderedSet + fetchedNode[0].positions = mutablePositions.copy() as? NSOrderedSet + } + } - + // Look for a MyInfo let fetchMyInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "MyInfoEntity") fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(nodeInfo.num)) - + do { let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) as! [MyInfoEntity] if fetchedMyInfo.count > 0 { @@ -930,17 +701,17 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje } func nodeInfoAppPacket (packet: MeshPacket, context: NSManagedObjectContext) { - + let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.nodeinfo.received %@", comment: "Node info received for: %@"), String(packet.from)) MeshLogger.log("📟 \(logString)") let fetchNodeInfoAppRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") fetchNodeInfoAppRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) - + do { - + let fetchedNode = try context.fetch(fetchNodeInfoAppRequest) as? [NodeInfoEntity] ?? [] - + if fetchedNode.count == 1 { fetchedNode[0].id = Int64(packet.from) fetchedNode[0].num = Int64(packet.from) @@ -988,10 +759,12 @@ func adminAppPacket (packet: MeshPacket, context: NSManagedObjectContext) { if let adminMessage = try? AdminMessage(serializedData: packet.decoded.payload) { + + if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getCannedMessageModuleMessagesResponse(adminMessage.getCannedMessageModuleMessagesResponse) { if let cmmc = try? CannedMessageModuleConfig(serializedData: packet.decoded.payload) { - + if !cmmc.messages.isEmpty { let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.cannedmessages.messages.received %@", comment: "Canned Messages Messages Received For: %@"), String(packet.from)) @@ -1022,69 +795,35 @@ func adminAppPacket (packet: MeshPacket, context: NSManagedObjectContext) { } } } - } - - else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getChannelResponse(adminMessage.getChannelResponse) { - + } else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getChannelResponse(adminMessage.getChannelResponse) { channelPacket(channel: adminMessage.getChannelResponse, fromNum: Int64(packet.from), context: context) - } - } - -} - -func positionPacket (packet: MeshPacket, context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.position.received %@", comment: "Position Packet received from node: %@"), String(packet.from)) - MeshLogger.log("📍 \(logString)") - - let fetchNodePositionRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") - fetchNodePositionRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) - - do { - - if let positionMessage = try? Position(serializedData: packet.decoded.payload) { - // Don't save empty position packets - if positionMessage.longitudeI > 0 || positionMessage.latitudeI > 0 { - let fetchedNode = try context.fetch(fetchNodePositionRequest) as! [NodeInfoEntity] - if fetchedNode.count == 1 { - let position = PositionEntity(context: context) - position.snr = packet.rxSnr - position.seqNo = Int32(positionMessage.seqNumber) - position.latitudeI = positionMessage.latitudeI - position.longitudeI = positionMessage.longitudeI - position.altitude = positionMessage.altitude - position.satsInView = Int32(positionMessage.satsInView) - position.speed = Int32(positionMessage.groundSpeed) - position.heading = Int32(positionMessage.groundTrack) - if positionMessage.timestamp != 0 { - position.time = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.timestamp))) - } else { - position.time = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.time))) - } - let mutablePositions = fetchedNode[0].positions!.mutableCopy() as! NSMutableOrderedSet - mutablePositions.add(position) - fetchedNode[0].id = Int64(packet.from) - fetchedNode[0].num = Int64(packet.from) - fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.time))) - fetchedNode[0].snr = packet.rxSnr - fetchedNode[0].positions = mutablePositions.copy() as? NSOrderedSet - do { - try context.save() - print("💾 Updated Node Position Coordinates, SNR and Time from Position App Packet For: \(fetchedNode[0].num)") - } catch { - context.rollback() - let nsError = error as NSError - print("💥 Error Saving NodeInfoEntity from POSITION_APP \(nsError)") - } + } else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getDeviceMetadataResponse(adminMessage.getDeviceMetadataResponse) { + deviceMetadataPacket(metadata: adminMessage.getDeviceMetadataResponse, fromNum: Int64(packet.from), context: context) + + } else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getConfigResponse(adminMessage.getConfigResponse) { + if let config = try? Config(serializedData: packet.decoded.payload) { + + if config.payloadVariant == Config.OneOf_PayloadVariant.bluetooth(config.bluetooth) { + upsertBluetoothConfigPacket(config: config, nodeNum: Int64(packet.from), context: context) + + } else if config.payloadVariant == Config.OneOf_PayloadVariant.device(config.device) { + upsertDeviceConfigPacket(config: config, nodeNum: Int64(packet.from), context: context) + + } else if config.payloadVariant == Config.OneOf_PayloadVariant.lora(config.lora) { + upsertLoRaConfigPacket(config: config, nodeNum: Int64(packet.from), context: context) + + } else if config.payloadVariant == Config.OneOf_PayloadVariant.network(config.network) { + upsertNetworkConfigPacket(config: config, nodeNum: Int64(packet.from), context: context) + + } else if config.payloadVariant == Config.OneOf_PayloadVariant.position(config.position) { + upsertPositionConfigPacket(config: config, nodeNum: Int64(packet.from), context: context) + } - } else { - print("💥 Empty POSITION_APP Packet") - print(try! packet.jsonString()) } + } else { + MeshLogger.log("🕸️ MESH PACKET received for Admin App \(try! packet.decoded.jsonString())") } - } catch { - print("💥 Error Deserializing POSITION_APP packet.") } } @@ -1097,10 +836,10 @@ func routingPacket (packet: MeshPacket, connectedNodeNum: Int64, context: NSMana let routingErrorString = routingError?.display ?? NSLocalizedString("unknown", comment: "") let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.routing.message %@ %@", comment: "Routing received for RequestID: %@ Ack Status: %@"), String(packet.decoded.requestID), routingErrorString) MeshLogger.log("🕸️ \(logString)") - + let fetchMessageRequest: NSFetchRequest = NSFetchRequest.init(entityName: "MessageEntity") fetchMessageRequest.predicate = NSPredicate(format: "messageId == %lld", Int64(packet.decoded.requestID)) - + do { let fetchedMessage = try context.fetch(fetchMessageRequest) as? [MessageEntity] if fetchedMessage?.count ?? 0 > 0 { @@ -1130,14 +869,14 @@ func routingPacket (packet: MeshPacket, connectedNodeNum: Int64, context: NSMana if fetchedMyInfo?.count ?? 0 > 0 { for ch in fetchedMyInfo![0].channels!.array as! [ChannelEntity] { - + if ch.index == packet.channel { ch.objectWillChange.send() } } } } catch { - + } } @@ -1153,7 +892,7 @@ func routingPacket (packet: MeshPacket, connectedNodeNum: Int64, context: NSMana } } } - + func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManagedObjectContext) { if let telemetryMessage = try? Telemetry(serializedData: packet.decoded.payload) { @@ -1168,9 +907,9 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage let fetchNodeTelemetryRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") fetchNodeTelemetryRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) - + do { - + let fetchedNode = try context.fetch(fetchNodeTelemetryRequest) as! [NodeInfoEntity] if fetchedNode.count == 1 { if telemetryMessage.variant == Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) { @@ -1213,16 +952,16 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage } func textMessageAppPacket(packet: MeshPacket, connectedNode: Int64, context: NSManagedObjectContext) { - + if let messageText = String(bytes: packet.decoded.payload, encoding: .utf8) { - + MeshLogger.log("💬 \(NSLocalizedString("mesh.log.textmessage.received", comment: "Message received from the text message app"))") let messageUsers: NSFetchRequest = NSFetchRequest.init(entityName: "UserEntity") messageUsers.predicate = NSPredicate(format: "num IN %@", [packet.to, packet.from]) - + do { - + let fetchedUsers = try context.fetch(messageUsers) as! [UserEntity] let newMessage = MessageEntity(context: context) newMessage.messageId = Int64(packet.id) @@ -1235,7 +974,7 @@ func textMessageAppPacket(packet: MeshPacket, connectedNode: Int64, context: NSM if packet.decoded.replyID > 0 { newMessage.replyID = Int64(packet.decoded.replyID) } - + if fetchedUsers.first(where: { $0.num == packet.to }) != nil && packet.to != 4294967295 { newMessage.toUser = fetchedUsers.first(where: { $0.num == packet.to }) } @@ -1244,14 +983,14 @@ func textMessageAppPacket(packet: MeshPacket, connectedNode: Int64, context: NSM } newMessage.messagePayload = messageText newMessage.messagePayloadMarkdown = generateMessageMarkdown(message: messageText) - + newMessage.fromUser?.objectWillChange.send() newMessage.toUser?.objectWillChange.send() var messageSaved = false - + do { - + try context.save() print("💾 Saved a new message for \(newMessage.messageId)") messageSaved = true @@ -1274,7 +1013,7 @@ func textMessageAppPacket(packet: MeshPacket, connectedNode: Int64, context: NSM let fetchMyInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "MyInfoEntity") fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(connectedNode)) - + do { let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) as! [MyInfoEntity] for channel in (fetchedMyInfo[0].channels?.array ?? []) as? [ChannelEntity] ?? [] { @@ -1311,3 +1050,63 @@ func textMessageAppPacket(packet: MeshPacket, connectedNode: Int64, context: NSM } } } + +func waypointPacket (packet: MeshPacket, context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.waypoint.received %@", comment: "Waypoint Packet received from node: %@"), String(packet.from)) + MeshLogger.log("📍 \(logString)") + + let fetchWaypointRequest: NSFetchRequest = NSFetchRequest.init(entityName: "WaypointEntity") + fetchWaypointRequest.predicate = NSPredicate(format: "id == %lld", Int64(packet.id)) + + do { + + if let waypointMessage = try? Waypoint(serializedData: packet.decoded.payload) { + + let fetchedWaypoint = try context.fetch(fetchWaypointRequest) as! [WaypointEntity] + if fetchedWaypoint.isEmpty { + let waypoint = WaypointEntity(context: context) + + waypoint.id = Int64(packet.id) + waypoint.name = waypointMessage.name + waypoint.longDescription = waypointMessage.description_p + waypoint.latitudeI = waypointMessage.latitudeI + waypoint.longitudeI = waypointMessage.longitudeI + waypoint.icon = Int64(waypointMessage.icon) + waypoint.locked = Int64(waypointMessage.lockedTo) + if waypointMessage.expire > 0 { + waypoint.expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire))) + } + do { + try context.save() + print("💾 Updated Node Waypoint App Packet For: \(waypoint.id)") + } catch { + context.rollback() + let nsError = error as NSError + print("💥 Error Saving WaypointEntity from WAYPOINT_APP \(nsError)") + } + } else { + fetchedWaypoint[0].id = Int64(packet.id) + fetchedWaypoint[0].name = waypointMessage.name + fetchedWaypoint[0].longDescription = waypointMessage.description_p + fetchedWaypoint[0].latitudeI = waypointMessage.latitudeI + fetchedWaypoint[0].longitudeI = waypointMessage.longitudeI + fetchedWaypoint[0].icon = Int64(waypointMessage.icon) + fetchedWaypoint[0].locked = Int64(waypointMessage.lockedTo) + if waypointMessage.expire > 0 { + fetchedWaypoint[0].expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire))) + } + do { + try context.save() + print("💾 Updated Node Waypoint App Packet For: \(fetchedWaypoint[0].id)") + } catch { + context.rollback() + let nsError = error as NSError + print("💥 Error Saving WaypointEntity from WAYPOINT_APP \(nsError)") + } + } + } + } catch { + print("💥 Error Deserializing WAYPOINT_APP packet.") + } +} diff --git a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion index 1b3c637e..a13f6b2e 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion +++ b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - MeshtasticDataModelV5.xcdatamodel + MeshtasticDataModelV6.xcdatamodel diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV5.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV5.xcdatamodel/contents index 1f463d49..4bb57e13 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV5.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV5.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -269,6 +269,7 @@ + diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV6.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV6.xcdatamodel/contents new file mode 100644 index 00000000..6f82e5d0 --- /dev/null +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV6.xcdatamodel/contents @@ -0,0 +1,294 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Meshtastic/Persistence/PositionEntityExtension.swift b/Meshtastic/Persistence/PositionEntityExtension.swift index a17f7e06..ca7bd3e9 100644 --- a/Meshtastic/Persistence/PositionEntityExtension.swift +++ b/Meshtastic/Persistence/PositionEntityExtension.swift @@ -23,7 +23,7 @@ extension PositionEntity { return d / 1e7 } - var coordinate: CLLocationCoordinate2D? { + var nodeCoordinate: CLLocationCoordinate2D? { if latitudeI != 0 && longitudeI != 0 { let coord = CLLocationCoordinate2D(latitude: latitude!, longitude: longitude!) return coord @@ -31,12 +31,18 @@ extension PositionEntity { return nil } } - + var annotaton: MKPointAnnotation { let pointAnn = MKPointAnnotation() - if coordinate != nil { - pointAnn.coordinate = coordinate! + if nodeCoordinate != nil { + pointAnn.coordinate = nodeCoordinate! } return pointAnn } } + +extension PositionEntity: MKAnnotation { + public var coordinate: CLLocationCoordinate2D { nodeCoordinate ?? LocationHelper.DefaultLocation } + public var title: String? { nodePosition?.user?.shortName ?? NSLocalizedString("unknown", comment: "Unknown") } + public var subtitle: String? { time?.formatted() } +} diff --git a/Meshtastic/Persistence/QueryCoreData.swift b/Meshtastic/Persistence/QueryCoreData.swift new file mode 100644 index 00000000..72692819 --- /dev/null +++ b/Meshtastic/Persistence/QueryCoreData.swift @@ -0,0 +1,24 @@ +// +// QueryCoreData.swift +// Meshtastic +// +// Created(c) Garth Vander Houwen 1/16/23. +// + +import CoreData + +public func getWaypoint(id: Int64, context: NSManagedObjectContext) -> WaypointEntity { + + let fetchWaypointRequest: NSFetchRequest = NSFetchRequest.init(entityName: "WaypointEntity") + fetchWaypointRequest.predicate = NSPredicate(format: "id == %lld", Int64(id)) + + do { + let fetchedWaypoint = try context.fetch(fetchWaypointRequest) as! [WaypointEntity] + if fetchedWaypoint.count == 1 { + return fetchedWaypoint[0] + } + } catch { + return WaypointEntity(context: context) + } + return WaypointEntity(context: context) +} diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index 40bc81b9..6552e54a 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -62,7 +62,7 @@ public func clearTelemetry(destNum: Int64, metricsType: Int32, context: NSManage public func deleteChannelMessages(channel: ChannelEntity, context: NSManagedObjectContext) { do { - let objects = channel.allPrivateMessages// try context.fetch(fetchChannelMessagesRequest) as! [NSManagedObject] + let objects = channel.allPrivateMessages for object in objects { context.delete(object) } @@ -75,7 +75,7 @@ public func deleteChannelMessages(channel: ChannelEntity, context: NSManagedObje public func deleteUserMessages(user: UserEntity, context: NSManagedObjectContext) { do { - let objects = user.messageList//try context.fetch(fetchUserMessagesRequest) as! [NSManagedObject] + let objects = user.messageList for object in objects { context.delete(object) } @@ -100,3 +100,354 @@ public func clearCoreDataDatabase(context: NSManagedObjectContext) { } } } + +func upsertPositionPacket (packet: MeshPacket, context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.position.received %@", comment: "Position Packet received from node: %@"), String(packet.from)) + MeshLogger.log("📍 \(logString)") + + let fetchNodePositionRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") + fetchNodePositionRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + + do { + + if let positionMessage = try? Position(serializedData: packet.decoded.payload) { + + // Don't save empty position packets + if positionMessage.longitudeI > 0 || positionMessage.latitudeI > 0 && (positionMessage.latitudeI != 373346000 && positionMessage.longitudeI != -1220090000) + { + let fetchedNode = try context.fetch(fetchNodePositionRequest) as! [NodeInfoEntity] + if fetchedNode.count == 1 { + + let position = PositionEntity(context: context) + position.snr = packet.rxSnr + position.seqNo = Int32(positionMessage.seqNumber) + position.latitudeI = positionMessage.latitudeI + position.longitudeI = positionMessage.longitudeI + position.altitude = positionMessage.altitude + position.satsInView = Int32(positionMessage.satsInView) + position.speed = Int32(positionMessage.groundSpeed) + position.heading = Int32(positionMessage.groundTrack) + if positionMessage.timestamp != 0 { + position.time = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.timestamp))) + } else { + position.time = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.time))) + } + let mutablePositions = fetchedNode[0].positions!.mutableCopy() as! NSMutableOrderedSet + mutablePositions.add(position) + fetchedNode[0].id = Int64(packet.from) + fetchedNode[0].num = Int64(packet.from) + fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.time))) + fetchedNode[0].snr = packet.rxSnr + fetchedNode[0].positions = mutablePositions.copy() as? NSOrderedSet + do { + try context.save() + print("💾 Updated Node Position Coordinates, SNR and Time from Position App Packet For: \(fetchedNode[0].num)") + } catch { + context.rollback() + let nsError = error as NSError + print("💥 Error Saving NodeInfoEntity from POSITION_APP \(nsError)") + } + } + } else { + print("💥 Empty POSITION_APP Packet") + print(try! packet.jsonString()) + } + } + } catch { + print("💥 Error Deserializing POSITION_APP packet.") + } +} + +func upsertBluetoothConfigPacket(config: Config, nodeNum: Int64, context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.bluetooth.config %@", comment: "Bluetooth config received: %@"), String(nodeNum)) + MeshLogger.log("📶 \(logString)") + + let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + + let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] + // Found a node, save Device Config + if !fetchedNode.isEmpty { + if fetchedNode[0].bluetoothConfig == nil { + let newBluetoothConfig = BluetoothConfigEntity(context: context) + newBluetoothConfig.enabled = config.bluetooth.enabled + newBluetoothConfig.mode = Int32(config.bluetooth.mode.rawValue) + newBluetoothConfig.fixedPin = Int32(config.bluetooth.fixedPin) + fetchedNode[0].bluetoothConfig = newBluetoothConfig + } else { + fetchedNode[0].bluetoothConfig?.enabled = config.bluetooth.enabled + fetchedNode[0].bluetoothConfig?.mode = Int32(config.bluetooth.mode.rawValue) + fetchedNode[0].bluetoothConfig?.fixedPin = Int32(config.bluetooth.fixedPin) + } + do { + try context.save() + print("💾 Updated Bluetooth Config for node number: \(String(nodeNum))") + } catch { + context.rollback() + let nsError = error as NSError + print("💥 Error Updating Core Data BluetoothConfigEntity: \(nsError)") + } + } else { + print("💥 No Nodes found in local database matching node number \(nodeNum) unable to save Bluetooth Config") + } + } catch { + let nsError = error as NSError + print("💥 Fetching node for core data BluetoothConfigEntity failed: \(nsError)") + } +} + +func upsertDeviceConfigPacket(config: Config, nodeNum: Int64, context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.device.config %@", comment: "Device config received: %@"), String(nodeNum)) + MeshLogger.log("📟 \(logString)") + let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + + let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] + // Found a node, save Device Config + if !fetchedNode.isEmpty { + if fetchedNode[0].deviceConfig == nil { + let newDeviceConfig = DeviceConfigEntity(context: context) + newDeviceConfig.role = Int32(config.device.role.rawValue) + newDeviceConfig.serialEnabled = config.device.serialEnabled + newDeviceConfig.debugLogEnabled = config.device.debugLogEnabled + newDeviceConfig.buttonGpio = Int32(config.device.buttonGpio) + newDeviceConfig.buzzerGpio = Int32(config.device.buzzerGpio) + fetchedNode[0].deviceConfig = newDeviceConfig + } else { + fetchedNode[0].deviceConfig?.role = Int32(config.device.role.rawValue) + fetchedNode[0].deviceConfig?.serialEnabled = config.device.serialEnabled + fetchedNode[0].deviceConfig?.debugLogEnabled = config.device.debugLogEnabled + fetchedNode[0].deviceConfig?.buttonGpio = Int32(config.device.buttonGpio) + fetchedNode[0].deviceConfig?.buzzerGpio = Int32(config.device.buzzerGpio) + } + do { + try context.save() + print("💾 Updated Device Config for node number: \(String(nodeNum))") + } catch { + context.rollback() + let nsError = error as NSError + print("💥 Error Updating Core Data DeviceConfigEntity: \(nsError)") + } + } + } catch { + let nsError = error as NSError + print("💥 Fetching node for core data DeviceConfigEntity failed: \(nsError)") + } +} + +func upsertDisplayConfigPacket(config: Config, nodeNum: Int64, context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.display.config %@", comment: "Display config received: %@"), String(nodeNum)) + MeshLogger.log("🖥️ \(logString)") + + let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + + let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] + + // Found a node, save Device Config + if !fetchedNode.isEmpty { + + if fetchedNode[0].displayConfig == nil { + + let newDisplayConfig = DisplayConfigEntity(context: context) + newDisplayConfig.gpsFormat = Int32(config.display.gpsFormat.rawValue) + newDisplayConfig.screenOnSeconds = Int32(config.display.screenOnSecs) + newDisplayConfig.screenCarouselInterval = Int32(config.display.autoScreenCarouselSecs) + newDisplayConfig.compassNorthTop = config.display.compassNorthTop + newDisplayConfig.flipScreen = config.display.flipScreen + newDisplayConfig.oledType = Int32(config.display.oled.rawValue) + newDisplayConfig.displayMode = Int32(config.display.displaymode.rawValue) + fetchedNode[0].displayConfig = newDisplayConfig + + } else { + + fetchedNode[0].displayConfig?.gpsFormat = Int32(config.display.gpsFormat.rawValue) + fetchedNode[0].displayConfig?.screenOnSeconds = Int32(config.display.screenOnSecs) + fetchedNode[0].displayConfig?.screenCarouselInterval = Int32(config.display.autoScreenCarouselSecs) + fetchedNode[0].displayConfig?.compassNorthTop = config.display.compassNorthTop + fetchedNode[0].displayConfig?.flipScreen = config.display.flipScreen + fetchedNode[0].displayConfig?.oledType = Int32(config.display.oled.rawValue) + fetchedNode[0].displayConfig?.displayMode = Int32(config.display.displaymode.rawValue) + } + + do { + + try context.save() + print("💾 Updated Display Config for node number: \(String(nodeNum))") + + } catch { + + context.rollback() + + let nsError = error as NSError + print("💥 Error Updating Core Data DisplayConfigEntity: \(nsError)") + } + } else { + + print("💥 No Nodes found in local database matching node number \(nodeNum) unable to save Display Config") + } + + } catch { + + let nsError = error as NSError + print("💥 Fetching node for core data DisplayConfigEntity failed: \(nsError)") + } +} + +func upsertLoRaConfigPacket(config: Config, nodeNum: Int64, context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.lora.config %@", comment: "LoRa config received: %@"), String(nodeNum)) + MeshLogger.log("📻 \(logString)") + + let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", nodeNum) + + do { + + let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] + // Found a node, save LoRa Config + if fetchedNode.count > 0 { + if fetchedNode[0].loRaConfig == nil { + // No lora config for node, save a new lora config + let newLoRaConfig = LoRaConfigEntity(context: context) + newLoRaConfig.regionCode = Int32(config.lora.region.rawValue) + newLoRaConfig.usePreset = config.lora.usePreset + newLoRaConfig.modemPreset = Int32(config.lora.modemPreset.rawValue) + newLoRaConfig.bandwidth = Int32(config.lora.bandwidth) + newLoRaConfig.spreadFactor = Int32(config.lora.spreadFactor) + newLoRaConfig.codingRate = Int32(config.lora.codingRate) + newLoRaConfig.frequencyOffset = config.lora.frequencyOffset + newLoRaConfig.hopLimit = Int32(config.lora.hopLimit) + newLoRaConfig.txPower = Int32(config.lora.txPower) + newLoRaConfig.txEnabled = config.lora.txEnabled + newLoRaConfig.channelNum = Int32(config.lora.channelNum) + fetchedNode[0].loRaConfig = newLoRaConfig + } else { + fetchedNode[0].loRaConfig?.regionCode = Int32(config.lora.region.rawValue) + fetchedNode[0].loRaConfig?.usePreset = config.lora.usePreset + fetchedNode[0].loRaConfig?.modemPreset = Int32(config.lora.modemPreset.rawValue) + fetchedNode[0].loRaConfig?.bandwidth = Int32(config.lora.bandwidth) + fetchedNode[0].loRaConfig?.spreadFactor = Int32(config.lora.spreadFactor) + fetchedNode[0].loRaConfig?.codingRate = Int32(config.lora.codingRate) + fetchedNode[0].loRaConfig?.frequencyOffset = config.lora.frequencyOffset + fetchedNode[0].loRaConfig?.hopLimit = Int32(config.lora.hopLimit) + fetchedNode[0].loRaConfig?.txPower = Int32(config.lora.txPower) + fetchedNode[0].loRaConfig?.txEnabled = config.lora.txEnabled + fetchedNode[0].loRaConfig?.channelNum = Int32(config.lora.channelNum) + } + do { + try context.save() + print("💾 Updated LoRa Config for node number: \(String(nodeNum))") + } catch { + context.rollback() + let nsError = error as NSError + print("💥 Error Updating Core Data LoRaConfigEntity: \(nsError)") + } + } else { + print("💥 No Nodes found in local database matching node number \(nodeNum) unable to save Lora Config") + } + } catch { + let nsError = error as NSError + print("💥 Fetching node for core data LoRaConfigEntity failed: \(nsError)") + } +} + +func upsertNetworkConfigPacket(config: Config, nodeNum: Int64, context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.network.config %@", comment: "Network config received: %@"), String(nodeNum)) + MeshLogger.log("🌐 \(logString)") + + let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + + let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] + // Found a node, save WiFi Config + if !fetchedNode.isEmpty { + if fetchedNode[0].networkConfig == nil { + let newNetworkConfig = NetworkConfigEntity(context: context) + newNetworkConfig.wifiSsid = config.network.wifiSsid + newNetworkConfig.wifiPsk = config.network.wifiPsk + fetchedNode[0].networkConfig = newNetworkConfig + } else { + fetchedNode[0].networkConfig?.wifiSsid = config.network.wifiSsid + fetchedNode[0].networkConfig?.wifiPsk = config.network.wifiPsk + } + + do { + try context.save() + print("💾 Updated Network Config for node number: \(String(nodeNum))") + + } catch { + context.rollback() + let nsError = error as NSError + print("💥 Error Updating Core Data WiFiConfigEntity: \(nsError)") + } + } else { + print("💥 No Nodes found in local database matching node number \(nodeNum) unable to save Network Config") + } + } catch { + let nsError = error as NSError + print("💥 Fetching node for core data NetworkConfigEntity failed: \(nsError)") + } +} + +func upsertPositionConfigPacket(config: Config, nodeNum: Int64, context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat(NSLocalizedString("mesh.log.position.config %@", comment: "Positon config received: %@"), String(nodeNum)) + MeshLogger.log("🗺️ \(logString)") + + let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + + let fetchedNode = try context.fetch(fetchNodeInfoRequest) as! [NodeInfoEntity] + // Found a node, save LoRa Config + if !fetchedNode.isEmpty { + if fetchedNode[0].positionConfig == nil { + let newPositionConfig = PositionConfigEntity(context: context) + newPositionConfig.smartPositionEnabled = config.position.positionBroadcastSmartEnabled + newPositionConfig.deviceGpsEnabled = config.position.gpsEnabled + newPositionConfig.fixedPosition = config.position.fixedPosition + newPositionConfig.gpsUpdateInterval = Int32(config.position.gpsUpdateInterval) + newPositionConfig.gpsAttemptTime = Int32(config.position.gpsAttemptTime) + newPositionConfig.positionBroadcastSeconds = Int32(config.position.positionBroadcastSecs) + newPositionConfig.positionFlags = Int32(config.position.positionFlags) + fetchedNode[0].positionConfig = newPositionConfig + } else { + fetchedNode[0].positionConfig?.smartPositionEnabled = config.position.positionBroadcastSmartEnabled + fetchedNode[0].positionConfig?.deviceGpsEnabled = config.position.gpsEnabled + fetchedNode[0].positionConfig?.fixedPosition = config.position.fixedPosition + fetchedNode[0].positionConfig?.gpsUpdateInterval = Int32(config.position.gpsUpdateInterval) + fetchedNode[0].positionConfig?.gpsAttemptTime = Int32(config.position.gpsAttemptTime) + fetchedNode[0].positionConfig?.positionBroadcastSeconds = Int32(config.position.positionBroadcastSecs) + fetchedNode[0].positionConfig?.positionFlags = Int32(config.position.positionFlags) + } + do { + try context.save() + print("💾 Updated Position Config for node number: \(String(nodeNum))") + } catch { + context.rollback() + let nsError = error as NSError + print("💥 Error Updating Core Data PositionConfigEntity: \(nsError)") + } + } else { + print("💥 No Nodes found in local database matching node number \(nodeNum) unable to save Position Config") + } + } catch { + let nsError = error as NSError + print("💥 Fetching node for core data PositionConfigEntity failed: \(nsError)") + } +} diff --git a/Meshtastic/Persistence/WaypointEntityExtension.swift b/Meshtastic/Persistence/WaypointEntityExtension.swift new file mode 100644 index 00000000..bc897615 --- /dev/null +++ b/Meshtastic/Persistence/WaypointEntityExtension.swift @@ -0,0 +1,57 @@ +// +// WaypointEntityExtension.swift +// Meshtastic +// +// Copyright (c) Garth Vander Houwen 1/13/23. +// +import CoreData +import CoreLocation +import MapKit +import SwiftUI + +extension WaypointEntity { + + 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 waypointCoordinate: CLLocationCoordinate2D? { + if latitudeI != 0 && longitudeI != 0 { + let coord = CLLocationCoordinate2D(latitude: latitude!, longitude: longitude!) + return coord + } else { + return nil + } + } + + var annotaton: MKPointAnnotation { + let pointAnn = MKPointAnnotation() + if waypointCoordinate != nil { + pointAnn.coordinate = waypointCoordinate! + } + return pointAnn + } +} + +extension WaypointEntity: MKAnnotation { + public var coordinate: CLLocationCoordinate2D { waypointCoordinate ?? LocationHelper.DefaultLocation } + public var title: String? { name ?? "Dropped Pin" } + public var subtitle: String? { + (longDescription ?? "") + + String(expire != nil ? "\n⌛ Expires \(String(describing: expire?.formatted()))" : "") + + String(locked > 0 ? "\n🔒 Locked" : "") } +} diff --git a/Meshtastic/Protobufs/config.pb.swift b/Meshtastic/Protobufs/config.pb.swift index bc6b37eb..546c28b4 100644 --- a/Meshtastic/Protobufs/config.pb.swift +++ b/Meshtastic/Protobufs/config.pb.swift @@ -487,6 +487,10 @@ struct Config { /// Clears the value of `ipv4Config`. Subsequent reads from it will return its default value. mutating func clearIpv4Config() {self._ipv4Config = nil} + /// + /// rsyslog Server and Port + var rsyslogServer: String = String() + var unknownFields = SwiftProtobuf.UnknownStorage() enum AddressMode: SwiftProtobuf.Enum { @@ -941,6 +945,14 @@ struct Config { /// /// WLAN Band case lora24 // = 13 + + /// + /// Ukraine 433mhz + case ua433 // = 14 + + /// + /// Ukraine 868mhz + case ua868 // = 15 case UNRECOGNIZED(Int) init() { @@ -963,6 +975,8 @@ struct Config { case 11: self = .nz865 case 12: self = .th case 13: self = .lora24 + case 14: self = .ua433 + case 15: self = .ua868 default: self = .UNRECOGNIZED(rawValue) } } @@ -983,6 +997,8 @@ struct Config { case .nz865: return 11 case .th: return 12 case .lora24: return 13 + case .ua433: return 14 + case .ua868: return 15 case .UNRECOGNIZED(let i): return i } } @@ -1217,6 +1233,8 @@ extension Config.LoRaConfig.RegionCode: CaseIterable { .nz865, .th, .lora24, + .ua433, + .ua868, ] } @@ -1674,6 +1692,7 @@ extension Config.NetworkConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImp 6: .standard(proto: "eth_enabled"), 7: .standard(proto: "address_mode"), 8: .standard(proto: "ipv4_config"), + 9: .standard(proto: "rsyslog_server"), ] mutating func decodeMessage(decoder: inout D) throws { @@ -1689,6 +1708,7 @@ extension Config.NetworkConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImp case 6: try { try decoder.decodeSingularBoolField(value: &self.ethEnabled) }() case 7: try { try decoder.decodeSingularEnumField(value: &self.addressMode) }() case 8: try { try decoder.decodeSingularMessageField(value: &self._ipv4Config) }() + case 9: try { try decoder.decodeSingularStringField(value: &self.rsyslogServer) }() default: break } } @@ -1720,6 +1740,9 @@ extension Config.NetworkConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImp try { if let v = self._ipv4Config { try visitor.visitSingularMessageField(value: v, fieldNumber: 8) } }() + if !self.rsyslogServer.isEmpty { + try visitor.visitSingularStringField(value: self.rsyslogServer, fieldNumber: 9) + } try unknownFields.traverse(visitor: &visitor) } @@ -1731,6 +1754,7 @@ extension Config.NetworkConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImp if lhs.ethEnabled != rhs.ethEnabled {return false} if lhs.addressMode != rhs.addressMode {return false} if lhs._ipv4Config != rhs._ipv4Config {return false} + if lhs.rsyslogServer != rhs.rsyslogServer {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -2028,6 +2052,8 @@ extension Config.LoRaConfig.RegionCode: SwiftProtobuf._ProtoNameProviding { 11: .same(proto: "NZ_865"), 12: .same(proto: "TH"), 13: .same(proto: "LORA_24"), + 14: .same(proto: "UA_433"), + 15: .same(proto: "UA_868"), ] } diff --git a/Meshtastic/Protobufs/device_metadata.pb.swift b/Meshtastic/Protobufs/device_metadata.pb.swift index 5e6698dc..7c41e60f 100644 --- a/Meshtastic/Protobufs/device_metadata.pb.swift +++ b/Meshtastic/Protobufs/device_metadata.pb.swift @@ -51,6 +51,14 @@ struct DeviceMetadata { /// Indicates that the device has an ethernet peripheral var hasEthernet_p: Bool = false + /// + /// Indicates that the device's role in the mesh + var role: Config.DeviceConfig.Role = .client + + /// + /// Indicates the device's current enabled position flags + var positionFlags: UInt32 = 0 + var unknownFields = SwiftProtobuf.UnknownStorage() init() {} @@ -71,6 +79,8 @@ extension DeviceMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplement 4: .same(proto: "hasWifi"), 5: .same(proto: "hasBluetooth"), 6: .same(proto: "hasEthernet"), + 7: .same(proto: "role"), + 8: .standard(proto: "position_flags"), ] mutating func decodeMessage(decoder: inout D) throws { @@ -85,6 +95,8 @@ extension DeviceMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplement case 4: try { try decoder.decodeSingularBoolField(value: &self.hasWifi_p) }() case 5: try { try decoder.decodeSingularBoolField(value: &self.hasBluetooth_p) }() case 6: try { try decoder.decodeSingularBoolField(value: &self.hasEthernet_p) }() + case 7: try { try decoder.decodeSingularEnumField(value: &self.role) }() + case 8: try { try decoder.decodeSingularUInt32Field(value: &self.positionFlags) }() default: break } } @@ -109,6 +121,12 @@ extension DeviceMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplement if self.hasEthernet_p != false { try visitor.visitSingularBoolField(value: self.hasEthernet_p, fieldNumber: 6) } + if self.role != .client { + try visitor.visitSingularEnumField(value: self.role, fieldNumber: 7) + } + if self.positionFlags != 0 { + try visitor.visitSingularUInt32Field(value: self.positionFlags, fieldNumber: 8) + } try unknownFields.traverse(visitor: &visitor) } @@ -119,6 +137,8 @@ extension DeviceMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplement if lhs.hasWifi_p != rhs.hasWifi_p {return false} if lhs.hasBluetooth_p != rhs.hasBluetooth_p {return false} if lhs.hasEthernet_p != rhs.hasEthernet_p {return false} + if lhs.role != rhs.role {return false} + if lhs.positionFlags != rhs.positionFlags {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Meshtastic/Protobufs/mesh.pb.swift b/Meshtastic/Protobufs/mesh.pb.swift index 05e93acc..c2a49acf 100644 --- a/Meshtastic/Protobufs/mesh.pb.swift +++ b/Meshtastic/Protobufs/mesh.pb.swift @@ -1143,17 +1143,22 @@ struct Waypoint { var expire: UInt32 = 0 /// - /// If true, only allow the original sender to update the waypoint. - var locked: Bool = false + /// If greater than zero, treat the value as a nodenum only allowing them to update the waypoint. + /// If zero, the waypoint is open to be edited by any member of the mesh. + var lockedTo: UInt32 = 0 /// /// Name of the waypoint - max 30 chars var name: String = String() - ///* + /// /// Description of the waypoint - max 100 chars var description_p: String = String() + /// + /// Designator icon for the waypoint in the form of a unicode emoji + var icon: UInt32 = 0 + var unknownFields = SwiftProtobuf.UnknownStorage() init() {} @@ -1924,7 +1929,8 @@ struct FromRadio { set {_uniqueStorage()._payloadVariant = .channel(newValue)} } - /// Queue status info + /// + /// Queue status info var queueStatus: QueueStatus { get { if case .queueStatus(let v)? = _storage._payloadVariant {return v} @@ -1933,6 +1939,16 @@ struct FromRadio { set {_uniqueStorage()._payloadVariant = .queueStatus(newValue)} } + /// + /// File Transfer Chunk + var xmodemPacket: XModem { + get { + if case .xmodemPacket(let v)? = _storage._payloadVariant {return v} + return XModem() + } + set {_uniqueStorage()._payloadVariant = .xmodemPacket(newValue)} + } + var unknownFields = SwiftProtobuf.UnknownStorage() /// @@ -1973,8 +1989,12 @@ struct FromRadio { /// /// One packet is sent for each channel case channel(Channel) - /// Queue status info + /// + /// Queue status info case queueStatus(QueueStatus) + /// + /// File Transfer Chunk + case xmodemPacket(XModem) #if !swift(>=4.1) static func ==(lhs: FromRadio.OneOf_PayloadVariant, rhs: FromRadio.OneOf_PayloadVariant) -> Bool { @@ -2022,6 +2042,10 @@ struct FromRadio { guard case .queueStatus(let l) = lhs, case .queueStatus(let r) = rhs else { preconditionFailure() } return l == r }() + case (.xmodemPacket, .xmodemPacket): return { + guard case .xmodemPacket(let l) = lhs, case .xmodemPacket(let r) = rhs else { preconditionFailure() } + return l == r + }() default: return false } } @@ -2084,6 +2108,14 @@ struct ToRadio { set {payloadVariant = .disconnect(newValue)} } + var xmodemPacket: XModem { + get { + if case .xmodemPacket(let v)? = payloadVariant {return v} + return XModem() + } + set {payloadVariant = .xmodemPacket(newValue)} + } + var unknownFields = SwiftProtobuf.UnknownStorage() /// @@ -2107,6 +2139,7 @@ struct ToRadio { /// This is useful for serial links where there is no hardware/protocol based notification that the client has dropped the link. /// (Sending this message is optional for clients) case disconnect(Bool) + case xmodemPacket(XModem) #if !swift(>=4.1) static func ==(lhs: ToRadio.OneOf_PayloadVariant, rhs: ToRadio.OneOf_PayloadVariant) -> Bool { @@ -2126,6 +2159,10 @@ struct ToRadio { guard case .disconnect(let l) = lhs, case .disconnect(let r) = rhs else { preconditionFailure() } return l == r }() + case (.xmodemPacket, .xmodemPacket): return { + guard case .xmodemPacket(let l) = lhs, case .xmodemPacket(let r) = rhs else { preconditionFailure() } + return l == r + }() default: return false } } @@ -2775,9 +2812,10 @@ extension Waypoint: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationB 2: .standard(proto: "latitude_i"), 3: .standard(proto: "longitude_i"), 4: .same(proto: "expire"), - 5: .same(proto: "locked"), + 5: .standard(proto: "locked_to"), 6: .same(proto: "name"), 7: .same(proto: "description"), + 8: .same(proto: "icon"), ] mutating func decodeMessage(decoder: inout D) throws { @@ -2790,9 +2828,10 @@ extension Waypoint: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationB case 2: try { try decoder.decodeSingularSFixed32Field(value: &self.latitudeI) }() case 3: try { try decoder.decodeSingularSFixed32Field(value: &self.longitudeI) }() case 4: try { try decoder.decodeSingularUInt32Field(value: &self.expire) }() - case 5: try { try decoder.decodeSingularBoolField(value: &self.locked) }() + case 5: try { try decoder.decodeSingularUInt32Field(value: &self.lockedTo) }() case 6: try { try decoder.decodeSingularStringField(value: &self.name) }() case 7: try { try decoder.decodeSingularStringField(value: &self.description_p) }() + case 8: try { try decoder.decodeSingularFixed32Field(value: &self.icon) }() default: break } } @@ -2811,8 +2850,8 @@ extension Waypoint: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationB if self.expire != 0 { try visitor.visitSingularUInt32Field(value: self.expire, fieldNumber: 4) } - if self.locked != false { - try visitor.visitSingularBoolField(value: self.locked, fieldNumber: 5) + if self.lockedTo != 0 { + try visitor.visitSingularUInt32Field(value: self.lockedTo, fieldNumber: 5) } if !self.name.isEmpty { try visitor.visitSingularStringField(value: self.name, fieldNumber: 6) @@ -2820,6 +2859,9 @@ extension Waypoint: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationB if !self.description_p.isEmpty { try visitor.visitSingularStringField(value: self.description_p, fieldNumber: 7) } + if self.icon != 0 { + try visitor.visitSingularFixed32Field(value: self.icon, fieldNumber: 8) + } try unknownFields.traverse(visitor: &visitor) } @@ -2828,9 +2870,10 @@ extension Waypoint: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationB if lhs.latitudeI != rhs.latitudeI {return false} if lhs.longitudeI != rhs.longitudeI {return false} if lhs.expire != rhs.expire {return false} - if lhs.locked != rhs.locked {return false} + if lhs.lockedTo != rhs.lockedTo {return false} if lhs.name != rhs.name {return false} if lhs.description_p != rhs.description_p {return false} + if lhs.icon != rhs.icon {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -3355,6 +3398,7 @@ extension FromRadio: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation 9: .same(proto: "moduleConfig"), 10: .same(proto: "channel"), 11: .same(proto: "queueStatus"), + 12: .same(proto: "xmodemPacket"), ] fileprivate class _StorageClass { @@ -3507,6 +3551,19 @@ extension FromRadio: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation _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) + } + }() default: break } } @@ -3563,6 +3620,10 @@ extension FromRadio: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation 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 nil: break } } @@ -3591,6 +3652,7 @@ extension ToRadio: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBa 1: .same(proto: "packet"), 3: .standard(proto: "want_config_id"), 4: .same(proto: "disconnect"), + 5: .same(proto: "xmodemPacket"), ] mutating func decodeMessage(decoder: inout D) throws { @@ -3628,6 +3690,19 @@ extension ToRadio: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBa self.payloadVariant = .disconnect(v) } }() + case 5: 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) + } + }() default: break } } @@ -3651,6 +3726,10 @@ extension ToRadio: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBa guard case .disconnect(let v)? = self.payloadVariant else { preconditionFailure() } try visitor.visitSingularBoolField(value: v, fieldNumber: 4) }() + case .xmodemPacket?: try { + guard case .xmodemPacket(let v)? = self.payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 5) + }() case nil: break } try unknownFields.traverse(visitor: &visitor) diff --git a/Meshtastic/Protobufs/rtttl.pb.swift b/Meshtastic/Protobufs/rtttl.pb.swift new file mode 100644 index 00000000..3fc7fa98 --- /dev/null +++ b/Meshtastic/Protobufs/rtttl.pb.swift @@ -0,0 +1,75 @@ +// DO NOT EDIT. +// swift-format-ignore-file +// +// Generated by the Swift generator plugin for the protocol buffer compiler. +// Source: rtttl.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/apple/swift-protobuf/ + +import Foundation +import SwiftProtobuf + +// If the compiler emits an error on this type, it is because this file +// was generated by a version of the `protoc` Swift plug-in that is +// incompatible with the version of SwiftProtobuf to which you are linking. +// Please ensure that you are building against the same version of the API +// that was used to generate this file. +fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { + struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} + typealias Version = _2 +} + +/// +/// Canned message module configuration. +struct RTTTLConfig { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// + /// Ringtone for PWM Buzzer in RTTTL Format. + var ringtone: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +#if swift(>=5.5) && canImport(_Concurrency) +extension RTTTLConfig: @unchecked Sendable {} +#endif // swift(>=5.5) && canImport(_Concurrency) + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +extension RTTTLConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = "RTTTLConfig" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "ringtone"), + ] + + 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.decodeSingularStringField(value: &self.ringtone) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.ringtone.isEmpty { + try visitor.visitSingularStringField(value: self.ringtone, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: RTTTLConfig, rhs: RTTTLConfig) -> Bool { + if lhs.ringtone != rhs.ringtone {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/Meshtastic/Protobufs/xmodem.pb.swift b/Meshtastic/Protobufs/xmodem.pb.swift new file mode 100644 index 00000000..fc57c812 --- /dev/null +++ b/Meshtastic/Protobufs/xmodem.pb.swift @@ -0,0 +1,173 @@ +// DO NOT EDIT. +// swift-format-ignore-file +// +// Generated by the Swift generator plugin for the protocol buffer compiler. +// Source: xmodem.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/apple/swift-protobuf/ + +import Foundation +import SwiftProtobuf + +// If the compiler emits an error on this type, it is because this file +// was generated by a version of the `protoc` Swift plug-in that is +// incompatible with the version of SwiftProtobuf to which you are linking. +// Please ensure that you are building against the same version of the API +// that was used to generate this file. +fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { + struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} + typealias Version = _2 +} + +struct XModem { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var control: XModem.Control = .nul + + var seq: UInt32 = 0 + + var crc16: UInt32 = 0 + + var buffer: Data = Data() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + enum Control: SwiftProtobuf.Enum { + typealias RawValue = Int + case nul // = 0 + case soh // = 1 + case stx // = 2 + case eot // = 4 + case ack // = 6 + case nak // = 21 + case can // = 24 + case ctrlz // = 26 + case UNRECOGNIZED(Int) + + init() { + self = .nul + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .nul + case 1: self = .soh + case 2: self = .stx + case 4: self = .eot + case 6: self = .ack + case 21: self = .nak + case 24: self = .can + case 26: self = .ctrlz + default: self = .UNRECOGNIZED(rawValue) + } + } + + var rawValue: Int { + switch self { + case .nul: return 0 + case .soh: return 1 + case .stx: return 2 + case .eot: return 4 + case .ack: return 6 + case .nak: return 21 + case .can: return 24 + case .ctrlz: return 26 + case .UNRECOGNIZED(let i): return i + } + } + + } + + init() {} +} + +#if swift(>=4.2) + +extension XModem.Control: CaseIterable { + // The compiler won't synthesize support with the UNRECOGNIZED case. + static var allCases: [XModem.Control] = [ + .nul, + .soh, + .stx, + .eot, + .ack, + .nak, + .can, + .ctrlz, + ] +} + +#endif // swift(>=4.2) + +#if swift(>=5.5) && canImport(_Concurrency) +extension XModem: @unchecked Sendable {} +extension XModem.Control: @unchecked Sendable {} +#endif // swift(>=5.5) && canImport(_Concurrency) + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +extension XModem: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = "XModem" + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "control"), + 2: .same(proto: "seq"), + 3: .same(proto: "crc16"), + 4: .same(proto: "buffer"), + ] + + 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.decodeSingularEnumField(value: &self.control) }() + case 2: try { try decoder.decodeSingularUInt32Field(value: &self.seq) }() + case 3: try { try decoder.decodeSingularUInt32Field(value: &self.crc16) }() + case 4: try { try decoder.decodeSingularBytesField(value: &self.buffer) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.control != .nul { + try visitor.visitSingularEnumField(value: self.control, fieldNumber: 1) + } + if self.seq != 0 { + try visitor.visitSingularUInt32Field(value: self.seq, fieldNumber: 2) + } + if self.crc16 != 0 { + try visitor.visitSingularUInt32Field(value: self.crc16, fieldNumber: 3) + } + if !self.buffer.isEmpty { + try visitor.visitSingularBytesField(value: self.buffer, fieldNumber: 4) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: XModem, rhs: XModem) -> Bool { + if lhs.control != rhs.control {return false} + if lhs.seq != rhs.seq {return false} + if lhs.crc16 != rhs.crc16 {return false} + if lhs.buffer != rhs.buffer {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension XModem.Control: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 0: .same(proto: "NUL"), + 1: .same(proto: "SOH"), + 2: .same(proto: "STX"), + 4: .same(proto: "EOT"), + 6: .same(proto: "ACK"), + 21: .same(proto: "NAK"), + 24: .same(proto: "CAN"), + 26: .same(proto: "CTRLZ"), + ] +} diff --git a/Meshtastic/Views/Bluetooth/Connect.swift b/Meshtastic/Views/Bluetooth/Connect.swift index d9e29e1b..fded4ec7 100644 --- a/Meshtastic/Views/Bluetooth/Connect.swift +++ b/Meshtastic/Views/Bluetooth/Connect.swift @@ -111,7 +111,7 @@ struct Connect: View { if isUnsetRegion { HStack { NavigationLink { - LoRaConfig(node: node) + LoRaConfig(node: node, connectedNode: node) } label: { Label("set.region", systemImage: "globe.americas.fill") .foregroundColor(.red) diff --git a/Meshtastic/Views/Helpers/DistanceText.swift b/Meshtastic/Views/Helpers/DistanceText.swift index 6028c1e5..5716ad57 100644 --- a/Meshtastic/Views/Helpers/DistanceText.swift +++ b/Meshtastic/Views/Helpers/DistanceText.swift @@ -12,7 +12,7 @@ import MapKit struct DistanceText: View { var meters: CLLocationDistance - + var body: some View { let distanceFormatter = MKDistanceFormatter() @@ -23,7 +23,6 @@ struct DistanceText_Previews: PreviewProvider { static var previews: some View { VStack { - DistanceText(meters: 100) DistanceText(meters: 1000) DistanceText(meters: 10000) diff --git a/Meshtastic/Views/Map/LocalMBTileOverlay.swift b/Meshtastic/Views/Map/Custom/LocalMBTileOverlay.swift similarity index 93% rename from Meshtastic/Views/Map/LocalMBTileOverlay.swift rename to Meshtastic/Views/Map/Custom/LocalMBTileOverlay.swift index 83409f4b..11c5af3e 100644 --- a/Meshtastic/Views/Map/LocalMBTileOverlay.swift +++ b/Meshtastic/Views/Map/Custom/LocalMBTileOverlay.swift @@ -2,7 +2,7 @@ // LocalMBTileOverlay.swift // MeshtasticApple // -// Created by Joshua Pirihi on 16/01/22. +// Copyright(c) Joshua Pirihi 16/01/22. // import UIKit @@ -41,9 +41,7 @@ enum MapTileError: Error { class LocalMBTileOverlay: MKTileOverlay { var path: String! - var mb: Connection! - private var _boundingMapRect: MKMapRect! override var boundingMapRect: MKMapRect { get { @@ -55,7 +53,6 @@ class LocalMBTileOverlay: MKTileOverlay { super.init(urlTemplate: nil) self.path = path - do { self.mb = try Connection(self.path, readonly: true) let metadata = Table("metadata") @@ -87,43 +84,29 @@ class LocalMBTileOverlay: MKTileOverlay { ] self._boundingMapRect = MKMapRect(coordinates: coords) - } catch { print("💥 Map tile error: \(error)") return nil } - - } override func loadTile(at path: MKTileOverlayPath, result: @escaping (Data?, Error?) -> Void) { - //} - - //override func loadTile(at path: MKTileOverlayPath) async throws -> Data { - let tileX = Int64(path.x) let tileY = Int64(path.y) - let tileZ = Int64(path.z) - + let tileZ = Int64(path.z) let tileData = Expression("tile_data") let zoomLevel = Expression("zoom_level") let tileColumn = Expression("tile_column") let tileRow = Expression("tile_row") if let dataQuery = try? self.mb.pluck(Table("tiles").select(tileData).filter(zoomLevel == tileZ).filter(tileColumn == tileX).filter(tileRow == tileY)) { - let data = Data(bytes: dataQuery[tileData].bytes, count: dataQuery[tileData].bytes.count)//dataQuery![tileData].bytes - - //return data result(data, nil) - } else { print("💥 No tile here: x:\(tileX) y:\(tileY) z:\(tileZ)") - //return Data() let error = NSError(domain: "LocalMBTileOverlay", code: 1, userInfo: ["reason": "no_tile"]) result(nil, error) } } - } diff --git a/Meshtastic/Views/Map/Custom/MapViewFitExtension.swift b/Meshtastic/Views/Map/Custom/MapViewFitExtension.swift new file mode 100644 index 00000000..52c13eaa --- /dev/null +++ b/Meshtastic/Views/Map/Custom/MapViewFitExtension.swift @@ -0,0 +1,37 @@ +// +// MapViewFitExtension.swift +// Meshtastic +// +// Created by Garth Vander Houwen on 1/15/23. +// + +import MapKit + +extension MKMapView { + + func fitAllAnnotations(with padding: UIEdgeInsets = UIEdgeInsets(top: 100, left: 100, bottom: 100, right: 100)) { + var zoomRect: MKMapRect = .null + annotations.forEach({ + let annotationPoint = MKMapPoint($0.coordinate) + let pointRect = MKMapRect(x: annotationPoint.x, y: annotationPoint.y, width: 0.01, height: 0.01) + zoomRect = zoomRect.union(pointRect) + }) + + setVisibleMapRect(zoomRect, edgePadding: padding, animated: true) + } + + func fit(annotations: [MKAnnotation], andShow show: Bool, with padding: UIEdgeInsets = UIEdgeInsets(top: 100, left: 100, bottom: 100, right: 100)) { + var zoomRect: MKMapRect = .null + annotations.forEach({ + let aPoint = MKMapPoint($0.coordinate) + let rect = MKMapRect(x: aPoint.x, y: aPoint.y, width: 0.1, height: 0.1) + zoomRect = zoomRect.isNull ? rect : zoomRect.union(rect) + }) + + if show { + addAnnotations(annotations) + } + + setVisibleMapRect(zoomRect, edgePadding: padding, animated: true) + } +} diff --git a/Meshtastic/Views/Map/Custom/MapViewModule.swift b/Meshtastic/Views/Map/Custom/MapViewModule.swift new file mode 100644 index 00000000..cba18980 --- /dev/null +++ b/Meshtastic/Views/Map/Custom/MapViewModule.swift @@ -0,0 +1,469 @@ +//// +//// MapView.swift +//// MapViewTest +//// +//// Created by Cem Yilmaz on 05.07.21. +//// +//import SwiftUI +//import MapKit +//import CoreData +// +//#if canImport(MapKit) && canImport(UIKit) +//public struct MapView: UIViewRepresentable { +// +// @Environment(\.managedObjectContext) var context +// +// //var context: NSManagedObjectContext? +// +// //@Binding private var region: MKCoordinateRegion +// +// //make this view dependent on the UserDefault that is updated when importing a new map file +// @AppStorage("lastUpdatedLocalMapFile") private var lastUpdatedLocalMapFile = 0 +// @State private var loadedLastUpdatedLocalMapFile = 0 +// +// private var customMapOverlay: CustomMapOverlay? +// @State private var presentCustomMapOverlayHash: CustomMapOverlay? +// +// private var mapType: MKMapType +// +// private var showZoomScale: Bool +// private var zoomEnabled: Bool +// private var zoomRange: (minHeight: CLLocationDistance?, maxHeight: CLLocationDistance?) +// +// private var scrollEnabled: Bool +// private var scrollBoundaries: MKCoordinateRegion? +// +// private var rotationEnabled: Bool +// private var showCompassWhenRotated: Bool +// +// private var showUserLocation: Bool +// private var userTrackingMode: MKUserTrackingMode +// @Binding private var userLocation: CLLocationCoordinate2D? +// +// private var overlays: [Overlay] +// +// @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "time", ascending: false)], animation: .default) +// private var positions: FetchedResults +// +// public init( +// customMapOverlay: CustomMapOverlay? = nil, +// mapType: String = "hybrid", +// zoomEnabled: Bool = true, +// showZoomScale: Bool = false, +// zoomRange: (minHeight: CLLocationDistance?, maxHeight: CLLocationDistance?) = (nil, nil), +// scrollEnabled: Bool = true, +// scrollBoundaries: MKCoordinateRegion? = nil, +// rotationEnabled: Bool = true, +// showCompassWhenRotated: Bool = true, +// showUserLocation: Bool = true, +// userTrackingMode: MKUserTrackingMode = MKUserTrackingMode.none, +// userLocation: Binding = .constant(nil), +// overlays: [Overlay] = [] +// ) { +// self.customMapOverlay = customMapOverlay +// +// switch mapType { +// case "satellite": +// self.mapType = .satellite +// break +// case "standard": +// self.mapType = .standard +// break +// case "hybrid": +// self.mapType = .hybrid +// break +// default: +// self.mapType = .hybrid +// } +// +// self.showZoomScale = showZoomScale +// self.zoomEnabled = zoomEnabled +// self.zoomRange = zoomRange +// +// self.scrollEnabled = scrollEnabled +// self.scrollBoundaries = scrollBoundaries +// +// self.rotationEnabled = rotationEnabled +// self.showCompassWhenRotated = showCompassWhenRotated +// +// self.showUserLocation = showUserLocation +// self.userTrackingMode = userTrackingMode +// self._userLocation = userLocation +// +// self.overlays = overlays +// +// } +// +// public func makeUIView(context: Context) -> MKMapView { +// let mapView = MKMapView() +// mapView.delegate = context.coordinator +// mapView.register(PositionAnnotationView.self, forAnnotationViewWithReuseIdentifier: NSStringFromClass(PositionAnnotationView.self)) +// +// return mapView +// } +// +// +// public func updateUIView(_ mapView: MKMapView, context: Context) { +// +// if self.customMapOverlay != self.presentCustomMapOverlayHash || self.loadedLastUpdatedLocalMapFile != self.lastUpdatedLocalMapFile { +// mapView.removeOverlays(mapView.overlays) +// if let customMapOverlay = self.customMapOverlay { +// +// let fileManager = FileManager.default +// let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! +// let tilePath = documentsDirectory.appendingPathComponent("offline_map.mbtiles", isDirectory: false).path +// if fileManager.fileExists(atPath: tilePath) { +// //if let tilePath = Bundle.main.path(forResource: "offline_map", ofType: "mbtiles") { +// +// print("Loading local map file") +// +// if let overlay = LocalMBTileOverlay(mbTilePath: tilePath) { +// +// overlay.canReplaceMapContent = false//customMapOverlay.canReplaceMapContent +// +// mapView.addOverlay(overlay) +// } +// } else { +// print("Couldn't find a local map file to load") +// } +// } +// DispatchQueue.main.async { +// self.presentCustomMapOverlayHash = self.customMapOverlay +// self.loadedLastUpdatedLocalMapFile = self.lastUpdatedLocalMapFile +// } +// } +// +// if mapView.mapType != self.mapType { +// mapView.mapType = self.mapType +// } +// +// mapView.showsScale = self.zoomEnabled ? self.showZoomScale : false +// +// if mapView.isZoomEnabled != self.zoomEnabled { +// mapView.isZoomEnabled = self.zoomEnabled +// } +// +// if mapView.cameraZoomRange.minCenterCoordinateDistance != self.zoomRange.minHeight ?? 0 || +// mapView.cameraZoomRange.maxCenterCoordinateDistance != self.zoomRange.maxHeight ?? .infinity { +// mapView.cameraZoomRange = MKMapView.CameraZoomRange( +// minCenterCoordinateDistance: self.zoomRange.minHeight ?? 0, +// maxCenterCoordinateDistance: self.zoomRange.maxHeight ?? .infinity +// ) +// } +// +// mapView.isScrollEnabled = self.userTrackingMode == MKUserTrackingMode.none ? self.scrollEnabled : false +// +// if let scrollBoundary = self.scrollBoundaries, (mapView.cameraBoundary?.region.center.latitude != scrollBoundary.center.latitude || mapView.cameraBoundary?.region.center.longitude != scrollBoundary.center.longitude || mapView.camera Boundary?.region.span.latitudeDelta != scrollBoundary.span.latitudeDelta || mapView.cameraBoundary?.region.span.longitudeDelta != scrollBoundary.span.longitudeDelta) { +// mapView.cameraBoundary = MKMapView.CameraBoundary(coordinateRegion: scrollBoundary) +// } else if self.scrollBoundaries == nil && mapView.cameraBoundary != nil { +// mapView.cameraBoundary = nil +// } +// +// mapView.isRotateEnabled = self.userTrackingMode != .followWithHeading ? self.rotationEnabled : false +// mapView.showsCompass = self.userTrackingMode != .followWithHeading ? self.showCompassWhenRotated : false +// +// if mapView.showsUserLocation != self.showUserLocation { +// mapView.showsUserLocation = self.showUserLocation +// } +// +// if mapView.userTrackingMode != self.userTrackingMode { +// mapView.userTrackingMode = self.userTrackingMode +// } +// +// // clear any existing annotations +// var shouldMoveRegion = false +// if !mapView.annotations.isEmpty { +// mapView.removeAnnotations(mapView.annotations) +// } else { +// shouldMoveRegion = true +// } +// +// var displayedNodes: [Int64] = [] +// for position in self.positions { +// if position.nodePosition == nil || displayedNodes.contains(position.nodePosition!.num) || position.coordinate == nil { +// continue +// } +// +// let annotation = PositionAnnotation() +// annotation.coordinate = position.nodeCoordinate! +// annotation.title = position.nodePosition!.user?.longName ?? NSLocalizedString("unknown", comment: "Unknown") +// annotation.shortName = position.nodePosition!.user?.shortName?.uppercased() ?? "???" +// +// mapView.addAnnotation(annotation) +// +// displayedNodes.append(position.nodePosition!.num) +// } +// +// if shouldMoveRegion { +// self.moveToMeshRegion(mapView) +// } +// +// +// } +// +// func moveToMeshRegion(_ mapView: MKMapView) { +// //go through the annotations and create a bounding box that encloses them +// +// var minLat: CLLocationDegrees = 90.0 +// var maxLat: CLLocationDegrees = -90.0 +// var minLon: CLLocationDegrees = 180.0 +// var maxLon: CLLocationDegrees = -180.0 +// +// for annotation in mapView.annotations { +// if annotation.isKind(of: PositionAnnotation.self) { +// minLat = min(minLat, annotation.coordinate.latitude) +// maxLat = max(maxLat, annotation.coordinate.latitude) +// minLon = min(minLon, annotation.coordinate.longitude) +// maxLon = max(maxLon, annotation.coordinate.longitude) +// } +// } +// +// //check if the mesh region looks sensible before we move to it. Otherwise we won't move the map (leave it at the current location) +// if maxLat < minLat || (maxLat-minLat) > 5 || maxLon < minLon || (maxLon-minLon) > 5 { +// return +// } else if minLat == maxLat && minLon == maxLon { +// //then we are focussed on a single point (probably because there is only one node with a position) +// //widen that out a little (don't zoom way in to that point) +// +// //0.001 degrees latitude is about 100m +// //the mapView.regionThatFits call below will expand this out to a rectangle +// minLat = minLat - 0.001 +// maxLat = maxLat + 0.001 +// } +// +// let centerCoord = CLLocationCoordinate2D(latitude: (minLat+maxLat)/2, longitude: (minLon+maxLon)/2) +// +// let span = MKCoordinateSpan(latitudeDelta: (maxLat-minLat)*1.5, longitudeDelta: (maxLon-minLon)*1.5) +// +// let region = mapView.regionThatFits(MKCoordinateRegion(center: centerCoord, span: span)) +// +// mapView.setRegion(region, animated: true) +// } +// +// public func makeCoordinator() -> Coordinator { +// Coordinator(parent: self) +// } +// +// public class Coordinator: NSObject, MKMapViewDelegate { +// +// private var parent: MapView +// public var overlays: [Overlay] = [] +// +// init(parent: MapView) { +// self.parent = parent +// } +// +// public func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { +// +// guard !annotation.isKind(of: MKUserLocation.self) else { +// // Make a fast exit if the annotation is the `MKUserLocation`, as it's not an annotation view we wish to customize. +// return nil +// } +// +// if let annotation = annotation as? PositionAnnotation { +// +// let annotationView = PositionAnnotationView(annotation: annotation, reuseIdentifier: "PositionAnnotation") +// annotationView.name = annotation.shortName ?? "????" +// annotationView.canShowCallout = true +// +// return annotationView +// } +// +// return nil +// } +// +// public func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { +// +// if let index = self.overlays.firstIndex(where: { overlay_ in overlay_.shape.hash == overlay.hash }) { +// +// let unwrappedOverlay = self.overlays[index] +// +// if let circleOverlay = unwrappedOverlay.shape as? MKCircle { +// +// let renderer = MKCircleRenderer(circle: circleOverlay) +// renderer.fillColor = unwrappedOverlay.fillColor +// renderer.strokeColor = unwrappedOverlay.strokeColor +// renderer.lineWidth = unwrappedOverlay.lineWidth +// return renderer +// +// } else if let polygonOverlay = unwrappedOverlay.shape as? MKPolygon { +// +// let renderer = MKPolygonRenderer(polygon: polygonOverlay) +// renderer.fillColor = unwrappedOverlay.fillColor +// renderer.strokeColor = unwrappedOverlay.strokeColor +// renderer.lineWidth = unwrappedOverlay.lineWidth +// return renderer +// +// } else if let multiPolygonOverlay = unwrappedOverlay.shape as? MKMultiPolygon { +// +// let renderer = MKMultiPolygonRenderer(multiPolygon: multiPolygonOverlay) +// renderer.fillColor = unwrappedOverlay.fillColor +// renderer.strokeColor = unwrappedOverlay.strokeColor +// renderer.lineWidth = unwrappedOverlay.lineWidth +// return renderer +// +// } else if let polyLineOverlay = unwrappedOverlay.shape as? MKPolyline { +// +// let renderer = MKPolylineRenderer(polyline: polyLineOverlay) +// renderer.fillColor = unwrappedOverlay.fillColor +// renderer.strokeColor = unwrappedOverlay.strokeColor +// renderer.lineWidth = unwrappedOverlay.lineWidth +// return renderer +// +// } else if let multiPolylineOverlay = unwrappedOverlay.shape as? MKMultiPolyline { +// +// let renderer = MKMultiPolylineRenderer(multiPolyline: multiPolylineOverlay) +// renderer.fillColor = unwrappedOverlay.fillColor +// renderer.strokeColor = unwrappedOverlay.strokeColor +// renderer.lineWidth = unwrappedOverlay.lineWidth +// return renderer +// +// } else { +// +// return MKOverlayRenderer() +// +// } +// +// } else if let tileOverlay = overlay as? MKTileOverlay { +// +// return MKTileOverlayRenderer(tileOverlay: tileOverlay) +// +// } else { +// return MKOverlayRenderer() +// } +// } +// } +// +// /// is supposed to be located in the folder with the map name +// public struct DefaultTile: Hashable { +// let tileName: String +// let tileType: String +// +// public init(tileName: String, tileType: String) { +// self.tileName = tileName +// self.tileType = tileType +// } +// } +// +// public struct CustomMapOverlay: Equatable, Hashable { +// let mapName: String +// let tileType: String +// var canReplaceMapContent: Bool +// var minimumZoomLevel: Int? +// var maximumZoomLevel: Int? +// let defaultTile: DefaultTile? +// +// public init( +// mapName: String, +// tileType: String, +// canReplaceMapContent: Bool = true, // false for transparent tiles +// minimumZoomLevel: Int? = nil, +// maximumZoomLevel: Int? = nil, +// defaultTile: DefaultTile? = nil +// ) { +// +// self.mapName = mapName +// self.tileType = tileType +// self.canReplaceMapContent = canReplaceMapContent +// self.minimumZoomLevel = minimumZoomLevel +// self.maximumZoomLevel = maximumZoomLevel +// self.defaultTile = defaultTile +// } +// +// public init?( +// mapName: String?, +// tileType: String, +// canReplaceMapContent: Bool = true, // false for transparent tiles +// minimumZoomLevel: Int? = nil, +// maximumZoomLevel: Int? = nil, +// defaultTile: DefaultTile? = nil +// ) { +// if (mapName == nil || mapName! == "") { +// return nil +// } +// self.mapName = mapName! +// self.tileType = tileType +// self.canReplaceMapContent = canReplaceMapContent +// self.minimumZoomLevel = minimumZoomLevel +// self.maximumZoomLevel = maximumZoomLevel +// self.defaultTile = defaultTile +// } +// } +// +// public class CustomMapOverlaySource: MKTileOverlay { +// +// // requires folder: tiles/{mapName}/z/y/y,{tileType} +// private var parent: MapView +// private let mapName: String +// private let tileType: String +// private let defaultTile: DefaultTile? +// +// public init( +// parent: MapView, +// mapName: String, +// tileType: String, +// defaultTile: DefaultTile? +// ) { +// self.parent = parent +// self.mapName = mapName +// self.tileType = tileType +// self.defaultTile = defaultTile +// super.init(urlTemplate: "") +// } +// +// public override func url(forTilePath path: MKTileOverlayPath) -> URL { +// if let tileUrl = Bundle.main.url( +// forResource: "\(path.y)", +// withExtension: self.tileType, +// subdirectory: "tiles/\(self.mapName)/\(path.z)/\(path.x)", +// localization: nil +// ) { +// return tileUrl +// } else if let defaultTile = self.defaultTile, let defaultTileUrl = Bundle.main.url( +// forResource: defaultTile.tileName, +// withExtension: defaultTile.tileType, +// subdirectory: "tiles/\(self.mapName)", +// localization: nil +// ) { +// return defaultTileUrl +// } else { +// let urlstring = self.mapName+"\(path.z)/\(path.x)/\(path.y).png" +// return URL(string: urlstring)! +// // Bundle.main.url(forResource: "surrounding", withExtension: "png", subdirectory: "tiles")! +// } +// +// } +// +// } +// +// public struct Overlay { +// +// public static func == (lhs: MapView.Overlay, rhs: MapView.Overlay) -> Bool { +// // maybe to use in the future for comparison of full array +// lhs.shape.coordinate.latitude == rhs.shape.coordinate.latitude && +// lhs.shape.coordinate.longitude == rhs.shape.coordinate.longitude && +// lhs.fillColor == rhs.fillColor +// } +// +// var shape: MKOverlay +// var fillColor: UIColor? +// var strokeColor: UIColor? +// var lineWidth: CGFloat +// +// public init( +// shape: MKOverlay, +// fillColor: UIColor? = nil, +// strokeColor: UIColor? = nil, +// lineWidth: CGFloat = 0 +// ) { +// self.shape = shape +// self.fillColor = fillColor +// self.strokeColor = strokeColor +// self.lineWidth = lineWidth +// } +// } +// +//} +// +//// MARK: End of implementation +//#endif diff --git a/Meshtastic/Views/Map/Custom/MapViewSwiftUI.swift b/Meshtastic/Views/Map/Custom/MapViewSwiftUI.swift new file mode 100644 index 00000000..56a0d1cc --- /dev/null +++ b/Meshtastic/Views/Map/Custom/MapViewSwiftUI.swift @@ -0,0 +1,354 @@ +// +// MapViewSwitUI.swift +// Meshtastic +// +// Copyright(c) Josh Pirihi & Garth Vander Houwen 1/16/22. + +import SwiftUI +import MapKit + +struct MapViewSwiftUI: UIViewRepresentable { + + var onLongPress: (_ waypointCoordinate: CLLocationCoordinate2D) -> Void + var onWaypointEdit: (_ waypointId: Int ) -> Void + let mapView = MKMapView() + let positions: [PositionEntity] + let waypoints: [WaypointEntity] + let mapViewType: MKMapType + let centerOnPositionsOnly: Bool + + // Offline Maps + //make this view dependent on the UserDefault that is updated when importing a new map file + @AppStorage("lastUpdatedLocalMapFile") private var lastUpdatedLocalMapFile = 0 + @State private var loadedLastUpdatedLocalMapFile = 0 + var customMapOverlay: CustomMapOverlay? + @State private var presentCustomMapOverlayHash: CustomMapOverlay? + var overlays: [Overlay] = [] + let dynamicRegion: Bool = true + + func makeUIView(context: Context) -> MKMapView { + // Parameters + mapView.addAnnotations(waypoints) + if centerOnPositionsOnly { + mapView.fit(annotations: positions, andShow: true) + } else { + mapView.addAnnotations(positions) + mapView.fitAllAnnotations() + } + mapView.mapType = mapViewType + mapView.setUserTrackingMode(.none, animated: true) + // Other MKMapView Settings + mapView.isPitchEnabled = true + mapView.isRotateEnabled = true + mapView.isScrollEnabled = true + mapView.isZoomEnabled = true + mapView.showsBuildings = true + mapView.showsCompass = true + mapView.showsScale = true + mapView.showsTraffic = true + mapView.showsUserLocation = true + #if targetEnvironment(macCatalyst) + mapView.showsZoomControls = true + #endif + mapView.delegate = context.coordinator + return mapView + } + + func updateUIView(_ mapView: MKMapView, context: Context) { + mapView.mapType = mapViewType + + if self.customMapOverlay != self.presentCustomMapOverlayHash || self.loadedLastUpdatedLocalMapFile != self.lastUpdatedLocalMapFile { + mapView.removeOverlays(mapView.overlays) + if self.customMapOverlay != nil { + + let fileManager = FileManager.default + let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + let tilePath = documentsDirectory.appendingPathComponent("offline_map.mbtiles", isDirectory: false).path + if fileManager.fileExists(atPath: tilePath) { + print("Loading local map file") + if let overlay = LocalMBTileOverlay(mbTilePath: tilePath) { + overlay.canReplaceMapContent = false//customMapOverlay.canReplaceMapContent + mapView.addOverlay(overlay) + } + } else { + print("Couldn't find a local map file to load") + } + } + DispatchQueue.main.async { + self.presentCustomMapOverlayHash = self.customMapOverlay + self.loadedLastUpdatedLocalMapFile = self.lastUpdatedLocalMapFile + } + } + mapView.removeAnnotations(mapView.annotations) + mapView.addAnnotations(positions) + mapView.addAnnotations(waypoints) + } + + func makeCoordinator() -> MapCoordinator { + return Coordinator(self) + } + + final class MapCoordinator: NSObject, MKMapViewDelegate, UIGestureRecognizerDelegate { + + var parent: MapViewSwiftUI + var longPressRecognizer = UILongPressGestureRecognizer() + var overlays: [Overlay] = [] + + init(_ parent: MapViewSwiftUI) { + self.parent = parent + super.init() + self.longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPressHandler)) + self.longPressRecognizer.minimumPressDuration = 0.5 + //self.longPressRecognizer.numberOfTouchesRequired = 1 + self.longPressRecognizer.cancelsTouchesInView = true + self.longPressRecognizer.delegate = self + self.parent.mapView.addGestureRecognizer(longPressRecognizer) + self.overlays = [] + } + + func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { + + switch annotation { + + case _ as MKClusterAnnotation: + let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "nodeGroup") as? MKMarkerAnnotationView ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: "nodeGroup") + annotationView.markerTintColor = .brown//.systemRed + annotationView.displayPriority = .defaultLow + annotationView.tag = -1 + return annotationView + case _ as PositionEntity: + let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "node") as? MKMarkerAnnotationView ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: "Node") + annotationView.tag = -1 + annotationView.canShowCallout = true + annotationView.glyphText = "📟" + annotationView.clusteringIdentifier = "nodeGroup" + annotationView.markerTintColor = UIColor(.indigo) + annotationView.titleVisibility = .adaptive + return annotationView + case let waypointAnnotation as WaypointEntity: + let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: "waypoint") as? MKMarkerAnnotationView ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: "Waypoint") + annotationView.tag = Int(waypointAnnotation.id) + annotationView.isEnabled = true + annotationView.canShowCallout = true + if waypointAnnotation.icon == 0 { + annotationView.glyphText = "📍" + } else { + annotationView.glyphText = String(UnicodeScalar(Int(waypointAnnotation.icon)) ?? "📍") + } + annotationView.clusteringIdentifier = "waypointGroup" + annotationView.markerTintColor = UIColor(.accentColor) + annotationView.displayPriority = .required + annotationView.titleVisibility = .adaptive + let leftIcon = UIImageView(image: annotationView.glyphText?.image()) + leftIcon.backgroundColor = UIColor(.accentColor) + annotationView.leftCalloutAccessoryView = leftIcon + let subtitle = UILabel() + subtitle.text = waypointAnnotation.longDescription + subtitle.numberOfLines = 0 + annotationView.detailCalloutAccessoryView = subtitle + let editIcon = UIButton(type: .detailDisclosure) + editIcon.setImage(UIImage(systemName: "square.and.pencil"), for: .normal) + annotationView.rightCalloutAccessoryView = editIcon + return annotationView + default: return nil + } + } + + func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) { + // Only Allow Edit for waypoint annotations with a id + if view.tag > 0 { + parent.onWaypointEdit(view.tag) + } + } + + @objc func longPressHandler(_ gesture: UILongPressGestureRecognizer) { + + if gesture.state != UIGestureRecognizer.State.ended { + return + } else if gesture.state != UIGestureRecognizer.State.began { + + // Screen Position - CGPoint + let location = longPressRecognizer.location(in: self.parent.mapView) + + // Map Coordinate - CLLocationCoordinate2D + let coordinate = self.parent.mapView.convert(location, toCoordinateFrom: self.parent.mapView) + let annotation = MKPointAnnotation() + annotation.title = "Dropped Pin" + annotation.coordinate = coordinate + parent.mapView.addAnnotation(annotation) + UINotificationFeedbackGenerator().notificationOccurred(.success) + parent.onLongPress(coordinate) + } + } + + public func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { + + if let index = self.overlays.firstIndex(where: { overlay_ in overlay_.shape.hash == overlay.hash }) { + + let unwrappedOverlay = self.overlays[index] + if let circleOverlay = unwrappedOverlay.shape as? MKCircle { + let renderer = MKCircleRenderer(circle: circleOverlay) + renderer.fillColor = unwrappedOverlay.fillColor + renderer.strokeColor = unwrappedOverlay.strokeColor + renderer.lineWidth = unwrappedOverlay.lineWidth + return renderer + } else if let polygonOverlay = unwrappedOverlay.shape as? MKPolygon { + let renderer = MKPolygonRenderer(polygon: polygonOverlay) + renderer.fillColor = unwrappedOverlay.fillColor + renderer.strokeColor = unwrappedOverlay.strokeColor + renderer.lineWidth = unwrappedOverlay.lineWidth + return renderer + } else if let multiPolygonOverlay = unwrappedOverlay.shape as? MKMultiPolygon { + let renderer = MKMultiPolygonRenderer(multiPolygon: multiPolygonOverlay) + renderer.fillColor = unwrappedOverlay.fillColor + renderer.strokeColor = unwrappedOverlay.strokeColor + renderer.lineWidth = unwrappedOverlay.lineWidth + return renderer + } else if let polyLineOverlay = unwrappedOverlay.shape as? MKPolyline { + let renderer = MKPolylineRenderer(polyline: polyLineOverlay) + renderer.fillColor = unwrappedOverlay.fillColor + renderer.strokeColor = unwrappedOverlay.strokeColor + renderer.lineWidth = unwrappedOverlay.lineWidth + return renderer + } else if let multiPolylineOverlay = unwrappedOverlay.shape as? MKMultiPolyline { + let renderer = MKMultiPolylineRenderer(multiPolyline: multiPolylineOverlay) + renderer.fillColor = unwrappedOverlay.fillColor + renderer.strokeColor = unwrappedOverlay.strokeColor + renderer.lineWidth = unwrappedOverlay.lineWidth + return renderer + } else { + return MKOverlayRenderer() + } + } else if let tileOverlay = overlay as? MKTileOverlay { + return MKTileOverlayRenderer(tileOverlay: tileOverlay) + } else { + return MKOverlayRenderer() + } + } + } + + /// is supposed to be located in the folder with the map name + public struct DefaultTile: Hashable { + let tileName: String + let tileType: String + + public init(tileName: String, tileType: String) { + self.tileName = tileName + self.tileType = tileType + } + } + + public struct CustomMapOverlay: Equatable, Hashable { + let mapName: String + let tileType: String + var canReplaceMapContent: Bool + var minimumZoomLevel: Int? + var maximumZoomLevel: Int? + let defaultTile: DefaultTile? + + public init( + mapName: String, + tileType: String, + canReplaceMapContent: Bool = true, // false for transparent tiles + minimumZoomLevel: Int? = nil, + maximumZoomLevel: Int? = nil, + defaultTile: DefaultTile? = nil + ) { + self.mapName = mapName + self.tileType = tileType + self.canReplaceMapContent = canReplaceMapContent + self.minimumZoomLevel = minimumZoomLevel + self.maximumZoomLevel = maximumZoomLevel + self.defaultTile = defaultTile + } + + public init?( + mapName: String?, + tileType: String, + canReplaceMapContent: Bool = true, // false for transparent tiles + minimumZoomLevel: Int? = nil, + maximumZoomLevel: Int? = nil, + defaultTile: DefaultTile? = nil + ) { + if (mapName == nil || mapName! == "") { + return nil + } + self.mapName = mapName! + self.tileType = tileType + self.canReplaceMapContent = canReplaceMapContent + self.minimumZoomLevel = minimumZoomLevel + self.maximumZoomLevel = maximumZoomLevel + self.defaultTile = defaultTile + } + } + + public class CustomMapOverlaySource: MKTileOverlay { + + // requires folder: tiles/{mapName}/z/y/y,{tileType} + private var parent: MapViewSwiftUI + private let mapName: String + private let tileType: String + private let defaultTile: DefaultTile? + + public init( + parent: MapViewSwiftUI, + mapName: String, + tileType: String, + defaultTile: DefaultTile? + ) { + self.parent = parent + self.mapName = mapName + self.tileType = tileType + self.defaultTile = defaultTile + super.init(urlTemplate: "") + } + + public override func url(forTilePath path: MKTileOverlayPath) -> URL { + if let tileUrl = Bundle.main.url( + forResource: "\(path.y)", + withExtension: self.tileType, + subdirectory: "tiles/\(self.mapName)/\(path.z)/\(path.x)", + localization: nil + ) { + return tileUrl + } else if let defaultTile = self.defaultTile, let defaultTileUrl = Bundle.main.url( + forResource: defaultTile.tileName, + withExtension: defaultTile.tileType, + subdirectory: "tiles/\(self.mapName)", + localization: nil + ) { + return defaultTileUrl + } else { + let urlstring = self.mapName+"\(path.z)/\(path.x)/\(path.y).png" + return URL(string: urlstring)! + } + } + } + + public struct Overlay { + + public static func == (lhs: MapViewSwiftUI.Overlay, rhs: MapViewSwiftUI.Overlay) -> Bool { + // maybe to use in the future for comparison of full array + lhs.shape.coordinate.latitude == rhs.shape.coordinate.latitude && + lhs.shape.coordinate.longitude == rhs.shape.coordinate.longitude && + lhs.fillColor == rhs.fillColor + } + + var shape: MKOverlay + var fillColor: UIColor? + var strokeColor: UIColor? + var lineWidth: CGFloat + + public init( + shape: MKOverlay, + fillColor: UIColor? = nil, + strokeColor: UIColor? = nil, + lineWidth: CGFloat = 0 + ) { + self.shape = shape + self.fillColor = fillColor + self.strokeColor = strokeColor + self.lineWidth = lineWidth + } + } +} diff --git a/Meshtastic/Views/Map/Custom/PositionAnnotationView.swift b/Meshtastic/Views/Map/Custom/PositionAnnotationView.swift index 3c3e0f79..cfb843c3 100644 --- a/Meshtastic/Views/Map/Custom/PositionAnnotationView.swift +++ b/Meshtastic/Views/Map/Custom/PositionAnnotationView.swift @@ -1,60 +1,60 @@ +//// +//// PositionAnnotationView.swift +//// MeshtasticApple +//// +//// Created by Joshua Pirihi on 24/12/21. +//// // -// PositionAnnotationView.swift -// MeshtasticApple +//import UIKit +//import MapKit +//import SwiftUI // -// Created by Joshua Pirihi on 24/12/21. +//// a simple circle annotation, with a string in it +//class PositionAnnotation: NSObject, MKAnnotation { // - -import UIKit -import MapKit -import SwiftUI - -// a simple circle annotation, with a string in it -class PositionAnnotation: NSObject, MKAnnotation { - - // This property must be key-value observable, which the `@objc dynamic` attributes provide. - @objc dynamic var coordinate = CLLocationCoordinate2D(latitude: 0, longitude: 0) - - // Required if you set the annotation view's `canShowCallout` property to `true` - // this string fills the callout label when you tap an annotation - var title: String? - - // the text to appear inside the little circle - var shortName: String? - -} - -class PositionAnnotationView: MKAnnotationView { - - private let annotationFrame = CGRect(x: 0, y: 0, width: 40, height: 40) - private let label: UILabel - - override init(annotation: MKAnnotation?, reuseIdentifier: String?) { - self.label = UILabel(frame: annotationFrame.offsetBy(dx: 0, dy: 0)) - super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) - self.frame = annotationFrame - self.label.font = UIFont.preferredFont(forTextStyle: .caption2) - self.label.textColor = .white - self.label.textAlignment = .center - self.backgroundColor = .clear - self.addSubview(label) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) not implemented!") - } - - public var name: String = "" { - didSet { - self.label.text = name - } - } - - override func draw(_ rect: CGRect) { - guard let context = UIGraphicsGetCurrentContext() else { return } - - let circleRect = CGRect(x: 1, y: 1, width: 38, height: 38) - context.setFillColor(Color.accentColor.cgColor ?? CGColor(red: 0, green: 0.5, blue: 1.0, alpha: 1.0)) - context.fillEllipse(in: circleRect) - } -} +// // This property must be key-value observable, which the `@objc dynamic` attributes provide. +// @objc dynamic var coordinate = CLLocationCoordinate2D(latitude: 0, longitude: 0) +// +// // Required if you set the annotation view's `canShowCallout` property to `true` +// // this string fills the callout label when you tap an annotation +// var title: String? +// +// // the text to appear inside the little circle +// var shortName: String? +// +//} +// +//class PositionAnnotationView: MKAnnotationView { +// +// private let annotationFrame = CGRect(x: 0, y: 0, width: 40, height: 40) +// private let label: UILabel +// +// override init(annotation: MKAnnotation?, reuseIdentifier: String?) { +// self.label = UILabel(frame: annotationFrame.offsetBy(dx: 0, dy: 0)) +// super.init(annotation: annotation, reuseIdentifier: reuseIdentifier) +// self.frame = annotationFrame +// self.label.font = UIFont.preferredFont(forTextStyle: .caption2) +// self.label.textColor = .white +// self.label.textAlignment = .center +// self.backgroundColor = .clear +// self.addSubview(label) +// } +// +// required init?(coder aDecoder: NSCoder) { +// fatalError("init(coder:) not implemented!") +// } +// +// public var name: String = "" { +// didSet { +// self.label.text = name +// } +// } +// +// override func draw(_ rect: CGRect) { +// guard let context = UIGraphicsGetCurrentContext() else { return } +// +// let circleRect = CGRect(x: 1, y: 1, width: 38, height: 38) +// context.setFillColor(Color.accentColor.cgColor ?? CGColor(red: 0, green: 0.5, blue: 1.0, alpha: 1.0)) +// context.fillEllipse(in: circleRect) +// } +//} diff --git a/Meshtastic/Views/Map/MapView.swift b/Meshtastic/Views/Map/MapView.swift deleted file mode 100644 index 02c752be..00000000 --- a/Meshtastic/Views/Map/MapView.swift +++ /dev/null @@ -1,173 +0,0 @@ -// -// MapView.swift -// MeshtasticApple -// -// Created by Joshua Pirihi on 22/12/21. -// - -import Foundation -import UIKit -import MapKit -import SwiftUI -import CoreData -#if false -// wrap a MKMapView into something we can use in SwiftUI -struct MapView: UIViewRepresentable { - - var nodes: FetchedResults - - var mapViewDelegate = MapViewDelegate() - - // observe changes to the key in UserDefaults - @AppStorage("meshMapType") var type: String = "hybrid" - - func makeUIView(context: Context) -> MKMapView { - - let map = MKMapView(frame: .zero) - - map.userTrackingMode = .follow - - let region = MKCoordinateRegion( center: map.centerCoordinate, latitudinalMeters: CLLocationDistance(exactly: 500)!, longitudinalMeters: CLLocationDistance(exactly: 500)!) - map.setRegion(map.regionThatFits(region), animated: false) - - //self.updateMapType(map) - self.showNodePositions(to: map) - self.moveToMeshRegion(in: map) - - map.register(PositionAnnotationView.self, forAnnotationViewWithReuseIdentifier: NSStringFromClass(PositionAnnotationView.self)) - - let overlay = MKTileOverlay(urlTemplate: //"http://tiles-a.data-cdn.linz.govt.nz/services;key=7fa19132d53240708c4ff436df5b9800/tiles/v4/layer=50767/EPSG:3857/{z}/{x}/{y}.png") - "http://10.147.253.250:5050/local/map/{z}/{x}/{y}.png") - overlay.canReplaceMapContent = true - self.mapViewDelegate.renderer = MKTileOverlayRenderer(tileOverlay: overlay) - map.addOverlay(overlay) - - return map - } - - func updateUIView(_ view: MKMapView, context: Context) { - view.delegate = mapViewDelegate // (1) This should be set in makeUIView, but it is getting reset to `nil` - view.translatesAutoresizingMaskIntoConstraints = false // (2) In the absence of this, we get constraints error on rotation; and again, it seems one should do this in makeUIView, but has to be here - - self.updateMapType(view) - - self.showNodePositions(to: view) - - //if (self.needToMoveToMeshRegion) { - // self.moveToMeshRegion(in: view) - // self.needToMoveToMeshRegion = false - //} - } - - func moveToMeshRegion(in mapView: MKMapView) { - //go through the annotations and create a bounding box that encloses them - - var minLat: CLLocationDegrees = 90.0 - var maxLat: CLLocationDegrees = -90.0 - var minLon: CLLocationDegrees = 180.0 - var maxLon: CLLocationDegrees = -180.0 - - for annotation in mapView.annotations { - if annotation.isKind(of: PositionAnnotation.self) { - minLat = min(minLat, annotation.coordinate.latitude) - maxLat = max(maxLat, annotation.coordinate.latitude) - minLon = min(minLon, annotation.coordinate.longitude) - maxLon = max(maxLon, annotation.coordinate.longitude) - } - } - - //check if the mesh region looks sensible before we move to it. Otherwise we won't move the map (leave it at the current location) - if maxLat < minLat || (maxLat-minLat) > 5 || maxLon < minLon || (maxLon-minLon) > 5 { - return - } - - let centerCoord = CLLocationCoordinate2D(latitude: (minLat+maxLat)/2, longitude: (minLon+maxLon)/2) - - let span = MKCoordinateSpan(latitudeDelta: (maxLat-minLat)*1.5, longitudeDelta: (maxLon-minLon)*1.5) - - let region = mapView.regionThatFits(MKCoordinateRegion(center: centerCoord, span: span)) - - mapView.setRegion(region, animated: true) - - - } - - func updateMapType(_ map: MKMapView) { - - switch self.type { - case "satellite": - map.mapType = .satellite - break - case "standard": - map.mapType = .standard - break - case "hybrid": - map.mapType = .hybrid - break - default: - map.mapType = .hybrid - } - } -} - -private extension MapView { - - func showNodePositions(to view: MKMapView) { - - // clear any existing annotations - if !view.annotations.isEmpty { - view.removeAnnotations(view.annotations) - } - - for node in self.nodes { - // try and get the last position - if (node.positions?.count ?? 0) > 0 && (node.positions!.lastObject as! PositionEntity).coordinate != nil { - let annotation = PositionAnnotation() - annotation.coordinate = (node.positions!.lastObject as! PositionEntity).coordinate! - annotation.title = node.user?.longName ?? NSLocalizedString("unknown", comment: "Unknown") - annotation.shortName = node.user?.shortName?.uppercased() ?? "???" - - view.addAnnotation(annotation) - } - } - } -} - -class MapViewDelegate: NSObject, MKMapViewDelegate { - - var renderer: MKTileOverlayRenderer? - - func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { - - guard !annotation.isKind(of: MKUserLocation.self) else { - // Make a fast exit if the annotation is the `MKUserLocation`, as it's not an annotation view we wish to customize. - return nil - } - - var annotationView: MKAnnotationView? - - if let annotation = annotation as? PositionAnnotation { - annotationView = self.setupPositionAnnotationView(for: annotation, on: mapView) - } - - return annotationView - } - - func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { - return self.renderer! - - } - - private func setupPositionAnnotationView(for annotation: PositionAnnotation, on mapView: MKMapView) -> PositionAnnotationView { - let identifier = NSStringFromClass(PositionAnnotationView.self) - - let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? PositionAnnotationView ?? PositionAnnotationView() - - annotationView.name = annotation.shortName ?? "???" - - annotationView.canShowCallout = true - - return annotationView - } -} -#endif diff --git a/Meshtastic/Views/Map/MapViewModule.swift b/Meshtastic/Views/Map/MapViewModule.swift deleted file mode 100644 index 13d5bb77..00000000 --- a/Meshtastic/Views/Map/MapViewModule.swift +++ /dev/null @@ -1,767 +0,0 @@ -// -// MapView.swift -// MapViewTest -// -// Created by Cem Yilmaz on 05.07.21. -// -import SwiftUI -import MapKit -import CoreData - -#if canImport(MapKit) && canImport(UIKit) -public struct MapView: UIViewRepresentable { - - @Environment(\.managedObjectContext) var context - - //var context: NSManagedObjectContext? - - //@Binding private var region: MKCoordinateRegion - - //make this view dependent on the UserDefault that is updated when importing a new map file - @AppStorage("lastUpdatedLocalMapFile") private var lastUpdatedLocalMapFile = 0 - @State private var loadedLastUpdatedLocalMapFile = 0 - - private var customMapOverlay: CustomMapOverlay? - @State private var presentCustomMapOverlayHash: CustomMapOverlay? - - private var mapType: MKMapType - - private var showZoomScale: Bool - private var zoomEnabled: Bool - private var zoomRange: (minHeight: CLLocationDistance?, maxHeight: CLLocationDistance?) - - private var scrollEnabled: Bool - private var scrollBoundaries: MKCoordinateRegion? - - private var rotationEnabled: Bool - private var showCompassWhenRotated: Bool - - private var showUserLocation: Bool - private var userTrackingMode: MKUserTrackingMode - @Binding private var userLocation: CLLocationCoordinate2D? - - //private var annotations: [MKPointAnnotation] - - private var overlays: [Overlay] - - //@FetchRequest(sortDescriptors: [NSSortDescriptor(key: "lastHeard", ascending: false)], animation: .default) - // private var locationNodes: FetchedResults - - @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "time", ascending: false)], animation: .default) - private var positions: FetchedResults - - //@State private var locationNodes: [NodeInfoEntity] - - public init( - //region: Binding = .constant(MKCoordinateRegion()), - customMapOverlay: CustomMapOverlay? = nil, - //mapType: MKMapType = MKMapType.standard, - mapType: String = "hybrid", - zoomEnabled: Bool = true, - showZoomScale: Bool = false, - zoomRange: (minHeight: CLLocationDistance?, maxHeight: CLLocationDistance?) = (nil, nil), - scrollEnabled: Bool = true, - scrollBoundaries: MKCoordinateRegion? = nil, - rotationEnabled: Bool = true, - showCompassWhenRotated: Bool = true, - showUserLocation: Bool = true, - userTrackingMode: MKUserTrackingMode = MKUserTrackingMode.none, - userLocation: Binding = .constant(nil), - //annotations: [MKPointAnnotation] = [], - //locationNodes: [NodeInfoEntity] = [], - overlays: [Overlay] = [] - //context: NSManagedObjectContext? = nil - ) { - //self._region = region - - self.customMapOverlay = customMapOverlay - - switch mapType { - case "satellite": - self.mapType = .satellite - break - case "standard": - self.mapType = .standard - break - case "hybrid": - self.mapType = .hybrid - break - default: - self.mapType = .hybrid - } - //self.mapType = mapType - - self.showZoomScale = showZoomScale - self.zoomEnabled = zoomEnabled - self.zoomRange = zoomRange - - self.scrollEnabled = scrollEnabled - self.scrollBoundaries = scrollBoundaries - - self.rotationEnabled = rotationEnabled - self.showCompassWhenRotated = showCompassWhenRotated - - self.showUserLocation = showUserLocation - self.userTrackingMode = userTrackingMode - self._userLocation = userLocation - - //self.annotations = annotations - - //self.locationNodes = locationNodes - - self.overlays = overlays - - } - - public func makeUIView(context: Context) -> MKMapView { - let mapView = MKMapView() - mapView.delegate = context.coordinator - mapView.register(PositionAnnotationView.self, forAnnotationViewWithReuseIdentifier: NSStringFromClass(PositionAnnotationView.self)) - - return mapView - } - - - public func updateUIView(_ mapView: MKMapView, context: Context) { - - //if self.userTrackingMode == MKUserTrackingMode.none && (mapView.region.center.latitude != self.region.center.latitude || mapView.region.center.longitude != self.region.center.longitude) { - //mapView.region = self.region - //} - - if self.customMapOverlay != self.presentCustomMapOverlayHash || self.loadedLastUpdatedLocalMapFile != self.lastUpdatedLocalMapFile { - mapView.removeOverlays(mapView.overlays) - if let customMapOverlay = self.customMapOverlay { - - let fileManager = FileManager.default - let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! - let tilePath = documentsDirectory.appendingPathComponent("offline_map.mbtiles", isDirectory: false).path - if fileManager.fileExists(atPath: tilePath) { - //if let tilePath = Bundle.main.path(forResource: "offline_map", ofType: "mbtiles") { - - print("Loading local map file") - - if let overlay = LocalMBTileOverlay(mbTilePath: tilePath) { - - overlay.canReplaceMapContent = false//customMapOverlay.canReplaceMapContent - - mapView.addOverlay(overlay) - } - } else { - print("Couldn't find a local map file to load") - } - } - DispatchQueue.main.async { - self.presentCustomMapOverlayHash = self.customMapOverlay - self.loadedLastUpdatedLocalMapFile = self.lastUpdatedLocalMapFile - } - } - - /*if mapView.overlays.count != (self.overlays.count + (self.customMapOverlay == nil ? 0 : 1)) { - context.coordinator.overlays = self.overlays - mapView.overlays.forEach { overlay in - if !(overlay is MKTileOverlay) { - mapView.removeOverlay(overlay) - } - } - mapView.addOverlays(self.overlays.map { overlay in overlay.shape }) - }*/ - - if mapView.mapType != self.mapType { - mapView.mapType = self.mapType - } - - mapView.showsScale = self.zoomEnabled ? self.showZoomScale : false - - if mapView.isZoomEnabled != self.zoomEnabled { - mapView.isZoomEnabled = self.zoomEnabled - } - - if mapView.cameraZoomRange.minCenterCoordinateDistance != self.zoomRange.minHeight ?? 0 || - mapView.cameraZoomRange.maxCenterCoordinateDistance != self.zoomRange.maxHeight ?? .infinity { - mapView.cameraZoomRange = MKMapView.CameraZoomRange( - minCenterCoordinateDistance: self.zoomRange.minHeight ?? 0, - maxCenterCoordinateDistance: self.zoomRange.maxHeight ?? .infinity - ) - } - - mapView.isScrollEnabled = self.userTrackingMode == MKUserTrackingMode.none ? self.scrollEnabled : false - - if let scrollBoundary = self.scrollBoundaries, (mapView.cameraBoundary?.region.center.latitude != scrollBoundary.center.latitude || mapView.cameraBoundary?.region.center.longitude != scrollBoundary.center.longitude || mapView.cameraBoundary?.region.span.latitudeDelta != scrollBoundary.span.latitudeDelta || mapView.cameraBoundary?.region.span.longitudeDelta != scrollBoundary.span.longitudeDelta) { - mapView.cameraBoundary = MKMapView.CameraBoundary(coordinateRegion: scrollBoundary) - } else if self.scrollBoundaries == nil && mapView.cameraBoundary != nil { - mapView.cameraBoundary = nil - } - - mapView.isRotateEnabled = self.userTrackingMode != .followWithHeading ? self.rotationEnabled : false - mapView.showsCompass = self.userTrackingMode != .followWithHeading ? self.showCompassWhenRotated : false - - if mapView.showsUserLocation != self.showUserLocation { - mapView.showsUserLocation = self.showUserLocation - } - - if mapView.userTrackingMode != self.userTrackingMode { - mapView.userTrackingMode = self.userTrackingMode - } - - //if mapView.annotations.filter({ annotation in !(annotation is MKUserLocation) }).count != self.annotations.count { - // mapView.removeAnnotations(mapView.annotations) - // mapView.addAnnotations(self.annotations) - //} - - // clear any existing annotations - var shouldMoveRegion = false - if !mapView.annotations.isEmpty { - mapView.removeAnnotations(mapView.annotations) - } else { - shouldMoveRegion = true - } - - var displayedNodes: [Int64] = [] - for position in self.positions { - if position.nodePosition == nil || displayedNodes.contains(position.nodePosition!.num) || position.coordinate == nil { - continue - } - - let annotation = PositionAnnotation() - annotation.coordinate = position.coordinate! - annotation.title = position.nodePosition!.user?.longName ?? NSLocalizedString("unknown", comment: "Unknown") - annotation.shortName = position.nodePosition!.user?.shortName?.uppercased() ?? "???" - - mapView.addAnnotation(annotation) - - displayedNodes.append(position.nodePosition!.num) - } - - if shouldMoveRegion { - self.moveToMeshRegion(mapView) - } - - - } - - func moveToMeshRegion(_ mapView: MKMapView) { - //go through the annotations and create a bounding box that encloses them - - var minLat: CLLocationDegrees = 90.0 - var maxLat: CLLocationDegrees = -90.0 - var minLon: CLLocationDegrees = 180.0 - var maxLon: CLLocationDegrees = -180.0 - - for annotation in mapView.annotations { - if annotation.isKind(of: PositionAnnotation.self) { - minLat = min(minLat, annotation.coordinate.latitude) - maxLat = max(maxLat, annotation.coordinate.latitude) - minLon = min(minLon, annotation.coordinate.longitude) - maxLon = max(maxLon, annotation.coordinate.longitude) - } - } - - //check if the mesh region looks sensible before we move to it. Otherwise we won't move the map (leave it at the current location) - if maxLat < minLat || (maxLat-minLat) > 5 || maxLon < minLon || (maxLon-minLon) > 5 { - return - } else if minLat == maxLat && minLon == maxLon { - //then we are focussed on a single point (probably because there is only one node with a position) - //widen that out a little (don't zoom way in to that point) - - //0.001 degrees latitude is about 100m - //the mapView.regionThatFits call below will expand this out to a rectangle - minLat = minLat - 0.001 - maxLat = maxLat + 0.001 - } - - let centerCoord = CLLocationCoordinate2D(latitude: (minLat+maxLat)/2, longitude: (minLon+maxLon)/2) - - let span = MKCoordinateSpan(latitudeDelta: (maxLat-minLat)*1.5, longitudeDelta: (maxLon-minLon)*1.5) - - let region = mapView.regionThatFits(MKCoordinateRegion(center: centerCoord, span: span)) - - mapView.setRegion(region, animated: true) - - - } - - public func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - public class Coordinator: NSObject, MKMapViewDelegate { - - private var parent: MapView - public var overlays: [Overlay] = [] - - init(parent: MapView) { - self.parent = parent - } - - /*public func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) { - DispatchQueue.main.async { - self.parent.region = mapView.region - } - }*/ - - public func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { - - guard !annotation.isKind(of: MKUserLocation.self) else { - // Make a fast exit if the annotation is the `MKUserLocation`, as it's not an annotation view we wish to customize. - return nil - } - //var annotationView: MKAnnotationView? - - if let annotation = annotation as? PositionAnnotation { - - let annotationView = PositionAnnotationView(annotation: annotation, reuseIdentifier: "PositionAnnotation") - annotationView.name = annotation.shortName ?? "????" - annotationView.canShowCallout = true - - return annotationView - } - - return nil - } - - public func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { - - if let index = self.overlays.firstIndex(where: { overlay_ in overlay_.shape.hash == overlay.hash }) { - - let unwrappedOverlay = self.overlays[index] - - if let circleOverlay = unwrappedOverlay.shape as? MKCircle { - - let renderer = MKCircleRenderer(circle: circleOverlay) - renderer.fillColor = unwrappedOverlay.fillColor - renderer.strokeColor = unwrappedOverlay.strokeColor - renderer.lineWidth = unwrappedOverlay.lineWidth - return renderer - - } else if let polygonOverlay = unwrappedOverlay.shape as? MKPolygon { - - let renderer = MKPolygonRenderer(polygon: polygonOverlay) - renderer.fillColor = unwrappedOverlay.fillColor - renderer.strokeColor = unwrappedOverlay.strokeColor - renderer.lineWidth = unwrappedOverlay.lineWidth - return renderer - - } else if let multiPolygonOverlay = unwrappedOverlay.shape as? MKMultiPolygon { - - let renderer = MKMultiPolygonRenderer(multiPolygon: multiPolygonOverlay) - renderer.fillColor = unwrappedOverlay.fillColor - renderer.strokeColor = unwrappedOverlay.strokeColor - renderer.lineWidth = unwrappedOverlay.lineWidth - return renderer - - } else if let polyLineOverlay = unwrappedOverlay.shape as? MKPolyline { - - let renderer = MKPolylineRenderer(polyline: polyLineOverlay) - renderer.fillColor = unwrappedOverlay.fillColor - renderer.strokeColor = unwrappedOverlay.strokeColor - renderer.lineWidth = unwrappedOverlay.lineWidth - return renderer - - } else if let multiPolylineOverlay = unwrappedOverlay.shape as? MKMultiPolyline { - - let renderer = MKMultiPolylineRenderer(multiPolyline: multiPolylineOverlay) - renderer.fillColor = unwrappedOverlay.fillColor - renderer.strokeColor = unwrappedOverlay.strokeColor - renderer.lineWidth = unwrappedOverlay.lineWidth - return renderer - - } else { - - return MKOverlayRenderer() - - } - - } else if let tileOverlay = overlay as? MKTileOverlay { - - return MKTileOverlayRenderer(tileOverlay: tileOverlay) - - } else { - - return MKOverlayRenderer() - - } - - } - - - - } - - /// is supposed to be located in the folder with the map name - public struct DefaultTile: Hashable { - let tileName: String - let tileType: String - - public init(tileName: String, tileType: String) { - self.tileName = tileName - self.tileType = tileType - } - } - - public struct CustomMapOverlay: Equatable, Hashable { - let mapName: String - let tileType: String - var canReplaceMapContent: Bool - var minimumZoomLevel: Int? - var maximumZoomLevel: Int? - let defaultTile: DefaultTile? - - public init( - mapName: String, - tileType: String, - canReplaceMapContent: Bool = true, // false for transparent tiles - minimumZoomLevel: Int? = nil, - maximumZoomLevel: Int? = nil, - defaultTile: DefaultTile? = nil - ) { - - self.mapName = mapName - self.tileType = tileType - self.canReplaceMapContent = canReplaceMapContent - self.minimumZoomLevel = minimumZoomLevel - self.maximumZoomLevel = maximumZoomLevel - self.defaultTile = defaultTile - } - - public init?( - mapName: String?, - tileType: String, - canReplaceMapContent: Bool = true, // false for transparent tiles - minimumZoomLevel: Int? = nil, - maximumZoomLevel: Int? = nil, - defaultTile: DefaultTile? = nil - ) { - if (mapName == nil || mapName! == "") { - return nil - } - self.mapName = mapName! - self.tileType = tileType - self.canReplaceMapContent = canReplaceMapContent - self.minimumZoomLevel = minimumZoomLevel - self.maximumZoomLevel = maximumZoomLevel - self.defaultTile = defaultTile - } - } - - public class CustomMapOverlaySource: MKTileOverlay { - - // requires folder: tiles/{mapName}/z/y/y,{tileType} - private var parent: MapView - private let mapName: String - private let tileType: String - private let defaultTile: DefaultTile? - - public init( - parent: MapView, - mapName: String, - tileType: String, - defaultTile: DefaultTile? - ) { - self.parent = parent - self.mapName = mapName - self.tileType = tileType - self.defaultTile = defaultTile - super.init(urlTemplate: "") - } - - public override func url(forTilePath path: MKTileOverlayPath) -> URL { - if let tileUrl = Bundle.main.url( - forResource: "\(path.y)", - withExtension: self.tileType, - subdirectory: "tiles/\(self.mapName)/\(path.z)/\(path.x)", - localization: nil - ) { - return tileUrl - } else if let defaultTile = self.defaultTile, let defaultTileUrl = Bundle.main.url( - forResource: defaultTile.tileName, - withExtension: defaultTile.tileType, - subdirectory: "tiles/\(self.mapName)", - localization: nil - ) { - return defaultTileUrl - } else { - let urlstring = self.mapName+"\(path.z)/\(path.x)/\(path.y).png" - return URL(string: urlstring)! - // Bundle.main.url(forResource: "surrounding", withExtension: "png", subdirectory: "tiles")! - } - - } - - } - - public struct Overlay { - - public static func == (lhs: MapView.Overlay, rhs: MapView.Overlay) -> Bool { - // maybe to use in the future for comparison of full array - lhs.shape.coordinate.latitude == rhs.shape.coordinate.latitude && - lhs.shape.coordinate.longitude == rhs.shape.coordinate.longitude && - lhs.fillColor == rhs.fillColor - } - - var shape: MKOverlay - var fillColor: UIColor? - var strokeColor: UIColor? - var lineWidth: CGFloat - - public init( - shape: MKOverlay, - fillColor: UIColor? = nil, - strokeColor: UIColor? = nil, - lineWidth: CGFloat = 0 - ) { - self.shape = shape - self.fillColor = fillColor - self.strokeColor = strokeColor - self.lineWidth = lineWidth - } - } - -} - -// MARK: End of implementation -// MARK: Demonstration -/* -public struct MapViewDemo: View { - - @State private var locationManager: CLLocationManager - - @State private var mapRegion: MKCoordinateRegion = MKCoordinateRegion( - center: CLLocationCoordinate2D( - latitude: -38.758247, - longitude: 175.360208 - ), - span: MKCoordinateSpan( - latitudeDelta: 0.01, - longitudeDelta: 0.01 - ) - ) - - @State private var customMapOverlay: MapView.CustomMapOverlay? - - @State private var mapType: MKMapType = MKMapType.standard - - @State private var zoomEnabled: Bool = true - @State private var showZoomScale: Bool = true - @State private var useMinZoomBoundary: Bool = false - @State private var minZoom: Double = 0 - @State private var useMaxZoomBoundary: Bool = false - @State private var maxZoom: Double = 3000000 - - @State private var scrollEnabled: Bool = true - @State private var useScrollBoundaries: Bool = false - @State private var scrollBoundaries: MKCoordinateRegion = MKCoordinateRegion() - - @State private var rotationEnabled: Bool = true - @State private var showCompassWhenRotated: Bool = true - - @State private var showUserLocation: Bool = true - @State private var userTrackingMode: MKUserTrackingMode = MKUserTrackingMode.none - @State private var userLocation: CLLocationCoordinate2D? - - @State private var showAnnotations: Bool = true - @State private var annotations: [MKPointAnnotation] = [] - - @State private var showOverlays: Bool = true - @State private var overlays: [MapView.Overlay] = [] - - @State private var showMapCenter: Bool = false - - public init() { - self.locationManager = CLLocationManager() - self.locationManager.requestWhenInUseAuthorization() - } - - public var body: some View { - - NavigationView { - - List { - - Section(header: Text("Scroll")) { - Toggle("Scroll enabled", isOn: self.$scrollEnabled) - Toggle("Use scroll boundaries", isOn: self.$useScrollBoundaries) - .onChange(of: self.useScrollBoundaries) { newValue in - if newValue { - self.scrollBoundaries = MKCoordinateRegion(center: self.mapRegion.center, span: MKCoordinateSpan()) - } - } - if self.useScrollBoundaries { - VStack(alignment: .leading) { - Text(String(format: "Vertical distance to center: %.2f m", self.scrollBoundaries.span.latitudeDelta * 10609)) - Slider(value: self.$scrollBoundaries.span.latitudeDelta, in: 0...(300/10609)) - } - VStack(alignment: .leading) { - Text(String(format: "Horizontal distance to center: %.2f m", self.self.scrollBoundaries.span.longitudeDelta * 10609)) - Slider(value: self.$scrollBoundaries.span.longitudeDelta, in: 0...(300/10609)) - } - } - } - - Section(header: Text("Zoom")) { - Toggle("Zoom enabled", isOn: self.$zoomEnabled) - Toggle("Show zoom scale", isOn: self.$showZoomScale) - Toggle("Use minimum zoom boundary", isOn: self.$useMinZoomBoundary) - if self.useMinZoomBoundary { - VStack(alignment: .leading) { - Text(String(format: "Minimum Height: %.2f m", self.minZoom)) - Slider(value: self.$minZoom, in: 0...(self.useMaxZoomBoundary ? self.maxZoom : 3000000), step: 10) - } - } - Toggle("Use maximum zoom boundary", isOn: self.$useMaxZoomBoundary) - if self.useMaxZoomBoundary { - VStack(alignment: .leading) { - Text(String(format: "Maximum Height: %.2f m", self.maxZoom)) - Slider(value: self.$maxZoom, in: (self.useMinZoomBoundary ? self.minZoom : 0)...3000000, step: 10) - } - } - } - - Section(header: Text("Rotation")) { - Toggle("Rotation enabled", isOn: self.$rotationEnabled) - Toggle("Show compass when rotated", isOn: self.$showCompassWhenRotated) - } - - Section { - Toggle("Show map Center", isOn: self.$showMapCenter) - } - - Section(header: Text("User Location")) { - Toggle("Show User Location", isOn: self.$showUserLocation) - Picker("Follow Mode", selection: self.$userTrackingMode) { - Text("Nicht folgen").tag(MKUserTrackingMode.none) - Text("Folgen").tag(MKUserTrackingMode.follow) - Text("Richtung folgen").tag(MKUserTrackingMode.followWithHeading) - }.pickerStyle(MenuPickerStyle()) - - } - - Section(header: Text("Annotations")) { - Toggle("Show Annotations", isOn: self.$showAnnotations) - Button("Add Annotation") { - let annotation = MKPointAnnotation() - annotation.coordinate = self.mapRegion.center - annotation.title = "Title" - annotation.subtitle = "Subtitle" - self.annotations.append(annotation) - } - - Button("Delete all") { self.annotations = [] }.foregroundColor(.red) - } - - Section(header: Text("Overlays")) { - Toggle("Show Overlays", isOn: self.$showOverlays) - Button("Add circle") { - self.overlays.append(MapView.Overlay( - shape: MKCircle( - center: self.mapRegion.center, - radius: 20 - ), - strokeColor: UIColor.systemBlue, - lineWidth: 10 - )) - } - - Button("Delete all") { self.overlays = [] }.foregroundColor(.red) - } - - Section(header: Text("Custom Map Overlay")) { - Button("Keine") { self.customMapOverlay = nil } - Button("OSM Online") { - self.customMapOverlay = MapView.CustomMapOverlay( - mapName: "https://tile.openstreetmap.org/", - tileType: "png", - canReplaceMapContent: true - ) - } - Button("Farm Map") { - self.customMapOverlay = MapView.CustomMapOverlay( - mapName: "http://10.147.253.250:5050/local/map/", - tileType: "png", - canReplaceMapContent: true - ) - } - } - - }.listStyle(GroupedListStyle()) - .navigationBarTitle("Map Configuration", displayMode: NavigationBarItem.TitleDisplayMode.inline) - - ZStack { - - MapView( - region: self.$mapRegion, - customMapOverlay: self.customMapOverlay, - mapType: self.mapType, - zoomEnabled: self.zoomEnabled, - showZoomScale: self.showZoomScale, - zoomRange: (minHeight: self.useMinZoomBoundary ? self.minZoom : 0, maxHeight: self.useMaxZoomBoundary ? self.maxZoom : .infinity), - scrollEnabled: self.scrollEnabled, - scrollBoundaries: self.useScrollBoundaries ? self.scrollBoundaries : nil, - rotationEnabled: self.rotationEnabled, - showCompassWhenRotated: self.showCompassWhenRotated, - showUserLocation: self.showUserLocation, - userTrackingMode: self.userTrackingMode, - userLocation: self.$userLocation, - annotations: self.showAnnotations ? self.annotations : [], - overlays: self.showOverlays ? self.overlays : [] - ) - - VStack { - - Spacer() - - HStack { - if let userLocation = self.userLocation, self.showUserLocation { - VStack(alignment: .leading) { - Button("Center user location") { - self.mapRegion.center = userLocation - } - Text("User Location").bold() - Text("\(userLocation.latitude)") - Text("\(userLocation.longitude)") - } - } - - Spacer() - - VStack(alignment: .leading) { - Text("Map Center").bold() - Text("\(self.mapRegion.center.latitude)") - Text("\(self.mapRegion.center.longitude)") - } - } - - Picker("", selection: self.$mapType) { - Text("Standard").tag(MKMapType.standard) - Text("Muted Standard").tag(MKMapType.mutedStandard) - Text("Satellite").tag(MKMapType.satellite) - Text("Satellite Flyover").tag(MKMapType.satelliteFlyover) - Text("Hybrid").tag(MKMapType.hybrid) - Text("Hybrid Flyover").tag(MKMapType.hybridFlyover) - }.pickerStyle(SegmentedPickerStyle()) - - if self.showMapCenter { - Circle().frame(width: 8, height: 8).foregroundColor(.red) - } - - }.padding() - - }.navigationBarTitle("SwiftUI MapView", displayMode: NavigationBarItem.TitleDisplayMode.inline) - .ignoresSafeArea(edges: .bottom) - - } - - } - -} - - -public struct MapView_Previews: PreviewProvider { - - public static var previews: some View { - - MapViewDemo() - - } - -}*/ -#endif diff --git a/Meshtastic/Views/Map/WaypointFormView.swift b/Meshtastic/Views/Map/WaypointFormView.swift new file mode 100644 index 00000000..767a9614 --- /dev/null +++ b/Meshtastic/Views/Map/WaypointFormView.swift @@ -0,0 +1,244 @@ +// +// WaypointFormView.swift +// Meshtastic +// +// Copyright Garth Vander Houwen 1/10/23. +// + +import SwiftUI +import CoreLocation + +struct WaypointFormView: View { + + @EnvironmentObject var bleManager: BLEManager + @Environment(\.dismiss) private var dismiss + @State var coordinate: CLLocationCoordinate2D + @State var waypointId : Int = 0 + + @FocusState private var iconIsFocused: Bool + + @State private var name: String = "" + @State private var description: String = "" + @State private var icon: String = "📍" + @State private var latitude: Double = 0 + @State private var longitude: Double = 0 + @State private var expires: Bool = false + @State private var expire: Date = Date() // = Date.now.addingTimeInterval(60 * 120) // 1 minute * 120 = 2 Hours + @State private var locked: Bool = false + @State private var lockedTo: Int64 = 0 + + var body: some View { + + Form { + let distance = CLLocation(latitude: LocationHelper.currentLocation.latitude, longitude: LocationHelper.currentLocation.longitude).distance(from: CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)) + Section(header: Text((waypointId > 0) ? "Editing Waypoint" : "Create Waypoint")) { + HStack { + Text("Location: \(String(format: "%.5f", latitude) + "," + String(format: "%.5f", longitude))") + .textSelection(.enabled) + .foregroundColor(Color.gray) + .font(.caption2) + if coordinate.latitude != LocationHelper.DefaultLocation.latitude && coordinate.longitude != LocationHelper.DefaultLocation.longitude { + DistanceText(meters: distance) + .foregroundColor(Color.gray) + .font(.caption2) + } + } + HStack { + Text("Name") + Spacer() + TextField( + "Name", + text: $name, + axis: .vertical + ) + .foregroundColor(Color.gray) + .onChange(of: name, perform: { value in + let totalBytes = name.utf8.count + // Only mess with the value if it is too big + if totalBytes > 30 { + let firstNBytes = Data(name.utf8.prefix(30)) + if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) { + // Set the name back to the last place where it was the right size + name = maxBytesString + } + } + }) + } + HStack { + Text("Description") + Spacer() + TextField( + "Description", + text: $description, + axis: .vertical + ) + .foregroundColor(Color.gray) + .onChange(of: description, perform: { value in + let totalBytes = description.utf8.count + // Only mess with the value if it is too big + if totalBytes > 100 { + let firstNBytes = Data(description.utf8.prefix(100)) + if let maxBytesString = String(data: firstNBytes, encoding: String.Encoding.utf8) { + // Set the name back to the last place where it was the right size + description = maxBytesString + } + } + }) + } + HStack { + Text("Icon") + Spacer() + EmojiOnlyTextField(text: $icon, placeholder: "Select an emoji") + .font(.title) + .focused($iconIsFocused) + .onChange(of: icon) { value in + + // If you have anything other than emojis in your string make it empty + if !value.onlyEmojis() { + icon = "" + } + // If a second emoji is entered delete the first one + if value.count >= 1 { + + if value.count > 1 { + let index = value.index(value.startIndex, offsetBy: 1) + icon = String(value[index]) + } + iconIsFocused = false + } + } + + } +// Toggle(isOn: $expires) { +// Label("Expires", systemImage: "clock.badge.xmark") +// } +// .toggleStyle(SwitchToggleStyle(tint: .accentColor)) +// if expires { +// DatePicker("Expire", selection: $expire, in: Date.now...) +// .datePickerStyle(.compact) +// .font(.callout) +// } + Toggle(isOn: $locked) { + Label("Locked", systemImage: "lock") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + } + } + HStack { + Button { + + var newWaypoint = Waypoint() + + if waypointId > 0 { + newWaypoint.id = UInt32(waypointId) + } else { + newWaypoint.id = UInt32.random(in: UInt32(UInt8.max).. 0 ? name : "Dropped Pin" + newWaypoint.description_p = description + newWaypoint.latitudeI = Int32(coordinate.latitude * 1e7) + newWaypoint.longitudeI = Int32(coordinate.longitude * 1e7) + // Unicode scalar value for the icon emoji string + let unicodeScalers = icon.unicodeScalars + // First element as an UInt32 + let unicode = unicodeScalers[unicodeScalers.startIndex].value + newWaypoint.icon = unicode + if locked { + + if lockedTo == 0 { + newWaypoint.lockedTo = UInt32(bleManager.connectedPeripheral!.num) + } else { + newWaypoint.lockedTo = UInt32(lockedTo) + } + } + if expire.timeIntervalSince1970 > 0 { + newWaypoint.expire = UInt32(expire.timeIntervalSince1970) + } + if bleManager.sendWaypoint(waypoint: newWaypoint) { + waypointId = 0 + dismiss() + } else { + waypointId = 0 + dismiss() + print("Send waypoint failed") + } + } label: { + Label("Send", systemImage: "arrow.up") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .disabled(bleManager.connectedPeripheral == nil) + .padding(.bottom) + + Button(role:.cancel) { + dismiss() + } label: { + Label("cancel", systemImage: "x.circle") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) + + if waypointId > 0 { + Button(role: .destructive) { + let waypoint = getWaypoint(id: Int64(waypointId), context: bleManager.context!) + bleManager.context!.delete(waypoint) + do { + try bleManager.context!.save() + } catch { + bleManager.context!.rollback() + } + dismiss() + } label: { + Label("delete", systemImage: "trash") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) + } + } + .onChange(of: waypointId) { newId in + print(newId) + + } + .onAppear { + if waypointId > 0 { + let waypoint = getWaypoint(id: Int64(waypointId), context: bleManager.context!) + waypointId = Int(waypoint.id) + name = waypoint.name ?? "Dropped Pin" + description = waypoint.longDescription ?? "" + icon = String(UnicodeScalar(Int(waypoint.icon)) ?? "📍") + latitude = Double(waypoint.latitudeI) / 1e7 + longitude = Double(waypoint.longitudeI) / 1e7 + if waypoint.expire != nil { + expires = true + expire = waypoint.expire ?? Date() + } else { + expires = false + } + if waypoint.locked > 0 { + locked = true + lockedTo = waypoint.locked + } + } else { + name = "" + description = "" + locked = false + expires = false + expire = Date.now.addingTimeInterval(60 * 120) + icon = "📍" + latitude = coordinate.latitude + longitude = coordinate.longitude + } + + if coordinate.distance(from: LocationHelper.DefaultLocation) == 0.0 { + // Too close to apple park, bail out + waypointId = 0 + dismiss() + } + } + } +} diff --git a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift index 0925335d..c3ba0a1c 100644 --- a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift +++ b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift @@ -37,7 +37,7 @@ struct DeviceMetricsLog: View { } } let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmma", options: 0, locale: Locale.current) - let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma") + let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma").replacingOccurrences(of: ",", with: "") if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { //Add a table for mac and ipad Table(node.telemetries!.reversed() as! [TelemetryEntity]) { @@ -78,11 +78,11 @@ struct DeviceMetricsLog: View { ScrollView { let columns = [ - GridItem(), - GridItem(), - GridItem(), - GridItem(), - GridItem(.fixed(140)) + GridItem(.flexible(minimum: 30, maximum: 60), spacing: 0.1), + GridItem(.flexible(minimum: 30, maximum: 60), spacing: 0.1), + GridItem(.flexible(minimum: 30, maximum: 70), spacing: 0.1), + GridItem(.flexible(minimum: 30, maximum: 65), spacing: 0.1), + GridItem(spacing: 0) ] LazyVGrid(columns: columns, alignment: .leading, spacing: 1) { GridRow { @@ -119,7 +119,7 @@ struct DeviceMetricsLog: View { Text("\(String(format: "%.2f", dm.airUtilTx))%") .font(.caption) - Text(dm.time?.formattedDate(format: dateFormatString) ?? NSLocalizedString("unknown.age", comment: "")) + Text(dm.time?.formattedDate(format: dateFormatString) ?? "Unknown time") .font(.caption2) } } diff --git a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift index e044a08f..1fc40fa4 100644 --- a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift +++ b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift @@ -22,7 +22,7 @@ struct EnvironmentMetricsLog: View { NavigationStack { let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmma", options: 0, locale: Locale.current) - let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma") + let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma").replacingOccurrences(of: ",", with: "") if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { //Add a table for mac and ipad Table(node.telemetries!.reversed() as! [TelemetryEntity]) { @@ -65,11 +65,11 @@ struct EnvironmentMetricsLog: View { } else { ScrollView { let columns = [ - GridItem(), - GridItem(), - GridItem(), - GridItem(), - GridItem(.fixed(140)) + GridItem(.flexible(minimum: 30, maximum: 60), spacing: 0.1), + GridItem(.flexible(minimum: 30, maximum: 60), spacing: 0.1), + GridItem(.flexible(minimum: 30, maximum: 60), spacing: 0.1), + GridItem(.flexible(minimum: 30, maximum: 60), spacing: 0.1), + GridItem(spacing: 0) ] LazyVGrid(columns: columns, alignment: .leading, spacing: 1) { diff --git a/Meshtastic/Views/Nodes/NodeDetail.swift b/Meshtastic/Views/Nodes/NodeDetail.swift index 932ae13a..032c72be 100644 --- a/Meshtastic/Views/Nodes/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/NodeDetail.swift @@ -1,73 +1,88 @@ /* -Abstract: -A view showing the details for a node. -*/ + Abstract: + A view showing the details for a node. + */ import SwiftUI import MapKit import CoreLocation struct NodeDetail: View { - + @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager - - @State private var showingDetailsPopover = false - @State var satsInView = 0 + @State private var mapType: MKMapType = .standard + @State var waypointCoordinate: CLLocationCoordinate2D? + @State var editingWaypoint: Int = 0 + @State private var showingDetailsPopover = false @State private var showingShutdownConfirm: Bool = false @State private var showingRebootConfirm: Bool = false - + @State private var presentingWaypointForm = false + @State private var showOverlays: Bool = true + @State private var overlays: [MapViewSwiftUI.Overlay] = [] + @State private var customMapOverlay: MapViewSwiftUI.CustomMapOverlay? = MapViewSwiftUI.CustomMapOverlay( + mapName: "offlinemap", + tileType: "png", + canReplaceMapContent: true + ) + var node: NodeInfoEntity - + + @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "name", ascending: false)], + predicate: NSPredicate( + format: "expire == nil || expire >= %@", Date() as NSDate + ), animation: .easeIn) + private var waypoints: FetchedResults + var body: some View { let hwModelString = node.user?.hwModel ?? "UNSET" - + NavigationStack { GeometryReader { bounds in VStack { if node.positions?.count ?? 0 > 0 { let mostRecent = node.positions?.lastObject as! PositionEntity - if mostRecent.coordinate != nil { - let nodeCoordinatePosition = CLLocationCoordinate2D(latitude: mostRecent.latitude!, longitude: mostRecent.longitude!) - - let regionBinding = Binding( - get: { - MKCoordinateRegion(center: nodeCoordinatePosition, span: MKCoordinateSpan(latitudeDelta: 0.005, longitudeDelta: 0.005)) - }, - set: { _ in } - ) + let nodeCoordinatePosition = CLLocationCoordinate2D(latitude: mostRecent.latitude!, longitude: mostRecent.longitude!) + ZStack { + let annotations = node.positions?.array as! [PositionEntity] ZStack { - let annotations = node.positions?.array as! [PositionEntity] - - Map(coordinateRegion: regionBinding, - interactionModes: [.all], - showsUserLocation: true, - userTrackingMode: .constant(.follow), - annotationItems: annotations) { location in + MapViewSwiftUI(onLongPress: { coord in + waypointCoordinate = coord + editingWaypoint = 0 + presentingWaypointForm = true + }, onWaypointEdit: { wpId in + if wpId > 0 { + editingWaypoint = wpId + presentingWaypointForm = true + } + }, positions: annotations, waypoints: Array(waypoints), mapViewType: mapType, + centerOnPositionsOnly: true, + customMapOverlay: self.customMapOverlay, + overlays: self.overlays - return MapAnnotation( - coordinate: location.coordinate ?? CLLocationCoordinate2D(latitude: 0, longitude: 0), - content: { - - NodeAnnotation(time: location.time!) + ) + VStack { + Spacer() + Text(mostRecent.satsInView > 0 ? "Sats: \(mostRecent.satsInView)" : " ") + .font(.caption) + .offset(y: 20) + Picker("Map Type", selection: $mapType) { + ForEach(MeshMapType.allCases) { map in + Text(map.description).tag(map.MKMapTypeValue()) } - ) + } + .pickerStyle(.menu) } - .ignoresSafeArea(.all, edges: [.leading, .trailing]) - .frame(idealWidth: bounds.size.width, minHeight: bounds.size.height / 2) - } - Text(mostRecent.satsInView > 0 ? "Sats: \(mostRecent.satsInView)" : " ") - .offset( y:-40) + .ignoresSafeArea(.all, edges: [.top, .leading, .trailing]) + .frame(idealWidth: bounds.size.width, minHeight: bounds.size.height / 1.65) } - } else { HStack { - } - .padding([.top], 60) + .padding([.top], 20) } ScrollView { @@ -80,13 +95,12 @@ struct NodeDetail: View { Divider() VStack { if node.user != nil { - Image(hwModelString) .resizable() .aspectRatio(contentMode: .fill) .frame(width: 100, height: 100) .cornerRadius(5) - + Text(String(hwModelString)) .foregroundColor(.gray) .font(.largeTitle).fixedSize() @@ -96,7 +110,7 @@ struct NodeDetail: View { if node.snr > 0 { Divider() VStack(alignment: .center) { - + Image(systemName: "waveform.path") .font(.title) .foregroundColor(.accentColor) @@ -109,15 +123,15 @@ struct NodeDetail: View { .fixedSize() } } - + if node.telemetries?.count ?? 0 >= 1 { - + let mostRecent = node.telemetries?.lastObject as! TelemetryEntity Divider() VStack(alignment: .center) { BatteryGauge(batteryLevel: Double(mostRecent.batteryLevel)) if mostRecent.voltage > 0 { - + Text(String(format: "%.2f", mostRecent.voltage) + " V") .font(.title) .foregroundColor(.gray) @@ -140,14 +154,13 @@ struct NodeDetail: View { .symbolRenderingMode(.hierarchical) Text("user").font(.title)+Text(":").font(.title) } - //Text(node.user?.userId ?? "??????").font(.title).foregroundColor(.gray) Text("!\(String(format:"%02x", node.num))") .font(.title).foregroundColor(.gray) } Divider() VStack { HStack { - Image(systemName: "number") + Image(systemName: "number") .font(.title2) .foregroundColor(.accentColor) .symbolRenderingMode(.hierarchical) @@ -160,8 +173,8 @@ struct NodeDetail: View { HStack { Image(systemName: "globe") .font(.title) - .foregroundColor(.accentColor) - .symbolRenderingMode(.hierarchical) + .foregroundColor(.accentColor) + .symbolRenderingMode(.hierarchical) Text("MAC Address: ").font(.title) } @@ -174,8 +187,8 @@ struct NodeDetail: View { HStack { Image(systemName: "clock.badge.checkmark.fill") .font(.title) - .foregroundColor(.accentColor) - .symbolRenderingMode(.hierarchical) + .foregroundColor(.accentColor) + .symbolRenderingMode(.hierarchical) Text("heard.last").font(.title)+Text(":").font(.title) } @@ -184,13 +197,12 @@ struct NodeDetail: View { .foregroundColor(.gray) } } - .padding() Divider() } else { HStack { - + VStack(alignment: .center) { CircleText(text: node.user?.shortName ?? "???", color: .accentColor) } @@ -205,12 +217,11 @@ struct NodeDetail: View { .font(.callout).fixedSize() } } - .padding(5) if node.snr > 0 { Divider() VStack(alignment: .center) { - + Image(systemName: "waveform.path") .font(.title) .foregroundColor(.accentColor) @@ -221,19 +232,13 @@ struct NodeDetail: View { .foregroundColor(.gray) .fixedSize() } - .padding(5) } - + if node.telemetries?.count ?? 0 >= 1 { - let mostRecent = node.telemetries?.lastObject as! TelemetryEntity - Divider() - VStack(alignment: .center) { - BatteryGauge(batteryLevel: Double(mostRecent.batteryLevel)) - if mostRecent.voltage > 0 { Text(String(format: "%.2f", mostRecent.voltage) + " V") @@ -241,14 +246,11 @@ struct NodeDetail: View { .foregroundColor(.gray) .fixedSize() } - } } } Divider() HStack(alignment: .center) { - - VStack { HStack { Image(systemName: "person") @@ -262,7 +264,7 @@ struct NodeDetail: View { Divider() VStack { HStack { - Image(systemName: "number") + Image(systemName: "number") .font(.title2) .foregroundColor(.accentColor) .symbolRenderingMode(.hierarchical) @@ -271,17 +273,16 @@ struct NodeDetail: View { Text(String(node.num)).font(.title3).foregroundColor(.gray) } } - .padding(5) Divider() HStack { Image(systemName: "globe") - .font(.headline) - .foregroundColor(.accentColor) - .symbolRenderingMode(.hierarchical) + .font(.headline) + .foregroundColor(.accentColor) + .symbolRenderingMode(.hierarchical) Text("MAC Address: ") Text(String(node.user?.macaddr?.macAddressString ?? "not a valid mac address")).foregroundColor(.gray) } - .padding([.bottom], 0) + .padding([.bottom], 10) Divider() } @@ -334,16 +335,16 @@ struct NodeDetail: View { } if self.bleManager.connectedPeripheral != nil && self.bleManager.connectedPeripheral.num == node.num && self.bleManager.connectedPeripheral.num == node.num { - + HStack { if hwModelString == "TBEAM" || hwModelString == "TECHO" || hwModelString.contains("4631") { - + Button(action: { showingShutdownConfirm = true }) { - + Label("Power Off", systemImage: "power") } .buttonStyle(.bordered) @@ -361,13 +362,10 @@ struct NodeDetail: View { } } } - + Button(action: { - showingRebootConfirm = true - }) { - Label("reboot", systemImage: "arrow.triangle.2.circlepath") } .buttonStyle(.bordered) @@ -375,33 +373,33 @@ struct NodeDetail: View { .controlSize(.large) .padding() .confirmationDialog("are.you.sure", - - isPresented: $showingRebootConfirm - ) { - Button("reboot.node", role: .destructive) { - - if !bleManager.sendReboot(fromUser: node.user!, toUser: node.user!) { - print("Reboot Failed") - } - } + isPresented: $showingRebootConfirm + ) { + Button("reboot.node", role: .destructive) { + if !bleManager.sendReboot(fromUser: node.user!, toUser: node.user!) { + print("Reboot Failed") } + } + } } .padding(5) } } - .offset( y:-40) } .edgesIgnoringSafeArea([.leading, .trailing]) + .sheet(isPresented: $presentingWaypointForm ) {//, onDismiss: didDismissSheet) { + WaypointFormView(coordinate: waypointCoordinate ?? LocationHelper.DefaultLocation, waypointId: editingWaypoint) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.automatic) + } .navigationBarTitle(String(node.user?.longName ?? NSLocalizedString("unknown", comment: "")), displayMode: .inline) - .padding(.bottom, 10) .navigationBarItems(trailing: ZStack { - ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "????") - } - ) + ConnectedDevice( + bluetoothOn: bleManager.isSwitchedOn, + deviceConnected: bleManager.connectedPeripheral != nil, + name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "????") + }) .onAppear { self.bleManager.context = context } diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 1925c767..fb6980d7 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -53,8 +53,8 @@ struct NodeList: View { HStack(alignment: .bottom) { let lastPostion = node.positions!.reversed()[0] as! PositionEntity let myCoord = CLLocation(latitude: LocationHelper.currentLocation.latitude, longitude: LocationHelper.currentLocation.longitude) - if lastPostion.coordinate != nil && myCoord.coordinate.longitude != LocationHelper.DefaultLocation.longitude && myCoord.coordinate.latitude != LocationHelper.DefaultLocation.latitude { - let nodeCoord = CLLocation(latitude: lastPostion.coordinate!.latitude, longitude: lastPostion.coordinate!.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(.title3) diff --git a/Meshtastic/Views/Nodes/NodeMap.swift b/Meshtastic/Views/Nodes/NodeMap.swift index 3f968bab..99f21bca 100644 --- a/Meshtastic/Views/Nodes/NodeMap.swift +++ b/Meshtastic/Views/Nodes/NodeMap.swift @@ -15,14 +15,13 @@ struct NodeMap: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager @EnvironmentObject var userSettings: UserSettings - @AppStorage("meshMapType") var type: String = "hybrid" @AppStorage("meshMapCustomTileServer") var customTileServer: String = "" { didSet { if customTileServer == "" { self.customMapOverlay = nil } else { - self.customMapOverlay = MapView.CustomMapOverlay( + self.customMapOverlay = MapViewSwiftUI.CustomMapOverlay( mapName: customTileServer, tileType: "png", canReplaceMapContent: true @@ -30,94 +29,72 @@ struct NodeMap: View { } } } + @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "time", ascending: false)], + predicate: NSPredicate(format: "time >= %@", Calendar.current.startOfDay(for: Date()) as NSDate), animation: .easeIn) + private var positions: FetchedResults - @State private var showLabels: Bool = false - - //@State private var annotationItems: [MapLocation] = [] - //@FetchRequest( sortDescriptors: [NSSortDescriptor(keyPath: \NodeInfoEntity.lastHeard, ascending: false)], animation: .default) - //private var locationNodes: FetchedResults - - /*@State private var mapRegion: MKCoordinateRegion = MKCoordinateRegion( - center: CLLocationCoordinate2D( - latitude: -38.758247, - longitude: 175.360208 - ), - span: MKCoordinateSpan( - latitudeDelta: 0.01, - longitudeDelta: 0.01 - ) - )*/ + @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "name", ascending: false)], + predicate: NSPredicate( + format: "expire == nil || expire >= %@", Date() as NSDate + ), animation: .easeIn) + private var waypoints: FetchedResults - @State private var customMapOverlay: MapView.CustomMapOverlay? = MapView.CustomMapOverlay( + @State private var mapType: MKMapType = .standard + @State var waypointCoordinate: CLLocationCoordinate2D = LocationHelper.DefaultLocation + @State var editingWaypoint: Int = 0 + @State private var presentingWaypointForm = false + @State private var customMapOverlay: MapViewSwiftUI.CustomMapOverlay? = MapViewSwiftUI.CustomMapOverlay( mapName: "offlinemap", tileType: "png", canReplaceMapContent: true ) - - //@State private var mapType: MKMapType = MKMapType.standard - - @State private var zoomEnabled: Bool = true - @State private var showZoomScale: Bool = true - @State private var useMinZoomBoundary: Bool = false - @State private var minZoom: Double = 0 - @State private var useMaxZoomBoundary: Bool = false - @State private var maxZoom: Double = 3000000 - - @State private var scrollEnabled: Bool = true - @State private var useScrollBoundaries: Bool = false - @State private var scrollBoundaries: MKCoordinateRegion = MKCoordinateRegion() - - @State private var rotationEnabled: Bool = true - @State private var showCompassWhenRotated: Bool = true - - @State private var showUserLocation: Bool = true - @State private var userTrackingMode: MKUserTrackingMode = MKUserTrackingMode.none - @State private var userLocation: CLLocationCoordinate2D? = LocationHelper.currentLocation - - @State private var showAnnotations: Bool = true - @State private var annotations: [MKPointAnnotation] = [] - - @State private var showOverlays: Bool = true - @State private var overlays: [MapView.Overlay] = [] - - @State private var showMapCenter: Bool = false + @State private var overlays: [MapViewSwiftUI.Overlay] = [] var body: some View { NavigationStack { - ZStack { - - //MapView(nodes: self.locationNodes)//.environmentObject(bleManager) - // } - MapView( - //region: self.$mapRegion, + ZStack { + + MapViewSwiftUI(onLongPress: { coord in + waypointCoordinate = coord + editingWaypoint = 0 + if waypointCoordinate.distance(from: LocationHelper.DefaultLocation) == 0.0 { + print("Apple Park") + } else { + presentingWaypointForm = true + } + }, onWaypointEdit: { wpId in + if wpId > 0 { + editingWaypoint = wpId + presentingWaypointForm = true + } + }, positions: Array(positions), waypoints: Array(waypoints), mapViewType: mapType, + centerOnPositionsOnly: false, customMapOverlay: self.customMapOverlay, - mapType: self.type, - zoomEnabled: self.zoomEnabled, - showZoomScale: self.showZoomScale, - zoomRange: (minHeight: self.useMinZoomBoundary ? self.minZoom : 0, maxHeight: self.useMaxZoomBoundary ? self.maxZoom : .infinity), - scrollEnabled: self.scrollEnabled, - scrollBoundaries: self.useScrollBoundaries ? self.scrollBoundaries : nil, - rotationEnabled: self.rotationEnabled, - showCompassWhenRotated: self.showCompassWhenRotated, - showUserLocation: self.showUserLocation, - userTrackingMode: self.userTrackingMode, - userLocation: self.$userLocation, - //annotations: self.annotations, - //locationNodes: self.locationNodes.map({ nodeinfo in return nodeinfo }), overlays: self.overlays - //context: self.context ) - - .frame(maxHeight: .infinity) - .ignoresSafeArea(.all, edges: [.top, .leading, .trailing]) + VStack { + Spacer() + Picker("Map Type", selection: $mapType) { + ForEach(MeshMapType.allCases) { map in + Text(map.description).tag(map.MKMapTypeValue()) + } + } + .pickerStyle(.menu) + } + } + .ignoresSafeArea(.all, edges: [.top, .leading, .trailing]) + .frame(maxHeight: .infinity) + .sheet(isPresented: $presentingWaypointForm ) {//, onDismiss: didDismissSheet) { + WaypointFormView(coordinate: waypointCoordinate, waypointId: editingWaypoint) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.automatic) + } } - .navigationBarItems(leading: MeshtasticLogo(), trailing: ZStack { - ConnectedDevice( bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, @@ -125,18 +102,8 @@ struct NodeMap: View { "????") }) .onAppear(perform: { - self.bleManager.context = context self.bleManager.userSettings = userSettings - }) } } - -struct NodeMap_Previews: PreviewProvider { - static let bleManager = BLEManager() - - static var previews: some View { - NodeMap() - } -} diff --git a/Meshtastic/Views/Nodes/PositionLog.swift b/Meshtastic/Views/Nodes/PositionLog.swift index 2be8210c..b4b67c23 100644 --- a/Meshtastic/Views/Nodes/PositionLog.swift +++ b/Meshtastic/Views/Nodes/PositionLog.swift @@ -22,7 +22,7 @@ struct PositionLog: View { NavigationStack { let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmma", options: 0, locale: Locale.current) - let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma") + let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma").replacingOccurrences(of: ",", with: "") if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { //Add a table for mac and ipad @@ -31,10 +31,10 @@ struct PositionLog: View { Text(String(position.seqNo)) } TableColumn("Latitude") { position in - Text(String(format: "%.6f", position.latitude ?? 0)) + Text(String(format: "%.5f", position.latitude ?? 0)) } TableColumn("Longitude") { position in - Text(String(format: "%.6f", position.longitude ?? 0)) + Text(String(format: "%.5f", position.longitude ?? 0)) } TableColumn("Altitude") { position in Text(String(position.altitude)) @@ -61,11 +61,11 @@ struct PositionLog: View { ScrollView { // Use a grid on iOS as a table only shows a single column let columns = [ - GridItem(.fixed(90)), - GridItem(.fixed(95)), - GridItem(.fixed(45)), - GridItem(.fixed(40)), - GridItem(.fixed(140)) + GridItem(spacing: 0.1), + GridItem(spacing: 0.1), + GridItem(.flexible(minimum: 35, maximum: 40), spacing: 0.1), + GridItem(.flexible(minimum: 30, maximum: 35), spacing: 0.1), + GridItem(spacing: 0) ] LazyVGrid(columns: columns, alignment: .leading, spacing: 1) { @@ -89,9 +89,9 @@ struct PositionLog: View { } ForEach(node.positions!.reversed() as! [PositionEntity], id: \.self) { (mappin: PositionEntity) in GridRow { - Text(String(format: "%.6f", mappin.latitude ?? 0)) + Text(String(format: "%.5f", mappin.latitude ?? 0)) .font(.caption2) - Text(String(format: "%.6f", mappin.longitude ?? 0)) + Text(String(format: "%.5f", mappin.longitude ?? 0)) .font(.caption2) Text(String(mappin.satsInView)) .font(.caption2) @@ -102,19 +102,15 @@ struct PositionLog: View { } } } - .padding(.leading, 15) - .padding(.trailing, 5) } + .padding(.leading) } HStack { Button(role: .destructive) { - isPresentingClearLogConfirm = true - } label: { - Label("Clear Log", systemImage: "trash.fill") } .buttonStyle(.bordered) diff --git a/Meshtastic/Views/Settings/AppSettings.swift b/Meshtastic/Views/Settings/AppSettings.swift index a9d9ada1..9e2a2af7 100644 --- a/Meshtastic/Views/Settings/AppSettings.swift +++ b/Meshtastic/Views/Settings/AppSettings.swift @@ -102,6 +102,9 @@ struct AppSettings: View { .onAppear { self.bleManager.context = context } + .onChange(of: userSettings.provideLocation) { newProvideLocation in + self.bleManager.sendWantConfig() + } } } diff --git a/Meshtastic/Views/Settings/Channels.swift b/Meshtastic/Views/Settings/Channels.swift index 52a4d8ec..52ef1d8d 100644 --- a/Meshtastic/Views/Settings/Channels.swift +++ b/Meshtastic/Views/Settings/Channels.swift @@ -250,16 +250,10 @@ struct Channels: View { let adminMessageId = bleManager.saveChannel(channel: channel, fromUser: node!.user!, toUser: node!.user!) 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. - self.isPresentingEditView = false channelName = "" hasChanges = false - // Would rather send a getChannel but I can't seem serialize it properly yet bleManager.getChannel(channel: channel, fromUser: node!.user!, toUser: node!.user!) - //bleManager.sendWantConfig() } } label: { Label("save", systemImage: "square.and.arrow.down") diff --git a/Meshtastic/Views/Settings/Config/BluetoothConfig.swift b/Meshtastic/Views/Settings/Config/BluetoothConfig.swift index dcc10981..c4c5a00f 100644 --- a/Meshtastic/Views/Settings/Config/BluetoothConfig.swift +++ b/Meshtastic/Views/Settings/Config/BluetoothConfig.swift @@ -14,6 +14,7 @@ struct BluetoothConfig: View { @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? + var connectedNode: NodeInfoEntity? @State private var isPresentingSaveConfirm: Bool = false @State var hasChanges = false @@ -125,6 +126,13 @@ struct BluetoothConfig: View { self.mode = Int(node?.bluetoothConfig?.mode ?? 0) self.fixedPin = String(node?.bluetoothConfig?.fixedPin ?? 123456) self.hasChanges = false + + // Need to request a LoRaConfig from the remote node before allowing changes + if node?.bluetoothConfig == nil { + print("empty bluetooth config") + + } + let adminMessageId = bleManager.requestBluetoothConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) } .onChange(of: enabled) { newEnabled in if node != nil && node!.bluetoothConfig != nil { diff --git a/Meshtastic/Views/Settings/Config/DisplayConfig.swift b/Meshtastic/Views/Settings/Config/DisplayConfig.swift index 9103c5ce..1554b536 100644 --- a/Meshtastic/Views/Settings/Config/DisplayConfig.swift +++ b/Meshtastic/Views/Settings/Config/DisplayConfig.swift @@ -24,29 +24,20 @@ struct DisplayConfig: View { @State var compassNorthTop = false @State var flipScreen = false @State var oledType = 0 + @State var displayMode = 0 var body: some View { Form { Section(header: Text("Device Screen")) { - Picker("Screen on for", selection: $screenOnSeconds ) { - ForEach(ScreenOnIntervals.allCases) { soi in - Text(soi.description) + Picker("Display Mode", selection: $displayMode ) { + ForEach(DisplayModes.allCases) { dm in + Text(dm.description) } } .pickerStyle(DefaultPickerStyle()) - - Text("How long the screen remains on after the user button is pressed or messages are received.") - .font(.caption) - - Picker("Carousel Interval", selection: $screenCarouselInterval ) { - ForEach(ScreenCarouselIntervals.allCases) { sci in - Text(sci.description) - } - } - .pickerStyle(DefaultPickerStyle()) - Text("Automatically toggles to the next page on the screen like a carousel, based the specified interval.") + Text("Override automatic OLED screen detection.") .font(.caption) Toggle(isOn: $compassNorthTop) { @@ -64,6 +55,7 @@ struct DisplayConfig: View { .toggleStyle(SwitchToggleStyle(tint: .accentColor)) Text("Flip screen vertically") .font(.caption) + Picker("OLED Type", selection: $oledType ) { ForEach(OledTypes.allCases) { ot in Text(ot.description) @@ -74,7 +66,25 @@ struct DisplayConfig: View { .font(.caption) } - Section(header: Text("Format")) { + Section(header: Text("Timing & Format")) { + Picker("Screen on for", selection: $screenOnSeconds ) { + ForEach(ScreenOnIntervals.allCases) { soi in + Text(soi.description) + } + } + .pickerStyle(DefaultPickerStyle()) + Text("How long the screen remains on after the user button is pressed or messages are received.") + .font(.caption) + + Picker("Carousel Interval", selection: $screenCarouselInterval ) { + ForEach(ScreenCarouselIntervals.allCases) { sci in + Text(sci.description) + } + } + .pickerStyle(DefaultPickerStyle()) + Text("Automatically toggles to the next page on the screen like a carousel, based the specified interval.") + .font(.caption) + Picker("GPS Format", selection: $gpsFormat ) { ForEach(GpsFormats.allCases) { lu in Text(lu.description) @@ -116,6 +126,7 @@ struct DisplayConfig: View { dc.compassNorthTop = compassNorthTop dc.flipScreen = flipScreen dc.oled = OledTypes(rawValue: oledType)!.protoEnumValue() + dc.displaymode = DisplayModes(rawValue: displayMode)!.protoEnumValue() let adminMessageId = bleManager.saveDisplayConfig(config: dc, fromUser: node!.user!, toUser: node!.user!) if adminMessageId > 0 { @@ -143,6 +154,7 @@ struct DisplayConfig: View { self.compassNorthTop = node?.displayConfig?.compassNorthTop ?? false self.flipScreen = node?.displayConfig?.flipScreen ?? false self.oledType = Int(node?.displayConfig?.oledType ?? 0) + self.displayMode = Int(node?.displayConfig?.displayMode ?? 0) self.hasChanges = false } .onChange(of: screenOnSeconds) { newScreenSecs in @@ -175,5 +187,10 @@ struct DisplayConfig: View { if newOledType != node!.displayConfig!.oledType { hasChanges = true } } } + .onChange(of: displayMode) { newDisplayMode in + if node != nil && node!.displayConfig != nil { + if newDisplayMode != node!.displayConfig!.displayMode { hasChanges = true } + } + } } } diff --git a/Meshtastic/Views/Settings/Config/LoRaConfig.swift b/Meshtastic/Views/Settings/Config/LoRaConfig.swift index dc4ea18e..7895d3ee 100644 --- a/Meshtastic/Views/Settings/Config/LoRaConfig.swift +++ b/Meshtastic/Views/Settings/Config/LoRaConfig.swift @@ -14,6 +14,7 @@ struct LoRaConfig: View { @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? + var connectedNode: NodeInfoEntity? @State var isPresentingSaveConfirm = false @State var hasChanges = false @@ -50,6 +51,8 @@ struct LoRaConfig: View { } Section(header: Text("Mesh Options")) { Picker("Number of hops", selection: $hopLimit) { + Text("Please Select") + .tag(0) ForEach(HopValues.allCases) { hop in Text(hop.description) } @@ -75,7 +78,7 @@ struct LoRaConfig: View { isPresented: $isPresentingSaveConfirm, titleVisibility: .visible ) { - let nodeName = bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral.longName : NSLocalizedString("unknown", comment: "Unknown") + let nodeName = node?.user?.longName ?? NSLocalizedString("unknown", comment: "Unknown") let buttonText = String.localizedStringWithFormat(NSLocalizedString("save.config %@", comment: "Save Config for %@"), nodeName) Button(buttonText) { var lc = Config.LoRaConfig() @@ -84,7 +87,7 @@ struct LoRaConfig: View { lc.modemPreset = ModemPresets(rawValue: modemPreset)!.protoEnumValue() lc.usePreset = true lc.txEnabled = true - let adminMessageId = bleManager.saveLoRaConfig(config: lc, fromUser: node!.user!, toUser: node!.user!) + let adminMessageId = bleManager.saveLoRaConfig(config: lc, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: node?.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 @@ -102,14 +105,21 @@ struct LoRaConfig: View { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "????") }) .onAppear { + self.bleManager.context = context - self.hopLimit = Int(node?.loRaConfig?.hopLimit ?? 0) + self.hopLimit = Int(node?.loRaConfig?.hopLimit ?? 3) self.region = Int(node?.loRaConfig?.regionCode ?? 0) self.usePreset = node?.loRaConfig?.usePreset ?? true self.modemPreset = Int(node?.loRaConfig?.modemPreset ?? 0) self.txEnabled = node?.loRaConfig?.txEnabled ?? true self.txPower = Int(node?.loRaConfig?.txPower ?? 0) self.hasChanges = false + + // Need to request a LoRaConfig from the remote node before allowing changes + if node?.loRaConfig == nil { + print("empty lora config") + let adminMessageId = bleManager.requestLoRaConfig(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) + } } .onChange(of: region) { newRegion in if node != nil && node!.loRaConfig != nil { diff --git a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift index 46071f37..c9c5374f 100644 --- a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift @@ -74,7 +74,7 @@ struct CannedMessagesConfig: View { HStack { Label("Messages", systemImage: "message.fill") - TextField("Messages seperate with |", text: $messages, axis: .vertical) + TextField("Messages separate with |", text: $messages, axis: .vertical) .foregroundColor(.gray) .autocapitalization(.none) .disableAutocorrection(true) diff --git a/Meshtastic/Views/Settings/Config/NetworkConfig.swift b/Meshtastic/Views/Settings/Config/NetworkConfig.swift index 65e73b2c..58710789 100644 --- a/Meshtastic/Views/Settings/Config/NetworkConfig.swift +++ b/Meshtastic/Views/Settings/Config/NetworkConfig.swift @@ -118,7 +118,7 @@ struct NetworkConfig: View { network.ethEnabled = self.ethEnabled //network.addressMode = Config.NetworkConfig.AddressMode.dhcp - let adminMessageId = bleManager.saveWiFiConfig(config: network, fromUser: node!.user!, toUser: node!.user!) + let adminMessageId = bleManager.saveNetworkConfig(config: network, fromUser: node!.user!, toUser: node!.user!) 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 diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index d4728fc1..87cfd1a8 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -12,22 +12,87 @@ struct Settings: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager @EnvironmentObject var userSettings: UserSettings - @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "lastHeard", ascending: false)], animation: .default) + @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "user.longName", ascending: true)], animation: .default) private var nodes: FetchedResults + @State private var selectedNode: Int = 0 + @State private var connectedNodeNum: Int = 0 + @State private var initialLoad: Bool = true + + @State private var selection: SettingsSidebar = .about + + enum SettingsSidebar { + case appSettings + case shareChannels + case userConfig + case loraConfig + case channelConfig + case bluetoothConfig + case deviceConfig + case displayConfig + case networkConfig + case positionConfig + case cannedMessagesConfig + case externalNotificationConfig + case mqttConfig + case rangeTestConfig + case serialConfig + case telemetryConfig + case meshLog + case adminMessageLog + case about + } var body: some View { NavigationSplitView { List { - let connectedNodeNum = bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral.num : 0 - NavigationLink() { AppSettings() } label: { - Image(systemName: "gearshape") .symbolRenderingMode(.hierarchical) Text("app.settings") } + .tag(SettingsSidebar.appSettings) + let node = nodes.first(where: { $0.num == connectedNodeNum }) + if node?.myInfo?.adminIndex ?? 0 > 0 { + Section("Configure") { + Picker("Configuring Node", selection: $selectedNode) { + if connectedNodeNum == 0 { + Text("Connect to a Node").tag(0) + } + ForEach(nodes) { node in + if node.num == bleManager.connectedPeripheral?.num ?? 0 { + Text("BLE Config: \(node.user?.longName ?? NSLocalizedString("unknown", comment: "Unknown"))") + .tag(Int(node.num)) + } else if node.metadata != nil { + Text("Remote Config: \(node.user?.longName ?? NSLocalizedString("unknown", comment: "Unknown"))") + .tag(Int(node.num)) + } else { + Text("Request Admin: \(node.user?.longName ?? NSLocalizedString("unknown", comment: "Unknown"))") + .tag(Int(node.num)) + } + } + } + .pickerStyle(.menu) + .labelsHidden() + .onChange(of: selectedNode) { newValue in +// if selectedNode > 0 { +// let node = nodes.first(where: { $0.num == newValue }) +// let connectedNode = nodes.first(where: { $0.num == connectedNodeNum }) +// connectedNodeNum = Int(bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral.num : 0) +// +// if node?.metadata == nil && node!.num != connectedNodeNum { +// let adminMessageId = bleManager.requestDeviceMetadata(fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode!.myInfo!.adminIndex, context: context) +// +// if adminMessageId > 0 { +// print("Saved node metadata") +// } +// } +// } + } + } + } + Section("radio.configuration") { NavigationLink { @@ -37,85 +102,97 @@ struct Settings: View { .symbolRenderingMode(.hierarchical) Text("share.channels") } + .tag(SettingsSidebar.shareChannels) + .disabled(selectedNode > 0 && selectedNode != connectedNodeNum) NavigationLink { - UserConfig(node: nodes.first(where: { $0.num == connectedNodeNum })) + UserConfig(node: nodes.first(where: { $0.num == selectedNode }), connectedNode: nodes.first(where: { $0.num == connectedNodeNum })) } label: { Image(systemName: "person.crop.rectangle.fill") .symbolRenderingMode(.hierarchical) Text("user") } + .tag(SettingsSidebar.userConfig) + .disabled(selectedNode == 0) NavigationLink() { - - LoRaConfig(node: nodes.first(where: { $0.num == connectedNodeNum })) + LoRaConfig(node: nodes.first(where: { $0.num == selectedNode }), connectedNode: nodes.first(where: { $0.num == connectedNodeNum })) } label: { - Image(systemName: "dot.radiowaves.left.and.right") .symbolRenderingMode(.hierarchical) Text("lora") } + .tag(SettingsSidebar.loraConfig) + .disabled(selectedNode > 0 && selectedNode != connectedNodeNum) NavigationLink() { - Channels(node: nodes.first(where: { $0.num == connectedNodeNum })) } label: { - Image(systemName: "fibrechannel") .symbolRenderingMode(.hierarchical) - Text("channels") } + .tag(SettingsSidebar.channelConfig) + .disabled(selectedNode > 0 && selectedNode != connectedNodeNum) NavigationLink() { - - BluetoothConfig(node: nodes.first(where: { $0.num == connectedNodeNum })) + BluetoothConfig(node: nodes.first(where: { $0.num == selectedNode }), connectedNode: nodes.first(where: { $0.num == connectedNodeNum })) } label: { Image(systemName: "antenna.radiowaves.left.and.right") .symbolRenderingMode(.hierarchical) Text("bluetooth") } + .tag(SettingsSidebar.bluetoothConfig) + .disabled(selectedNode > 0 && selectedNode != connectedNodeNum) NavigationLink { - DeviceConfig(node: nodes.first(where: { $0.num == connectedNodeNum })) + DeviceConfig(node: nodes.first(where: { $0.num == selectedNode })) } label: { Image(systemName: "flipphone") .symbolRenderingMode(.hierarchical) Text("device") } + .tag(SettingsSidebar.deviceConfig) + .disabled(selectedNode > 0 && selectedNode != connectedNodeNum) NavigationLink { - DisplayConfig(node: nodes.first(where: { $0.num == connectedNodeNum })) + DisplayConfig(node: nodes.first(where: { $0.num == selectedNode })) } label: { Image(systemName: "display") .symbolRenderingMode(.hierarchical) Text("display") } + .tag(SettingsSidebar.displayConfig) + .disabled(selectedNode > 0 && selectedNode != connectedNodeNum) NavigationLink { - NetworkConfig(node: nodes.first(where: { $0.num == connectedNodeNum })) + NetworkConfig(node: nodes.first(where: { $0.num == selectedNode })) } label: { Image(systemName: "network") .symbolRenderingMode(.hierarchical) Text("network") } + .tag(SettingsSidebar.networkConfig) + .disabled(selectedNode > 0 && selectedNode != connectedNodeNum) NavigationLink { - PositionConfig(node: nodes.first(where: { $0.num == connectedNodeNum })) + PositionConfig(node: nodes.first(where: { $0.num == selectedNode })) } label: { Image(systemName: "location") .symbolRenderingMode(.hierarchical) Text("position") } + .tag(SettingsSidebar.positionConfig) + .disabled(selectedNode > 0 && selectedNode != connectedNodeNum) } Section("module.configuration") { NavigationLink { - CannedMessagesConfig(node: nodes.first(where: { $0.num == connectedNodeNum })) + CannedMessagesConfig(node: nodes.first(where: { $0.num == selectedNode })) } label: { Image(systemName: "list.bullet.rectangle.fill") @@ -123,42 +200,58 @@ struct Settings: View { Text("canned.messages") } + .tag(SettingsSidebar.cannedMessagesConfig) + .disabled(selectedNode > 0 && selectedNode != connectedNodeNum) NavigationLink { - ExternalNotificationConfig(node: nodes.first(where: { $0.num == connectedNodeNum })) + ExternalNotificationConfig(node: nodes.first(where: { $0.num == selectedNode })) } label: { Image(systemName: "megaphone") .symbolRenderingMode(.hierarchical) Text("external.notification") } + .tag(SettingsSidebar.externalNotificationConfig) + .disabled(selectedNode > 0 && selectedNode != connectedNodeNum) + NavigationLink { - MQTTConfig(node: nodes.first(where: { $0.num == connectedNodeNum })) + MQTTConfig(node: nodes.first(where: { $0.num == selectedNode })) } label: { Image(systemName: "dot.radiowaves.right") .symbolRenderingMode(.hierarchical) Text("mqtt") } + .tag(SettingsSidebar.mqttConfig) + .disabled(selectedNode > 0 && selectedNode != connectedNodeNum) + NavigationLink { - RangeTestConfig(node: nodes.first(where: { $0.num == connectedNodeNum })) + RangeTestConfig(node: nodes.first(where: { $0.num == selectedNode })) } label: { Image(systemName: "point.3.connected.trianglepath.dotted") .symbolRenderingMode(.hierarchical) Text("range.test") } + .tag(SettingsSidebar.rangeTestConfig) + .disabled(selectedNode > 0 && selectedNode != connectedNodeNum) + NavigationLink { - SerialConfig(node: nodes.first(where: { $0.num == connectedNodeNum })) + SerialConfig(node: nodes.first(where: { $0.num == selectedNode })) } label: { Image(systemName: "terminal") .symbolRenderingMode(.hierarchical) Text("serial") } + .tag(SettingsSidebar.serialConfig) + .disabled(selectedNode > 0 && selectedNode != connectedNodeNum) + NavigationLink { - TelemetryConfig(node: nodes.first(where: { $0.num == connectedNodeNum })) + TelemetryConfig(node: nodes.first(where: { $0.num == selectedNode })) } label: { Image(systemName: "chart.xyaxis.line") .symbolRenderingMode(.hierarchical) Text("telemetry") } + .tag(SettingsSidebar.telemetryConfig) + .disabled(selectedNode > 0 && selectedNode != connectedNodeNum) } Section(header: Text("logging")) { NavigationLink { @@ -168,6 +261,8 @@ struct Settings: View { .symbolRenderingMode(.hierarchical) Text("mesh.log") } + .tag(SettingsSidebar.meshLog) + NavigationLink { let connectedNode = nodes.first(where: { $0.num == connectedNodeNum }) AdminMessageList(user: connectedNode?.user) @@ -176,6 +271,7 @@ struct Settings: View { .symbolRenderingMode(.hierarchical) Text("admin.log") } + .tag(SettingsSidebar.adminMessageLog) } Section(header: Text("about")) { NavigationLink { @@ -186,11 +282,18 @@ struct Settings: View { Text("about.meshtastic") } + .tag(SettingsSidebar.about) } } .onAppear { self.bleManager.context = context self.bleManager.userSettings = userSettings + self.connectedNodeNum = Int(bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral.num : 0) + if initialLoad { + selectedNode = Int(bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral.num : 0) + initialLoad = false + } + } .listStyle(GroupedListStyle()) .navigationTitle("settings") diff --git a/Meshtastic/Views/Settings/UserConfig.swift b/Meshtastic/Views/Settings/UserConfig.swift index f372499e..4b3dedc1 100644 --- a/Meshtastic/Views/Settings/UserConfig.swift +++ b/Meshtastic/Views/Settings/UserConfig.swift @@ -13,6 +13,7 @@ struct UserConfig: View { @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? + var connectedNode: NodeInfoEntity? @State private var isPresentingFactoryResetConfirm: Bool = false @State private var isPresentingSaveConfirm: Bool = false @@ -84,11 +85,11 @@ struct UserConfig: View { isPresented: $isPresentingSaveConfirm, titleVisibility: .visible ) { - Button("Save User Config to \(bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral.longName : "Unknown")?") { + Button("Save User Config to \(node?.user?.longName ?? "Unknown")?") { var u = User() u.shortName = shortName u.longName = longName - let adminMessageId = bleManager.saveUser(config: u, fromUser: node!.user!, toUser: node!.user!) + let adminMessageId = bleManager.saveUser(config: u, fromUser: connectedNode!.user!, toUser: node!.user!, adminIndex: connectedNode?.myInfo?.adminIndex ?? 0) if adminMessageId > 0 { hasChanges = false goBack() diff --git a/de.lproj/Localizable.strings b/de.lproj/Localizable.strings index 54aeb93d..7da486b3 100644 --- a/de.lproj/Localizable.strings +++ b/de.lproj/Localizable.strings @@ -92,6 +92,7 @@ "heard"="Gehört"; "heard.last"="Zuletzt gehört"; "hybrid"="Hybrid"; +"hybrid.flyover"="Hybrid Flyover"; "include"="Include"; "inputevent.none"="Keins"; "inputevent.up"="Hoch"; @@ -168,6 +169,7 @@ "mesh.log.traceroute.received.route %@"="Traceroute Ergebnis: %@"; "mesh.log.wantconfig %@"="Issuing Want Config to %@"; "mesh.log.waypoint.sent %@"="Sent a Waypoint Packet from: %@"; +"mesh.log.waypoint.received %@"="Waypoint Packet received from node: %@"; "message"="Nachricht"; "message.details"="Nachrichtendetails"; "messages"="Nachrichten"; @@ -214,6 +216,7 @@ "routing.badRequest"="Bad Request"; "routing.notauthorized"="Nicht authorisiert"; "satellite"="Satellit"; +"satellite.flyover"="Satellite Flyover"; "save"="Speichern"; "save.config %@"="Save Config for %@"; "serial"="Serial"; @@ -232,6 +235,7 @@ "select.menu.item"="Wähle einen Menüeintrag aus"; "set.region"="Setze LoRa Region"; "standard"="Standard"; +"standard.muted"="Standard Muted"; "ssid"="SSID"; "tapback"="Tapback Response"; "tapback.heart"="Gehört"; diff --git a/en.lproj/Localizable.strings b/en.lproj/Localizable.strings index 6b772ea7..eb3f3caa 100644 --- a/en.lproj/Localizable.strings +++ b/en.lproj/Localizable.strings @@ -21,7 +21,7 @@ "battery.level.trend"="Battery Level Trend"; "ble.name"="BLE Name"; "ble.connection.timeout %d %@"="Connection failed after %d attempts to connect to %@. You may need to forget your device under Settings > Bluetooth."; -"ble.errorcode.6 %@"="%@ The app will automatically reconnect to the preferred radio if it come back in range."; +"ble.errorcode.6 %@"="%@ The app will automatically reconnect to the preferred radio if it comes back in range."; "ble.errorcode.14 %@"="%@ This error usually cannot be fixed without forgetting the device unders Settings > Bluetooth and re-connecting to the radio."; "ble.errorcode.pin %@"="%@ Please try connecting again and check the PIN carefully."; "bluetooth"="Bluetooth"; @@ -92,6 +92,7 @@ "heard"="Heard"; "heard.last"="Last Heard"; "hybrid"="Hybrid"; +"hybrid.flyover"="Hybrid Flyover"; "include"="Include"; "inputevent.none"="None"; "inputevent.up"="Up"; @@ -168,6 +169,7 @@ "mesh.log.traceroute.sent %@"="Sent a Trace Route Request to node: %@"; "mesh.log.wantconfig %@"="Issuing Want Config to %@"; "mesh.log.waypoint.sent %@"="Sent a Waypoint Packet from: %@"; +"mesh.log.waypoint.received %@"="Waypoint Packet received from node: %@"; "message"="Message"; "message.details"="Message Details"; "messages"="Messages"; @@ -214,6 +216,7 @@ "routing.badRequest"="Bad Request"; "routing.notauthorized"="Not Authorized"; "satellite"="Satellite"; +"satellite.flyover"="Satellite Flyover"; "save"="Save"; "save.config %@"="Save Config for %@"; "serial"="Serial"; @@ -232,6 +235,7 @@ "select.menu.item"="Select an item from the menu"; "set.region"="Set LoRa Region"; "standard"="Standard"; +"standard.muted"="Standard Muted"; "ssid"="SSID"; "tapback"="Tapback Response"; "tapback.heart"="Heart"; diff --git a/gen_protos.sh b/gen_protos.sh index 94c685c2..fd756c64 100755 --- a/gen_protos.sh +++ b/gen_protos.sh @@ -1,8 +1,8 @@ #!/bin/bash # simple sanity checking for repo -if [ ! -d "../Meshtastic-protobufs" ]; then - echo "Please check out the https://github.com/meshtastic/Meshtastic-protobufs the parent directory." +if [ ! -d "../protobufs/meshtastic" ]; then + echo "Please check out the https://github.com/meshtastic/protobufs parent directory." exit fi @@ -12,11 +12,19 @@ if [ ! -x "`which protoc`" ]; then exit fi -pdir=$(realpath "../Meshtastic-protobufs") +if [ ! -x "`which gsed`" ]; then + echo "Please install gnu-sed by running: brew install gnu-sed" + exit +fi + +pdir=$(realpath "../protobufs/meshtastic") sdir=$(realpath "./Meshtastic/Protobufs") + +gsed -i 's/import "meshtastic\//import "/g' ../protobufs/meshtastic/* +gsed -i 's/package meshtastic;//g' ../protobufs/meshtastic/* + echo "pdir:$pdir sdir:$sdir" -pfiles="admin.proto apponly.proto cannedmessages.proto channel.proto config.proto device_metadata.proto deviceonly.proto localonly.proto mesh.proto module_config.proto mqtt.proto portnums.proto remote_hardware.proto -storeforward.proto telemetry.proto" +pfiles="admin.proto apponly.proto cannedmessages.proto channel.proto config.proto device_metadata.proto deviceonly.proto localonly.proto mesh.proto module_config.proto mqtt.proto portnums.proto remote_hardware.proto rtttl.proto storeforward.proto telemetry.proto xmodem.proto" for pf in $pfiles do echo "Generating $pf..." @@ -24,3 +32,5 @@ do done echo "Done generating the swift files from the proto files." echo "Build, test, and commit changes." + +cd ../protobufs/meshtastic && git reset --hard diff --git a/zh-Hans.lproj/Localizable.strings b/zh-Hans.lproj/Localizable.strings new file mode 100644 index 00000000..8a9f0699 --- /dev/null +++ b/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,261 @@ +/* + Localizable.strings + Meshtastic + + Created by BG6TNB on 01/06/23. + +*/ +"about"="关于"; +"about.meshtastic"="关于 Meshtastic"; +"admin"="管理员"; +"admin.log"="管理员消息日志"; +"ago"="ago"; +"airtime"="广播时间"; +"always.on"="常亮"; +"app.settings"="通用设置"; +"are.you.sure"="是否确认?"; +"ascii.capable"="ASCII Capable"; +"available.radios"="可用的电台"; +"automatic.detection"="自动识别"; +"battery.level"="电池电量"; +"battery.level.trend"="电池电量趋势"; +"ble.name"="蓝牙名称"; +"ble.connection.timeout %d %@"="尝试连接%@失败,你可能需要在系统设置的蓝牙选项中忽略该电台。"; +"ble.errorcode.6 %@"="%@ 如果在首选电台的旁边,App 将会自动重连。"; +"ble.errorcode.14 %@"="%@ 这个错误通常无法自动修复,你需要在系统设置的蓝牙选项中忽略该电台并重新配对。"; +"ble.errorcode.pin %@"="%@ 请再次尝试连接并仔细检查 PIN 码。"; +"bluetooth"="蓝牙"; +"bluetooth.off"="蓝牙已关闭"; +"bluetooth.config"="蓝牙配置"; +"bluetooth.mode.randompin"="随机 PIN 码"; +"bluetooth.mode.fixedpin"="固定 PIN 码"; +"bluetooth.mode.nopin"="不使用 PIN 码(直接配对)"; +"bluetooth.pairingmode"="配对模式"; +"bluetooth.pin.validation"="蓝牙 PIN 码必须是 6 位数字。"; +"bytes"="字节"; +"cancel"="取消"; +"canned.messages"="快捷消息"; +"canned.messages.config"="快捷消息配置"; +"canned.messages.preset.manual"="手动配置"; +"canned.messages.preset.rakrotary"="RAK 旋转编码器"; +"canned.messages.preset.cardkb"="M5 Stack 卡片键盘 / RAK 键盘"; +"channel"="频道"; +"channel.role.disabled"="禁用"; +"channel.role.primary"="主要"; +"channel.role.secondary"="次要"; +"channel.utilization"="频道利用率"; +"channels"="频道"; +"clear.app.data"="清除 App 数据"; +"clear.log"="清除日志"; +"close"="关闭"; +"config.save.confirm"="电台将会在配置保存后重启。"; +"connected.radio"="连接到电台"; +"communicating"="与电台进行通讯中..."; +"connected"="已连接到电台"; +"connecting"="连接中..."; +"contacts"="联系人"; +"copy"="复制"; +"current"="当前"; +"default"="默认"; +"delete"="删除"; +"device"="电台"; +"device.config"="电台配置"; +"device.metrics.delete"="删除所有电台指标?"; +"device.metrics.log"="电台指标日志"; +"device.role.client"="标准模式 - App 可以连接到电台进行收发操作,并且会自动转发 Mesh 网络中其他节点的消息。"; +"device.role.clientmute"="静默模式 - 与标准模式类似,App 可以连接到电台进行收发操作,但不会转发 Mesh 网络中其他节点的消息。"; +"device.role.router"="纯中继模式 - 自动转发 Mesh 网络中其他节点的消息,中继模式下屏幕会熄灭,Wi-Fi 和蓝牙将会进入睡眠模式,App 将无法连接到电台进行收发操作。"; +"device.role.routerclient"="中继模式 - 优先转发 Mesh 网络中其他节点的消息,App 也可以连接到电台进行收发操作。"; +"direct.messages"="直接收到的消息"; +"dismiss.keyboard"="隐藏键盘"; +"display"="屏幕(电台屏幕)"; +"display.config"="屏幕配置"; +"distance"="距离"; +"disconnect"="断开连接"; +"echo"="回声"; +"email.address"="邮件地址"; +"enabled"="启用"; +"encrypted"="加密"; +"external.notification"="外部通知"; +"external.notification.config"="外部通知配置"; +"firmware.version"="固件版本"; +"firmware.version.unsupported"="检测到不支持的固件版本,无法连接到电台。"; +"gas"="Gas"; +"gas.resistance"="Gas Resistance"; +"generate.qr.code"="生成二维码"; +"gpsformat.dec"="十进制"; +"gpsformat.dms"="度分秒"; +"gpsformat.utm"="通用横轴墨卡托投影"; +"gpsformat.mgrs"="军事网格参考系统"; +"gpsformat.olc"="开放的位置代码(又称加码)"; +"gpsformat.osgr"="英国国土测量局网格参考"; +"heard"="收到"; +"heard.last"="最后收到"; +"hybrid"="混合"; +"hybrid.flyover"="Hybrid Flyover"; +"include"="包含"; +"inputevent.none"="无"; +"inputevent.up"="上"; +"inputevent.down"="下"; +"inputevent.left"="左"; +"inputevent.right"="右"; +"inputevent.select"="选择"; +"inputevent.back"="后退"; +"inputevent.cancel"="取消"; +"interval.one.second"="一秒"; +"interval.two.seconds"="两秒"; +"interval.three.seconds"="三秒"; +"interval.four.seconds"="四秒"; +"interval.five.seconds"="五秒"; +"interval.ten.seconds"="十秒"; +"interval.fifteen.seconds"="十五秒"; +"interval.twenty.seconds"="二十秒"; +"interval.twentyfive.seconds"="二十五秒"; +"interval.thirty.seconds"="三十秒"; +"interval.one.minute"="一分钟"; +"interval.two.minutes"="两分钟"; +"interval.five.minutes"="五分钟"; +"interval.ten.minutes"="十分钟"; +"interval.fifteen.minutes"="十五分钟"; +"interval.thirty.minutes"="三十分钟"; +"interval.one.hour"="一小时"; +"interval.two.hours"="两小时"; +"interval.three.hours"="三小时"; +"interval.four.hours"="四小时"; +"interval.five.hours"="五小时"; +"interval.six.hours"="六小时"; +"interval.twelve.hours"="十二小时"; +"interval.eighteen.hours"="十八小时"; +"interval.twentyfour.hours"="二十四小时"; +"interval.thirtysix.hours"="三十六小时"; +"interval.tyeight.hours"="四十八小时小时"; +"interval.eventytwo.hours"="七十二小时"; +"keyboard.type"="键盘类型"; +"logging"="Logging"; +"lora"="LoRa"; +"lora.config"="LoRa 配置"; +"map"="Mesh 地图"; +"map.type"="地图类型"; +"mesh.log"="Mesh 日志"; +"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: %@"; +"mesh.log.cannedmessages.messages.received %@"="Canned Messages Messages Received For: %@"; +"mesh.log.channel.sent %@ %d"="Sent a Channel for: %@ Channel Index %d"; +"mesh.log.channel.received %d %@"="Channel %d received from: %@"; +"mesh.log.device.config %@"="Device config received: %@"; +"mesh.log.display.config %@"="Display config received: %@"; +"mesh.log.devicemetadata %@"="Requesting Device Metadata for %@"; +"mesh.log.externalnotification.config %@"="External Notifiation module config received: %@"; +"mesh.log.lora.config %@"="LoRa config received: %@"; +"mesh.log.lora.config.sent %@"="Sent a LoRa.Config for: %@"; +"mesh.log.mqtt.config %@"="MQTT module config received: %@"; +"mesh.log.myinfo %@"="MyInfo received: %@"; +"mesh.log.network.config %@"="Network config received: %@"; +"mesh.log.nodeinfo.received %@"="Node info received for: %@"; +"mesh.log.position.config %@"="Positon config received: %@"; +"mesh.log.position.received %@"="Position Packet received from node: %@"; +"mesh.log.rangetest.config %@"="Range Test module config received: %@"; +"mesh.log.routing.message %@ %@"="Routing received for RequestID: %@ Ack Status: %@"; +"mesh.log.serial.config %@"="Serial module config received: %@"; +"mesh.log.sharelocation %@"="Sent a Position Packet from the Apple device GPS to node: %@"; +"mesh.log.telemetry.config %@"="Telemetry module config received: %@"; +"mesh.log.telemetry.received %@"="Telemetry received for: %@"; +"mesh.log.textmessage.received"="Message received from the text message app."; +"mesh.log.textmessage.send.failed %@"="Message Send Failed, not properly connected to %@"; +"mesh.log.textmessage.sent %@ %@ %@"="Sent message %@ from %@ to %@"; +"mesh.log.traceroute.received.direct %@"="Trace Route request sent to node: %@ was recieived directly."; +"mesh.log.traceroute.received.route %@"="Trace Route request returned: %@"; +"mesh.log.traceroute.sent %@"="Sent a Trace Route Request to node: %@"; +"mesh.log.wantconfig %@"="Issuing Want Config to %@"; +"mesh.log.waypoint.sent %@"="Sent a Waypoint Packet from: %@"; +"mesh.log.waypoint.received %@"="Waypoint Packet received from node: %@"; +"message"="消息"; +"message.details"="消息详情"; +"messages"="消息"; +"mode"="模式"; +"module.configuration"="模块配置"; +"mqtt"="MQTT"; +"mqtt.config"="MQTT 配置"; +"mqtt.username"="用户名称"; +"name"="名称"; +"network"="网络"; +"network.config"="网络配置"; +"nodes"="节点"; +"no.nodes"="未找到 Meshtastic 节点"; +"not.connected"="未连接到电台"; +"numbers.punctuation"="数字和标点符号"; +"off"="关闭"; +"on.boot"="仅在启动时"; +"options"="选项"; +"password"="密码"; +"phone.gps"="手机 GPS"; +"phone.gps.interval.description"="电台通过手机刷新定位的时间间隔,但是向 Mesh 网络中刷新定位的时间间隔由电台控制。"; +"position"="定位"; +"position.config"="定位配置"; +"preferred.radio"="首选电台"; +"provide.location"="提供定位到 Mesh 网络"; +"radio.configuration"="电台配置"; +"range.test"="拉距测试"; +"range.test.config"="拉距测试配置"; +"reply"="回复"; +"reboot"="重启"; +"reboot.node"="重启节点?"; +"received.ack"="收到确认"; +"received.ack.real"="收件人确认"; +"routing.acknowledged"="确认"; +"routing.noroute"="找不到目标"; +"routing.gotnak"="收到否认"; +"routing.timeout"="超时"; +"routing.nointerface"="无连接"; +"routing.maxretransmit"="已达到最大重试次数"; +"routing.nochannel"="没有频道"; +"routing.toolarge"="数据包过大"; +"routing.noresponse"="无响应"; +"routing.dutycyclelimit"="已达到当前区域循环周期发射上限"; +"routing.badRequest"="错误请求"; +"routing.notauthorized"="未授权"; +"satellite"="卫星"; +"satellite.flyover"="Satellite Flyover"; +"save"="保存"; +"save.config %@"="保存%@的配置"; +"serial"="串口"; +"serial.config"="串口配置"; +"serial.mode.default"="默认"; +"serial.mode.simple"="简单"; +"serial.mode.proto"="Protobufs"; +"serial.mode.txtmsg"="文本消息"; +"serial.mode.nmea"="NMEA 位置"; +"settings"="设置"; +"share.channels"="分享频道二维码"; +"share.position"="分享位置"; +"subscribed"="连接到 Mesh 网络"; +"select.contact"="选择一名联系人"; +"select.node"="选择一个节点"; +"select.menu.item"="从菜单选择一个选项"; +"set.region"="设置 LoRa 区域"; +"standard"="标准"; +"standard.muted"="Standard Muted"; +"ssid"="SSID"; +"tapback"="Tapback Response"; +"tapback.heart"="Heart"; +"tapback.thumbsup"="Thumbs Up"; +"tapback.thumbsdown"="Thumbs Down"; +"tapback.haha"="HaHa"; +"tapback.exclamation"="Exclamation Mark"; +"tapback.question"="Question Mark"; +"tapback.poop"="Poop"; +"telemetry"="遥测(传感器)"; +"telemetry.config"="遥测配置"; +"timeout"="超时"; +"timestamp"="时间戳"; +"twitter"="Twitter"; +"unknown"="未知"; +"unknown.age"="Unknown Age"; +"unset"="未设置"; +"update.firmware"="更新你的固件"; +"update.interval"="更新间隔"; +"user"="用户"; +"user.details"="用户信息"; +"voltage"="电压"; +"waiting"="等待中...";