diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 9339e55d..000b7ec6 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -374,6 +374,7 @@ 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 = ""; }; + DD9681A22BBB22BE00FD2C47 /* MeshtasticDataModelV32.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV32.xcdatamodel; 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 = ""; }; @@ -1610,7 +1611,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.3.2; + MARKETING_VERSION = 2.3.3; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1644,7 +1645,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.3.2; + MARKETING_VERSION = 2.3.3; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1766,7 +1767,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.2; + MARKETING_VERSION = 2.3.3; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1799,7 +1800,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.2; + MARKETING_VERSION = 2.3.3; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1910,6 +1911,7 @@ DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + DD9681A22BBB22BE00FD2C47 /* MeshtasticDataModelV32.xcdatamodel */, DDDCD5712BB3246500BE6B60 /* MeshtasticDataModelV 31.xcdatamodel */, DD9A1A912BA2D2D3001E602E /* MeshtasticDataModelV 30.xcdatamodel */, DD398EBD2B93F640002B4C51 /* MeshtasticDataModelV 29.xcdatamodel */, @@ -1942,7 +1944,7 @@ DD5D0A9A2931AD6B00F7EA61 /* MeshtasticDataModelV2.xcdatamodel */, DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */, ); - currentVersion = DDDCD5712BB3246500BE6B60 /* MeshtasticDataModelV 31.xcdatamodel */; + currentVersion = DD9681A22BBB22BE00FD2C47 /* MeshtasticDataModelV32.xcdatamodel */; name = Meshtastic.xcdatamodeld; path = Meshtastic/Meshtastic.xcdatamodeld; sourceTree = ""; diff --git a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 85e8785d..a62998be 100644 --- a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "e9855e3a299c14a10f11ee0b8f29e4170b09548533939361223a0f50e7caac8c", "pins" : [ { "identity" : "cocoamqtt", @@ -46,5 +47,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/Meshtastic/Enums/AppSettingsEnums.swift b/Meshtastic/Enums/AppSettingsEnums.swift index c53c3826..3dc3d49c 100644 --- a/Meshtastic/Enums/AppSettingsEnums.swift +++ b/Meshtastic/Enums/AppSettingsEnums.swift @@ -56,11 +56,12 @@ enum MeshMapDistances: Double, CaseIterable, Identifiable { case twoHundredMiles = 321869 case fiveHundredMiles = 804672 case oneThousandMiles = 1609000 + case fifteenHundredMiles = 2414016 case twentyFiveHundredMiles = 4023360 var id: Double { self.rawValue } var description: String { let distanceFormatter = MKDistanceFormatter() - return "up to \(distanceFormatter.string(fromDistance: Double(self.rawValue))) away" + return String.localizedStringWithFormat("nodelist.filter.distance %@".localized, distanceFormatter.string(fromDistance: Double(self.rawValue))) } } diff --git a/Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift b/Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift index 1e04282a..a9e22a39 100644 --- a/Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift @@ -14,7 +14,7 @@ extension MyInfoEntity { } var unreadMessages: Int { - let unreadMessages = messageList.filter{ ($0 as AnyObject).read == false } + let unreadMessages = messageList.filter{ ($0 as AnyObject).read == false && ($0 as AnyObject).isEmoji == false } return unreadMessages.count } } diff --git a/Meshtastic/Extensions/CoreData/PositionEntityExtension.swift b/Meshtastic/Extensions/CoreData/PositionEntityExtension.swift index 0221a1da..680b88bc 100644 --- a/Meshtastic/Extensions/CoreData/PositionEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/PositionEntityExtension.swift @@ -14,13 +14,12 @@ extension PositionEntity { static func allPositionsFetchRequest() -> NSFetchRequest { let request: NSFetchRequest = PositionEntity.fetchRequest() - request.fetchLimit = 200 - //request.fetchBatchSize = 1 + request.fetchLimit = 100 request.returnsObjectsAsFaults = false request.includesSubentities = true request.returnsDistinctResults = true request.sortDescriptors = [NSSortDescriptor(key: "time", ascending: false)] - let positionPredicate = NSPredicate(format: "nodePosition != nil && (nodePosition.user.shortName != nil || nodePosition.user.shortName != '') && latest == true && time >= %@", Calendar.current.date(byAdding: .day, value: -2, to: Date())! as NSDate) + let positionPredicate = NSPredicate(format: "nodePosition != nil && (nodePosition.user.shortName != nil || nodePosition.user.shortName != '') && latest == true") let pointOfInterest = LocationHelper.currentLocation diff --git a/Meshtastic/Extensions/Url.swift b/Meshtastic/Extensions/Url.swift index a8589e55..20d3ca6e 100644 --- a/Meshtastic/Extensions/Url.swift +++ b/Meshtastic/Extensions/Url.swift @@ -8,13 +8,26 @@ import Foundation extension URL { - - func regularFileAllocatedSize() throws -> UInt64 { - let resourceValues = try self.resourceValues(forKeys: allocatedSizeResourceKeys) - - guard resourceValues.isRegularFile ?? false else { - return 0 + + func regularFileAllocatedSize() throws -> UInt64 { + let resourceValues = try self.resourceValues(forKeys: allocatedSizeResourceKeys) + + guard resourceValues.isRegularFile ?? false else { + return 0 + } + return UInt64(resourceValues.totalFileAllocatedSize ?? resourceValues.fileAllocatedSize ?? 0) + } + subscript(queryParam: String) -> String? { + guard let url = URLComponents(string: self.absoluteString) else { return nil } + if let parameters = url.queryItems { + return parameters.first(where: { $0.name == queryParam })?.value + } else if let paramPairs = url.fragment?.components(separatedBy: "?").last?.components(separatedBy: "&") { + for pair in paramPairs where pair.contains(queryParam) { + return pair.components(separatedBy: "=").last + } + return nil + } else { + return nil + } } - return UInt64(resourceValues.totalFileAllocatedSize ?? resourceValues.fileAllocatedSize ?? 0) - } } diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index 9327ebc8..e234cd35 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -759,19 +759,6 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate if fetchedNodeInfo.count == 1 && fetchedNodeInfo[0].storeForwardConfig?.enabled == true { wantStoreAndForwardPackets = true; } - if fetchedNodeInfo.count == 1 { - if !(fetchedNodeInfo[0].user?.vip ?? false) { - fetchedNodeInfo[0].user?.vip = true - do { - try context!.save() - - } catch { - context!.rollback() - let nsError = error as NSError - print("💥 Core Data error. Error: \(nsError)") - } - } - } } catch { print("Failed to find a node info for the connected node") @@ -1317,18 +1304,36 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate return 0 } - public func saveChannelSet(base64UrlString: String) -> Bool { + public func saveChannelSet(base64UrlString: String, addChannels: Bool = false) -> Bool { if isConnected { - // Before we get started delete the existing channels from the myNodeInfo - let fetchMyInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "MyInfoEntity") - fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(connectedPeripheral.num)) - tryClearExistingChannels() + var i: Int32 = 0 + // Before we get started delete the existing channels from the myNodeInfo + if !addChannels { + tryClearExistingChannels() + } else { + // We are trying to add a channel so lets get the last index + let fetchMyInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "MyInfoEntity") + fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(connectedPeripheral.num)) + do { + let fetchedMyInfo = try context?.fetch(fetchMyInfoRequest) as? [MyInfoEntity] ?? [] + if fetchedMyInfo.count == 1 { + if addChannels { + i = Int32(fetchedMyInfo[0].channels?.count ?? -1) + // Bail out if the index is negative or bigger than our max of 8 + if i < 0 || i > 8 { + return false + } + } + } + } catch { + print("Failed to find a node MyInfo to save these channels to") + } + } let decodedString = base64UrlString.base64urlToBase64() if let decodedData = Data(base64Encoded: decodedString) { do { let channelSet: ChannelSet = try ChannelSet(serializedData: decodedData) - var i: Int32 = 0 for cs in channelSet.settings { var chan = Channel() if i == 0 { @@ -1385,7 +1390,12 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let logString = String.localizedStringWithFormat("mesh.log.lora.config.sent %@".localized, String(connectedPeripheral.num)) MeshLogger.log("📻 \(logString)") } - return true + + if self.connectedPeripheral != nil { + self.sendWantConfig() + return true + } + } catch { return false } @@ -1448,6 +1458,54 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate return false } + public func setFavoriteNode(node: NodeInfoEntity, connectedNodeNum: Int64) -> Bool { + var adminPacket = AdminMessage() + adminPacket.setFavoriteNode = UInt32(node.num) + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(connectedNodeNum) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { + var adminPacket = AdminMessage() + adminPacket.removeFavoriteNode = UInt32(node.num) + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(connectedNodeNum) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { var adminPacket = AdminMessage() adminPacket.setHamMode = ham diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index a408af14..bbc57937 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -293,6 +293,7 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje } else { let newUser = UserEntity(context: context) newUser.num = Int64(nodeInfo.num) + newUser.numString = String(nodeInfo.num) let userId = String(format:"%2X", nodeInfo.num) newUser.userId = "!\(userId)" let last4 = String(userId.suffix(4)) @@ -357,6 +358,7 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje } fetchedNode[0].user!.userId = nodeInfo.user.id fetchedNode[0].user!.num = Int64(nodeInfo.num) + fetchedNode[0].user!.numString = String(nodeInfo.num) fetchedNode[0].user!.longName = nodeInfo.user.longName fetchedNode[0].user!.shortName = nodeInfo.user.shortName fetchedNode[0].user!.isLicensed = nodeInfo.user.isLicensed diff --git a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion index 825d8915..75e1f5e3 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion +++ b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - MeshtasticDataModelV 31.xcdatamodel + MeshtasticDataModelV32.xcdatamodel diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV32.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV32.xcdatamodel/contents new file mode 100644 index 00000000..5d065d55 --- /dev/null +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV32.xcdatamodel/contents @@ -0,0 +1,461 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index e21b96e9..aeb6488c 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -18,6 +18,7 @@ struct MeshtasticAppleApp: App { @State var saveChannels = false @State var incomingUrl: URL? @State var channelSettings: String? + @State var addChannels = false @StateObject var appState = AppState.shared var body: some Scene { @@ -26,7 +27,7 @@ struct MeshtasticAppleApp: App { .environment(\.managedObjectContext, persistenceController.container.viewContext) .environmentObject(bleManager) .sheet(isPresented: $saveChannels) { - SaveChannelQRCode(channelSetLink: channelSettings ?? "Empty Channel URL", bleManager: bleManager) + SaveChannelQRCode(channelSetLink: channelSettings ?? "Empty Channel URL", addChannels: addChannels, bleManager: bleManager) .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) } @@ -36,9 +37,13 @@ struct MeshtasticAppleApp: App { self.incomingUrl = userActivity.webpageURL if (self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/e/#")) != nil { - if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") { - self.channelSettings = components.last! + guard let cs = components.last!.components(separatedBy: "?").first else { + return + } + self.channelSettings = cs + self.addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false + print("Add Channel \(self.addChannels)") } self.saveChannels = true print("User wants to open a Channel Settings URL: \(self.incomingUrl?.absoluteString ?? "No QR Code Link")") diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index f2933922..8841e7c2 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -236,7 +236,6 @@ func upsertNodeInfoPacket (packet: MeshPacket, context: NSManagedObjectContext) fetchedNode[0].telemetries? = NSOrderedSet(array: newTelemetries) } if nodeInfoMessage.hasUser { - fetchedNode[0].user!.vip = nodeInfoMessage.isFavorite /// Seeing Some crashes here ? fetchedNode[0].user!.userId = nodeInfoMessage.user.id fetchedNode[0].user!.num = Int64(nodeInfoMessage.num) diff --git a/Meshtastic/Protobufs/meshtastic/admin.pb.swift b/Meshtastic/Protobufs/meshtastic/admin.pb.swift index fa1ac990..1f2b193e 100644 --- a/Meshtastic/Protobufs/meshtastic/admin.pb.swift +++ b/Meshtastic/Protobufs/meshtastic/admin.pb.swift @@ -350,7 +350,7 @@ struct AdminMessage { } /// - /// Clear fixed position coordinates and then set position.fixed_position = false + /// Clear fixed position coordinates and then set position.fixed_position = false var removeFixedPosition: Bool { get { if case .removeFixedPosition(let v)? = payloadVariant {return v} @@ -547,7 +547,7 @@ struct AdminMessage { /// Set fixed position data on the node and then set the position.fixed_position = true case setFixedPosition(Position) /// - /// Clear fixed position coordinates and then set position.fixed_position = false + /// Clear fixed position coordinates and then set position.fixed_position = false case removeFixedPosition(Bool) /// /// Begins an edit transaction for config, module config, owner, and channel settings changes diff --git a/Meshtastic/Protobufs/meshtastic/atak.pb.swift b/Meshtastic/Protobufs/meshtastic/atak.pb.swift index 31cf5313..f1bc14ad 100644 --- a/Meshtastic/Protobufs/meshtastic/atak.pb.swift +++ b/Meshtastic/Protobufs/meshtastic/atak.pb.swift @@ -255,7 +255,7 @@ extension MemberRole: CaseIterable { #endif // swift(>=4.2) /// -/// Packets for the official ATAK Plugin +/// Packets for the official ATAK Plugin struct TAKPacket { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for diff --git a/Meshtastic/Protobufs/meshtastic/config.pb.swift b/Meshtastic/Protobufs/meshtastic/config.pb.swift index 7506bc88..cb99fded 100644 --- a/Meshtastic/Protobufs/meshtastic/config.pb.swift +++ b/Meshtastic/Protobufs/meshtastic/config.pb.swift @@ -226,14 +226,14 @@ struct Config { /// /// Description: Broadcasts GPS position packets as priority. /// Technical Details: Position Mesh packets will be prioritized higher and sent more frequently by default. - /// When used in conjunction with power.is_power_saving = true, nodes will wake up, + /// When used in conjunction with power.is_power_saving = true, nodes will wake up, /// send position, and then sleep for position.position_broadcast_secs seconds. case tracker // = 5 /// /// Description: Broadcasts telemetry packets as priority. /// Technical Details: Telemetry Mesh packets will be prioritized higher and sent more frequently by default. - /// When used in conjunction with power.is_power_saving = true, nodes will wake up, + /// When used in conjunction with power.is_power_saving = true, nodes will wake up, /// send environment telemetry, and then sleep for telemetry.environment_update_interval seconds. case sensor // = 6 @@ -249,12 +249,12 @@ struct Config { /// Technical Details: Used for nodes that "only speak when spoken to" /// Turns all of the routine broadcasts but allows for ad-hoc communication /// Still rebroadcasts, but with local only rebroadcast mode (known meshes only) - /// Can be used for clandestine operation or to dramatically reduce airtime / power consumption + /// Can be used for clandestine operation or to dramatically reduce airtime / power consumption case clientHidden // = 8 /// /// Description: Broadcasts location as message to default channel regularly for to assist with device recovery. - /// Technical Details: Used to automatically send a text message to the mesh + /// Technical Details: Used to automatically send a text message to the mesh /// with the current position of the device on a frequent interval: /// "I'm lost! Position: lat / long" case lostAndFound // = 9 diff --git a/Meshtastic/Protobufs/meshtastic/mesh.pb.swift b/Meshtastic/Protobufs/meshtastic/mesh.pb.swift index 1ae038f4..a881d288 100644 --- a/Meshtastic/Protobufs/meshtastic/mesh.pb.swift +++ b/Meshtastic/Protobufs/meshtastic/mesh.pb.swift @@ -1565,8 +1565,8 @@ struct MeshPacket { set {_uniqueStorage()._viaMqtt = newValue} } - /// - /// Hop limit with which the original packet started. Sent via LoRa using three bits in the unencrypted header. + /// + /// Hop limit with which the original packet started. Sent via LoRa using three bits in the unencrypted header. /// When receiving a packet, the difference between hop_start and hop_limit gives how many hops it traveled. var hopStart: UInt32 { get {return _storage._hopStart} @@ -2606,7 +2606,7 @@ struct DeviceMetadata { init() {} } -/// +/// /// A heartbeat message is sent to the node from the client to keep the connection alive. /// This is currently only needed to keep serial connections alive, but can be used by any PhoneAPI. struct Heartbeat { diff --git a/Meshtastic/Protobufs/meshtastic/telemetry.pb.swift b/Meshtastic/Protobufs/meshtastic/telemetry.pb.swift index 72d378bc..aa2ebae4 100644 --- a/Meshtastic/Protobufs/meshtastic/telemetry.pb.swift +++ b/Meshtastic/Protobufs/meshtastic/telemetry.pb.swift @@ -370,7 +370,7 @@ struct Telemetry { } /// - /// Power Metrics + /// Power Metrics var powerMetrics: PowerMetrics { get { if case .powerMetrics(let v)? = variant {return v} @@ -392,7 +392,7 @@ struct Telemetry { /// Air quality metrics case airQualityMetrics(AirQualityMetrics) /// - /// Power Metrics + /// Power Metrics case powerMetrics(PowerMetrics) #if !swift(>=4.1) diff --git a/Meshtastic/Views/Helpers/ConnectedDevice.swift b/Meshtastic/Views/Helpers/ConnectedDevice.swift index 3a3b6d2a..7e9d1852 100644 --- a/Meshtastic/Views/Helpers/ConnectedDevice.swift +++ b/Meshtastic/Views/Helpers/ConnectedDevice.swift @@ -9,6 +9,7 @@ struct ConnectedDevice: View { var bluetoothOn: Bool var deviceConnected: Bool var name: String + var mqttProxyEnabled: Bool = false var mqttProxyConnected: Bool = false var phoneOnly: Bool = false @@ -18,11 +19,11 @@ struct ConnectedDevice: View { if (phoneOnly && UIDevice.current.userInterfaceIdiom == .phone) || !phoneOnly { if bluetoothOn { - if deviceConnected && mqttProxyConnected { - if mqttProxyConnected { + if deviceConnected && (mqttProxyEnabled || mqttProxyConnected) { + if (mqttProxyConnected || mqttProxyEnabled) { Image(systemName: "iphone.gen3.radiowaves.left.and.right.circle.fill") .imageScale(.large) - .foregroundColor(.green) + .foregroundColor(mqttProxyConnected ? .green : .gray) .symbolRenderingMode(.hierarchical) } } diff --git a/Meshtastic/Views/Helpers/LoRaSignalStrengthIndicator.swift b/Meshtastic/Views/Helpers/LoRaSignalStrengthIndicator.swift index 85db8ec1..4405e819 100644 --- a/Meshtastic/Views/Helpers/LoRaSignalStrengthIndicator.swift +++ b/Meshtastic/Views/Helpers/LoRaSignalStrengthIndicator.swift @@ -85,7 +85,7 @@ func getRssiColor(rssi: Int32) -> Color { if rssi > -115 { /// Good return .green - } else if rssi > -115 && rssi < -120 { + } else if rssi > -120 { /// Fair return .yellow } else if rssi > -126 { diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index 22438b6c..234d705a 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -156,7 +156,10 @@ struct ChannelMessageList: View { ConnectedDevice( bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, - name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") + name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?", + mqttProxyEnabled: channel.uplinkEnabled || channel.downlinkEnabled, + mqttProxyConnected: channel.uplinkEnabled || channel.downlinkEnabled ? bleManager.mqttProxyConnected : false + ) } } } diff --git a/Meshtastic/Views/Messages/UserList.swift b/Meshtastic/Views/Messages/UserList.swift index 978e8c08..03dcf882 100644 --- a/Meshtastic/Views/Messages/UserList.swift +++ b/Meshtastic/Views/Messages/UserList.swift @@ -17,23 +17,20 @@ struct UserList: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager @State private var searchText = "" + @State private var viaLora = true + @State private var viaMqtt = true + @State private var isOnline = false + @State private var isFavorite = false + @State private var distanceFilter = false + @State private var maxDistance: Double = 800000 + @State private var hopsAway: Int = -1 + @State private var deviceRole: Int = -1 + @State var isEditingFilters = false - var usersQuery: Binding { - Binding { - searchText - } set: { newValue in - searchText = newValue - /// Case Insensitive Search Text Predicates - let searchPredicates = ["userId", "hwModel", "longName", "shortName"].map { property in - return NSPredicate(format: "%K CONTAINS[c] %@", property, searchText) - } - /// Create a compound predicate using each text search predicate as an OR - let textSearchPredicate = NSCompoundPredicate(type: .or, subpredicates: searchPredicates) - users.nsPredicate = newValue.isEmpty ? nil : textSearchPredicate - } - } @FetchRequest( - sortDescriptors: [NSSortDescriptor(key: "lastMessage", ascending: false), NSSortDescriptor(key: "vip", ascending: false), NSSortDescriptor(key: "longName", ascending: true)], + sortDescriptors: [NSSortDescriptor(key: "lastMessage", ascending: false), + NSSortDescriptor(key: "userNode.favorite", ascending: false), + NSSortDescriptor(key: "longName", ascending: true)], animation: .default) private var users: FetchedResults @@ -71,7 +68,7 @@ struct UserList: View { Text(user.longName ?? "unknown".localized) .font(.headline) Spacer() - if user.vip { + if (user.userNode?.favorite ?? false) { Image(systemName: "star.fill") .foregroundColor(.yellow) } @@ -108,15 +105,29 @@ struct UserList: View { .frame(height: 62) .contextMenu { Button { - user.vip = !user.vip + + if node != nil && !(user.userNode?.favorite ?? false) { + let success = bleManager.setFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num)) + if success { + user.userNode?.favorite = !(user.userNode?.favorite ?? true) + print("Favorited a node") + } + } else { + let success = bleManager.removeFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num)) + if success { + user.userNode?.favorite = !(user.userNode?.favorite ?? true) + print("Favorited a node") + } + } + context.refresh(user, mergeChanges: true) do { try context.save() } catch { context.rollback() - print("💥 Save User VIP Error") + print("💥 Save Node Favorite Error") } } label: { - Label(user.vip ? "Un-Favorite" : "Favorite", systemImage: user.vip ? "star.slash.fill" : "star.fill") + Label((user.userNode?.favorite ?? false) ? "Un-Favorite" : "Favorite", systemImage: (user.userNode?.favorite ?? false) ? "star.slash.fill" : "star.fill") } Button { user.mute = !user.mute @@ -156,9 +167,142 @@ struct UserList: View { } .listStyle(.plain) .navigationTitle(String.localizedStringWithFormat("contacts %@".localized, String(users.count == 0 ? 0 : users.count - 1))) - .searchable(text: usersQuery, placement: users.count > 10 ? .navigationBarDrawer(displayMode: .always) : .automatic, prompt: "Find a contact") + .sheet(isPresented: $isEditingFilters) { + NodeListFilter(filterTitle: "Contact Filters", viaLora: $viaLora, viaMqtt: $viaMqtt, isOnline: $isOnline, isFavorite: $isFavorite, distanceFilter: $distanceFilter, maximumDistance: $maxDistance, hopsAway: $hopsAway, deviceRole: $deviceRole) + } + .onChange(of: searchText) { _ in + searchUserList() + } + .onChange(of: viaLora) { _ in + if !viaLora && !viaMqtt { + viaMqtt = true + } + searchUserList() + } + .onChange(of: viaMqtt) { _ in + if !viaLora && !viaMqtt { + viaLora = true + } + searchUserList() + } + .onChange(of: deviceRole) { _ in + searchUserList() + } + .onChange(of: hopsAway) { _ in + searchUserList() + } + .onChange(of: isOnline) { _ in + searchUserList() + } + .onChange(of: isFavorite) { _ in + searchUserList() + } + .onChange(of: maxDistance) { _ in + searchUserList() + } + .onChange(of: distanceFilter) { _ in + searchUserList() + } + .onAppear { + if self.bleManager.context == nil { + self.bleManager.context = context + } + searchUserList() + } + .safeAreaInset(edge: .bottom, alignment: .trailing) { + HStack { + Button(action: { + withAnimation { + isEditingFilters = !isEditingFilters + } + }) { + Image(systemName: !isEditingFilters ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") + .padding(.vertical, 5) + } + .tint(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .buttonStyle(.borderedProminent) + + } + .controlSize(.regular) + .padding(5) + } + .padding(.bottom, 5) + .searchable(text: $searchText, placement: users.count > 10 ? .navigationBarDrawer(displayMode: .always) : .automatic, prompt: "Find a contact") .disableAutocorrection(true) .scrollDismissesKeyboard(.immediately) } } + + private func searchUserList() { + + /// Case Insensitive Search Text Predicates + let searchPredicates = ["userId", "numString", "hwModel", "longName", "shortName"].map { property in + return NSPredicate(format: "%K CONTAINS[c] %@", property, searchText) + } + /// Create a compound predicate using each text search preicate as an OR + let textSearchPredicate = NSCompoundPredicate(type: .or, subpredicates: searchPredicates) + /// Create an array of predicates to hold our AND predicates + var predicates: [NSPredicate] = [] + /// Mqtt + if !(viaLora && viaMqtt) { + if viaLora { + let loraPredicate = NSPredicate(format: "userNode.viaMqtt == NO") + predicates.append(loraPredicate) + } else { + let mqttPredicate = NSPredicate(format: "userNode.viaMqtt == YES") + predicates.append(mqttPredicate) + } + } + /// Role + if deviceRole > -1 { + let rolePredicate = NSPredicate(format: "role == %i", Int32(deviceRole)) + predicates.append(rolePredicate) + } + /// Hops Away + if hopsAway > 0 { + let hopsAwayPredicate = NSPredicate(format: "userNode.hopsAway == %i", Int32(hopsAway)) + predicates.append(hopsAwayPredicate) + } + + /// Online + if isOnline { + let isOnlinePredicate = NSPredicate(format: "userNode.lastHeard >= %@", Calendar.current.date(byAdding: .minute, value: -15, to: Date())! as NSDate) + predicates.append(isOnlinePredicate) + } + /// Favorites + if isFavorite { + let isFavoritePredicate = NSPredicate(format: "userNode.favorite == YES") + predicates.append(isFavoritePredicate) + } + /// Distance + if distanceFilter { + let pointOfInterest = LocationHelper.currentLocation + + if pointOfInterest.latitude != LocationHelper.DefaultLocation.latitude && pointOfInterest.longitude != LocationHelper.DefaultLocation.longitude { + let D: Double = maxDistance * 1.1 + let R: Double = 6371009 + let meanLatitidue = pointOfInterest.latitude * .pi / 180 + let deltaLatitude = D / R * 180 / .pi + let deltaLongitude = D / (R * cos(meanLatitidue)) * 180 / .pi + let minLatitude: Double = pointOfInterest.latitude - deltaLatitude + let maxLatitude: Double = pointOfInterest.latitude + deltaLatitude + let minLongitude: Double = pointOfInterest.longitude - deltaLongitude + let maxLongitude: Double = pointOfInterest.longitude + deltaLongitude + let distancePredicate = NSPredicate(format: "(SUBQUERY(userNode.positions, $position, $position.latest == TRUE && (%lf <= ($position.longitudeI / 1e7)) AND (($position.longitudeI / 1e7) <= %lf) AND (%lf <= ($position.latitudeI / 1e7)) AND (($position.latitudeI / 1e7) <= %lf))).@count > 0", minLongitude, maxLongitude,minLatitude, maxLatitude) + predicates.append(distancePredicate) + } + } + + if predicates.count > 0 || !searchText.isEmpty { + if !searchText.isEmpty { + let filterPredicates = NSCompoundPredicate(type: .and, subpredicates: predicates) + users.nsPredicate = NSCompoundPredicate(type: .and, subpredicates: [textSearchPredicate, filterPredicates]) + } else { + users.nsPredicate = NSCompoundPredicate(type: .and, subpredicates: predicates) + } + } else { + users.nsPredicate = nil + } + } } diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift index 2655fbd4..6a03aba8 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift @@ -93,7 +93,7 @@ struct MeshMapContent: MapContent { } /// Node History and Route Lines for favorites - if position.nodePosition?.user?.vip ?? false { + if position.nodePosition?.favorite ?? false { if showRouteLines { let nodePositions = Array(position.nodePosition!.positions!) as! [PositionEntity] let routeCoords = nodePositions.compactMap({(pos) -> CLLocationCoordinate2D in @@ -112,7 +112,7 @@ struct MeshMapContent: MapContent { } if showNodeHistory { ForEach(Array(position.nodePosition!.positions!) as! [PositionEntity], id: \.self) { (mappin: PositionEntity) in - if mappin.latest == false && mappin.nodePosition?.user?.vip ?? false { + if mappin.latest == false && mappin.nodePosition?.favorite ?? false { let pf = PositionFlags(rawValue: Int(mappin.nodePosition?.metadata?.positionFlags ?? 771)) let headingDegrees = Angle.degrees(Double(mappin.heading)) Annotation("", coordinate: mappin.coordinate) { diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/NodeMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/NodeMapContent.swift index f196bf07..83a5ccbc 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/NodeMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/NodeMapContent.swift @@ -128,13 +128,13 @@ struct NodeMapContent: MapContent { } } } - // .tag(position.time) + .tag(position.time) .annotationTitles(.automatic) .annotationSubtitles(.automatic) } /// Node History if showNodeHistory { - if position.latest == false && position.nodePosition?.user?.vip ?? false { + if position.latest == false && position.nodePosition?.favorite ?? false { let pf = PositionFlags(rawValue: Int(position.nodePosition?.metadata?.positionFlags ?? 771)) let headingDegrees = Angle.degrees(Double(position.heading)) Annotation("", coordinate: position.coordinate) { diff --git a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift index 9cbb352b..cf983edb 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift @@ -138,7 +138,7 @@ struct NodeMapSwiftUI: View { } } } - .safeAreaInset(edge: .bottom, alignment: UIDevice.current.userInterfaceIdiom == .phone ? .leading : .trailing) { + .safeAreaInset(edge: .bottom, alignment: .trailing) { HStack { Button(action: { withAnimation { diff --git a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift index b5fc9fd8..daa43ac9 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift @@ -162,7 +162,7 @@ struct PositionPopover: View { Spacer() VStack (alignment: .center) { if position.nodePosition != nil { - if position.nodePosition?.user?.vip ?? false { + if position.nodePosition?.favorite ?? false { Image(systemName: "star.fill") .foregroundColor(.yellow) .symbolRenderingMode(.hierarchical) diff --git a/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift b/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift index 79574927..321054c1 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeListFilter.swift @@ -11,9 +11,11 @@ import SwiftUI struct NodeListFilter: View { @Environment(\.dismiss) private var dismiss /// Filters + var filterTitle = "Node Filters" @Binding var viaLora: Bool @Binding var viaMqtt: Bool @Binding var isOnline: Bool + @Binding var isFavorite: Bool @Binding var distanceFilter: Bool @Binding var maximumDistance: Double @Binding var hopsAway: Int @@ -23,7 +25,7 @@ struct NodeListFilter: View { NavigationStack { Form { - Section(header: Text("Node Filters")) { + Section(header: Text(filterTitle)) { Toggle(isOn: $viaLora) { Label { @@ -48,7 +50,7 @@ struct NodeListFilter: View { Toggle(isOn: $isOnline) { Label { - Text("Online Only") + Text("Online") } icon: { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) @@ -58,29 +60,43 @@ struct NodeListFilter: View { .toggleStyle(SwitchToggleStyle(tint: .accentColor)) .listRowSeparator(.visible) -// Toggle(isOn: $distanceFilter) { -// -// Label { -// Text("Distance") -// } icon: { -// Image(systemName: "map") -// } -// } -// .toggleStyle(SwitchToggleStyle(tint: .accentColor)) -// -// .listRowSeparator(distanceFilter ? .hidden : .visible) -// if distanceFilter { -// HStack { -// Label("Show nodes", systemImage: "lines.measurement.horizontal") -// Picker("", selection: $maximumDistance) { -// ForEach(MeshMapDistances.allCases) { di in -// Text(di.description) -// .tag(di.id) -// } -// } -// .pickerStyle(DefaultPickerStyle()) -// } -// } + Toggle(isOn: $isFavorite) { + + Label { + Text("Favorites") + } icon: { + + Image(systemName: "star.fill") + .foregroundColor(.yellow) + .symbolRenderingMode(.hierarchical) + } + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .listRowSeparator(.visible) + + Toggle(isOn: $distanceFilter) { + + Label { + Text("Distance") + } icon: { + Image(systemName: "map") + } + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + + .listRowSeparator(distanceFilter ? .hidden : .visible) + if distanceFilter { + HStack { + Label("Show nodes", systemImage: "lines.measurement.horizontal") + Picker("", selection: $maximumDistance) { + ForEach(MeshMapDistances.allCases) { di in + Text(di.description) + .tag(di.id) + } + } + .pickerStyle(DefaultPickerStyle()) + } + } HStack { Label("Hops Away", systemImage: "hare") Picker("", selection: $hopsAway) { @@ -125,7 +141,7 @@ struct NodeListFilter: View { .padding(.bottom) #endif } - .presentationDetents([.fraction(0.40), .fraction(0.50)]) + .presentationDetents([.fraction(0.6), .fraction(0.75)]) .presentationDragIndicator(.visible) } } diff --git a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift index f7ea4ffc..21bf839d 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift @@ -31,7 +31,7 @@ struct NodeListItem: View { Text(node.user?.longName ?? "unknown".localized) .fontWeight(.medium) .font(.headline) - if node.user?.vip ?? false { + if node.favorite { Spacer() Image(systemName: "star.fill") .foregroundColor(.yellow) diff --git a/Meshtastic/Views/Nodes/MeshMap.swift b/Meshtastic/Views/Nodes/MeshMap.swift index 7f1e803e..5a848908 100644 --- a/Meshtastic/Views/Nodes/MeshMap.swift +++ b/Meshtastic/Views/Nodes/MeshMap.swift @@ -148,7 +148,7 @@ struct MeshMap: View { return } } - .safeAreaInset(edge: .bottom, alignment: UIDevice.current.userInterfaceIdiom == .phone ? .leading : .trailing) { + .safeAreaInset(edge: .bottom, alignment: .trailing) { HStack { Button(action: { withAnimation { @@ -166,7 +166,6 @@ struct MeshMap: View { .padding(5) } } - .navigationTitle("Mesh Map") .navigationBarItems(leading: MeshtasticLogo(), trailing: ZStack { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 8a63d2a9..4eb8b001 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -20,6 +20,7 @@ struct NodeList: View { @State private var viaLora = true @State private var viaMqtt = true @State private var isOnline = false + @State private var isFavorite = false @State private var distanceFilter = false @State private var maxDistance: Double = 800000 @State private var hopsAway: Int = -1 @@ -33,7 +34,9 @@ struct NodeList: View { @EnvironmentObject var bleManager: BLEManager @FetchRequest( - sortDescriptors: [NSSortDescriptor(key: "user.vip", ascending: false), NSSortDescriptor(key: "lastHeard", ascending: false)], + sortDescriptors: [NSSortDescriptor(key: "favorite", ascending: false), + NSSortDescriptor(key: "lastHeard", ascending: false), + NSSortDescriptor(key: "user.longName", ascending: true)], animation: .default) var nodes: FetchedResults @@ -49,19 +52,39 @@ struct NodeList: View { connected: bleManager.connectedPeripheral != nil && bleManager.connectedPeripheral?.num ?? -1 == node.num, connectedNode: (bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral?.num ?? -1 : -1)) .contextMenu { - if node.user != nil { - Button { - node.user!.vip = !node.user!.vip - context.refresh(node, mergeChanges: true) - do { - try context.save() - } catch { - context.rollback() - print("💥 Save User VIP Error") + + Button { + if !node.favorite { + + let success = bleManager.setFavoriteNode(node: node, connectedNodeNum: Int64(connectedNodeNum)) + if success { + node.favorite = !node.favorite + do { + try context.save() + } catch { + context.rollback() + print("💥 Save Node Favorite Error") + } + print("Favorited a node") + } + } else { + let success = bleManager.removeFavoriteNode(node: node, connectedNodeNum: Int64(connectedNodeNum)) + if success { + node.favorite = !node.favorite + do { + try context.save() + } catch { + context.rollback() + print("💥 Save Node Favorite Error") + } + print("Favorited a node") } - } label: { - Label(node.user?.vip ?? false ? "Un-Favorite" : "Favorite", systemImage: node.user?.vip ?? false ? "star.slash.fill" : "star.fill") } + + } label: { + Label(node.favorite ? "Un-Favorite" : "Favorite", systemImage: node.favorite ? "star.slash.fill" : "star.fill") + } + if node.user != nil { Button { node.user!.mute = !node.user!.mute context.refresh(node, mergeChanges: true) @@ -145,7 +168,7 @@ struct NodeList: View { } } .sheet(isPresented: $isEditingFilters) { - NodeListFilter(viaLora: $viaLora, viaMqtt: $viaMqtt, isOnline: $isOnline, distanceFilter: $distanceFilter, maximumDistance: $maxDistance, hopsAway: $hopsAway, deviceRole: $deviceRole) + NodeListFilter(viaLora: $viaLora, viaMqtt: $viaMqtt, isOnline: $isOnline, isFavorite: $isFavorite, distanceFilter: $distanceFilter, maximumDistance: $maxDistance, hopsAway: $hopsAway, deviceRole: $deviceRole) } .safeAreaInset(edge: .bottom, alignment: .trailing) { HStack { @@ -263,6 +286,15 @@ struct NodeList: View { .onChange(of: isOnline) { _ in searchNodeList() } + .onChange(of: isFavorite) { _ in + searchNodeList() + } + .onChange(of: maxDistance) { _ in + searchNodeList() + } + .onChange(of: distanceFilter) { _ in + searchNodeList() + } .onAppear { if self.bleManager.context == nil { self.bleManager.context = context @@ -273,7 +305,7 @@ struct NodeList: View { private func searchNodeList() { /// Case Insensitive Search Text Predicates - let searchPredicates = ["user.userId", "user.hwModel", "user.longName", "user.shortName"].map { property in + let searchPredicates = ["user.userId", "user.numString", "user.hwModel", "user.longName", "user.shortName"].map { property in return NSPredicate(format: "%K CONTAINS[c] %@", property, searchText) } /// Create a compound predicate using each text search preicate as an OR @@ -306,6 +338,11 @@ struct NodeList: View { let isOnlinePredicate = NSPredicate(format: "lastHeard >= %@", Calendar.current.date(byAdding: .minute, value: -15, to: Date())! as NSDate) predicates.append(isOnlinePredicate) } + /// Favorites + if isFavorite { + let isFavoritePredicate = NSPredicate(format: "favorite == YES") + predicates.append(isFavoritePredicate) + } /// Distance if distanceFilter { let pointOfInterest = LocationHelper.currentLocation @@ -320,15 +357,12 @@ struct NodeList: View { let maxLatitude: Double = pointOfInterest.latitude + deltaLatitude let minLongitude: Double = pointOfInterest.longitude - deltaLongitude let maxLongitude: Double = pointOfInterest.longitude + deltaLongitude - let distancePredicate = NSPredicate(format: "(%lf <= (positions[first].longitudeI / 1e7))", minLongitude, maxLongitude,minLatitude, maxLatitude) - //let distancePredicate = NSPredicate(format: "(%lf <= (positions[LAST].longitudeI / 1e7)) AND ((positions[LAST].longitudeI / 1e7) <= %lf) AND (%lf <= (positions[LAST].latitudeI / 1e7)) AND ((positions[LAST].latitudeI / 1e7) <= %lf)", minLongitude, maxLongitude,minLatitude, maxLatitude) - - //predicates.append(distancePredicate) + let distancePredicate = NSPredicate(format: "(SUBQUERY(positions, $position, $position.latest == TRUE && (%lf <= ($position.longitudeI / 1e7)) AND (($position.longitudeI / 1e7) <= %lf) AND (%lf <= ($position.latitudeI / 1e7)) AND (($position.latitudeI / 1e7) <= %lf))).@count > 0", minLongitude, maxLongitude,minLatitude, maxLatitude) + predicates.append(distancePredicate) } } if predicates.count > 0 || !searchText.isEmpty { - if !searchText.isEmpty { let filterPredicates = NSCompoundPredicate(type: .and, subpredicates: predicates) nodes.nsPredicate = NSCompoundPredicate(type: .and, subpredicates: [textSearchPredicate, filterPredicates]) diff --git a/Meshtastic/Views/Settings/Channels.swift b/Meshtastic/Views/Settings/Channels.swift index 6c5fe5ed..5f0f44fc 100644 --- a/Meshtastic/Views/Settings/Channels.swift +++ b/Meshtastic/Views/Settings/Channels.swift @@ -32,21 +32,29 @@ struct Channels: View { @State var hasChanges = false @State var hasValidKey = true @State private var isPresentingSaveConfirm: Bool = false - @State private var channelIndex: Int32 = 0 - @State private var channelName = "" - @State private var channelKeySize = 16 - @State private var channelKey = "AQ==" - @State private var channelRole = 0 - @State private var uplink = false - @State private var downlink = false - @State private var positionPrecision = 32.0 - @State private var preciseLocation = true - @State private var positionsEnabled = true - @State private var supportedVersion = true + @State var channelIndex: Int32 = 0 + @State var channelName = "" + @State var channelKeySize = 16 + @State var channelKey = "AQ==" + @State var channelRole = 0 + @State var uplink = false + @State var downlink = false + @State var positionPrecision = 32.0 + @State var preciseLocation = true + @State var positionsEnabled = true + @State var supportedVersion = true @State var selectedChannel: ChannelEntity? /// Minimum Version for granular position configuration @State var minimumVersion = "2.2.24" + + @FetchRequest( + sortDescriptors: [NSSortDescriptor(key: "favorite", ascending: false), + NSSortDescriptor(key: "lastHeard", ascending: false), + NSSortDescriptor(key: "user.longName", ascending: true)], + animation: .default) + + var nodes: FetchedResults var body: some View { @@ -134,6 +142,8 @@ struct Channels: View { .padding() #endif ChannelForm(channelIndex: $channelIndex, channelName: $channelName, channelKeySize: $channelKeySize, channelKey: $channelKey, channelRole: $channelRole, uplink: $uplink, downlink: $downlink, positionPrecision: $positionPrecision, preciseLocation: $preciseLocation, positionsEnabled: $positionsEnabled, hasChanges: $hasChanges, hasValidKey: $hasValidKey, supportedVersion: $supportedVersion) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) .onAppear { supportedVersion = bleManager.connectedVersion == "0.0.0" || self.minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedAscending || minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedSame } @@ -150,26 +160,24 @@ struct Channels: View { channel.settings.downlinkEnabled = downlink channel.settings.moduleSettings.positionPrecision = UInt32(positionPrecision) - let newChannel = ChannelEntity(context: context) - newChannel.id = Int32(channel.index) - newChannel.index = Int32(channel.index) - newChannel.uplinkEnabled = channel.settings.uplinkEnabled - newChannel.downlinkEnabled = channel.settings.downlinkEnabled - newChannel.name = channel.settings.name - newChannel.role = Int32(channel.role.rawValue) - newChannel.psk = channel.settings.psk - newChannel.positionPrecision = Int32(positionPrecision) + selectedChannel!.role = Int32(channelRole) + selectedChannel!.index = channelIndex + selectedChannel!.name = channelName + selectedChannel!.psk = Data(base64Encoded: channelKey) ?? Data() + selectedChannel!.uplinkEnabled = uplink + selectedChannel!.downlinkEnabled = downlink + selectedChannel!.positionPrecision = Int32(positionPrecision) guard let mutableChannels = node?.myInfo?.channels?.mutableCopy() as? NSMutableOrderedSet else { return } - if mutableChannels.contains(newChannel) { - mutableChannels.replaceObject(at: Int(newChannel.index), with: newChannel) + if mutableChannels.contains(selectedChannel as Any) { + mutableChannels.replaceObject(at: Int(channel.index), with: selectedChannel as Any) } else { - mutableChannels.add(newChannel) + mutableChannels.add(selectedChannel as Any) } - node!.myInfo!.channels = mutableChannels.copy() as? NSOrderedSet - context.refresh(newChannel, mergeChanges: true) + node?.myInfo?.channels = mutableChannels.copy() as? NSOrderedSet + context.refresh(selectedChannel!, mergeChanges: true) do { try context.save() print("💾 Saved Channel: \(channel.settings.name)") @@ -179,24 +187,28 @@ struct Channels: View { print("💥 Unresolved Core Data error in the channel editor. Error: \(nsError)") } } else { - if channelIndex <= node!.myInfo!.channels?.count ?? 0 { - guard let channelEntity = node!.myInfo!.channels?[Int(channelIndex)] as? ChannelEntity else { - return - } - let objects = channelEntity.allPrivateMessages - for object in objects { - context.delete(object) - } - context.delete(channelEntity) - do { - try context.save() - print("💾 Deleted Channel: \(channel.settings.name)") - } catch { - context.rollback() - let nsError = error as NSError - print("💥 Unresolved Core Data error in the channel editor. Error: \(nsError)") + guard let channelEntity = node?.myInfo?.channels?.first(where: { ($0 as! ChannelEntity).index == channelIndex }) else { + return + } + + let objects = (channelEntity as! ChannelEntity).allPrivateMessages + for object in objects { + context.delete(object) + } + for node in nodes { + if node.channel == (channelEntity as AnyObject).index { + context.delete(node) } } + context.delete(channelEntity as! ChannelEntity) + do { + try context.save() + print("💾 Deleted Channel: \(channel.settings.name)") + } catch { + context.rollback() + let nsError = error as NSError + print("💥 Unresolved Core Data error in the channel editor. Error: \(nsError)") + } } let adminMessageId = bleManager.saveChannel(channel: channel, fromUser: node!.user!, toUser: node!.user!) @@ -227,8 +239,6 @@ struct Channels: View { .padding(.bottom) #endif } - .presentationDetents([.fraction(0.85), .large]) - .presentationDragIndicator(.visible) } if node?.myInfo?.channels?.array.count ?? 0 < 8 && node != nil { @@ -249,7 +259,17 @@ struct Channels: View { uplink = false downlink = false hasChanges = true - selectedChannel = ChannelEntity(context: context) + + let newChannel = ChannelEntity(context: context) + newChannel.id = channelIndex + newChannel.index = channelIndex + newChannel.uplinkEnabled = uplink + newChannel.downlinkEnabled = downlink + newChannel.name = channelName + newChannel.role = Int32(channelRole) + newChannel.psk = Data(base64Encoded: channelKey) ?? Data() + newChannel.positionPrecision = Int32(positionPrecision) + selectedChannel = newChannel } label: { Label("Add Channel", systemImage: "plus.square") @@ -282,7 +302,6 @@ func firstMissingChannelIndex(_ indexes: [Int]) -> Int { return indexes.count + 1 } - enum PositionPrecision: Int, CaseIterable, Identifiable { case eleven = 11 diff --git a/Meshtastic/Views/Settings/Channels/ChannelForm.swift b/Meshtastic/Views/Settings/Channels/ChannelForm.swift index da6127df..09238d49 100644 --- a/Meshtastic/Views/Settings/Channels/ChannelForm.swift +++ b/Meshtastic/Views/Settings/Channels/ChannelForm.swift @@ -105,6 +105,7 @@ struct ChannelForm: View { ) .onChange(of: channelKey, perform: { _ in + let tempKey = Data(base64Encoded: channelKey) ?? Data() if tempKey.count == channelKeySize || channelKeySize == -1{ hasValidKey = true @@ -245,7 +246,5 @@ struct ChannelForm: View { } } } - .presentationDetents([.large]) - .presentationDragIndicator(.visible) } } diff --git a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift index be067293..b0de0cbb 100644 --- a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift @@ -289,7 +289,7 @@ struct MQTTConfig: View { .navigationTitle("mqtt.config") .navigationBarItems(trailing: ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?", mqttProxyConnected: bleManager.mqttProxyConnected) + ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?", mqttProxyEnabled: self.enabled, mqttProxyConnected: bleManager.mqttProxyConnected) }) .onChange(of: address) { newAddress in if node != nil && node?.mqttConfig != nil { diff --git a/Meshtastic/Views/Settings/SaveChannelQRCode.swift b/Meshtastic/Views/Settings/SaveChannelQRCode.swift index 7a825bb5..f9ca2557 100644 --- a/Meshtastic/Views/Settings/SaveChannelQRCode.swift +++ b/Meshtastic/Views/Settings/SaveChannelQRCode.swift @@ -11,22 +11,23 @@ struct SaveChannelQRCode: View { @Environment(\.dismiss) private var dismiss var channelSetLink: String + var addChannels: Bool = false var bleManager: BLEManager @State var connectedToDevice = false var body: some View { VStack { - Text("Save Channel Settings?") + Text("\(addChannels ? "Add" : "Replace all") Channels?") .font(.title) - Text("These settings will replace the current LoRa Config and Channel Settings on your radio. After everything saves your device will reboot.") + Text("These settings will \(addChannels ? "add" : "replace all") channels. The current LoRa Config will be replaced. After everything saves your device will reboot.") .foregroundColor(.gray) - .font(.callout) + .font(.title3) .padding() HStack { Button { - let success = bleManager.saveChannelSet(base64UrlString: channelSetLink) + let success = bleManager.saveChannelSet(base64UrlString: channelSetLink, addChannels: addChannels) if success { dismiss() } diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index b990b0f4..ec2c747f 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -13,7 +13,8 @@ import TipKit struct Settings: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager - @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "user.longName", ascending: true)], animation: .default) + @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "favorite", ascending: false), + NSSortDescriptor(key: "user.longName", ascending: true)], animation: .default) private var nodes: FetchedResults @State private var selectedNode: Int = 0 @State private var preferredNodeNum: Int = 0 @@ -106,16 +107,29 @@ struct Settings: View { if selectedNode == 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 ?? "unknown".localized)") - .tag(Int(node.num)) + Label { + Text("BLE: \(node.user?.longName ?? "unknown".localized)") + } icon: { + Image(systemName: "antenna.radiowaves.left.and.right") + } + .tag(Int(node.num)) } else if node.metadata != nil { - Text("Remote Config: \(node.user?.longName ?? "unknown".localized)") - .tag(Int(node.num)) + Label { + Text("Remote: \(node.user?.longName ?? "unknown".localized)") + } icon: { + Image(systemName: "av.remote") + } + .tag(Int(node.num)) } else if hasAdmin { - Text("Request Admin: \(node.user?.longName ?? "unknown".localized)") - .tag(Int(node.num)) + Label { + Text("Request Admin: \(node.user?.longName ?? "unknown".localized)") + } icon: { + Image(systemName: "rectangle.and.hand.point.up.left") + } + .tag(Int(node.num)) } } } diff --git a/Meshtastic/Views/Settings/ShareChannels.swift b/Meshtastic/Views/Settings/ShareChannels.swift index 8fa6bf32..627b7874 100644 --- a/Meshtastic/Views/Settings/ShareChannels.swift +++ b/Meshtastic/Views/Settings/ShareChannels.swift @@ -46,6 +46,7 @@ struct ShareChannels: View { @State var includeChannel5 = true @State var includeChannel6 = true @State var includeChannel7 = true + @State var replaceChannels = true var node: NodeInfoEntity? @State private var channelsUrl = "https://www.meshtastic.org/e/#" var qrCodeImage = QrCodeImage() @@ -53,9 +54,9 @@ struct ShareChannels: View { var body: some View { if #available(iOS 17.0, macOS 14.0, *) { - VStack { - TipView(ShareChannelsTip(), arrowEdge: .bottom) - } +// VStack { +// TipView(ShareChannelsTip(), arrowEdge: .bottom) +// } } GeometryReader { bounds in let smallest = min(bounds.size.width, bounds.size.height) @@ -191,6 +192,17 @@ struct ShareChannels: View { let qrImage = qrCodeImage.generateQRCode(from: channelsUrl) VStack { if node != nil { + Toggle(isOn: $replaceChannels) { + Label(replaceChannels ? "Replace Channels" : "Add Channels", systemImage: replaceChannels ? "arrow.triangle.2.circlepath.circle" : "plus.app") + } + .tint(.accentColor) + .toggleStyle(.button) + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.top) + .padding(.bottom) + ShareLink("Share QR Code & Link", item: Image(uiImage: qrImage), subject: Text("Meshtastic Node \(node?.user?.shortName ?? "????") has shared channels with you"), @@ -235,6 +247,7 @@ struct ShareChannels: View { .onChange(of: includeChannel5) { _ in generateChannelSet() } .onChange(of: includeChannel6) { _ in generateChannelSet() } .onChange(of: includeChannel7) { _ in generateChannelSet() } + .onChange(of: replaceChannels) { _ in generateChannelSet() } } } func generateChannelSet() { @@ -272,7 +285,7 @@ struct ShareChannels: View { } } let settingsString = try! channelSet.serializedData().base64EncodedString() - channelsUrl = ("https://meshtastic.org/e/#" + settingsString.base64ToBase64url()) + channelsUrl = ("https://meshtastic.org/e/#" + settingsString.base64ToBase64url() + (replaceChannels ? "" : "?add=true")) } } } diff --git a/README.md b/README.md index ec8a8b5c..8ef16e48 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ SwiftUI client applications for iOS, iPadOS and macOS. brew install swift-protobuf ``` - check out the latest protobuf commit from the master branch + ```bash + git submodule update --init + ``` - run: ```bash ./gen_proto.sh diff --git a/de.lproj/Localizable.strings b/de.lproj/Localizable.strings index bf9e8d39..54b004f6 100644 --- a/de.lproj/Localizable.strings +++ b/de.lproj/Localizable.strings @@ -239,6 +239,7 @@ "network.config"="Netzwerkeinstellungen"; "nodes"="Nodes"; "nodes %@"="Nodes (%@)"; +"nodelist.filter.distance %@"="up to %@ away"; "no.nodes"="Keine Meshtastic Nodes gefunden"; "not.connected"="Kein Gerät verbunden"; "numbers.punctuation"="Ziffern und Interpunktion"; diff --git a/en.lproj/Localizable.strings b/en.lproj/Localizable.strings index e031fd63..b395eb99 100644 --- a/en.lproj/Localizable.strings +++ b/en.lproj/Localizable.strings @@ -245,6 +245,8 @@ "network.config"="Network Config"; "nodes"="Nodes"; "nodes %@"="Nodes (%@)"; +"nodelist.filter.distance %@"="up to %@ away"; +"save.config %@"="Save Config for %@"; "no.nodes"="No Meshtastic Nodes Found"; "not.connected"="No device connected"; "numbers.punctuation"="Numbers and Punctuation"; diff --git a/fr.lproj/Localizable.strings b/fr.lproj/Localizable.strings index 34a507da..717d5997 100644 --- a/fr.lproj/Localizable.strings +++ b/fr.lproj/Localizable.strings @@ -219,6 +219,7 @@ "network.config"="Configuration du réseau"; "nodes"="Noeuds"; "nodes %@"="Noeuds (%@)"; +"nodelist.filter.distance %@"="up to %@ away"; "no.nodes"="Aucun noeud Meshtastic trouvé"; "not.connected"="Aucun appareil connecté"; "numbers.punctuation"="Nombres and Ponctuation"; diff --git a/gen_protos.sh b/gen_protos.sh index d36fafed..b829095f 100755 --- a/gen_protos.sh +++ b/gen_protos.sh @@ -1,14 +1,14 @@ #!/bin/bash # simple sanity checking for repo -if [ ! -d "../protobufs" ]; then - echo "Please check out the protobuf submodule by running: `git submodule update --init`" +if [ ! -d "./protobufs" ]; then + echo 'Please check out the protobuf submodule by running: `git submodule update --init`' exit fi # simple sanity checking for executable if [ ! -x "$(which protoc)" ]; then - echo "Please install swift-protobuf by running: brew install swift-protobuf" + echo 'Please install swift-protobuf by running: `brew install swift-protobuf`' exit fi diff --git a/he.lproj/Localizable.strings b/he.lproj/Localizable.strings index d7e0a05d..ce0d9316 100644 --- a/he.lproj/Localizable.strings +++ b/he.lproj/Localizable.strings @@ -243,6 +243,7 @@ "network.config"="הגדרות רשת"; "nodes"="מכשירים"; "nodes %@"="מכשירים (%@)"; +"nodelist.filter.distance %@"="up to %@ away"; "no.nodes"="לא נמצאו מכשירי משטסטיק"; "not.connected"="אין מכשיר מחובר"; "numbers.punctuation"="מספרים וסימני פיסוק "; diff --git a/pl.lproj/Localizable.strings b/pl.lproj/Localizable.strings index 6a3b18b8..82b7aa4c 100644 --- a/pl.lproj/Localizable.strings +++ b/pl.lproj/Localizable.strings @@ -240,6 +240,7 @@ "network"="Sieć"; "network.config"="Konfiguracja sieci"; "nodes %@"="Węzły (%@)"; +"nodelist.filter.distance %@"="up to %@ away"; "no.nodes"="Nie znaleziono węzłów Meshtastic"; "not.connected"="Brak podłączonych urządzeń"; "numbers.punctuation"="Cyfry i interpunkcja"; diff --git a/protobufs b/protobufs index dea3a82e..e6b4c590 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit dea3a82ef2accd25112b4ef1c6f8991b579740f4 +Subproject commit e6b4c590e7c489306c9c44e3ad1fcf62a3efd288 diff --git a/zh-Hans.lproj/Localizable.strings b/zh-Hans.lproj/Localizable.strings index 886cab0a..9a424c0e 100644 --- a/zh-Hans.lproj/Localizable.strings +++ b/zh-Hans.lproj/Localizable.strings @@ -239,6 +239,7 @@ "network.config"="网络配置"; "nodes"="节点"; "nodes %@"="节点 (%@)"; +"nodelist.filter.distance %@"="up to %@ away"; "no.nodes"="未找到 Meshtastic 节点"; "not.connected"="未连接到电台"; "numbers.punctuation"="数字和标点符号"; diff --git a/zh-Hant-TW.lproj/Localizable.strings b/zh-Hant-TW.lproj/Localizable.strings index 68562e56..4a557826 100644 --- a/zh-Hant-TW.lproj/Localizable.strings +++ b/zh-Hant-TW.lproj/Localizable.strings @@ -238,6 +238,7 @@ "network.config"="網路設定"; "nodes"="中繼點"; "nodes %@"="中繼點 (%@)"; +"nodelist.filter.distance %@"="up to %@ away"; "no.nodes"="未找到 Meshtastic 中繼點"; "not.connected"="未連接到電台"; "numbers.punctuation"="數字和標點符號"; diff --git a/zh-TW.lproj/Localizable.strings b/zh-TW.lproj/Localizable.strings index 6f36c512..f25fc223 100644 --- a/zh-TW.lproj/Localizable.strings +++ b/zh-TW.lproj/Localizable.strings @@ -23,9 +23,9 @@ "automatic.detection"="自動識別"; "battery.level"="電池電量"; "ble.name"="藍芽名稱"; -"ble.connection.timeout %d %@"="嘗試連接%@失敗,你可能需要在系统設定的藍芽選項中忽略該電台。"; -"ble.errorcode.6 %@"="%@ 如果在首選電台的旁邊,App 將會自動重連。"; -"ble.errorcode.14 %@"="%@ 這個錯誤通常無法自動修復,你需要在系統設定的藍芽選項中忽略該電台並重新配對。"; +"ble.connection.timeout %d %@"="嘗試連接%@失敗,你可能需要在系统設定的藍芽選項中忽略該設備。"; +"ble.errorcode.6 %@"="%@ 如果在首選裝置的旁邊,App 將會自動重連。"; +"ble.errorcode.14 %@"="%@ 這個錯誤通常無法自動修復,你需要在系統設定的藍芽選項中忽略該裝置並重新配對。"; "ble.errorcode.pin %@"="%@ 請再次嘗試連接並仔細檢查 PIN 碼。"; "bluetooth"="藍芽"; "bluetooth.off"="藍芽已關閉"; @@ -60,22 +60,22 @@ "config.power.ls.secs"="Light Sleep Interval"; "config.power.min.wake.secs"="最小的喚醒間隔時間"; "config.power.saving"="省電模式"; -"config.power.saving.description"="Will sleep everything as much as possible, for the tracker and sensor role this will also include the lora radio. Don't use this setting if you want to use your device with the phone apps or are using a device without a user button."; +"config.power.saving.description"="將會盡可能的進入休眠,追蹤器模式和感測器模式將會包含在內"; "config.power.shutdown.on.power.loss"="失去電源後關機"; "config.power.shutdown.after.secs"="之後"; "config.power.wait.bluetooth.secs"="等待藍芽"; "config.ringtone"="RTTTL Ringtone"; "config.ringtone.title"="鈴聲"; "config.ringtone.label"="Ringtone Transfer Language"; -"config.ringtone.description"="Ringtone Transfer Language(RTTTL) Ringtone String used by supported buzzers in external notifications."; +"config.ringtone.description"="支援外部通知的蜂鳴器所使用的 RTTTL(Ringtone Transfer Language)鈴聲字串"; "config.module.paxcounter.settings"="PAX Counter"; "config.module.paxcounter.title"="PAX Counter Config"; -"config.module.paxcounter.enabled.description"="When enabled the PAX Counter module counts the number of people passing by using WiFi and Bluetooth. Both WiFI and Bluetooth must be enabled for PAX counter to work."; -"config.module.paxcounter.updateinterval"="Update Interval"; +"config.module.paxcounter.enabled.description"="啟用 PAX 計數器模組後,將使用 WiFi 和藍牙計算經過的人數。PAX 計數器需要同時啟用 WiFi 和藍牙才能正常運作"; +"config.module.paxcounter.updateinterval"="更新間隔"; "config.module.paxcounter.updateinterval.description"="How often we can send a message to the mesh when people are detected."; -"config.save.confirm"="電台將會在設定儲存後重啟。"; -"connected.radio"="已連接的電台"; -"communicating"="與電台進行通訊中..."; +"config.save.confirm"="裝置將會在設定儲存後重啟。"; +"connected.radio"="已連接的裝置"; +"communicating"="與裝置進行通訊中..."; "connected"="已連接"; "connecting"="連接中..."; "contacts"="聯絡人"; @@ -86,21 +86,21 @@ "delete"="刪除"; "detection.sensor"="檢測感測器"; "device"="設備"; -"device.config"="電台設定"; +"device.config"="裝置設定"; "device.configuration"="設備設定"; -"device.metrics.delete"="刪除所有電台指標??"; -"device.metrics.log"="電台指標紀錄檔"; -"device.role.client"="標準模式 - App 可以連接到電台進行收發操作,並且會自動轉發 Mesh 網路中其他中繼點的消息。"; -"device.role.clientmute"="靜音模式 - 與標準模式類似,App 可以連接到電台進行收發操作,但不會轉發 Mesh 網路中其他中繼點的消息。"; +"device.metrics.delete"="刪除所有設備指標??"; +"device.metrics.log"="設備指標紀錄檔"; +"device.role.client"="標準模式 - App 可以連接到裝置進行收發操作,並且會自動轉發 Mesh 網路中其他中繼節點的消息。"; +"device.role.clientmute"="靜音模式 - 與標準模式類似,App 可以連接到裝置進行收發操作,但不會轉發 Mesh 網路中其他中繼節點的消息。"; "device.role.clienthidden"=" Used for nodes that \"only speak when spoken to\" Turns all of the routine broadcasts but allows for ad-hoc communication. Still rebroadcasts, but with local only rebroadcast mode (known meshes only). Can be used for private operation or to dramatically reduce airtime / power consumption."; "device.role.lostandfound"="Used to automatically send a text message to the mesh with the current position of the device on a frequent interval: \"I'm lost! Position: lat / long\""; -"device.role.router"="纯路由模式 - 自動轉發 Mesh 網路中其他中繼點的消息,中繼模式下螢幕會熄滅,Wi-Fi 和藍芽將會進入睡眠模式,App 將無法連接到電台進行收發操作。"; -"device.role.routerclient"="路由客户端模式 - 優先轉發 Mesh 網路中其他中繼點的消息,App 也可以連接到電台進行收發操作。"; -"device.role.repeater"="中繼模式 - Mesh 網路數據包將優先通過此中繼點路由。此模式可消除不必要的開銷,如 NodeInfo、DeviceTelemetry 和任何其他 Mesh 數據包,從而使設備不顯示為 Mesh 網路的一部分。有關此角色的其他特定設置,請參閱轉播模式。"; +"device.role.router"="纯路由模式 - 自動轉發 Mesh 網路中其他中繼節點的消息,中繼模式下螢幕會熄滅,Wi-Fi 和藍芽將會進入睡眠模式,App 將無法連接到裝置進行收發操作。"; +"device.role.routerclient"="路由客户端模式 - 優先轉發 Mesh 網路中其他中繼節點的消息,App 也可以連接到裝置進行收發操作。"; +"device.role.repeater"="中繼模式 - Mesh 網路數據包將優先通過此中繼節點路由。此模式可消除不必要的開銷,如 NodeInfo、DeviceTelemetry 和任何其他 Mesh 數據包,從而使設備不顯示為 Mesh 網路的一部分。有關此角色的其他特定設置,請參閱轉播模式。"; "device.role.tracker"="追蹤模式 - 用於作為 GPS 追蹤器。從該設備發送的定位數據包優先級較高,每兩分鐘廣播一次。智能位置廣播預設為關閉。"; "direct.messages"="聊天"; "dismiss.keyboard"="隱藏鍵盤"; -"display"="螢幕(電台螢幕)"; +"display"="螢幕(設備螢幕)"; "display.config"="螢幕設定"; "distance"="距離"; "disconnect"="斷開連接"; @@ -112,7 +112,7 @@ "external.notification.config"="外部通知設定"; "finish"="完成"; "firmware.version"="韌體版本"; -"firmware.version.unsupported"="檢測到不支援的韌體版本,無法連接到電台。"; +"firmware.version.unsupported"="檢測到不支援的韌體版本,無法連接到裝置。"; "gas"="Gas"; "gas.resistance"="Gas Resistance"; "generate.qr.code"="生成QRcode"; @@ -165,13 +165,13 @@ "interval.tyeight.hours"="四十八小时小時"; "interval.eventytwo.hours"="七十二小時"; "keyboard.type"="鍵盤類型"; -"logging"="加載中"; +"logging"="載入中"; "lora"="LoRa"; "lora.config"="LoRa 設定"; "map"="Mesh 地圖"; -"map.centering"="居中"; +"map.centering"="置中"; "map.tiles.delete"="刪除已緩存的地圖區塊"; -"map.recentering"="自動重新居中"; +"map.recentering"="自動重新置中"; "map.use.legacy"="Use Legacy Mesh Map"; "map.type"="地圖類型"; "map.usertrackingmode"="使用者跟隨模式"; @@ -198,18 +198,18 @@ "mesh.log.mqtt.config %@"="MQTT module config received: %@"; "mesh.log.myinfo %@"="MyInfo received: %@"; "mesh.log.network.config %@"="收到網路設定: %@"; -"mesh.log.nodeinfo.received %@"="收到中繼點訊息: %@"; +"mesh.log.nodeinfo.received %@"="收到中繼節點訊息: %@"; "mesh.log.paxcounter %@"="PAX Counter message received for: %@"; "mesh.log.position.config %@"="Positon config received: %@"; -"mesh.log.position.received %@"="從中繼點接收到定位封包: %@"; +"mesh.log.position.received %@"="從中繼節點接收到定位封包: %@"; "mesh.log.rangetest.config %@"="收到拉距測試模組設定: %@"; "mesh.log.ringtone.config %@"="RTTTL Ringtone config received: %@"; "mesh.log.routing.message %@ %@"="Routing received for RequestID: %@ Ack Status: %@"; "mesh.log.serial.config %@"="Serial module config received: %@"; -"mesh.log.sharelocation %@"="傳送iOS裝置的GPS定位封包到中繼點上: %@"; +"mesh.log.sharelocation %@"="傳送iOS裝置的GPS定位封包到中繼節點上: %@"; "mesh.log.storeforward.config %@"="Store & Forward module config received: %@"; -"mesh.log.telemetry.config %@"="收到遠測模組設定: %@"; -"mesh.log.telemetry.received %@"="收到遠測資料: %@"; +"mesh.log.telemetry.config %@"="收到遙測模組設定: %@"; +"mesh.log.telemetry.received %@"="收到遙測資料: %@"; "mesh.log.textmessage.received"="Message received from the text message app."; "mesh.log.textmessage.send.failed %@"="訊息傳送失敗, 沒有正確連接到 %@"; "mesh.log.textmessage.sent %@ %@ %@"="傳送訊息 %@ 從 %@ 到 %@"; @@ -233,10 +233,10 @@ "name"="名稱"; "network"="網路"; "network.config"="網路設定"; -"nodes"="中繼點"; -"nodes %@"="中繼點 (%@)"; -"no.nodes"="未找到 Meshtastic 中繼點"; -"not.connected"="未連接到電台"; +"nodes"="中繼節點"; +"nodes %@"="中繼節點 (%@)"; +"no.nodes"="未找到 Meshtastic 中繼節點"; +"not.connected"="未連接到設備"; "numbers.punctuation"="數字和標點符號"; "off"="關閉"; "offline"="離線"; @@ -245,17 +245,17 @@ "password"="密碼"; "pause"="暫停"; "phone.gps"="手機 GPS"; -"phone.gps.interval.description"="電台通過手機獲得定位的時間間隔,但是向 Mesh 網路中更新定位的時間間隔由電台控制。"; +"phone.gps.interval.description"="設備通過手機獲得定位的時間間隔,但是向 Mesh 網路中更新定位的時間間隔由裝置控制。"; "position"="定位"; "position.config"="定位設定"; -"preferred.radio"="首選電台"; -"radio.configuration"="電台設定"; +"preferred.radio"="首選設備"; +"radio.configuration"="設備設定"; "range.test"="拉距測試"; "range.test.blocked"="區塊範圍測試"; "range.test.config"="拉距測試設定"; "reply"="回復"; "reboot"="重新啟動"; -"reboot.node"="重啟中繼點"; +"reboot.node"="重啟中繼節點"; "received.ack"="收到確認"; "received.ack.real"="收件人確認"; "resume"="恢復"; @@ -272,7 +272,7 @@ "routing.nochannel"="没有頻道"; "routing.toolarge"="數據包過大"; "routing.noresponse"="無回應"; -"routing.dutycyclelimit"="已達到物錢區域循環週期發射上限"; +"routing.dutycyclelimit"="已達到目前區域循環週期發射上限"; "routing.badRequest"="錯誤請求"; "routing.notauthorized"="未授權"; "satellite"="衛星"; @@ -291,7 +291,7 @@ "share.position"="分享位置"; "subscribed"="連接到 Mesh 網路"; "select.contact"="選擇聯絡人"; -"select.node"="選擇中繼點"; +"select.node"="選擇中繼節點"; "select.menu.item"="從菜單選擇項目"; "set.region"="設定 LoRa 區域"; "standard"="標準"; @@ -303,22 +303,22 @@ "storeforward.heartbeat"="發送心跳包"; "tapback"="響應"; "tapback.heart"="心"; -"tapback.thumbsup"="豎大拇指"; -"tapback.thumbsdown"="倒大拇指"; +"tapback.thumbsup"="讚"; +"tapback.thumbsdown"="倒讚"; "tapback.haha"="哈哈"; "tapback.exclamation"="驚嘆號"; "tapback.question"="問號"; "tapback.poop"="便便"; -"telemetry"="遠測(傳感器)"; -"telemetry.config"="遠側設定"; +"telemetry"="遙測(傳感器)"; +"telemetry.config"="遙測設定"; "timeout"="超時"; "timestamp"="時間戳記"; -"tip.bluetooth.connect.title"="連接到 LoRa 電台"; -"tip.bluetooth.connect.message"="顯示目前通過藍芽連接的 Lora 電台的信息。您可以向左滑動斷開電台,長按查看統計訊息或開始即時活動。"; +"tip.bluetooth.connect.title"="連接到 LoRa 設備"; +"tip.bluetooth.connect.message"="顯示目前通過藍芽連接的 Lora 裝置的信息。您可以向左滑動斷開裝置,長按查看統計訊息或開始即時活動。"; "tip.channels.create.title"="管理頻道"; "tip.channels.create.message"="現在 Mesh 上的資料會通過主通道發送。您可以設定輔助通道來建立由自己的金鑰保護的其他訊息組 [頻道設定提示](https://meshtastic.org/docs/configuration/radio/channels/)"; "tip.channels.share.title"="共享 Meshtastic 頻道"; -"tip.channels.share.message"="在 Meshtastic 網路中最多有 8 個頻道。第一個頻道是主頻道,大多數活動都發生在這裡,也是必需的。如果您不共享主頻道,您的第一個共享頻道就會成為其他網路的主頻道。它會在其主頻道和您的輔助頻道上對話。名稱為 admin 的頻道可遠端控制中繼點。其他頻道用於私人群组,每個群組都有自己的密鑰。"; +"tip.channels.share.message"="在 Meshtastic 網路中最多有 8 個頻道。第一個頻道是主頻道,大多數活動都發生在這裡,也是必需的。如果您不共享主頻道,您的第一個共享頻道就會成為其他網路的主頻道。它會在其主頻道和您的輔助頻道上對話。名稱為 admin 的頻道可遠端控制中繼節點。其他頻道用於私人群组,每個群組都有自己的密鑰。"; "tip.messages.title"="消息"; "tip.messages.message"="您可以發送和接收1對1聊天和群聊。在任何訊息中,您都可以長按查看可用的操作,如複製、回復、拍一拍、刪除以及詳情。"; "twitter"="Twitter";