diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 89b0f090..b53e62c0 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ C9697FA527933B8C00250207 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = C9697FA427933B8C00250207 /* SQLite */; }; DD0D3D222A55CEB10066DB71 /* CocoaMQTT in Frameworks */ = {isa = PBXBuildFile; productRef = DD0D3D212A55CEB10066DB71 /* CocoaMQTT */; }; DD0F791B28713C8A00A6FDAD /* AdminMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0F791A28713C8A00A6FDAD /* AdminMessageList.swift */; }; + DD14E72E2A82A614006E39BC /* RemoteHardware.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD14E72D2A82A614006E39BC /* RemoteHardware.swift */; }; DD1925B728CDA5A400720036 /* CannedMessagesConfigEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1925B628CDA5A400720036 /* CannedMessagesConfigEnums.swift */; }; DD1925B928CDA93900720036 /* SerialConfigEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1925B828CDA93900720036 /* SerialConfigEnums.swift */; }; DD1BF2F92776FE2E008C8D2F /* UserMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */; }; @@ -121,6 +122,7 @@ DDCDC6CB29481FCC004C1DDA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DDCDC6CD29481FCC004C1DDA /* Localizable.strings */; }; DDCE4E2C2869F92900BE9F8F /* UserConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */; }; DDD3BBD5292D763200D609B3 /* MeshtasticTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD3BBD4292D763200D609B3 /* MeshtasticTests.swift */; }; + DDD43FE32A78C8900083A3E9 /* MqttClientProxyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD43FE22A78C8900083A3E9 /* MqttClientProxyManager.swift */; }; DDD6EEAF29BC024700383354 /* Firmware.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD6EEAE29BC024700383354 /* Firmware.swift */; }; DDD94A502845C8F5004A87A0 /* DateTimeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD94A4F2845C8F5004A87A0 /* DateTimeText.swift */; }; DDD9E4E4284B208E003777C5 /* UserEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD9E4E3284B208E003777C5 /* UserEntityExtension.swift */; }; @@ -196,6 +198,8 @@ C9697F9C279336B700250207 /* LocalMBTileOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMBTileOverlay.swift; sourceTree = ""; }; DD0E9C222A30CE3A00580CBB /* MeshtasticDataModelV14.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV14.xcdatamodel; sourceTree = ""; }; DD0F791A28713C8A00A6FDAD /* AdminMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminMessageList.swift; sourceTree = ""; }; + DD14E72C2A80738F006E39BC /* MeshtasticDataModelV15.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV15.xcdatamodel; sourceTree = ""; }; + DD14E72D2A82A614006E39BC /* RemoteHardware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteHardware.swift; sourceTree = ""; }; DD1925B628CDA5A400720036 /* CannedMessagesConfigEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CannedMessagesConfigEnums.swift; sourceTree = ""; }; DD1925B828CDA93900720036 /* SerialConfigEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialConfigEnums.swift; sourceTree = ""; }; DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMessageList.swift; sourceTree = ""; }; @@ -323,6 +327,7 @@ DDCDC6CE294821AD004C1DDA /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConfig.swift; sourceTree = ""; }; DDD3BBD4292D763200D609B3 /* MeshtasticTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MeshtasticTests.swift; sourceTree = ""; }; + DDD43FE22A78C8900083A3E9 /* MqttClientProxyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MqttClientProxyManager.swift; sourceTree = ""; }; DDD6EEAE29BC024700383354 /* Firmware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Firmware.swift; sourceTree = ""; }; DDD94A4F2845C8F5004A87A0 /* DateTimeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTimeText.swift; sourceTree = ""; }; DDD9E4E3284B208E003777C5 /* UserEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEntityExtension.swift; sourceTree = ""; }; @@ -422,6 +427,7 @@ DD47E3CD26F103C600029299 /* NodeList.swift */, DD90860D26F69BAE00DC5189 /* NodeMap.swift */, DD73FD1028750779000852D6 /* PositionLog.swift */, + DD14E72D2A82A614006E39BC /* RemoteHardware.swift */, ); path = Nodes; sourceTree = ""; @@ -710,6 +716,7 @@ DDC2E1A526CEB32B0042C5E4 /* Helpers */ = { isa = PBXGroup; children = ( + DDD43FE12A78C86B0083A3E9 /* Mqtt */, DDB75A122A0593CD006ED576 /* Map */, DDAF8C5226EB1DF10058C060 /* BLEManager.swift */, DDC2E1A626CEB3400042C5E4 /* LocationHelper.swift */, @@ -736,6 +743,14 @@ path = Persistence; sourceTree = ""; }; + DDD43FE12A78C86B0083A3E9 /* Mqtt */ = { + isa = PBXGroup; + children = ( + DDD43FE22A78C8900083A3E9 /* MqttClientProxyManager.swift */, + ); + path = Mqtt; + sourceTree = ""; + }; DDDB443E29F79A9400EE2349 /* Extensions */ = { isa = PBXGroup; children = ( @@ -1032,6 +1047,7 @@ DD3CC6C228EB9D4900FA9159 /* UpdateCoreData.swift in Sources */, DDE0F7C5295F77B700B8AAB3 /* AppSettingsEnums.swift in Sources */, DDB6ABE628B1406100384BA1 /* LoraConfigEnums.swift in Sources */, + DDD43FE32A78C8900083A3E9 /* MqttClientProxyManager.swift in Sources */, DDDB444629F8A96500EE2349 /* Character.swift in Sources */, DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */, DDB6ABDB28B0AC6000384BA1 /* DistanceText.swift in Sources */, @@ -1098,6 +1114,7 @@ DD8169F9271F1A6100F4AB02 /* MeshLogger.swift in Sources */, DD41582A28585C32009B0E59 /* RangeTestConfig.swift in Sources */, DD1925B728CDA5A400720036 /* CannedMessagesConfigEnums.swift in Sources */, + DD14E72E2A82A614006E39BC /* RemoteHardware.swift in Sources */, DDDB444429F8A8DD00EE2349 /* Float.swift in Sources */, DD5E5211298EE33B00D21B61 /* remote_hardware.pb.swift in Sources */, DD5E5204298EE33B00D21B61 /* xmodem.pb.swift in Sources */, @@ -1311,7 +1328,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.1.16; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1345,7 +1362,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.1.16; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1464,7 +1481,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.1.16; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1495,7 +1512,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.1.16; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1605,6 +1622,7 @@ DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + DD14E72C2A80738F006E39BC /* MeshtasticDataModelV15.xcdatamodel */, DD0E9C222A30CE3A00580CBB /* MeshtasticDataModelV14.xcdatamodel */, DDB75A1F2A10766D006ED576 /* MeshtasticDataModelV13.xcdatamodel */, DDB759E12A04B264006ED576 /* MeshtasticDataModelV12.xcdatamodel */, @@ -1620,7 +1638,7 @@ DD5D0A9A2931AD6B00F7EA61 /* MeshtasticDataModelV2.xcdatamodel */, DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */, ); - currentVersion = DD0E9C222A30CE3A00580CBB /* MeshtasticDataModelV14.xcdatamodel */; + currentVersion = DD14E72C2A80738F006E39BC /* MeshtasticDataModelV15.xcdatamodel */; name = Meshtastic.xcdatamodeld; path = Meshtastic/Meshtastic.xcdatamodeld; sourceTree = ""; diff --git a/Meshtastic/Enums/HardwareModels.swift b/Meshtastic/Enums/HardwareModels.swift index 04dc9328..36575bed 100644 --- a/Meshtastic/Enums/HardwareModels.swift +++ b/Meshtastic/Enums/HardwareModels.swift @@ -32,6 +32,13 @@ enum HardwareModels: String, CaseIterable, Identifiable { case M5STACK case HELTECV3 case HELTECWSLV3 + case NANOG2ULTRA + case RAK11310 + case RPIPICO + case HELTECWIRELESSTRACKER + case HELTECWIRELESSPAPER + case TDECK + case TWATCHS3 var id: String { self.rawValue } var description: String { @@ -83,6 +90,20 @@ enum HardwareModels: String, CaseIterable, Identifiable { return "Heltec V3" case .HELTECWSLV3: return "Heltec wireless stick lite V3" + case .NANOG2ULTRA: + return "Nano G2 Ultra" + case .RAK11310: + return "RAK 11310 Pi Pico" + case .RPIPICO: + return "Pi Pico" + case .HELTECWIRELESSTRACKER: + return "Heltec Wireless Tracker" + case .HELTECWIRELESSPAPER: + return "Heltec Wireless Paper" + case .TDECK: + return "T-Deck" + case .TWATCHS3: + return "T-Watch S3" } } @@ -135,6 +156,20 @@ enum HardwareModels: String, CaseIterable, Identifiable { return ["firmware-heltec-v3-"] case .HELTECWSLV3: return ["firmware-heltec-wsl-v3-"] + case .NANOG2ULTRA: + return ["firmware-nano-g2-ultra-"] + case .RAK11310: + return ["firmware-rak11310-"] + case .RPIPICO: + return ["firmware-pico-"] + case .HELTECWIRELESSTRACKER: + return ["firmware-heltec-wireless-tracker-"] + case .HELTECWIRELESSPAPER: + return ["firmware-heltec-wireless-paper-"] + case .TDECK: + return ["firmware-t-echo-"] + case .TWATCHS3: + return ["firmware-t-watch-s3-"] } } @@ -188,6 +223,20 @@ enum HardwareModels: String, CaseIterable, Identifiable { return HardwarePlatforms.esp32 case .HELTECWSLV3: return HardwarePlatforms.esp32 + case .NANOG2ULTRA: + return HardwarePlatforms.nrf52 + case .RAK11310: + return HardwarePlatforms.piPico + case .RPIPICO: + return HardwarePlatforms.piPico + case .HELTECWIRELESSTRACKER: + return HardwarePlatforms.esp32 + case .HELTECWIRELESSPAPER: + return HardwarePlatforms.esp32 + case .TDECK: + return HardwarePlatforms.esp32 + case .TWATCHS3: + return HardwarePlatforms.esp32 } } func protoEnumValue() -> HardwareModel { @@ -240,6 +289,20 @@ enum HardwareModels: String, CaseIterable, Identifiable { return HardwareModel.heltecV3 case .HELTECWSLV3: return HardwareModel.heltecWslV3 + case .NANOG2ULTRA: + return HardwareModel.nanoG2Ultra + case .RAK11310: + return HardwareModel.rak11310 + case .RPIPICO: + return HardwareModel.rpiPico + case .HELTECWIRELESSTRACKER: + return HardwareModel.heltecWirelessTracker + case .HELTECWIRELESSPAPER: + return HardwareModel.heltecWirelessPaper + case .TDECK: + return HardwareModel.tDeck + case .TWATCHS3: + return HardwareModel.tWatchS3 } } } diff --git a/Meshtastic/Enums/LoraConfigEnums.swift b/Meshtastic/Enums/LoraConfigEnums.swift index 805a4288..85ed35ce 100644 --- a/Meshtastic/Enums/LoraConfigEnums.swift +++ b/Meshtastic/Enums/LoraConfigEnums.swift @@ -135,6 +135,26 @@ enum ModemPresets: Int, CaseIterable, Identifiable { return "Short Range - Fast" } } + var name: String { + switch self { + case .longFast: + return "LongFast" + case .longSlow: + return "LongSlow" + case .longModerate: + return "LongModerate" + case .vLongSlow: + return "VLongFast" + case .medSlow: + return "MediumSlow" + case .medFast: + return "MediumFast" + case .shortSlow: + return "ShortSlow" + case .shortFast: + return "ShortFast" + } + } func snrLimit() -> Float { switch self { case .longFast: diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index 8830ce33..0c758bb9 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -3,12 +3,52 @@ import CoreData import CoreBluetooth import SwiftUI import MapKit +import CocoaMQTT // --------------------------------------------------------------------------------------- // Meshtastic BLE Device Manager // --------------------------------------------------------------------------------------- -class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { +class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate, ObservableObject { + // MqttClientProxyManagerDelegate + func onMqttConnected() { + mqttManager.status = .connected + print("๐Ÿ“ฒ Mqtt Client Proxy onMqttConnected now subscribing to \(mqttManager.topic).") + mqttManager.mqttClientProxy?.subscribe(mqttManager.topic) + } + + func onMqttDisconnected() { + mqttManager.status = .disconnected + print("MQTT Disconnected") + } + + func onMqttMessageReceived(message: CocoaMQTTMessage) { + + print("๐Ÿ“ฒ Mqtt Client Proxy onMqttMessageReceived for topic: \(message.topic)") + if message.topic.contains("/stat/") { + return + } + var proxyMessage = MqttClientProxyMessage() + proxyMessage.topic = message.topic + proxyMessage.data = Data(message.payload) + proxyMessage.retained = message.retained + + var toRadio: ToRadio! + toRadio = ToRadio() + toRadio.mqttClientProxyMessage = proxyMessage + let binaryData: Data = try! toRadio.serializedData() + if connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected { + connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse) + print("๐Ÿ“ฒ Sent Mqtt client proxy message to the connected device.") + } + + } + + func onMqttError(message: String) { + print("MQTT Error") + } + + private static var documentsFolder: URL { do { return try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) @@ -37,6 +77,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { var positionTimer: Timer? var lastPosition: CLLocationCoordinate2D? let emptyNodeNum: UInt32 = 4294967295 + let mqttManager = MqttClientProxyManager.shared /* Meshtastic Service Details */ var TORADIO_characteristic: CBCharacteristic! var FROMRADIO_characteristic: CBCharacteristic! @@ -55,6 +96,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { self.connectedVersion = "0.0.0" super.init() centralManager = CBCentralManager(delegate: self, queue: nil) + mqttManager.delegate = self // centralManager = CBCentralManager(delegate: self, queue: nil, options: [CBCentralManagerOptionRestoreIdentifierKey: restoreKey]) } @@ -272,7 +314,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { func requestDeviceMetadata(fromUser: UserEntity, toUser: UserEntity, adminIndex: Int32, context: NSManagedObjectContext) -> Int64 { - guard connectedPeripheral!.peripheral.state == CBPeripheralState.connected else { return 0 } + guard connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected else { return 0 } var adminPacket = AdminMessage() adminPacket.getDeviceMetadataRequest = true @@ -298,7 +340,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { func sendTraceRouteRequest(destNum: Int64, wantResponse: Bool) -> Bool { var success = false - guard connectedPeripheral!.peripheral.state == CBPeripheralState.connected else { return success } + guard connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected else { return success } let fromNodeNum = connectedPeripheral.num let routePacket = RouteDiscovery() @@ -317,7 +359,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { toRadio.packet = meshPacket let binaryData: Data = try! toRadio.serializedData() - if connectedPeripheral!.peripheral.state == CBPeripheralState.connected { + if connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected { connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse) success = true @@ -328,7 +370,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { } func sendWantConfig() { - guard connectedPeripheral!.peripheral.state == CBPeripheralState.connected else { return } + guard connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected else { return } if FROMRADIO_characteristic == nil { MeshLogger.log("๐Ÿšจ \("firmware.version.unsupported".localized)") @@ -391,6 +433,17 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { print(characteristic.value!) } + // Publish mqttClientProxyMessages received on the from radio + if decodedInfo.payloadVariant == FromRadio.OneOf_PayloadVariant.mqttClientProxyMessage(decodedInfo.mqttClientProxyMessage) { + let message = CocoaMQTTMessage ( + topic: decodedInfo.mqttClientProxyMessage.topic, + payload: [UInt8](decodedInfo.mqttClientProxyMessage.data), + retained: decodedInfo.mqttClientProxyMessage.retained + ) + print("๐Ÿ“ฒ Publish Mqtt client proxy message received on FromRadio to the Mqtt server \(message)") + mqttManager.mqttClientProxy?.publish(message) + } + switch decodedInfo.packet.decoded.portnum { // Handle Any local only packets we get over BLE @@ -538,22 +591,6 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { print("MAX PORT NUM OF 511") } - // MARK: Check for an All / Broadcast User and delete it as a transition to multi channel - let fetchBCUserRequest: NSFetchRequest = NSFetchRequest.init(entityName: "UserEntity") - fetchBCUserRequest.predicate = NSPredicate(format: "num == %lld", Int64(emptyNodeNum)) - - do { - guard let fetchedUser = try context?.fetch(fetchBCUserRequest) as? [UserEntity] else { - return - } - if fetchedUser.count > 0 { - context?.delete(fetchedUser[0]) - print("๐Ÿ—‘๏ธ Deleted the All - Broadcast User") - } - } catch { - print("๐Ÿ’ฅ Error Deleting the All - Broadcast User") - } - if decodedInfo.configCompleteID != 0 && decodedInfo.configCompleteID == configNonce { invalidVersion = false lastConnectionError = "" @@ -561,6 +598,26 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { print("๐Ÿคœ Want Config Complete. ID:\(decodedInfo.configCompleteID)") peripherals.removeAll(where: { $0.peripheral.state == CBPeripheralState.disconnected }) // Config conplete returns so we don't read the characteristic again + + /// MQTT Client Proxy + if connectedPeripheral.num > 0 { + + let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(connectedPeripheral.num)) + do { + let fetchedNodeInfo = try context?.fetch(fetchNodeInfoRequest) as? [NodeInfoEntity] ?? [] + if fetchedNodeInfo.count == 1 && fetchedNodeInfo[0].mqttConfig != nil { + + //Subscribe to Mqtt Client Proxy if enabled + if fetchedNodeInfo[0].mqttConfig?.proxyToClientEnabled ?? false { + mqttManager.connectFromConfigSettings(node: fetchedNodeInfo[0]) + } + } + } catch { + print("Failed to find a node info for the connected node") + } + } + // MARK: Share Location Position Update Timer // Use context to pass the radio name with the timer // Use a RunLoop to prevent the timer from running on the main UI thread @@ -573,8 +630,10 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { RunLoop.current.add(positionTimer!, forMode: .common) } } + return } + case FROMNUM_UUID: print("๐Ÿ—ž๏ธ BLE (Notify) characteristic, value will be read next") @@ -587,6 +646,8 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { } } + + public func sendMessage(message: String, toUserNum: Int64, channel: Int32, isEmoji: Bool, replyID: Int64) -> Bool { var success = false @@ -676,7 +737,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { toRadio = ToRadio() toRadio.packet = meshPacket let binaryData: Data = try! toRadio.serializedData() - if connectedPeripheral!.peripheral.state == CBPeripheralState.connected { + if connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected { connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse) let logString = String.localizedStringWithFormat("mesh.log.textmessage.sent %@ %@ %@".localized, String(newMessage.messageId), String(fromUserNum), String(toUserNum)) @@ -721,7 +782,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { let binaryData: Data = try! toRadio.serializedData() let logString = String.localizedStringWithFormat("mesh.log.waypoint.sent %@".localized, String(fromNodeNum)) MeshLogger.log("๐Ÿ“ \(logString)") - if connectedPeripheral!.peripheral.state == CBPeripheralState.connected { + if connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected { connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse) success = true let wayPointEntity = getWaypoint(id: Int64(waypoint.id), context: context!) @@ -803,7 +864,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { toRadio = ToRadio() toRadio.packet = meshPacket let binaryData: Data = try! toRadio.serializedData() - if connectedPeripheral!.peripheral.state == CBPeripheralState.connected { + if connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected { connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse) success = true let logString = String.localizedStringWithFormat("mesh.log.sharelocation %@".localized, String(fromNodeNum)) @@ -1026,7 +1087,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { toRadio = ToRadio() toRadio.packet = meshPacket let binaryData: Data = try! toRadio.serializedData() - if connectedPeripheral!.peripheral.state == CBPeripheralState.connected { + if connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected { self.connectedPeripheral.peripheral.writeValue(binaryData, for: self.TORADIO_characteristic, type: .withResponse) let logString = String.localizedStringWithFormat("mesh.log.channel.sent %@ %d".localized, String(connectedPeripheral.num), chan.index) MeshLogger.log("๐ŸŽ›๏ธ \(logString)") @@ -1050,7 +1111,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { toRadio = ToRadio() toRadio.packet = meshPacket let binaryData: Data = try! toRadio.serializedData() - if connectedPeripheral!.peripheral.state == CBPeripheralState.connected { + if connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected { self.connectedPeripheral.peripheral.writeValue(binaryData, for: self.TORADIO_characteristic, type: .withResponse) let logString = String.localizedStringWithFormat("mesh.log.lora.config.sent %@".localized, String(connectedPeripheral.num)) MeshLogger.log("๐Ÿ“ป \(logString)") @@ -1527,7 +1588,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { let binaryData: Data = try! toRadio.serializedData() - if connectedPeripheral!.peripheral.state == CBPeripheralState.connected { + if connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected { connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse) let logString = String.localizedStringWithFormat("mesh.log.cannedmessages.messages.get %@".localized, String(connectedPeripheral.num)) MeshLogger.log("๐Ÿฅซ \(logString)") @@ -1902,7 +1963,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, ObservableObject { toRadio.packet = meshPacket let binaryData: Data = try! toRadio.serializedData() - if connectedPeripheral!.peripheral.state == CBPeripheralState.connected { + if connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected{ let newMessage = MessageEntity(context: context!) newMessage.messageId = Int64(meshPacket.id) newMessage.messageTimestamp = Int32(Date().timeIntervalSince1970) diff --git a/Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift b/Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift new file mode 100644 index 00000000..af22fcd1 --- /dev/null +++ b/Meshtastic/Helpers/Mqtt/MqttClientProxyManager.swift @@ -0,0 +1,219 @@ +// +// MQTTManager.swift +// Meshtastic +// +// Created by Garth Vander Houwen on 7/31/23. +// + +import Foundation +import CocoaMQTT + +protocol MqttClientProxyManagerDelegate: AnyObject { + func onMqttConnected() + func onMqttDisconnected() + func onMqttMessageReceived(message: CocoaMQTTMessage) + func onMqttError(message: String) +} + +class MqttClientProxyManager { + enum ConnectionStatus { + case connecting + case connected + case disconnecting + case disconnected + case error + case none + } + + enum MqttQos: Int { + case atMostOnce = 0 + case atLeastOnce = 1 + case exactlyOnce = 2 + } + + // Singleton Instance + static let shared = MqttClientProxyManager() + + private static let defaultKeepAliveInterval: Int32 = 60 + + weak var delegate: MqttClientProxyManagerDelegate? + var status = ConnectionStatus.none + + var mqttClientProxy: CocoaMQTT? + + var topic = "msh/2/c" + + private init() { + + } + + func connectFromConfigSettings(node: NodeInfoEntity) { + + let defaultServerAddress = "mqtt.meshtastic.org" + let useSsl = node.mqttConfig?.tlsEnabled == true + var defaultServerPort = useSsl ? 8883 : 1883 + var host = node.mqttConfig?.address + if host == nil || host!.isEmpty { + host = defaultServerAddress + } + else if host != nil && host!.contains(":") { + host = host!.components(separatedBy: ":")[0] + defaultServerPort = Int(host!.components(separatedBy: ":")[1])! + } + + if let host = host { + let port = defaultServerPort + let username = node.mqttConfig?.username + let password = node.mqttConfig?.password + + let root = node.mqttConfig?.root?.count ?? 0 > 0 ? node.mqttConfig?.root : "msh" + let prefix = root! + "/2/c" + topic = prefix + "/#" + let qos = CocoaMQTTQoS(rawValue :UInt8(1))! + connect(host: host, port: port, useSsl: useSsl, username: username, password: password, topic: topic, qos: qos, cleanSession: true) + } + } + + func connect(host: String, port: Int, useSsl: Bool, username: String?, password: String?, topic: String?, qos: CocoaMQTTQoS, cleanSession: Bool) { + + guard !host.isEmpty else { + delegate?.onMqttDisconnected() + return + } + + status = .connecting + + let clientId = "MeshtasticAppleMqttProxy-" + String(ProcessInfo().processIdentifier) + + mqttClientProxy = CocoaMQTT(clientID: clientId, host: host, port: UInt16(port)) + if let mqttClient = mqttClientProxy { + + mqttClient.enableSSL = useSsl + mqttClient.allowUntrustCACertificate = true + mqttClient.username = username + mqttClient.password = password + mqttClient.keepAlive = 60 + mqttClient.cleanSession = cleanSession +#if DEBUG + mqttClient.logLevel = .debug +#endif + mqttClient.willMessage = CocoaMQTTMessage(topic: "/will", string: "dieout") + mqttClient.autoReconnect = true + mqttClient.delegate = self + let success = mqttClient.connect() + if !success { + delegate?.onMqttError(message: "Mqtt connect error") + status = .error + } + } else { + delegate?.onMqttError(message: "Mqtt initialization error") + status = .error + } + } + + func subscribe(topic: String, qos: MqttQos) { + print("๐Ÿ“ฒ MQTT Client Proxy subscribed to: " + topic) + let qos = CocoaMQTTQoS(rawValue :UInt8(qos.rawValue))! + mqttClientProxy?.subscribe(topic, qos: qos) + } + + func unsubscribe(topic: String) { + mqttClientProxy?.unsubscribe(topic) + print("๐Ÿ“ฒ MQTT Client Proxy unsubscribe for: " + topic) + } + + func publish(message: String, topic: String, qos: MqttQos) { + let qos = CocoaMQTTQoS(rawValue :UInt8(qos.rawValue))! + mqttClientProxy?.publish(topic, withString: message, qos: qos) + print("๐Ÿ“ฒ MQTT Client Proxy publish for: " + topic) + } + + func disconnect() { + //MqttSettings.shared.isConnected = false + + if let client = mqttClientProxy { + status = .disconnecting + client.disconnect() + print("๐Ÿ“ฒ MQTT Client Proxy Disconnected") + } else { + status = .disconnected + } + } +} + +extension MqttClientProxyManager: CocoaMQTTDelegate { + + func mqtt(_ mqtt: CocoaMQTT, didConnectAck ack: CocoaMQTTConnAck) { + + print("๐Ÿ“ฒ MQTT Client Proxy didConnectAck: \(ack)") + if ack == .accept { + delegate?.onMqttConnected() + } else { + // Connection error + var errorDescription = "Unknown Error" + switch ack { + case .accept: + errorDescription = "No Error" + case .unacceptableProtocolVersion: + errorDescription = "Proto ver" + case .identifierRejected: + errorDescription = "Invalid Id" + case .serverUnavailable: + errorDescription = "Invalid Server" + case .badUsernameOrPassword: + errorDescription = "Invalid Credentials" + case .notAuthorized: + errorDescription = "Authorization Error" + default: + errorDescription = "Unknown Error" + } + print(errorDescription) + delegate?.onMqttError(message: errorDescription) + + //self.disconnect() // Stop reconnecting + //mqttSettings.isConnected = false // Disable automatic connect on start + } + + self.status = ack == .accept ? ConnectionStatus.connected : ConnectionStatus.error // Set AFTER sending onMqttError (so the delegate can detect that was an error while establishing connection) + } + + func mqttDidDisconnect(_ mqtt: CocoaMQTT, withError err: Error?) { + print("mqttDidDisconnect: \(err?.localizedDescription ?? "")") + + if let error = err, status == .connecting { + delegate?.onMqttError(message: error.localizedDescription) + } + + status = err == nil ? .disconnected : .error + delegate?.onMqttDisconnected() + } + + func mqtt(_ mqtt: CocoaMQTT, didPublishMessage message: CocoaMQTTMessage, id: UInt16) { + print("๐Ÿ“ฒ MQTT Client Proxy didPublishMessage from MqttClientProxyManager: \(message)") + } + + func mqtt(_ mqtt: CocoaMQTT, didPublishAck id: UInt16) { + print("๐Ÿ“ฒ MQTT Client Proxy didPublishAck from MqttClientProxyManager: \(id)") + } + + public func mqtt(_ mqtt: CocoaMQTT, didReceiveMessage message: CocoaMQTTMessage, id: UInt16) { + delegate?.onMqttMessageReceived(message: message) + print("๐Ÿ“ฒ MQTT Client Proxy message received on topic: \(message.topic)") + } + + func mqtt(_ mqtt: CocoaMQTT, didSubscribeTopics success: NSDictionary, failed: [String]) { + print("๐Ÿ“ฒ MQTT Client Proxy didSubscribeTopics: \(success.allKeys.count) topics. failed: \(failed.count) topics") + } + + func mqtt(_ mqtt: CocoaMQTT, didUnsubscribeTopics topics: [String]) { + print("didUnsubscribeTopics: \(topics.joined(separator: ", "))") + } + + func mqttDidPing(_ mqtt: CocoaMQTT) { + print("๐Ÿ“ฒ MQTT Client Proxy mqttDidPing") + } + + func mqttDidReceivePong(_ mqtt: CocoaMQTT) { + print("๐Ÿ“ฒ MQTT Client Proxy mqttDidReceivePong") + } +} diff --git a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion index 9235346e..fdb1b5c8 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion +++ b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - MeshtasticDataModelV14.xcdatamodel + MeshtasticDataModelV15.xcdatamodel diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV15.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV15.xcdatamodel/contents new file mode 100644 index 00000000..bb210b96 --- /dev/null +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV15.xcdatamodel/contents @@ -0,0 +1,332 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index 2430d577..eb760bc0 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -776,6 +776,7 @@ func upsertMqttModuleConfigPacket(config: Meshtastic.ModuleConfig.MQTTConfig, no if fetchedNode[0].mqttConfig == nil { let newMQTTConfig = MQTTConfigEntity(context: context) newMQTTConfig.enabled = config.enabled + newMQTTConfig.proxyToClientEnabled = config.proxyToClientEnabled newMQTTConfig.address = config.address newMQTTConfig.username = config.username newMQTTConfig.password = config.password @@ -786,6 +787,7 @@ func upsertMqttModuleConfigPacket(config: Meshtastic.ModuleConfig.MQTTConfig, no fetchedNode[0].mqttConfig = newMQTTConfig } else { fetchedNode[0].mqttConfig?.enabled = config.enabled + fetchedNode[0].mqttConfig?.proxyToClientEnabled = config.proxyToClientEnabled fetchedNode[0].mqttConfig?.address = config.address fetchedNode[0].mqttConfig?.username = config.username fetchedNode[0].mqttConfig?.password = config.password diff --git a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift index c34862f2..86e3bd33 100644 --- a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift +++ b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift @@ -23,10 +23,10 @@ struct DeviceMetricsLog: View { var body: some View { - let oneDayAgo = Calendar.current.date(byAdding: .day, value: -1, to: Date()) + let oneWeekAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date()) let deviceMetrics = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0")).reversed() as? [TelemetryEntity] ?? [] let chartData = deviceMetrics - .filter { $0.time != nil && $0.time! >= oneDayAgo! } + .filter { $0.time != nil && $0.time! >= oneWeekAgo! } .sorted { $0.time! < $1.time! } NavigationStack { @@ -48,6 +48,7 @@ struct DeviceMetricsLog: View { .accessibilityValue("X: \(point.time!), Y: \(point.batteryLevel)") .foregroundStyle(batteryChartColor) .interpolationMethod(.cardinal) + //.interpolationMethod(.catmullRom(alpha: 1.0)) Plot { PointMark( diff --git a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift index 0e5f13fe..26e24e21 100644 --- a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift +++ b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift @@ -206,7 +206,7 @@ struct EnvironmentMetricsLog: View { isPresented: $isExporting, document: CsvDocument(emptyCsv: exportString), contentType: .commaSeparatedText, - defaultFilename: String("\(node.user!.longName ?? "Node") Environment Metrics Log"), + defaultFilename: String("\(node.user?.longName ?? "Node") Environment Metrics Log"), onCompletion: { result in if case .success = result { print("Environment metrics log download succeeded.") diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 251962e0..759cd6ca 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -102,7 +102,7 @@ struct NodeList: View { .padding([.top, .bottom]) } } - .navigationTitle("nodes") + .navigationTitle(String.localizedStringWithFormat("nodes %@".localized, String(nodes.count))) .navigationBarItems(leading: MeshtasticLogo() ) diff --git a/Meshtastic/Views/Nodes/RemoteHardware.swift b/Meshtastic/Views/Nodes/RemoteHardware.swift new file mode 100644 index 00000000..42ddc4d4 --- /dev/null +++ b/Meshtastic/Views/Nodes/RemoteHardware.swift @@ -0,0 +1,8 @@ +// +// RemoteHardware.swift +// Meshtastic +// +// Created by Garth Vander Houwen on 8/8/23. +// + +import Foundation diff --git a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift index 22a44edf..e462f9a5 100644 --- a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift @@ -15,6 +15,7 @@ struct MQTTConfig: View { @State private var isPresentingSaveConfirm: Bool = false @State var hasChanges: Bool = false @State var enabled = false + @State var proxyToClientEnabled = false @State var address = "" @State var username = "" @State var password = "" @@ -59,6 +60,11 @@ struct MQTTConfig: View { Label("enabled", systemImage: "dot.radiowaves.right") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + Toggle(isOn: $proxyToClientEnabled) { + + Label("mqtt.clientproxy", systemImage: "iphone.radiowaves.left.and.right") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) Toggle(isOn: $encryptionEnabled) { @@ -209,6 +215,7 @@ struct MQTTConfig: View { Button(buttonText) { var mqtt = ModuleConfig.MQTTConfig() mqtt.enabled = self.enabled + mqtt.proxyToClientEnabled = self.proxyToClientEnabled mqtt.address = self.address mqtt.username = self.username mqtt.password = self.password @@ -272,6 +279,11 @@ struct MQTTConfig: View { if newEnabled != node!.mqttConfig!.enabled { hasChanges = true } } } + .onChange(of: proxyToClientEnabled) { newProxyToClientEnabled in + if node != nil && node?.mqttConfig != nil { + if newProxyToClientEnabled != node!.mqttConfig!.proxyToClientEnabled { hasChanges = true } + } + } .onChange(of: encryptionEnabled) { newEncryptionEnabled in if node != nil && node?.mqttConfig != nil { if newEncryptionEnabled != node!.mqttConfig!.encryptionEnabled { hasChanges = true } @@ -291,6 +303,7 @@ struct MQTTConfig: View { func setMqttValues() { self.enabled = (node?.mqttConfig?.enabled ?? false) + self.proxyToClientEnabled = (node?.mqttConfig?.proxyToClientEnabled ?? false) self.address = node?.mqttConfig?.address ?? "" self.username = node?.mqttConfig?.username ?? "" self.password = node?.mqttConfig?.password ?? "" diff --git a/Meshtastic/Views/Settings/Config/PositionConfig.swift b/Meshtastic/Views/Settings/Config/PositionConfig.swift index 7078f17d..d99043c4 100644 --- a/Meshtastic/Views/Settings/Config/PositionConfig.swift +++ b/Meshtastic/Views/Settings/Config/PositionConfig.swift @@ -302,6 +302,8 @@ struct PositionConfig: View { pc.positionBroadcastSecs = UInt32(positionBroadcastSeconds) pc.broadcastSmartMinimumIntervalSecs = UInt32(broadcastSmartMinimumIntervalSecs) pc.broadcastSmartMinimumDistance = UInt32(broadcastSmartMinimumDistance) + pc.rxGpio = UInt32(rxGpio) + pc.txGpio = UInt32(txGpio) var pf: PositionFlags = [] if includeAltitude { pf.insert(.Altitude) } if includeAltitudeMsl { pf.insert(.AltitudeMsl) } diff --git a/de.lproj/Localizable.strings b/de.lproj/Localizable.strings index 720cccf5..ea8b3b90 100644 --- a/de.lproj/Localizable.strings +++ b/de.lproj/Localizable.strings @@ -189,11 +189,12 @@ "module.configuration"="Modul Konfiguration"; "mqtt"="MQTT"; "mqtt.config"="MQTT Config"; +"mqtt.clientproxy"="MQTT Client Proxy"; "mqtt.username"="Benutzername"; "name"="Name"; "network"="Netzwerk"; "network.config"="Netzwerkeinstellungen"; -"nodes"="Nodes"; +"nodes %@"="Nodes (%@)"; "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 040aa124..a7b870a6 100644 --- a/en.lproj/Localizable.strings +++ b/en.lproj/Localizable.strings @@ -189,11 +189,12 @@ "module.configuration"="Module Configuration"; "mqtt"="MQTT"; "mqtt.config"="MQTT Config"; +"mqtt.clientproxy"="MQTT Client Proxy"; "mqtt.username"="Username"; "name"="Name"; "network"="Network"; "network.config"="Network Config"; -"nodes"="Nodes"; +"nodes %@"="Nodes (%@)"; "no.nodes"="No Meshtastic Nodes Found"; "not.connected"="No device connected"; "numbers.punctuation"="Numbers and Punctuation"; diff --git a/zh-Hans.lproj/Localizable.strings b/zh-Hans.lproj/Localizable.strings index 949d7d8a..dac0df52 100644 --- a/zh-Hans.lproj/Localizable.strings +++ b/zh-Hans.lproj/Localizable.strings @@ -189,11 +189,12 @@ "module.configuration"="ๆจกๅ—้…็ฝฎ"; "mqtt"="MQTT"; "mqtt.config"="MQTT ้…็ฝฎ"; +"mqtt.clientproxy"="MQTT Client Proxy"; "mqtt.username"="็”จๆˆทๅ็งฐ"; "name"="ๅ็งฐ"; "network"="็ฝ‘็ปœ"; "network.config"="็ฝ‘็ปœ้…็ฝฎ"; -"nodes"="่Š‚็‚น"; +"nodes %@"="่Š‚็‚น (%@)"; "no.nodes"="ๆœชๆ‰พๅˆฐ Meshtastic ่Š‚็‚น"; "not.connected"="ๆœช่ฟžๆŽฅๅˆฐ็”ตๅฐ"; "numbers.punctuation"="ๆ•ฐๅญ—ๅ’Œๆ ‡็‚น็ฌฆๅท";