diff --git a/.swiftlint-precommit.yml b/.swiftlint-precommit.yml new file mode 100644 index 00000000..bc0b61f6 --- /dev/null +++ b/.swiftlint-precommit.yml @@ -0,0 +1,60 @@ +# Exclude automatically generated Swift files +excluded: + - MeshtasticProtobufs + +line_length: 400 + +type_name: + min_length: 1 + max_length: + warning: 60 + error: 70 + excluded: iPhone # excluded via string + allowed_symbols: ["_"] # these are allowed in type names +identifier_name: + min_length: 1 + max_length: + warning: 60 + allowed_symbols: ["_"] # these are allowed in type names + +# TODO: should review +force_try: + severity: warning # explicitly + +# TODO: should review +file_length: + warning: 3500 + error: 4000 + +# TODO: should review +cyclomatic_complexity: + warning: 70 + error: 80 + ignores_case_statements: true + +# TODO: should review +function_body_length: + warning: 200 + +# TODO: should review +type_body_length: + warning: 400 + +# TODO: should review +disabled_rules: # rule identifiers to exclude from running + - operator_whitespace + - multiple_closures_with_trailing_closure + - todo + +# TODO: should review +nesting: + type_level: + warning: 3 + +custom_rules: + disable_print: + included: ".*\\.swift" + name: "Disable `print()`" + regex: "((\\bprint)|(Swift\\.print))\\s*\\(" + message: "Consider using a dedicated log message or the Xcode debugger instead of using `print`. ex. logger.debug(...)" + severity: warning diff --git a/.swiftlint.yml b/.swiftlint.yml index bc0b61f6..f936c6e3 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -28,8 +28,8 @@ file_length: # TODO: should review cyclomatic_complexity: - warning: 70 - error: 80 + warning: 60 + error: 70 ignores_case_statements: true # TODO: should review @@ -45,6 +45,7 @@ disabled_rules: # rule identifiers to exclude from running - operator_whitespace - multiple_closures_with_trailing_closure - todo + - trailing_whitespace # TODO: should review nesting: diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 4551f194..3002916a 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -713,198 +713,6 @@ } } }, - "%@ Please try connecting again and check the PIN carefully." : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Bitte versuche es erneut. Achte sorgfältig auf die richtige PIN." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Merci d'essayer à nouveau en vérifiant bien le code PIN." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ בבקשה נסה שנית להתחבר למכשיר ובדוק את הקוד." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Si prega di provare a connettersi nuovamente e di controllare attentamente il PIN." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 再度接続を試行し、PINを慎重に確認してください。" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Spróbuj połączyć się ponownie i dokładnie sprawdź PIN." - } - }, - "se" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Försök att ansluta igen och kontrollera PIN-koden noggrant." - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Покушајте поново да се повежете и пажљиво проверите ПИН." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 请再次尝试连接并仔细检查 PIN 码。" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 請再次嘗試連接並仔細檢查 PIN 碼。" - } - } - } - }, - "%@ The app will automatically reconnect to the preferred radio if it comes back in range." : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Die App wird automatisch wieder zum präferierten Gerät verbinden, sobald es in Reichweite kommt." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ L'application se reconnectera automatiquement à la radio en favori dès qu'elle sera à nouveau disponible." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ האפליקציה תנסה אוטומטית להתחבר מחדש למכשיר המועדף אם ייראה." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ L'applicazione si riconnette automaticamente alla radio preferita se torna nel raggio d'azione." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 優先無線機が範囲内に戻った場合、アプリは自動的に再接続します。" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Aplikacja automatycznie ponownie połączy się z preferowanym radiem, jeśli wróci w zasięg." - } - }, - "se" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Appen kommer automatiskt att återansluta till den föredragna radion om den kommer inom räckhåll igen." - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Апликација ће се аутоматски поново повезати са жељеним радиом ако се врати у домет." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 如果在默认电台的旁边,App 将会自动重连。" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 如果在首選節點的旁邊,App 將會自動重連。" - } - } - } - }, - "%@ This error usually cannot be fixed without forgetting the device under Settings > Bluetooth and re-connecting to the radio." : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Dieser Fehler kann üblicherweise behoben werden, indem man unter Einstellungen > Bluetooth die Verbindung manuell löscht und sich erneut mit dem Gerät verbindet." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Cette erreur ne peut généralement pas être corrigée sans aller dans Réglages > Bluetooth et faire > Oublier cet appareil, puis reconnecter la radio." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ שגיאה זו בדרך כלל אינה ניתנת לתיקון ללא שכחחת המכשיר בהגדרות מכשיר > בלוטוס ואז להתחבר מחדש למכשיר." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Questo errore di solito non può essere risolto senza dimenticare il dispositivo sotto Impostazioni > Bluetooth e riconnettersi alla radio." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ このエラーは通常、設定 > Bluetooth でデバイスの登録を解除し、無線機に再接続しない限り修正できません。" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Ten błąd zwykle nie może być naprawiony bez zapomnienia urządzenia w Ustawienia > Bluetooth i ponownego połączenia z radiem." - } - }, - "se" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Detta fel kan vanligtvis inte åtgärdas utan att glömma enheten under Inställningar > Bluetooth och återansluta till radion." - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Ова грешка обично не може да се поправи без заборављања уређаја испод подешавања > Блутут и поново повезивање са радиом." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 这个错误通常无法自动修复,你需要在系统设置的蓝牙选项中忽略该电台并重新配对。" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 這個錯誤通常無法自動修復,您需要在系統設定的藍芽選項中忽略該節點並重新配對。" - } - } - } - }, "%@, %@" : { "localizations" : { "en" : { @@ -4441,6 +4249,9 @@ } } } + }, + "Automatically Connect" : { + }, "Automatically toggles to the next page on the screen like a carousel, based the specified interval." : { "localizations" : { @@ -5024,6 +4835,24 @@ "Battery Level %" : { "comment" : "VoiceOver value for battery level", "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batterie Ladung %" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niveau de batterie %" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "רמת סוללה %" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5036,6 +4865,18 @@ "value" : "バッテリーレベル %" } }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poziom naładowania baterii %" + } + }, + "se" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batterinivå %" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5056,6 +4897,71 @@ } } }, + "Battery Level %d" : { + "comment" : "VoiceOver value for battery level", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batterie Ladung %d" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niveau de batterie %d" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "רמת סוללה %d" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Livello della batteria %d" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "バッテリーレベル %d" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poziom naładowania baterii %d" + } + }, + "se" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batterinivå %d" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ниво батерије у %d" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "电池电量 %d" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "電池電量 %d" + } + } + } + }, "Baud" : { "localizations" : { "it" : { @@ -5164,70 +5070,6 @@ } } }, - "BLE Name" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "BLE Name" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nom du BLE" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שם בלוטוס" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nome BLE" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "BLE 名前" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nazwa BLE" - } - }, - "se" : { - "stringUnit" : { - "state" : "translated", - "value" : "BLE-namn" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "BLE назив" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "蓝牙名称" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "藍芽名稱" - } - } - } - }, "BLE Pin must be 6 digits long." : { "localizations" : { "de" : { @@ -5292,56 +5134,6 @@ } } }, - "BLE RSSI %lld" : { - "localizations" : { - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "BLE RSSI %lld" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "BLE RSSI %lld" - } - } - } - }, - "BLE: %@" : { - "localizations" : { - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "BLE: %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "BLE: %@" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "BLE: %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "BLE: %@" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "藍牙名稱:%@" - } - } - } - }, "Bluetooth" : { "localizations" : { "de" : { @@ -5534,69 +5326,9 @@ } } }, - "Bluetooth is off" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bluetooth ist aus" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Le Bluetooth est arrêté" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "בלוטוס כבוי" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Il Bluetooth è spento" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bluetoothがオフです" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bluetooth jest wyłączony" - } - }, - "se" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bluetooth är avstängt" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Блутут је искључен" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "蓝牙已关闭" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "藍芽已關閉" - } - } - } + "Bluetooth Connectivity" : { + "comment" : "A heading displayed on a view that guides users to configure Bluetooth connectivity for the app.", + "isCommentAutoGenerated" : true }, "Bold Heading" : { @@ -8277,6 +8009,14 @@ } } }, + "Configure Bluetooth Connectivity" : { + "comment" : "Button label to guide users to configure Bluetooth connectivity for the app.", + "isCommentAutoGenerated" : true + }, + "Configure Local Network Access" : { + "comment" : "Button label to configure local network access permissions.", + "isCommentAutoGenerated" : true + }, "Configure Location Permissions" : { }, @@ -8310,6 +8050,9 @@ } } } + }, + "Connect" : { + }, "Connect to a Node" : { "localizations" : { @@ -8653,69 +8396,8 @@ } } }, - "Connection failed after %d attempts to connect to %@. You may need to forget your device under Settings > Bluetooth." : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verbindung nach %d Versuchen zu %@ fehlgeschlagen. Evtl. hilft es, die Verbindung unter Einstellungen > Bluetooth manuell zu löschen." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Connexion impossible après %d essais avec %@. Allez dans Réglages > Bluetooth et essayez de faire de faire > Oublier cet appareil." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "התחברות נכשלה לאחר %d נסיונות להתחבר ל%@. יתכן ויש צורך 'לשכוח' את המכשיר בהגדרות מכשיר > בלוטוס." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Connessione fallita dopo %d tentativi di connessione a %@. Potrebbe essere necessario disaccoppiare il tuo dispositivo in Impostazioni > Bluetooth." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%d への接続が %@ 回の試行後に失敗しました。設定 > Bluetooth でデバイスを削除する必要があるかもしれません。" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Połączenie nie powiodło się po %d próbach połączenia z %@. Zapomnij o urządzeniu w Ustawienia > Bluetooth." - } - }, - "se" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anslutningen misslyckades efter %d försök att ansluta till %@. Du kan behöva glömma din enhet under Inställningar > Bluetooth." - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Веза није успела након %d покушаја да се повеже са %@. Можда ћете морати да заборавите уређај у Подешавања > Блутут." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "尝试连接%d失败,你可能需要在系统设置的蓝牙选项中忽略该设备。" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "嘗試連接%d失敗,您可能需要在系统設定的藍芽選項中忽略該節點。" - } - } - } + "Connection Name" : { + }, "Consent to Share Unencrypted Node Data via MQTT" : { "localizations" : { @@ -12895,27 +12577,8 @@ } } }, - "Enabling WiFi will disable the bluetooth connection to the app. TCP node connections are not available on Apple devices." : { - "localizations" : { - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "L'attivazione del WiFi disabilita la connessione bluetooth all'applicazione. La connessione a nodi TCP non è disponibile su dispositivi Apple." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "WiFiを有効にすると、アプリへのBluetooth接続が無効になります。AppleデバイスではTCPノード接続は利用できません。" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Омогућавање WiFi-а ће онемогућити Bluetooth везу са апликацијом. TCP везе са чвором нису доступне на Apple уређајима.\n" - } - } - } + "Enabling WiFi will disable the bluetooth connection to the app." : { + }, "Encoder Press Event" : { "localizations" : { @@ -13117,6 +12780,10 @@ } } }, + "Enter hostname[:port]" : { + "comment" : "A label for a text field where the user can enter a hostname or IP address and optionally a port number.", + "isCommentAutoGenerated" : true + }, "environment" : { "localizations" : { "it" : { @@ -15705,6 +15372,9 @@ } } } + }, + "From Radio (RX): %lld" : { + }, "Full Support" : { "localizations" : { @@ -16743,22 +16413,6 @@ } } }, - "Hide sidebar" : { - "localizations" : { - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "サイドバーを隠す" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Сакриј бочну траку" - } - } - } - }, "HIGH" : { "localizations" : { "de" : { @@ -18521,64 +18175,6 @@ } } }, - "Issuing Want Config to %@" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Envoi d'un Want Config à %@" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שולח בקשת הגדרות ל-%@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Emissione di Want Config a %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ に設定要求を送信中" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wydawanie Want Config to %@" - } - }, - "se" : { - "stringUnit" : { - "state" : "translated", - "value" : "Utfärdar Want Config till %@" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Издавање захтева за конфигурацију на: %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Issuing Want Config to %@" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "Issuing Want Config to %@" - } - } - } - }, "Japan" : { "localizations" : { "it" : { @@ -19281,6 +18877,10 @@ } } }, + "Local Network Access" : { + "comment" : "A label displayed above the options for local network access.", + "isCommentAutoGenerated" : true + }, "Location:" : { "localizations" : { "de" : { @@ -20264,6 +19864,9 @@ } } } + }, + "Manual" : { + }, "Manual Configuration" : { "localizations" : { @@ -20328,40 +19931,12 @@ } } } + }, + "Manual connection string" : { + }, "Map Data" : { - "localizations" : { - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dati Mappa" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "マップデータ" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Podaci Mape" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "地图数据" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "地圖資料" - } - } - } + }, "Map Options" : { "localizations" : { @@ -21072,70 +20647,6 @@ } } }, - "Message Send Failed, not properly connected to %@" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nachricht senden fehlgeschlagen. Nicht korrekt verbunden zu %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Erreur d'envoi du message, mauvaise connexion à %@" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שליחת הודעה נכשלה, אין חיבוריות ל-%@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Invio messaggio fallito, connessione non corretta a %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "メッセージ送信に失敗しました。%@ に適切に接続されていません" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nieudane wysłanie wiadomości, brak prawidłowego połączenia z %@" - } - }, - "se" : { - "stringUnit" : { - "state" : "translated", - "value" : "Misslyckades med att skicka meddelande, inte korrekt ansluten till %@" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Слање поруке није успело, није правилно повезано са: %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Message Send Failed, not properly connected to %@" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "Message Send Failed, not properly connected to %@" - } - } - } - }, "Message Size" : { "comment" : "VoiceOver label for message size", "localizations" : { @@ -23607,6 +23118,7 @@ } }, "Node info received for: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -25311,6 +24823,9 @@ }, "Override default screen layout." : { + }, + "Packet Count" : { + }, "Pairing Mode" : { "localizations" : { @@ -26572,6 +26087,7 @@ } }, "Position Packet received from node: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -27923,34 +27439,6 @@ } } }, - "Radio Disconnected" : { - "localizations" : { - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Radio scollegata" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "無線切断" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Радио веза је прекинута" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "已斷線" - } - } - } - }, "RAK Rotary Encoder" : { "localizations" : { "de" : { @@ -29502,6 +28990,21 @@ } } } + }, + "Retreiving nodes . ." : { + + }, + "Retreiving nodes %lld" : { + + }, + "Retrieving nodes" : { + + }, + "Retrieving nodes %lld" : { + + }, + "Retrying (attempt %lld)" : { + }, "Review the app" : { "localizations" : { @@ -33879,15 +33382,8 @@ } } }, - "Shows information for the Lora radio connected via bluetooth. You can swipe left to disconnect the radio and long press to start the live activity." : { - "localizations" : { - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Приказује информације о LoRa радио уређају повезаном преко Bluetooth-а. Можете превући улево да бисте искључили радио, а дугим притиском покренути активан режим праћења." - } - } - } + "Shows information for the connected Lora radio. You can swipe left to disconnect the radio and long press to start the live activity." : { + }, "Shut Down" : { "localizations" : { @@ -35403,6 +34899,9 @@ } } }, + "TCP" : { + "shouldTranslate" : false + }, "Telemetry" : { "localizations" : { "de" : { @@ -35595,70 +35094,6 @@ } } }, - "Telemetry received for: %@" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Telemetrie empfangen für: %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Télémetrie reçue pour : %@" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "התקבל טלמטריה עבור: %@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Telemetria ricevuta per: %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ のテレメトリーを受信しました" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Telemetria odebrana dla: %@" - } - }, - "se" : { - "stringUnit" : { - "state" : "translated", - "value" : "Telemetri mottagen för: %@" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Телеметрија примљена за: %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Telemetry received for: %@" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "Telemetry received for: %@" - } - } - } - }, "Temp" : { "localizations" : { "de" : { @@ -36403,6 +35838,7 @@ } }, "The public key does not match the recorded key. You may delete the node and let it exchange keys again, but this may indicate a more serious security problem. Contact the user through another trusted channel, to determine if the key change was due to a factory reset or other intentional action." : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -36547,34 +35983,6 @@ } } }, - "The specified device has disconnected from us" : { - "localizations" : { - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Il dispositivo specificato si è disconnesso" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "指定されたデバイスが切断されました" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Наведени уређај је прекинуо везу са нама" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "指定的裝置已斷線" - } - } - } - }, "The state of the LED (on/off)" : { "localizations" : { "it" : { @@ -37718,6 +37126,9 @@ } } } + }, + "To Radio (TX): %lld" : { + }, "Topic: %@" : { "localizations" : { @@ -39135,70 +38546,6 @@ } } }, - "Unsupported Firmware Version Detected, unable to connect to device." : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nicht unterstützte Firmware Version erkannt. Kann nicht verbinden." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Version non supportée du firmware détectée, impossible de se connecter à l'appareil." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "גרסת קושחה אינה נתמכת, לא ניתן להתחבר למכשיר." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rilevata versione firmware non supportata, impossibile connettersi al dispositivo." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "サポートされていないファームウェアバージョンが検出されました。デバイスに接続できません。" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wykryto nieobsługiwany wersję oprogramowania, brak możliwości połączenia z urządzeniem." - } - }, - "se" : { - "stringUnit" : { - "state" : "translated", - "value" : "Okänd Firmwareversion upptäckt, kan inte ansluta till enheten." - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Откривена је неподржана верзија фирмвера, није могуће повезати са уређајем." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "检测到不支持的固件版本,无法连接到设备。" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "檢測到不支援的韌體版本,無法連接到節點。" - } - } - } - }, "Up" : { "localizations" : { "de" : { @@ -40127,40 +39474,6 @@ } } }, - "User Initiated Disconnect" : { - "localizations" : { - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Disconnessione avviata dall'utente" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ユーザーによる切断" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Корисник је покренуо прекид везе" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "用户主动断开连接" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用者主動斷線" - } - } - } - }, "User Uploaded" : { "comment" : "Data source label for user uploaded files", "localizations" : { @@ -42214,5 +41527,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 47bbb13e..1772d5a1 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -15,10 +15,14 @@ 108FFECD2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */; }; 10D109F22E2047D600536CE6 /* DatadogSessionReplay in Frameworks */ = {isa = PBXBuildFile; productRef = 10D109F12E2047D600536CE6 /* DatadogSessionReplay */; }; 10D109F42E2047D600536CE6 /* DatadogTrace in Frameworks */ = {isa = PBXBuildFile; productRef = 10D109F32E2047D600536CE6 /* DatadogTrace */; }; + 230BC3972E31071E0046BF2A /* AccessoryManager+Discovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */; }; + 231251382E3BC96400E6ED07 /* BLEAuthorizationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231251372E3BC96400E6ED07 /* BLEAuthorizationHelper.swift */; }; 231B3F212D087A4C0069A07D /* MetricTableColumn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */; }; 231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */; }; 231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */; }; 231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */; }; + 232ED4C32E2C5E89009DA392 /* TCPTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 232ED4C22E2C5E89009DA392 /* TCPTransport.swift */; }; + 232ED4C52E2C5EDD009DA392 /* TCPConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 232ED4C42E2C5EDD009DA392 /* TCPConnection.swift */; }; 233E99B62D849C3D00CC3A77 /* WeatherConditionsCompactWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233E99B52D849C3D00CC3A77 /* WeatherConditionsCompactWidget.swift */; }; 233E99B82D849C6500CC3A77 /* HumidityCompactWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233E99B72D849C6500CC3A77 /* HumidityCompactWidget.swift */; }; 233E99BA2D849C7000CC3A77 /* PressureCompactWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233E99B92D849C7000CC3A77 /* PressureCompactWidget.swift */; }; @@ -33,10 +37,28 @@ 2344A2AF2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */; }; 2344A2B02D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */; }; 2344A2B12D68DFF800170A77 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C49D8F2C471AEA0024FBD1 /* Constants.swift */; }; + 2346A7192E2FB9A300CB9239 /* SerialConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2346A7182E2FB9A300CB9239 /* SerialConnection.swift */; }; + 2346A71D2E2FB9C500CB9239 /* SerialTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2346A71C2E2FB9C500CB9239 /* SerialTransport.swift */; }; 2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */; }; 2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */; }; 2373AE172D0A26620086C749 /* EnvironmentDefaultSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */; }; + 23769D882E39521400E3601C /* View+iOS26Modifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23769D872E39521400E3601C /* View+iOS26Modifier.swift */; }; + 237AEB8F2E1FE457003B7CE3 /* Transport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237AEB8E2E1FE456003B7CE3 /* Transport.swift */; }; + 237AEB912E1FE46D003B7CE3 /* AccessoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237AEB902E1FE46D003B7CE3 /* AccessoryManager.swift */; }; + 237AEB932E1FE4BA003B7CE3 /* Connection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237AEB922E1FE4BA003B7CE3 /* Connection.swift */; }; + 237AEB952E1FE516003B7CE3 /* Device.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237AEB942E1FE516003B7CE3 /* Device.swift */; }; + 237AEB972E1FE627003B7CE3 /* BLETransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237AEB962E1FE627003B7CE3 /* BLETransport.swift */; }; + 237AEB992E20098B003B7CE3 /* BLEConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237AEB982E20098B003B7CE3 /* BLEConnection.swift */; }; 237B46962DC8F1C100B22D99 /* RateLimitedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */; }; + 23A1AFB72E42BD2500E46C96 /* RXTXIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A1AFB62E42BD2500E46C96 /* RXTXIndicatorView.swift */; }; + 23AD54692E2A6EAA0046E9AB /* AccessoryManager+FromRadio.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AD54682E2A6EAA0046E9AB /* AccessoryManager+FromRadio.swift */; }; + 23AD546B2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AD546A2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift */; }; + 23AD546D2E2AE9630046E9AB /* AccessoryManager+MQTT.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AD546C2E2AE9630046E9AB /* AccessoryManager+MQTT.swift */; }; + 23D316932E5618D2002FA4FB /* AsyncGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23D316922E5618D2002FA4FB /* AsyncGate.swift */; }; + 23D9D9392E50DA97005D1C18 /* ResettableTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23D9D9382E50DA97005D1C18 /* ResettableTimer.swift */; }; + 23E23F922E392C2B00919073 /* LogRecord+StringRepresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23E23F912E392C2B00919073 /* LogRecord+StringRepresentation.swift */; }; + 23F488122E32980B002C776F /* AccessoryManager+Position.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23F488112E32980B002C776F /* AccessoryManager+Position.swift */; }; + 23FF00B62E323C75001DF095 /* AccessoryManager+Connect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23FF00B52E323C75001DF095 /* AccessoryManager+Connect.swift */; }; 251926852C3BA97800249DF5 /* FavoriteNodeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */; }; 251926872C3BAE2200249DF5 /* NodeAlertsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */; }; 2519268A2C3BB1B200249DF5 /* ExchangePositionsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */; }; @@ -185,7 +207,6 @@ DDAB580D2B0DAA9E00147258 /* Routes.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAB580C2B0DAA9E00147258 /* Routes.swift */; }; DDAB580F2B0DAFBC00147258 /* LocationEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAB580E2B0DAFBC00147258 /* LocationEntityExtension.swift */; }; DDAD49ED2AFB39DC00B4425D /* MeshMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAD49EC2AFB39DC00B4425D /* MeshMap.swift */; }; - DDAF8C5326EB1DF10058C060 /* BLEManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF8C5226EB1DF10058C060 /* BLEManager.swift */; }; DDB6ABD628AE742000384BA1 /* BluetoothConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6ABD528AE742000384BA1 /* BluetoothConfig.swift */; }; DDB6ABD928B0A4BA00384BA1 /* BluetoothModes.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6ABD828B0A4BA00384BA1 /* BluetoothModes.swift */; }; DDB6ABDB28B0AC6000384BA1 /* DistanceText.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6ABDA28B0AC6000384BA1 /* DistanceText.swift */; }; @@ -295,10 +316,14 @@ /* Begin PBXFileReference section */ 108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContactQRDialog.swift; sourceTree = ""; }; 108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = ""; }; + 230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Discovery.swift"; sourceTree = ""; }; + 231251372E3BC96400E6ED07 /* BLEAuthorizationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEAuthorizationHelper.swift; sourceTree = ""; }; 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnList.swift; sourceTree = ""; }; 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = ""; }; 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = ""; }; 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnDetail.swift; sourceTree = ""; }; + 232ED4C22E2C5E89009DA392 /* TCPTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPTransport.swift; sourceTree = ""; }; + 232ED4C42E2C5EDD009DA392 /* TCPConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPConnection.swift; sourceTree = ""; }; 233E99B32D84969500CC3A77 /* MeshtasticDataModelV 50.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 50.xcdatamodel"; sourceTree = ""; }; 233E99B52D849C3D00CC3A77 /* WeatherConditionsCompactWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherConditionsCompactWidget.swift; sourceTree = ""; }; 233E99B72D849C6500CC3A77 /* HumidityCompactWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HumidityCompactWidget.swift; sourceTree = ""; }; @@ -313,10 +338,28 @@ 2344A2AA2D66973D00170A77 /* ManagedAttributePropertyWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedAttributePropertyWrapper.swift; sourceTree = ""; }; 2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataClass.swift"; sourceTree = ""; }; 2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataProperties.swift"; sourceTree = ""; }; + 2346A7182E2FB9A300CB9239 /* SerialConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialConnection.swift; sourceTree = ""; }; + 2346A71C2E2FB9C500CB9239 /* SerialTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialTransport.swift; sourceTree = ""; }; 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = ""; }; 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = ""; }; 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultSeries.swift; sourceTree = ""; }; + 23769D872E39521400E3601C /* View+iOS26Modifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+iOS26Modifier.swift"; sourceTree = ""; }; + 237AEB8E2E1FE456003B7CE3 /* Transport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = ""; }; + 237AEB902E1FE46D003B7CE3 /* AccessoryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessoryManager.swift; sourceTree = ""; }; + 237AEB922E1FE4BA003B7CE3 /* Connection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connection.swift; sourceTree = ""; }; + 237AEB942E1FE516003B7CE3 /* Device.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Device.swift; sourceTree = ""; }; + 237AEB962E1FE627003B7CE3 /* BLETransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLETransport.swift; sourceTree = ""; }; + 237AEB982E20098B003B7CE3 /* BLEConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEConnection.swift; sourceTree = ""; }; 237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimitedButton.swift; sourceTree = ""; }; + 23A1AFB62E42BD2500E46C96 /* RXTXIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RXTXIndicatorView.swift; sourceTree = ""; }; + 23AD54682E2A6EAA0046E9AB /* AccessoryManager+FromRadio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+FromRadio.swift"; sourceTree = ""; }; + 23AD546A2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+ToRadio.swift"; sourceTree = ""; }; + 23AD546C2E2AE9630046E9AB /* AccessoryManager+MQTT.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+MQTT.swift"; sourceTree = ""; }; + 23D316922E5618D2002FA4FB /* AsyncGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncGate.swift; sourceTree = ""; }; + 23D9D9382E50DA97005D1C18 /* ResettableTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResettableTimer.swift; sourceTree = ""; }; + 23E23F912E392C2B00919073 /* LogRecord+StringRepresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LogRecord+StringRepresentation.swift"; sourceTree = ""; }; + 23F488112E32980B002C776F /* AccessoryManager+Position.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Position.swift"; sourceTree = ""; }; + 23FF00B52E323C75001DF095 /* AccessoryManager+Connect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Connect.swift"; sourceTree = ""; }; 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteNodeButton.swift; sourceTree = ""; }; 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeAlertsButton.swift; sourceTree = ""; }; 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangePositionsButton.swift; sourceTree = ""; }; @@ -497,7 +540,6 @@ DDAB580C2B0DAA9E00147258 /* Routes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Routes.swift; sourceTree = ""; }; DDAB580E2B0DAFBC00147258 /* LocationEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationEntityExtension.swift; sourceTree = ""; }; DDAD49EC2AFB39DC00B4425D /* MeshMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMap.swift; sourceTree = ""; }; - DDAF8C5226EB1DF10058C060 /* BLEManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEManager.swift; sourceTree = ""; }; DDB234392B5CA9B000DA6FB1 /* MeshtasticDataModelV 24.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 24.xcdatamodel"; sourceTree = ""; }; DDB6ABD528AE742000384BA1 /* BluetoothConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothConfig.swift; sourceTree = ""; }; DDB6ABD828B0A4BA00384BA1 /* BluetoothModes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothModes.swift; sourceTree = ""; }; @@ -679,6 +721,89 @@ path = CoreData; sourceTree = ""; }; + 237AEB8D2E1FE120003B7CE3 /* Accessory */ = { + isa = PBXGroup; + children = ( + 23D9D9312E50DA0E005D1C18 /* Protocols */, + 23D9D9322E50DA1F005D1C18 /* Accessory Manager */, + 23D9D9332E50DA33005D1C18 /* Transports */, + 23D9D9372E50DA81005D1C18 /* Helpers */, + ); + path = Accessory; + sourceTree = ""; + }; + 23D9D9312E50DA0E005D1C18 /* Protocols */ = { + isa = PBXGroup; + children = ( + 237AEB8E2E1FE456003B7CE3 /* Transport.swift */, + 237AEB922E1FE4BA003B7CE3 /* Connection.swift */, + 237AEB942E1FE516003B7CE3 /* Device.swift */, + ); + path = Protocols; + sourceTree = ""; + }; + 23D9D9322E50DA1F005D1C18 /* Accessory Manager */ = { + isa = PBXGroup; + children = ( + 237AEB902E1FE46D003B7CE3 /* AccessoryManager.swift */, + 23F488112E32980B002C776F /* AccessoryManager+Position.swift */, + 23FF00B52E323C75001DF095 /* AccessoryManager+Connect.swift */, + 230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */, + 23AD54682E2A6EAA0046E9AB /* AccessoryManager+FromRadio.swift */, + 23AD546A2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift */, + 23AD546C2E2AE9630046E9AB /* AccessoryManager+MQTT.swift */, + ); + path = "Accessory Manager"; + sourceTree = ""; + }; + 23D9D9332E50DA33005D1C18 /* Transports */ = { + isa = PBXGroup; + children = ( + 23D9D9342E50DA40005D1C18 /* Bluetooth Low Energy */, + 23D9D9352E50DA4D005D1C18 /* TCP */, + 23D9D9362E50DA5A005D1C18 /* Serial */, + ); + path = Transports; + sourceTree = ""; + }; + 23D9D9342E50DA40005D1C18 /* Bluetooth Low Energy */ = { + isa = PBXGroup; + children = ( + 237AEB962E1FE627003B7CE3 /* BLETransport.swift */, + 237AEB982E20098B003B7CE3 /* BLEConnection.swift */, + 231251372E3BC96400E6ED07 /* BLEAuthorizationHelper.swift */, + ); + path = "Bluetooth Low Energy"; + sourceTree = ""; + }; + 23D9D9352E50DA4D005D1C18 /* TCP */ = { + isa = PBXGroup; + children = ( + 232ED4C22E2C5E89009DA392 /* TCPTransport.swift */, + 232ED4C42E2C5EDD009DA392 /* TCPConnection.swift */, + ); + path = TCP; + sourceTree = ""; + }; + 23D9D9362E50DA5A005D1C18 /* Serial */ = { + isa = PBXGroup; + children = ( + 2346A71C2E2FB9C500CB9239 /* SerialTransport.swift */, + 2346A7182E2FB9A300CB9239 /* SerialConnection.swift */, + ); + path = Serial; + sourceTree = ""; + }; + 23D9D9372E50DA81005D1C18 /* Helpers */ = { + isa = PBXGroup; + children = ( + 23D316922E5618D2002FA4FB /* AsyncGate.swift */, + 23E23F912E392C2B00919073 /* LogRecord+StringRepresentation.swift */, + 23D9D9382E50DA97005D1C18 /* ResettableTimer.swift */, + ); + path = Helpers; + sourceTree = ""; + }; 251926882C3BAF2E00249DF5 /* Actions */ = { isa = PBXGroup; children = ( @@ -784,13 +909,13 @@ path = Nodes; sourceTree = ""; }; - DD47E3D726F2F21A00029299 /* Bluetooth */ = { + DD47E3D726F2F21A00029299 /* Connect */ = { isa = PBXGroup; children = ( DD836AE626F6B38600ABCC23 /* Connect.swift */, DD86D409287F04F100BAEB7A /* InvalidVersion.swift */, ); - path = Bluetooth; + path = Connect; sourceTree = ""; }; DD4A911C2708C57100501B7E /* Settings */ = { @@ -999,6 +1124,7 @@ DDC2E15626CE248E0042C5E4 /* Meshtastic */ = { isa = PBXGroup; children = ( + 237AEB8D2E1FE120003B7CE3 /* Accessory */, BCB6137F2C6728E700485544 /* AppIntents */, DD1BD0EC2C603C5B008C0C70 /* Measurement */, 25F5D5BC2C3F6D7B008036E3 /* Router */, @@ -1032,7 +1158,7 @@ DDC2E18726CE24E40042C5E4 /* Views */ = { isa = PBXGroup; children = ( - DD47E3D726F2F21A00029299 /* Bluetooth */, + DD47E3D726F2F21A00029299 /* Connect */, DDC2E18D26CE25CB0042C5E4 /* Helpers */, DD6D5A312CA1176A00ED3032 /* Layouts */, DDC2E18B26CE25A70042C5E4 /* Messages */, @@ -1102,6 +1228,8 @@ DD6F65712C6AB8EC0053C113 /* SecureInput.swift */, 8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */, 237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */, + 23769D872E39521400E3601C /* View+iOS26Modifier.swift */, + 23A1AFB62E42BD2500E46C96 /* RXTXIndicatorView.swift */, ); path = Helpers; sourceTree = ""; @@ -1113,7 +1241,6 @@ 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */, BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */, DDD43FE12A78C86B0083A3E9 /* Mqtt */, - DDAF8C5226EB1DF10058C060 /* BLEManager.swift */, DD1BEF492E0292220090CE24 /* KeychainHelper.swift */, DD913638270DFF4C00D7ACF3 /* LocalNotificationManager.swift */, DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */, @@ -1424,6 +1551,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 230BC3972E31071E0046BF2A /* AccessoryManager+Discovery.swift in Sources */, 25F26B1F2C2F611300C9CD9D /* AppData.swift in Sources */, 25F26B1E2C2F610D00C9CD9D /* Logger.swift in Sources */, 259792252C2F114500AD1659 /* ChannelEntityExtension.swift in Sources */, @@ -1445,6 +1573,8 @@ DD5D0A9C2931B9F200F7EA61 /* EthernetModes.swift in Sources */, 6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */, DD798B072915928D005217CD /* ChannelMessageList.swift in Sources */, + 23769D882E39521400E3601C /* View+iOS26Modifier.swift in Sources */, + 237AEB992E20098B003B7CE3 /* BLEConnection.swift in Sources */, 231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */, DD77093D2AA1AFA3007A8BF0 /* ChannelTips.swift in Sources */, 6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */, @@ -1461,14 +1591,15 @@ DD41A61529AB0035003C5A37 /* NodeWeatherForecast.swift in Sources */, DDB6ABD628AE742000384BA1 /* BluetoothConfig.swift in Sources */, 251926902C3CB44900249DF5 /* ClientHistoryButton.swift in Sources */, + 237AEB932E1FE4BA003B7CE3 /* Connection.swift in Sources */, DDD5BB102C285FB3007E03CA /* AppLogFilter.swift in Sources */, + 237AEB952E1FE516003B7CE3 /* Device.swift in Sources */, 2373AE172D0A26620086C749 /* EnvironmentDefaultSeries.swift in Sources */, 233E99B82D849C6500CC3A77 /* HumidityCompactWidget.swift in Sources */, DD4640202AFF10F4002A5ECB /* WaypointForm.swift in Sources */, 233E99C12D849D6000CC3A77 /* DistanceCompactWidget.swift in Sources */, DD769E0328D18BF1001A3F05 /* DeviceMetricsLog.swift in Sources */, 108FFECD2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift in Sources */, - DDAF8C5326EB1DF10058C060 /* BLEManager.swift in Sources */, DD15E4F32B8BA56E00654F61 /* PaxCounterConfig.swift in Sources */, DDDB445229F8ACF900EE2349 /* Date.swift in Sources */, 2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */, @@ -1483,6 +1614,7 @@ BCD93CBA2D9E11A2006C9214 /* DisconnectNodeIntent.swift in Sources */, DDC94FC129CE063B0082EA6E /* BatteryLevel.swift in Sources */, 231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */, + 23AD546D2E2AE9630046E9AB /* AccessoryManager+MQTT.swift in Sources */, 25F5D5BE2C3F6D87008036E3 /* NavigationState.swift in Sources */, 2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */, DD354FD92BD96A0B0061A25F /* IAQScale.swift in Sources */, @@ -1498,6 +1630,7 @@ DDD5BB0B2C285E45007E03CA /* LogDetail.swift in Sources */, DDA0B6B2294CDC55001356EC /* Channels.swift in Sources */, DD74ED0D2DC6A0C90059AC10 /* DeviceOnboarding.swift in Sources */, + 237AEB972E1FE627003B7CE3 /* BLETransport.swift in Sources */, DDE9659C2B1C3B6A00531070 /* RouteRecorder.swift in Sources */, B399E8A42B6F486400E4488E /* RetryButton.swift in Sources */, DDB8F4102A9EE5B400230ECE /* Messages.swift in Sources */, @@ -1513,13 +1646,16 @@ DDF45C342BC1A48E005ED5F2 /* MQTTIcon.swift in Sources */, DDA9515A2BC6624100CEA535 /* TelemetryWeather.swift in Sources */, DD1BD0EB2C601795008C0C70 /* CLLocation.swift in Sources */, + 231251382E3BC96400E6ED07 /* BLEAuthorizationHelper.swift in Sources */, DDF924CA26FBB953009FE055 /* ConnectedDevice.swift in Sources */, DD3CC6BE28E4CD9800FA9159 /* BatteryGauge.swift in Sources */, + 237AEB912E1FE46D003B7CE3 /* AccessoryManager.swift in Sources */, DD6193772862F90F00E59241 /* CannedMessagesConfig.swift in Sources */, DD3619152B1EF9F900C41C8C /* LocationsHandler.swift in Sources */, DD6F65792C6EADE60053C113 /* DirectMessagesHelp.swift in Sources */, 233E99B62D849C3D00CC3A77 /* WeatherConditionsCompactWidget.swift in Sources */, 25F5D5C02C3F6DA6008036E3 /* Router.swift in Sources */, + 2346A7192E2FB9A300CB9239 /* SerialConnection.swift in Sources */, DDDB444A29F8AA3A00EE2349 /* CLLocationCoordinate2D.swift in Sources */, 25C49D902C471AEA0024FBD1 /* Constants.swift in Sources */, DD41582628582E9B009B0E59 /* DeviceConfig.swift in Sources */, @@ -1527,10 +1663,12 @@ DD007BAE2AA4E91200F5FA12 /* MyInfoEntityExtension.swift in Sources */, DD33DB622B3D27C7003E1EA0 /* FirmwareApi.swift in Sources */, DD3CC6B528E33FD100FA9159 /* ShareChannels.swift in Sources */, + 23D9D9392E50DA97005D1C18 /* ResettableTimer.swift in Sources */, DD1BF2F92776FE2E008C8D2F /* UserMessageList.swift in Sources */, DD3CC6C228EB9D4900FA9159 /* UpdateCoreData.swift in Sources */, DDE0F7C5295F77B700B8AAB3 /* AppSettingsEnums.swift in Sources */, DDB6ABE628B1406100384BA1 /* LoraConfigEnums.swift in Sources */, + 232ED4C52E2C5EDD009DA392 /* TCPConnection.swift in Sources */, DDB8F4142A9EE5F000230ECE /* ChannelList.swift in Sources */, DDD43FE32A78C8900083A3E9 /* MqttClientProxyManager.swift in Sources */, BCD7448D2E0F2FAA00F265A2 /* ContactURLHandler.swift in Sources */, @@ -1542,6 +1680,8 @@ DDB6ABDB28B0AC6000384BA1 /* DistanceText.swift in Sources */, DD94B7402ACCE3BE00DCD1D1 /* MapSettingsForm.swift in Sources */, DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */, + 23AD546B2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift in Sources */, + 23F488122E32980B002C776F /* AccessoryManager+Position.swift in Sources */, 6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */, D93068DB2B81C85E0066FBC8 /* PowerConfig.swift in Sources */, D93068D32B8129510066FBC8 /* MessageContextMenuItems.swift in Sources */, @@ -1558,6 +1698,7 @@ DD2553592855B52700E55709 /* PositionConfig.swift in Sources */, DD97E96828EFE9A00056DDA4 /* About.swift in Sources */, DDDB444029F79AB000EE2349 /* UserDefaults.swift in Sources */, + 23AD54692E2A6EAA0046E9AB /* AccessoryManager+FromRadio.swift in Sources */, 233E99BA2D849C7000CC3A77 /* PressureCompactWidget.swift in Sources */, DDB6ABE028B13AC700384BA1 /* DeviceEnums.swift in Sources */, DD86D40C287F401000BAEB7A /* SaveChannelQRCode.swift in Sources */, @@ -1587,6 +1728,7 @@ DD6F65762C6EA5490053C113 /* AckErrors.swift in Sources */, DDDB445029F8AC9C00EE2349 /* UIImage.swift in Sources */, DD86D40F2881BE4C00BAEB7A /* CsvDocument.swift in Sources */, + 23A1AFB72E42BD2500E46C96 /* RXTXIndicatorView.swift in Sources */, DDB75A1E2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift in Sources */, DDA6B2E928419CF2003E8C16 /* MeshPackets.swift in Sources */, DDCE4E2C2869F92900BE9F8F /* UserConfig.swift in Sources */, @@ -1607,6 +1749,8 @@ DDD94A502845C8F5004A87A0 /* DateTimeText.swift in Sources */, DDB6ABE228B13FB500384BA1 /* PositionConfigEnums.swift in Sources */, DD994B69295F88B60013760A /* IntervalEnums.swift in Sources */, + 23D316932E5618D2002FA4FB /* AsyncGate.swift in Sources */, + 23FF00B62E323C75001DF095 /* AccessoryManager+Connect.swift in Sources */, DDDCD5702BB26F5C00BE6B60 /* NodeListFilter.swift in Sources */, DD6F65742C6CB80A0053C113 /* View.swift in Sources */, DD1933762B0835D500771CD5 /* PositionAltitudeChart.swift in Sources */, @@ -1617,6 +1761,7 @@ DD73FD1128750779000852D6 /* PositionLog.swift in Sources */, DD15E4F52B8BFC8E00654F61 /* PaxCounterLog.swift in Sources */, BCE2D3C32C7ADF42008E6199 /* ShutDownNodeIntent.swift in Sources */, + 23E23F922E392C2B00919073 /* LogRecord+StringRepresentation.swift in Sources */, 25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */, DD3CC6C028E7A60700FA9159 /* MessagingEnums.swift in Sources */, DD6F657B2C6EC2900053C113 /* LockLegend.swift in Sources */, @@ -1628,12 +1773,14 @@ BC10380F2DD4334400B00BFA /* AddContactIntent.swift in Sources */, DDA9515E2BC6F56F00CEA535 /* IndoorAirQuality.swift in Sources */, DDDB444E29F8AB0E00EE2349 /* Int.swift in Sources */, + 2346A71D2E2FB9C500CB9239 /* SerialTransport.swift in Sources */, DD3CC6BC28E366DF00FA9159 /* Meshtastic.xcdatamodeld in Sources */, BCDDFA9A2DBB180D0065189C /* ScrollToBottomButton.swift in Sources */, DDC4C9FF2A8D982900CE201C /* DetectionSensorConfig.swift in Sources */, 2344A2AF2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift in Sources */, 2344A2B02D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift in Sources */, D9C983A22B79D1A600BDBE6A /* RequestPositionButton.swift in Sources */, + 237AEB8F2E1FE457003B7CE3 /* Transport.swift in Sources */, DDDB26442AAC0206003AFCB7 /* NodeDetail.swift in Sources */, DD77093F2AA1B146007A8BF0 /* UIColor.swift in Sources */, DDF6B2482A9AEBF500BA6931 /* StoreForwardConfig.swift in Sources */, @@ -1647,6 +1794,7 @@ DDAB580F2B0DAFBC00147258 /* LocationEntityExtension.swift in Sources */, 233E99BC2D849C8C00CC3A77 /* WindCompactWidget.swift in Sources */, B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */, + 232ED4C32E2C5E89009DA392 /* TCPTransport.swift in Sources */, BC6B45FF2CB2F98900723CEB /* SaveChannelSettingsIntent.swift in Sources */, D93068D72B8146690066FBC8 /* MessageText.swift in Sources */, DDC2E15826CE248E0042C5E4 /* MeshtasticApp.swift in Sources */, @@ -1693,7 +1841,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.MeshtasticTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1715,7 +1863,7 @@ CURRENT_PROJECT_VERSION = 1; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.MeshtasticTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1789,6 +1937,8 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = targeted; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -1849,6 +1999,8 @@ SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = targeted; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2,6"; VALIDATE_PRODUCT = YES; }; @@ -1875,7 +2027,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.17; + MARKETING_VERSION = 2.7.0; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1908,7 +2060,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.17; + MARKETING_VERSION = 2.7.0; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1939,7 +2091,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.6.17; + MARKETING_VERSION = 2.7.0; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1971,7 +2123,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.6.17; + MARKETING_VERSION = 2.7.0; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2037,7 +2189,7 @@ repositoryURL = "https://github.com/DataDog/dd-sdk-ios.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.29.0; + minimumVersion = 2.30.0; }; }; 259792242C2F10B600AD1659 /* XCRemoteSwiftPackageReference "swift-protobuf" */ = { diff --git a/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4a0652bc..b690bba1 100644 --- a/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meshtastic.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fd71b247ba909b0eb360db5530e1068363839c5e169dea6f6a9974b2d98276f4", + "originHash" : "ec45e53bfccc0a9f0df47733b15acfccda455638f3114d1407bb14e89aa23639", "pins" : [ { "identity" : "cocoamqtt", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DataDog/dd-sdk-ios.git", "state" : { - "revision" : "d0a42d8067665cb6ee86af51251ccc071f62bd54", - "version" : "2.29.0" + "revision" : "ba59a958b9a4894b0d281d9220ed1bb28fc1fae1", + "version" : "2.30.0" } }, { diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift new file mode 100644 index 00000000..3e78e489 --- /dev/null +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift @@ -0,0 +1,334 @@ +// +// AccessoryManager+Connect.swift +// Meshtastic +// +// Created by Jake Bordens on 7/24/25. +// + +import Foundation +import OSLog +import MeshtasticProtobufs +import CoreBluetooth + +private let maxRetries = 10 +private let retryDelay: Duration = .seconds(1) + +extension AccessoryManager { + func connect(to device: Device) async throws { + + // Prevent new connection if one is active + if activeConnection != nil { + throw AccessoryError.connectionFailed("Already connected to a device") + } + + guard let transport = transportForType(device.transportType) else { + throw AccessoryError.connectionFailed("No transport for type") + } + + // Clear any errors from last time + lastConnectionError = nil + packetsSent = 0 + packetsReceived = 0 + expectedNodeDBSize = nil + + // Prepare to connect + self.connectionStepper = SequentialSteps(maxRetries: maxRetries, retryDelay: retryDelay) { + + // Step 0 + Step { @MainActor retryAttempt in + Logger.transport.info("🔗👟 [Connect] Starting connection to \(device.id)") + if retryAttempt > 0 { + try await self.closeConnection() // clean-up before retries. + self.updateState(.retrying(attempt: retryAttempt + 1)) + self.allowDisconnect = true + } else { + self.updateState(.connecting) + } + self.updateDevice(deviceId: device.id, key: \.connectionState, value: .connecting) + } + + // Step 1: Setup the connection + Step(timeout: .seconds(2)) { @MainActor _ in + Logger.transport.info("🔗👟[Connect] Step 1: connection to \(device.id)") + do { + let connection = try await transport.connect(to: device) + let eventStream = try await connection.connect() + self.updateState(.communicating) + self.connectionEventTask = Task { + for await event in eventStream { + self.didReceive(event) + } + Logger.transport.info("[Accessory] Event stream closed") + } + self.activeConnection = (device: device, connection: connection) + + if UserDefaults.preferredPeripheralId.count < 1 { + UserDefaults.preferredPeripheralId = device.id.uuidString + } + } catch let error as CBError where error.code == .peerRemovedPairingInformation { + await self.connectionStepper?.cancelCurrentlyExecutingStep(withError: error, cancelFullProcess: true) + } + } + + // Step 2: Send Heartbeat before wantConfig (config) + Step { @MainActor _ in + Logger.transport.info("💓👟 [Connect] Step 2: Send heartbeat") + try await self.sendHeartbeat() + } + + // Step 3: Send WantConfig (config) + Step(timeout: .seconds(30)) { @MainActor _ in + Logger.transport.info("🔗👟 [Connect] Step 3: Send wantConfig (config)") + try await self.sendWantConfig() + } + + // Step 4: Send Heartbeat before wantConfig (database) + Step { @MainActor _ in + Logger.transport.info("💓 [Connect] Step 4: Send heartbeat") + try await self.sendHeartbeat() + } + + // Step 5: Send WantConfig (database) + Step(timeout: .seconds(3.0), onFailure: .retryStep(attempts: 3)) { @MainActor _ in + Logger.transport.info("🔗👟 [Connect] Step 5: Send wantConfig (database)") + self.updateState(.retrievingDatabase(nodeCount: 0)) + self.allowDisconnect = true + try await self.sendWantDatabase() + } + + // Step 5a: Wait for end of WantConfig (database) + Step { @MainActor _ in + Logger.transport.info("🔗👟 [Connect] Step 5a: Wait for the final database") + try await self.waitForWantDatabaseResponse() + } + + // Step 6: Version check + Step { @MainActor _ in + Logger.transport.info("🔗👟 [Connect] Step 6: Version check") + + guard let firmwareVersion = self.activeConnection?.device.firmwareVersion else { + Logger.transport.error("🔗 [Connect] Firmware version not available for device \(device.name, privacy: .public)") + throw AccessoryError.connectionFailed("Firmware version not available") + } + + let lastDotIndex = firmwareVersion.lastIndex(of: ".") + if lastDotIndex == nil { + throw AccessoryError.versionMismatch("🚨" + "Update Your Firmware".localized) + } + + let version = firmwareVersion[...(lastDotIndex ?? String.Index(utf16Offset: 6, in: firmwareVersion))].dropLast() + + // TODO: do we really need to store the firmware version in the UserDefaults? + UserDefaults.firmwareVersion = String(version) + + let supportedVersion = self.checkIsVersionSupported(forVersion: self.minimumVersion) + if !supportedVersion { + throw AccessoryError.connectionFailed("🚨" + "Update Your Firmware".localized) + } + } + + // Step 7: Update UI and status to connected + Step { @MainActor _ in + Logger.transport.info("🔗👟 [Connect] Step 7: Update UI and status") + + // We have an active connection + self.updateDevice(deviceId: device.id, key: \.connectionState, value: .connected) + self.updateState(.subscribed) + } + + // Step 8: Update UI and status to connected + Step { @MainActor _ in + Logger.transport.debug("🔗👟 [Connect] Step 8: Initialize MQTT and Location Provider") + self.stopDiscovery() + await self.initializeMqtt() + self.initializeLocationProvider() + if transport.requiresPeriodicHeartbeat { + await self.setupPeriodicHeartbeat() + } + } + } + + // Run the connection process + do { + try await connectionStepper?.run() + Logger.transport.debug("🔗 [Connect] ConnectionStepper completed.") + } catch { + Logger.transport.error("🔗 [Connect] Error returned by connectionStepper: \(error)") + try await self.closeConnection() + updateState(.discovering) + self.lastConnectionError = error + } + + // All done, one way or another, clean up + self.connectionStepper = nil + } +} + +// Sequentially stepped tasks +typealias Step = SequentialSteps.Step +actor SequentialSteps { + + typealias StepClosure = @Sendable (_ retryAttempt: Int) async throws -> Void + + enum FailureBehavior { + case fail + case retryStep(attempts: Int) + case retryAll + } + + struct Step { + let timeout: Duration? + let failureBehavior: FailureBehavior + let operation: StepClosure + + init(timeout: Duration? = nil, onFailure: FailureBehavior = .retryAll, operation: @escaping StepClosure) { + self.timeout = timeout + self.failureBehavior = onFailure + self.operation = operation + } + } + + private enum SequentialStepError: Error, LocalizedError { + case timeout(stepNumber: Int, afterWaiting: Duration) + + var errorDescription: String? { + switch self { + case .timeout(let stepNumber, let afterWaiting): + return "Timeout after \(afterWaiting) waiting for step \(stepNumber)." + } + } + } + let steps: [Step] + var currentlyExecutingStep: Task? + var cancelled = false + var maxRetries: Int + var retryDelay: Duration + var isRunning: Bool = false + var externalError: Error? + + init(maxRetries: Int = 1, retryDelay: Duration = .seconds(3), @StepsBuilder _ builder: () -> [Step]) { + self.maxRetries = maxRetries + self.retryDelay = retryDelay + self.steps = builder() + } + + func run() async throws { + self.isRunning = true + retryLoop: for attempt in 0.. 0) + if isRetry { + try await Task.sleep(for: retryDelay) + } + do { + let stepRetries = if case let .retryStep(attempts) = currentStep.failureBehavior, attempts > 0 { attempts } else { 1 } + stepRetryLoop: for stepRetryAttempt in 0.. 0 { + Logger.transport.info("[Retry Step Loop] Retrying step \(stepNumber + 1) for the \(stepRetryAttempt + 1) time.") + try await Task.sleep(for: retryDelay) + } + do { + // Starting a new attempt for this step. + if let duration = currentStep.timeout { + // Execute this task with a timeout + self.currentlyExecutingStep = executeWithTimeout(stepNumber: stepNumber, timeout: duration) { + try await currentStep.operation(attempt) + } + try await self.currentlyExecutingStep!.value + } else { + // Execute this task without a timeout + self.currentlyExecutingStep = Task { + try await currentStep.operation(attempt) + } + try await self.currentlyExecutingStep!.value + } + break stepRetryLoop // Exit retry loop if successful + } catch { + if stepRetryAttempt == stepRetries - 1 { + // If this is the last retry attempt, we throw the error to the outer loop + throw error + } else { + switch error { + case let SequentialStepError.timeout(stepNumber, afterWaiting): + Logger.transport.info("[Inner Retry Step Loop] Sequential process timed out on step \(stepNumber) of \(stepRetries) after waiting \(afterWaiting)") + case is CancellationError: + if let externalError { + // Something from the outside had an error which caused the cancellation of this step + let errorToThrow = externalError + self.externalError = nil + throw errorToThrow + } + break stepRetryLoop + default: + Logger.transport.error("[Inner Retry Step Loop] Sequential process failed on step \(stepNumber) with error: \(error.localizedDescription, privacy: .public)") + } + } + } + } + } catch { + switch error { + case let SequentialStepError.timeout(stepNumber, afterWaiting): + Logger.transport.info("[Outer Step Retry Loop] Sequential process timed out on step \(stepNumber) after waiting \(afterWaiting)") + default: + Logger.transport.error("[Outer Step Retry Loop] Sequential process failed on step \(stepNumber) with error: \(error.localizedDescription, privacy: .public)") + } + switch currentStep.failureBehavior { + case .retryAll, .retryStep: + // TODO: we could have a .retryStepAndFail and a .retryStepAndContinue instead of just .retryStep to clarify the behavior here + continue retryLoop + case .fail: + isRunning = false + throw error + } + } + } + // We have finished all steps + isRunning = false + return + } + isRunning = false + throw AccessoryError.tooManyRetries + } + + func cancel() { + cancelled = true + self.currentlyExecutingStep?.cancel() + } + + func cancelCurrentlyExecutingStep(withError: Error?, cancelFullProcess: Bool = false) { + self.externalError = withError + if cancelFullProcess { + cancel() + } else { + self.currentlyExecutingStep?.cancel() + } + } + + func executeWithTimeout(stepNumber: Int, timeout: Duration, operation: @escaping @Sendable () async throws -> ReturnType) -> Task { + return Task { + try await withThrowingTaskGroup(of: ReturnType.self) { group -> ReturnType in + group.addTask(operation: operation) + group.addTask { + try await _Concurrency.Task.sleep(for: timeout) + throw SequentialStepError.timeout(stepNumber: stepNumber, afterWaiting: timeout) + } + guard let success = try await group.next() else { + throw SequentialStepError.timeout(stepNumber: stepNumber, afterWaiting: timeout) + } + group.cancelAll() + return success + } + } + } + + @resultBuilder + struct StepsBuilder { + static func buildBlock(_ components: Step...) -> [Step] { + return components + } + } +} diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift new file mode 100644 index 00000000..70118ce1 --- /dev/null +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift @@ -0,0 +1,89 @@ +// +// AccessoryManager+Discovery.swift +// Meshtastic +// +// Created by Jake Bordens on 7/23/25. +// + +import Foundation +import OSLog + +extension AccessoryManager { + + private func discoverAllDevices() -> AsyncStream { + AsyncStream { continuation in + let tasks = transports.map { transport in + Task { + Logger.transport.info("🔎 [Discovery] Discovery stream started for transport \(String(describing: transport.type))") + for await event in transport.discoverDevices() { + continuation.yield(event) + } + Logger.transport.info("🔎 [Discovery] Discovery stream closed for transport \(String(describing: transport.type))") + } + } + continuation.onTermination = { _ in + Logger.transport.info("🔎 [Discovery] Cancelling discovery for all transports.") + tasks.forEach { $0.cancel() } + } + } + } + + func startDiscovery() { + if discoveryTask != nil { + Logger.transport.debug("🔎 [Discovery] Existing discovery task is active.") + return + } + updateState(.discovering) + + discoveryTask = Task { @MainActor in + for await event in self.discoverAllDevices() { + do { + try Task.checkCancellation() + switch event { + case .deviceFound(let newDevice), .deviceUpdated(let newDevice): + // Update existing device or add new + if let index = self.devices.firstIndex(where: { $0.id == newDevice.id }) { + // This device already exists. + var existing = self.devices[index] + existing.name = newDevice.name + existing.transportType = newDevice.transportType + existing.identifier = newDevice.identifier + existing.connectionState = newDevice.connectionState + existing.rssi = newDevice.rssi + self.devices[index] = existing + } else { + // This is a new device, add it to our list + self.devices.append(newDevice) + } + + if self.shouldAutomaticallyConnectToPreferredPeripheral, + UserDefaults.autoconnectOnDiscovery, UserDefaults.preferredPeripheralId == newDevice.id.uuidString { + Logger.transport.debug("🔎 [Discovery] Found preferred peripheral \(newDevice.name)") + self.connectToPreferredDevice() + } + + // Update the list of discovered devices on the main thread for presentation + // in the user interface + self.devices = devices.sorted { $0.name < $1.name } + + case .deviceLost(let deviceId): + devices.removeAll { $0.id == deviceId } + + case .deviceReportedRssi(let deviceId, let newRssi): + updateDevice(deviceId: deviceId, key: \.rssi, value: newRssi) + } + } catch { + break + } + } + } + } + + func stopDiscovery() { + devices.removeAll() + discoveryTask?.cancel() + discoveryTask?.cancel() + discoveryTask = nil + } + +} diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift new file mode 100644 index 00000000..82f93253 --- /dev/null +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift @@ -0,0 +1,483 @@ +// +// AccessoryManager+FromRadio.swift +// Meshtastic +// +// Created by Jake Bordens on 7/18/25. +// + +import Foundation +import MeshtasticProtobufs +import CocoaMQTT +import OSLog + +extension AccessoryManager { + + func handleMqttClientProxyMessage(_ mqttClientProxyMessage: MqttClientProxyMessage) { + Logger.services.info("handleMqttClientProxyMessage: \(mqttClientProxyMessage.debugDescription)") + let message = CocoaMQTTMessage(topic: mqttClientProxyMessage.topic, + payload: [UInt8](mqttClientProxyMessage.data), + retained: mqttClientProxyMessage.retained) + MqttClientProxyManager.shared.mqttClientProxy?.publish(message) + } + + func handleClientNotification(_ clientNotification: ClientNotification) { + Logger.services.info("handleClientNotification: \(clientNotification.debugDescription)") + var path = "meshtastic:///settings/debugLogs" + if clientNotification.hasReplyID { + /// Set Sent bool on TraceRouteEntity to false if we got rate limited + if clientNotification.message.starts(with: "TraceRoute") { + // CoreData operation happens on the Main Actor + + let traceRoute = getTraceRoute(id: Int64(clientNotification.replyID), context: context) + traceRoute?.sent = false + do { + try context.save() + Logger.data.info("💾 [TraceRouteEntity] Trace Route Rate Limited") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [TraceRouteEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + + } + + switch clientNotification.payloadVariant { + case .lowEntropyKey, .duplicatedPublicKey: + path = "meshtastic:///settings/security" + default: + break + } + } + + // TODO: Look at this to see if LocationManager should be singleton + let manager = LocalNotificationManager() + manager.notifications = [ + Notification( + id: UUID().uuidString, + title: "Firmware Notification".localized, + subtitle: "\(clientNotification.level)".capitalized, + content: clientNotification.message, + target: "settings", + path: path + ) + ] + manager.schedule() + Logger.services.error("⚠️ Client Notification: \(clientNotification.message, privacy: .public)") + } + + func handleMyInfo(_ myNodeInfo: MyNodeInfo) { + // TODO: this works for connections like BLE that have a uniqueId, but what about ones like serial? + guard let connectedDeviceId = activeConnection?.device.id.uuidString else { + Logger.services.error("⚠️ Failed to decode MyInfo, no connected device ID") + return + } + Logger.services.info("handleMyInfo: \(myNodeInfo.debugDescription)") + + updateDevice(key: \.num, value: Int64(myNodeInfo.myNodeNum)) + + if let myInfo = myInfoPacket(myInfo: myNodeInfo, peripheralId: connectedDeviceId, context: context) { + if let bleName = myInfo.bleName { + updateDevice(key: \.name, value: bleName) + updateDevice(key: \.longName, value: bleName) + } + + if myNodeInfo.nodedbCount > 0 { + expectedNodeDBSize = Int(myNodeInfo.nodedbCount) + } + + UserDefaults.preferredPeripheralNum = Int(myInfo.myNodeNum) + let newConnection = Int64(UserDefaults.preferredPeripheralNum) != Int64(myInfo.myNodeNum) + if newConnection { + // Onboard a new device connection here + } + } + tryClearExistingChannels() + + } + + func handleNodeInfo(_ nodeInfo: NodeInfo) { + if let continuation = self.firstDatabaseNodeInfoContinuation { + continuation.resume() + self.firstDatabaseNodeInfoContinuation = nil + } + + guard nodeInfo.num > 0 else { + Logger.services.error("NodeInfo packet with a zero nodeNum") + return + } + + // TODO: nodeInfoPacket's channel: parameter is not used + if let nodeInfo = nodeInfoPacket(nodeInfo: nodeInfo, channel: 0, context: context) { + if let activeDevice = activeConnection?.device, activeDevice.num == nodeInfo.num { + if let user = nodeInfo.user { + updateDevice(deviceId: activeDevice.id, key: \.shortName, value: user.shortName ?? "?") + updateDevice(deviceId: activeDevice.id, key: \.longName, value: user.longName ?? "Unknown".localized) + } + } + } + + // Bump the nodeCount + if case let .retrievingDatabase(nodeCount: nodeCount) = self.state { + updateState(.retrievingDatabase(nodeCount: nodeCount+1)) + } + + } + + func handleChannel(_ channel: Channel) { + guard let deviceNum = activeConnection?.device.num else { + Logger.data.error("Attempt to process channel information when no connected device.") + return + } + + channelPacket(channel: channel, fromNum: Int64(truncatingIfNeeded: deviceNum), context: context) + + } + + func handleConfig(_ config: Config) { + guard let device = activeConnection?.device, let deviceNum = device.num, let longName = device.longName else { + Logger.data.error("Attempt to process channel information when no connected device.") + return + } + + // Local config parses out the variants. Should we do that here maybe? + localConfig(config: config, context: context, nodeNum: Int64(truncatingIfNeeded: deviceNum), nodeLongName: longName) + + // Handle Timezone + if config.payloadVariant == Config.OneOf_PayloadVariant.device(config.device) { + var dc = config.device + if dc.tzdef.isEmpty { + dc.tzdef = TimeZone.current.posixDescription + Task { + try? await saveTimeZone(config: dc, user: deviceNum) + } + } + } + } + + func handleModuleConfig(_ moduleConfigPacket: ModuleConfig) { + guard let device = activeConnection?.device, let deviceNum = device.num, let longName = device.longName else { + Logger.services.error("Attempt to process channel information when no connected device.") + return + } + moduleConfig(config: moduleConfigPacket, context: context, nodeNum: Int64(truncatingIfNeeded: deviceNum), nodeLongName: longName) + // Get Canned Message Message List if the Module is Canned Messages + if moduleConfigPacket.payloadVariant == ModuleConfig.OneOf_PayloadVariant.cannedMessage(moduleConfigPacket.cannedMessage) { + try? getCannedMessageModuleMessages(destNum: deviceNum, wantResponse: true) + } + } + + func handleDeviceMetadata(_ metadata: DeviceMetadata) { + // Note: moved firmware version check to be inline with connection process + guard let device = activeConnection?.device, let deviceNum = device.num else { + Logger.services.error("Attempt to process device metadata information when no connected device.") + return + } + + Logger.transport.debug("[Version] handleDeviceMetadata returned version: \(metadata.firmwareVersion)") + + updateDevice(key: \.firmwareVersion, value: metadata.firmwareVersion) + + deviceMetadataPacket(metadata: metadata, fromNum: deviceNum, context: context) + } + + internal func tryClearExistingChannels() { + guard let device = activeConnection?.device, let deviceNum = device.num else { + Logger.services.error("Attempt to clear existing channels when no connected device.") + return + } + + // Before we get started delete the existing channels from the myNodeInfo + let fetchMyInfoRequest = MyInfoEntity.fetchRequest() + fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(deviceNum)) + + do { + let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) + if fetchedMyInfo.count == 1 { + let mutableChannels = fetchedMyInfo[0].channels?.mutableCopy() as? NSMutableOrderedSet + mutableChannels?.removeAllObjects() + fetchedMyInfo[0].channels = mutableChannels + do { + try context.save() + } catch { + Logger.data.error("Failed to clear existing channels from local app database: \(error.localizedDescription, privacy: .public)") + } + } + } catch { + Logger.data.error("Failed to find a node MyInfo to save these channels to: \(error.localizedDescription, privacy: .public)") + } + + } + + func handleTextMessageAppPacket(_ packet: MeshPacket) { + guard let device = activeConnection?.device, let deviceNum = device.num else { + Logger.services.error("Attempt to handle text message when no connected device.") + return + } + + textMessageAppPacket( + packet: packet, + wantRangeTestPackets: wantRangeTestPackets, + connectedNode: deviceNum, + context: context, + appState: appState + ) + + } + + func storeAndForwardPacket(packet: MeshPacket, connectedNodeNum: Int64) { + if let storeAndForwardMessage = try? StoreAndForward(serializedBytes: packet.decoded.payload) { + // Handle each of the store and forward request / response messages + switch storeAndForwardMessage.rr { + case .unset: + Logger.mesh.info("\("📮 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + case .routerError: + Logger.mesh.info("\("☠️ Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + case .routerHeartbeat: + /// When we get a router heartbeat we know there is a store and forward node on the network + /// Check if it is the primary S&F Router and save the timestamp of the last heartbeat so that we can show the request message history menu item on node long press if the router has been seen recently + if storeAndForwardMessage.heartbeat.secondary == 0 { + + guard let routerNode = getNodeInfo(id: Int64(packet.from), context: context) else { + return + } + if routerNode.storeForwardConfig != nil { + routerNode.storeForwardConfig?.enabled = true + routerNode.storeForwardConfig?.isRouter = storeAndForwardMessage.heartbeat.secondary == 0 + routerNode.storeForwardConfig?.lastHeartbeat = Date() + } else { + let newConfig = StoreForwardConfigEntity(context: context) + newConfig.enabled = true + newConfig.isRouter = storeAndForwardMessage.heartbeat.secondary == 0 + newConfig.lastHeartbeat = Date() + routerNode.storeForwardConfig = newConfig + } + + do { + try context.save() + } catch { + context.rollback() + Logger.data.error("Save Store and Forward Router Error") + } + } + Logger.mesh.info("\("💓 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + case .routerPing: + Logger.mesh.info("\("🏓 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + case .routerPong: + Logger.mesh.info("\("🏓 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + case .routerBusy: + Logger.mesh.info("\("🐝 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + case .routerHistory: + /// Set the Router History Last Request Value + guard let routerNode = getNodeInfo(id: Int64(packet.from), context: context) else { + return + } + if routerNode.storeForwardConfig != nil { + routerNode.storeForwardConfig?.lastRequest = Int32(storeAndForwardMessage.history.lastRequest) + } else { + let newConfig = StoreForwardConfigEntity(context: context) + newConfig.lastRequest = Int32(storeAndForwardMessage.history.lastRequest) + routerNode.storeForwardConfig = newConfig + } + + do { + try context.save() + } catch { + context.rollback() + Logger.data.error("Save Store and Forward Router Error") + } + Logger.mesh.info("\("📜 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + case .routerStats: + Logger.mesh.info("\("📊 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + case .clientError: + Logger.mesh.info("\("☠️ Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + case .clientHistory: + Logger.mesh.info("\("📜 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + case .clientStats: + Logger.mesh.info("\("📊 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + case .clientPing: + Logger.mesh.info("\("🏓 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + case .clientPong: + Logger.mesh.info("\("🏓 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + case .clientAbort: + Logger.mesh.info("\("🛑 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + case .UNRECOGNIZED: + Logger.mesh.info("\("📮 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + case .routerTextDirect: + Logger.mesh.info("\("💬 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + textMessageAppPacket( + packet: packet, + wantRangeTestPackets: false, + connectedNode: connectedNodeNum, + storeForward: true, + context: context, + appState: appState + ) + case .routerTextBroadcast: + Logger.mesh.info("\("✉️ Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + textMessageAppPacket( + packet: packet, + wantRangeTestPackets: false, + connectedNode: connectedNodeNum, + storeForward: true, + context: context, + appState: appState + ) + } + } + } + + func handleTraceRouteApp(_ packet: MeshPacket) { + guard let device = activeConnection?.device, let deviceNum = device.num else { + Logger.services.error("Attempt to handle text message when no connected device.") + return + } + + if let routingMessage = try? RouteDiscovery(serializedBytes: packet.decoded.payload) { + let traceRoute = getTraceRoute(id: Int64(packet.decoded.requestID), context: context) + traceRoute?.response = true + guard let connectedNode = getNodeInfo(id: Int64(deviceNum), context: context) else { + return + } + var hopNodes: [TraceRouteHopEntity] = [] + let connectedHop = TraceRouteHopEntity(context: context) + connectedHop.time = Date() + connectedHop.num = deviceNum + connectedHop.name = connectedNode.user?.longName ?? "???" + // If nil, set to unknown, INT8_MIN (-128) then divide by 4 + connectedHop.snr = Float(routingMessage.snrBack.last ?? -128) / 4 + if let mostRecent = traceRoute?.node?.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! { + connectedHop.altitude = mostRecent.altitude + connectedHop.latitudeI = mostRecent.latitudeI + connectedHop.longitudeI = mostRecent.longitudeI + traceRoute?.hasPositions = true + } + var routeString = "\(connectedNode.user?.longName ?? "???") --> " + hopNodes.append(connectedHop) + traceRoute?.hopsTowards = Int32(routingMessage.route.count) + for (index, node) in routingMessage.route.enumerated() { + var hopNode = getNodeInfo(id: Int64(node), context: context) + if hopNode == nil && hopNode?.num ?? 0 > 0 && node != 4294967295 { + hopNode = createNodeInfo(num: Int64(node), context: context) + } + let traceRouteHop = TraceRouteHopEntity(context: context) + traceRouteHop.time = Date() + if routingMessage.snrTowards.count >= index + 1 { + traceRouteHop.snr = Float(routingMessage.snrTowards[index]) / 4 + } else { + // If no snr in route, set unknown + traceRouteHop.snr = -32 + } + if let hn = hopNode, hn.hasPositions { + if let mostRecent = hn.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! { + traceRouteHop.altitude = mostRecent.altitude + traceRouteHop.latitudeI = mostRecent.latitudeI + traceRouteHop.longitudeI = mostRecent.longitudeI + traceRoute?.hasPositions = true + } + } + traceRouteHop.num = hopNode?.num ?? 0 + if hopNode != nil { + if packet.rxTime > 0 { + hopNode?.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) + } + } + hopNodes.append(traceRouteHop) + + let hopName = hopNode?.user?.longName ?? (node == 4294967295 ? "Repeater" : String(hopNode?.num.toHex() ?? "Unknown".localized)) + let mqttLabel = hopNode?.viaMqtt ?? false ? "MQTT " : "" + let snrLabel = (traceRouteHop.snr != -32) ? String(traceRouteHop.snr) : "unknown ".localized + routeString += "\(hopName) \(mqttLabel)(\(snrLabel)dB) --> " + } + let destinationHop = TraceRouteHopEntity(context: context) + destinationHop.name = traceRoute?.node?.user?.longName ?? "Unknown".localized + destinationHop.time = Date() + // If nil, set to unknown, INT8_MIN (-128) then divide by 4 + destinationHop.snr = Float(routingMessage.snrTowards.last ?? -128) / 4 + destinationHop.num = traceRoute?.node?.num ?? 0 + if let mostRecent = traceRoute?.node?.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! { + destinationHop.altitude = mostRecent.altitude + destinationHop.latitudeI = mostRecent.latitudeI + destinationHop.longitudeI = mostRecent.longitudeI + traceRoute?.hasPositions = true + } + hopNodes.append(destinationHop) + /// Add the destination node to the end of the route towards string and the beginning of the route back string + routeString += "\(traceRoute?.node?.user?.longName ?? "Unknown".localized) \((traceRoute?.node?.num ?? 0).toHex()) (\(destinationHop.snr != -32 ? String(destinationHop.snr) : "unknown ".localized)dB)" + traceRoute?.routeText = routeString + // Default to -1 only fill in if routeBack is valid below + traceRoute?.hopsBack = -1 + // Only if hopStart is set and there is an SNR entry + if packet.hopStart > 0 && routingMessage.snrBack.count > 0 { + traceRoute?.hopsBack = Int32(routingMessage.routeBack.count) + var routeBackString = "\(traceRoute?.node?.user?.longName ?? "Unknown".localized) \((traceRoute?.node?.num ?? 0).toHex()) --> " + for (index, node) in routingMessage.routeBack.enumerated() { + var hopNode = getNodeInfo(id: Int64(node), context: context) + if hopNode == nil && hopNode?.num ?? 0 > 0 && node != 4294967295 { + hopNode = createNodeInfo(num: Int64(node), context: context) + } + let traceRouteHop = TraceRouteHopEntity(context: context) + traceRouteHop.time = Date() + traceRouteHop.back = true + if routingMessage.snrBack.count >= index + 1 { + traceRouteHop.snr = Float(routingMessage.snrBack[index]) / 4 + } else { + // If no snr in route, set to unknown + traceRouteHop.snr = -32 + } + if let hn = hopNode, hn.hasPositions { + if let mostRecent = hn.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! { + traceRouteHop.altitude = mostRecent.altitude + traceRouteHop.latitudeI = mostRecent.latitudeI + traceRouteHop.longitudeI = mostRecent.longitudeI + traceRoute?.hasPositions = true + } + } + traceRouteHop.num = hopNode?.num ?? 0 + if hopNode != nil { + if packet.rxTime > 0 { + hopNode?.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) + } + } + hopNodes.append(traceRouteHop) + + let hopName = hopNode?.user?.longName ?? (node == 4294967295 ? "Repeater" : String(hopNode?.num.toHex() ?? "Unknown".localized)) + let mqttLabel = hopNode?.viaMqtt ?? false ? "MQTT " : "" + let snrLabel = (traceRouteHop.snr != -32) ? String(traceRouteHop.snr) : "unknown ".localized + routeBackString += "\(hopName) \(mqttLabel)(\(snrLabel)dB) --> " + } + // If nil, set to unknown, INT8_MIN (-128) then divide by 4 + let snrBackLast = Float(routingMessage.snrBack.last ?? -128) / 4 + routeBackString += "\(connectedNode.user?.longName ?? String(connectedNode.num.toHex())) (\(snrBackLast != -32 ? String(snrBackLast) : "unknown ".localized)dB)" + traceRoute?.routeBackText = routeBackString + } + traceRoute?.hops = NSOrderedSet(array: hopNodes) + traceRoute?.time = Date() + + if let tr = traceRoute { + let manager = LocalNotificationManager() + manager.notifications = [ + Notification( + id: (UUID().uuidString), + title: "Traceroute Complete", + subtitle: "TR received back from \(destinationHop.name ?? "unknown")", + content: "Hops from: \(tr.hopsTowards), Hops back: \(tr.hopsBack)\n\(tr.routeText ?? "Unknown".localized)\n\(tr.routeBackText ?? "Unknown".localized)", + target: "nodes", + path: "meshtastic:///nodes?nodenum=\(tr.node?.num ?? 0)" + ) + ] + manager.schedule() + } + + do { + try context.save() + Logger.data.info("💾 Saved Trace Route") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Error Updating Core Data TraceRouteHop: \(nsError, privacy: .public)") + } + let logString = String.localizedStringWithFormat("Trace Route request returned: %@".localized, routeString) + Logger.mesh.info("🪧 \(logString, privacy: .public)") + } + } +} diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+MQTT.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+MQTT.swift new file mode 100644 index 00000000..7c286336 --- /dev/null +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+MQTT.swift @@ -0,0 +1,85 @@ +// +// AccessoryManager+MQTT.swift +// Meshtastic +// +// Created by Jake Bordens on 7/18/25. +// + +import Foundation +import CocoaMQTT +import OSLog +import MeshtasticProtobufs + +extension AccessoryManager { + + func initializeMqtt() async { + guard let deviceNum = activeConnection?.device.num else { + Logger.services.error("Attempt to initialize MQTT without an active connection") + return + } + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(deviceNum)) + do { + let fetchedNodeInfo = try context.fetch(fetchNodeInfoRequest) + if fetchedNodeInfo.count == 1 { + // Subscribe to Mqtt Client Proxy if enabled + if fetchedNodeInfo[0].mqttConfig != nil && fetchedNodeInfo[0].mqttConfig?.enabled ?? false && fetchedNodeInfo[0].mqttConfig?.proxyToClientEnabled ?? false { + mqttManager.connectFromConfigSettings(node: fetchedNodeInfo[0]) + } else { + if mqttProxyConnected { + mqttManager.mqttClientProxy?.disconnect() + } + } + // Set initial unread message badge states + appState.unreadChannelMessages = fetchedNodeInfo[0].myInfo?.unreadMessages ?? 0 + appState.unreadDirectMessages = fetchedNodeInfo[0].user?.unreadMessages ?? 0 + } + if fetchedNodeInfo.count == 1 && fetchedNodeInfo[0].rangeTestConfig?.enabled == true { + wantRangeTestPackets = true + } + if fetchedNodeInfo.count == 1 && fetchedNodeInfo[0].storeForwardConfig?.enabled == true { + wantStoreAndForwardPackets = true + } + } catch { + Logger.data.error("Failed to find a node info for the connected node \(error.localizedDescription, privacy: .public)") + } + + } + + // MARK: MqttClientProxyManagerDelegate Methods + func onMqttConnected() { + mqttProxyConnected = true + mqttError = "" + Logger.services.info("📲 [MQTT Client Proxy] onMqttConnected now subscribing to \(self.mqttManager.topic, privacy: .public).") + mqttManager.mqttClientProxy?.subscribe(mqttManager.topic) + } + + func onMqttDisconnected() { + mqttProxyConnected = false + Logger.services.info("📲 MQTT Disconnected") + } + + func onMqttMessageReceived(message: CocoaMQTTMessage) { + 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 + Task { + try? await self.send(toRadio) + } + } + + func onMqttError(message: String) { + mqttProxyConnected = false + mqttError = message + Logger.services.info("📲 [MQTT Client Proxy] onMqttError: \(message, privacy: .public)") + } +} diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Position.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Position.swift new file mode 100644 index 00000000..f42a590c --- /dev/null +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Position.swift @@ -0,0 +1,97 @@ +// +// AccessoryManager+Position.swift +// Meshtastic +// +// Created by Jake Bordens on 7/24/25. +// + +import Foundation +import OSLog +import MeshtasticProtobufs +import CoreLocation + +extension AccessoryManager { + func initializeLocationProvider() { + self.locationTask = Task { + repeat { + try? await Task.sleep(for: .seconds(30)) // sleep for 30 seconds. This throws if task is cancelled + + guard let fromNodeNum = activeConnection?.device.num else { + return + } + + if UserDefaults.provideLocation { + _ = try await sendPosition(channel: 0, destNum: fromNodeNum, wantResponse: false) + } + } while !Task.isCancelled + } + } + + public func sendPosition(channel: Int32, destNum: Int64, wantResponse: Bool) async throws { + guard let fromNodeNum = activeConnection?.device.num else { + throw AccessoryError.ioFailed("Not connected to any device") + } + + guard let positionPacket = try await getPositionFromPhoneGPS(destNum: destNum, fixedPosition: false) else { + Logger.services.error("Unable to get position data from device GPS to send to node") + throw AccessoryError.appError("Unable to get position data from device GPS to send to node") + } + + var meshPacket = MeshPacket() + meshPacket.to = UInt32(destNum) + meshPacket.channel = UInt32(channel) + meshPacket.from = UInt32(fromNodeNum) + var dataMessage = DataMessage() + if let serializedData: Data = try? positionPacket.serializedData() { + dataMessage.payload = serializedData + dataMessage.portnum = PortNum.positionApp + dataMessage.wantResponse = wantResponse + meshPacket.decoded = dataMessage + } else { + Logger.services.error("Failed to serialize position packet data") + throw AccessoryError.ioFailed("sendPosition: Unable to serialize position packet data") + } + + var toRadio: ToRadio! + toRadio = ToRadio() + toRadio.packet = meshPacket + try await self.send(toRadio) + } + + public func getPositionFromPhoneGPS(destNum: Int64, fixedPosition: Bool) async throws -> Position? { + var positionPacket = Position() + + guard let lastLocation = LocationsHandler.shared.locationsArray.last else { + return nil + } + + if lastLocation == CLLocation(latitude: 0, longitude: 0) { + return nil + } + + positionPacket.latitudeI = Int32(lastLocation.coordinate.latitude * 1e7) + positionPacket.longitudeI = Int32(lastLocation.coordinate.longitude * 1e7) + let timestamp = lastLocation.timestamp + positionPacket.time = UInt32(timestamp.timeIntervalSince1970) + positionPacket.timestamp = UInt32(timestamp.timeIntervalSince1970) + positionPacket.altitude = Int32(lastLocation.altitude) + positionPacket.satsInView = UInt32(LocationsHandler.satsInView) + let currentSpeed = lastLocation.speed + if currentSpeed > 0 && (!currentSpeed.isNaN || !currentSpeed.isInfinite) { + positionPacket.groundSpeed = UInt32(currentSpeed) + } + let currentHeading = lastLocation.course + if (currentHeading > 0 && currentHeading <= 360) && (!currentHeading.isNaN || !currentHeading.isInfinite) { + positionPacket.groundTrack = UInt32(currentHeading) + } + /// Set location source for time + if !fixedPosition { + /// From GPS treat time as good + positionPacket.locationSource = Position.LocSource.locExternal + } else { + /// From GPS, but time can be old and have drifted + positionPacket.locationSource = Position.LocSource.locManual + } + return positionPacket + } +} diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift new file mode 100644 index 00000000..11c2f442 --- /dev/null +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift @@ -0,0 +1,2058 @@ +// +// AccessoryManager+ToRadio.swift +// Meshtastic +// +// Created by Jake Bordens on 7/18/25. +// + +import Foundation +import MeshtasticProtobufs +import OSLog + +extension AccessoryManager { + + public func getCannedMessageModuleMessages(destNum: Int64, wantResponse: Bool) throws { + guard let deviceNum = self.activeConnection?.device.num else { + Logger.services.error("Error while sending CannedMessageModule request. No active device.") + throw AccessoryError.ioFailed("No active device") + } + + var adminPacket = AdminMessage() + adminPacket.getCannedMessageModuleMessagesRequest = true + + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(destNum) + meshPacket.from = UInt32(deviceNum) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + var adminPacket = AdminMessage() + adminPacket.setConfig.device = config + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(user) + meshPacket.from = UInt32(user) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. 0 else { + // Don't send an empty message + Logger.mesh.info("🚫 Don't Send an Empty Message") + return + } + + let messageUsers = UserEntity.fetchRequest() + messageUsers.predicate = NSPredicate(format: "num IN %@", [fromUserNum, Int64(toUserNum)]) + + do { + let fetchedUsers = try context.fetch(messageUsers) + if fetchedUsers.isEmpty { + + Logger.data.error("🚫 Message Users Not Found, Fail") + throw AccessoryError.ioFailed("🚫 Message Users Not Found, Fail") + } else if fetchedUsers.count >= 1 { + let newMessage = MessageEntity(context: context) + newMessage.messageId = Int64(UInt32.random(in: UInt32(UInt8.max).. 0 { + newMessage.toUser = fetchedUsers.first(where: { $0.num == toUserNum }) + newMessage.toUser?.lastMessage = Date() + if newMessage.toUser?.pkiEncrypted ?? false { + newMessage.publicKey = newMessage.toUser?.publicKey + newMessage.pkiEncrypted = true + } + } + newMessage.fromUser = fetchedUsers.first(where: { $0.num == fromUserNum }) + newMessage.isEmoji = isEmoji + newMessage.admin = false + newMessage.channel = channel + if replyID > 0 { + newMessage.replyID = replyID + } + newMessage.messagePayload = message + newMessage.messagePayloadMarkdown = generateMessageMarkdown(message: message) + newMessage.read = true + + let dataType = PortNum.textMessageApp + var messageQuotesReplaced = message.replacingOccurrences(of: "’", with: "'") + messageQuotesReplaced = message.replacingOccurrences(of: "”", with: "\"") + let payloadData: Data = messageQuotesReplaced.data(using: String.Encoding.utf8)! + + var dataMessage = DataMessage() + dataMessage.payload = payloadData + dataMessage.portnum = dataType + + var meshPacket = MeshPacket() + if newMessage.toUser?.pkiEncrypted ?? false { + meshPacket.pkiEncrypted = true + meshPacket.publicKey = newMessage.toUser?.publicKey ?? Data() + // Auto Favorite nodes you DM so they don't roll out of the nodedb + if !(newMessage.toUser?.userNode?.favorite ?? true) { + newMessage.toUser?.userNode?.favorite = true + do { + try context.save() + Logger.data.info("💾 Auto favorited node based on sending a message \(self.activeDeviceNum?.toHex() ?? "0", privacy: .public) to \(toUserNum.toHex(), privacy: .public)") + + guard let userNode = newMessage.toUser?.userNode else { + Logger.data.warning("⚠️ Unable to set favorite node: userNode is nil.") + return + } + Task { + do { + try await self.setFavoriteNode(node: userNode, connectedNodeNum: fromUserNum) + } catch { + Logger.data.warning("⚠️ Unable to set favorite node: userNode is nil.") + return + } + } + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Unresolved Core Data error when auto favoriting in Send Message Function. Error: \(nsError, privacy: .public)") + } + } + } + meshPacket.id = UInt32(newMessage.messageId) + if toUserNum > 0 { + meshPacket.to = UInt32(toUserNum) + } else { + meshPacket.to = Constants.maximumNodeNum + } + meshPacket.channel = UInt32(channel) + meshPacket.from = UInt32(fromUserNum) + meshPacket.decoded = dataMessage + meshPacket.decoded.emoji = isEmoji ? 1 : 0 + if replyID > 0 { + meshPacket.decoded.replyID = UInt32(replyID) + } + meshPacket.wantAck = true + + var toRadio: ToRadio! + toRadio = ToRadio() + toRadio.packet = meshPacket + Task { + let logString = String.localizedStringWithFormat("Sent message %@ from %@ to %@".localized, String(newMessage.messageId), fromUserNum.toHex(), toUserNum.toHex()) + try await send(toRadio, debugDescription: logString) + } + do { + try context.save() + Logger.data.info("💾 Saved a new sent message from \(self.activeDeviceNum?.toHex() ?? "0", privacy: .public) to \(toUserNum.toHex(), privacy: .public)") + + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Unresolved Core Data error in Send Message Function your database is corrupted running a node db reset should clean up the data. Error: \(nsError, privacy: .public)") + throw error + } + } + } catch { + Logger.data.error("💥 Send message failure \(self.activeDeviceNum?.toHex() ?? "0", privacy: .public) to \(toUserNum.toHex(), privacy: .public)") + } + + } + + public func setFavoriteNode(node: NodeInfoEntity, connectedNodeNum: Int64) async throws { + var adminPacket = AdminMessage() + adminPacket.setFavoriteNode = UInt32(node.num) + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(connectedNodeNum) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. 8 { + throw AccessoryError.appError("Index out of range \(i)") + } + // Bail out if there are no channels or if the same channel name already exists + guard let mutableChannels = myInfo.channels!.mutableCopy() as? NSMutableOrderedSet else { + throw AccessoryError.appError("No channels or channel") + } + if mutableChannels.first(where: {($0 as AnyObject).name == cs.name }) is ChannelEntity { + throw AccessoryError.appError("Channel already exists") + } + } + } catch { + Logger.data.error("Failed to find a node MyInfo to save these channels to: \(error.localizedDescription, privacy: .public)") + } + } + + var chan = Channel() + if i == 0 { + chan.role = Channel.Role.primary + } else { + chan.role = Channel.Role.secondary + } + chan.settings = cs + chan.index = i + i += 1 + + var adminPacket = AdminMessage() + adminPacket.setChannel = chan + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(deviceNum) + meshPacket.from = UInt32(deviceNum) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + var adminPacket = AdminMessage() + adminPacket.setChannel = channel + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..= 1 ? waypoint.name : "Dropped Pin" + wayPointEntity.longDescription = waypoint.description_p + wayPointEntity.icon = Int64(waypoint.icon) + wayPointEntity.latitudeI = waypoint.latitudeI + wayPointEntity.longitudeI = waypoint.longitudeI + if waypoint.expire > 1 { + wayPointEntity.expire = Date.init(timeIntervalSince1970: Double(waypoint.expire)) + } else { + wayPointEntity.expire = nil + } + if waypoint.lockedTo > 0 { + wayPointEntity.locked = Int64(waypoint.lockedTo) + } else { + wayPointEntity.locked = 0 + } + if wayPointEntity.created == nil { + wayPointEntity.created = Date() + } else { + wayPointEntity.lastUpdated = Date() + } + do { + try context.save() + Logger.data.info("💾 Updated Waypoint from Waypoint App Packet From: \(fromNodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Error Saving NodeInfoEntity from WAYPOINT_APP \(nsError, privacy: .public)") + } + + } + + func sendTraceRouteRequest(destNum: Int64, wantResponse: Bool) async throws { + guard let fromNodeNum = self.activeConnection?.device.num else { + Logger.services.error("Error while sending traceroute request. No active device.") + throw AccessoryError.ioFailed("No active device") + } + + let routePacket = RouteDiscovery() + var meshPacket = MeshPacket() + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + guard isConnected else { + throw AccessoryError.ioFailed("No connected accessory") + } + + let fromUserNum = fromUser.map { UInt32($0.num) } ?? UInt32(activeDeviceNum ?? 0) + let toUserNum = toUser.map { UInt32($0.num) } ?? UInt32(activeDeviceNum ?? 0) + + var adminPacket = AdminMessage() + adminPacket.getDeviceMetadataRequest = true + var meshPacket: MeshPacket = MeshPacket() + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setModuleConfig.ambientLighting = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setModuleConfig.cannedMessage = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setCannedMessageModuleMessages = messages + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setModuleConfig.detectionSensor = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + var adminPacket = AdminMessage() + adminPacket.setModuleConfig.externalNotification = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setModuleConfig.paxcounter = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setRingtoneMessage = ringtone + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setModuleConfig.mqtt = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setModuleConfig.rangeTest = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setModuleConfig.serial = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setModuleConfig.storeForward = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + var adminPacket = AdminMessage() + adminPacket.setOwner = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + var adminPacket = AdminMessage() + adminPacket.setHamMode = ham + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setConfig.position = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setConfig.power = config + + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setConfig.network = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setConfig.security = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + var adminPacket = AdminMessage() + adminPacket.setConfig.bluetooth = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + if let adminIndex = adminIndex { + meshPacket.channel = UInt32(adminIndex) + } + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setModuleConfig.telemetry = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + var adminPacket = AdminMessage() + adminPacket.setConfig.display = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setConfig.device = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { + + var adminPacket = AdminMessage() + adminPacket.setConfig.lora = config + if fromUser != toUser { + adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() + } + var meshPacket: MeshPacket = MeshPacket() + meshPacket.to = UInt32(toUser.num) + meshPacket.from = UInt32(fromUser.num) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..? + var connectionEventTask: Task ? + var locationTask: Task? + var connectionStepper: SequentialSteps? + + // Flash subjects + @Published var packetsSent: Int = 0 + @Published var packetsReceived: Int = 0 + + // Continuations + var wantConfigContinuation: CheckedContinuation? + var firstDatabaseNodeInfoContinuation: CheckedContinuation? + var wantDatabaseGate: AsyncGate = AsyncGate() + + // Misc + @Published var expectedNodeDBSize: Int? + + var heartbeatTimer: ResettableTimer? + var heartbeatResponseTimer: ResettableTimer? + + init(transports: [any Transport] = [BLETransport(), TCPTransport()]) { + self.transports = transports + self.state = .uninitialized + self.mqttManager.delegate = self + } + + func transportForType(_ type: TransportType) -> Transport? { + return transports.first(where: {$0.type == type }) + } + + func connectToPreferredDevice() { + if !self.isConnected && !self.isConnecting, + let preferredDevice = self.devices.first(where: { $0.id.uuidString == UserDefaults.preferredPeripheralId }) { + Task { try await self.connect(to: preferredDevice) } + } + } + + func sendWantConfig() async throws { + if let inProgressWantConfigContinuation = wantConfigContinuation { + Logger.transport.info("[Accessory] Existing continuation for wantConfig(Config). Cancelling.") + inProgressWantConfigContinuation.resume(throwing: CancellationError()) + wantConfigContinuation = nil + } + guard let connection = activeConnection?.connection else { + Logger.transport.error("Unable to send wantConfig (config): No device connected") + return + } + + try await withTaskCancellationHandler { + var toRadio: ToRadio = ToRadio() + toRadio.wantConfigID = UInt32(NONCE_ONLY_CONFIG) + try await self.send(toRadio) + try await connection.startDrainPendingPackets() + try await withCheckedThrowingContinuation { cont in + self.wantConfigContinuation = cont + } + self.wantConfigContinuation = nil + Logger.transport.info("✅ [Accessory] NONCE_ONLY_CONFIG Done") + } onCancel: { + Task { @MainActor in + wantConfigContinuation?.resume(throwing: CancellationError()) + wantConfigContinuation = nil + } + } + } + + func sendWantDatabase() async throws { + if let firstDatabaseNodeInfoContinuation = firstDatabaseNodeInfoContinuation { + Logger.transport.info("[Accessory] Existing continuation for firstDatabaseNodeInfo. Cancelling.") + firstDatabaseNodeInfoContinuation.resume(throwing: CancellationError()) + self.firstDatabaseNodeInfoContinuation = nil + } + + guard let connection = activeConnection?.connection else { + Logger.transport.error("Unable to send wantConfig (Database): No device connected") + return + } + + try await withTaskCancellationHandler { + var toRadio: ToRadio = ToRadio() + toRadio.wantConfigID = UInt32(NONCE_ONLY_DB) + try await self.send(toRadio) + try await connection.startDrainPendingPackets() + try await withCheckedThrowingContinuation { cont in + firstDatabaseNodeInfoContinuation = cont + } + firstDatabaseNodeInfoContinuation = nil + Logger.transport.info("✅ [Accessory] NONCE_ONLY_DB first NodeInfo received.") + } onCancel: { + Task { @MainActor in + firstDatabaseNodeInfoContinuation?.resume(throwing: CancellationError()) + firstDatabaseNodeInfoContinuation = nil + } + } + } + + func waitForWantDatabaseResponse() async throws { + try await wantDatabaseGate.wait() + } + + // Fully tears down a connection and sets up the AccessoryManager for the next. + // If you are calling this in response to an error, then you should have + // exposed the error to the UI or handled the error prior to calling this. + func closeConnection() async throws { + Logger.transport.debug("[AccessoryManager] received disconnect request") + + if let activeConnection { + updateDevice(deviceId: activeConnection.device.id, key: \.connectionState, value: .disconnected) + self.activeConnection = nil + } + + connectionEventTask?.cancel() + connectionEventTask = nil + + locationTask?.cancel() + locationTask = nil + + await heartbeatTimer?.cancel(withReason: "Closing connection") + await heartbeatResponseTimer?.cancel(withReason: "Closing connection") + heartbeatTimer = nil + heartbeatResponseTimer = nil + + // Clean up continuations + wantConfigContinuation?.resume(throwing: CancellationError()) + wantConfigContinuation = nil + firstDatabaseNodeInfoContinuation?.resume(throwing: CancellationError()) + firstDatabaseNodeInfoContinuation = nil + + await wantDatabaseGate.cancelAll() + await wantDatabaseGate.reset() + + // Turn off the disconnect buttons + allowDisconnect = false + self.startDiscovery() + } + + // Should only be called by UI-facing callers. + func disconnect() async throws { + // Cancel ongoing connection task if it exists + await self.connectionStepper?.cancel() + + // Close out the connection + if let activeConnection = activeConnection { + try await activeConnection.connection.disconnect(withError: nil, shouldReconnect: false) + } + } + + // Update device attributes on MainActor for presentation in the UI + func updateDevice(deviceId: UUID? = nil, key: WritableKeyPath, value: T) where T: Equatable { + guard let deviceId = deviceId ?? self.activeConnection?.device.id else { + Logger.transport.error("updateDevice with nil deviceId") + return + } + + // Update the active device + if let activeConnection { + var device = activeConnection.device + if device[keyPath: key] != value { + // Update the @Published stuff for the UI + self.objectWillChange.send() + + device[keyPath: key] = value + self.activeConnection = (device: device, connection: activeConnection.connection) + self.activeDeviceNum = device.num + } + } + + // Update the device in the devices array if it exists + if let index = devices.firstIndex(where: { $0.id == deviceId }) { + var device = devices[index] + device[keyPath: key] = value + if device[keyPath: key] != value { + // Update the @Published stuff for the UI + self.objectWillChange.send() + + if let index = devices.firstIndex(where: { $0.id == deviceId }) { + devices[index] = device + } + } + } else { + // Durring active connections, this discover list will be empty, so this is expected. + // Logger.transport.error("Device with ID \(deviceId) not found in devices list.") + } + + } + + // Update state on MainActor for presentation in the UI + func updateState(_ newState: AccessoryManagerState) { +#if DEBUG + Logger.transport.info("🔗 Updating state from \(self.state.description, privacy: .public) to \(newState.description, privacy: .public)") +#endif + switch newState { + case .uninitialized, .idle, .discovering: + self.isConnected = false + self.isConnecting = false + case .connecting, .communicating, .retrying: + self.isConnected = false + self.isConnecting = true + case .subscribed, .retrievingDatabase: + self.isConnected = true + self.isConnecting = false + } + self.state = newState + } + + func send(_ data: ToRadio, debugDescription: String? = nil) async throws { + packetsSent += 1 + + guard let active = activeConnection, + await active.connection.isConnected else { + throw AccessoryError.connectionFailed("Not connected to any device") + } + try await active.connection.send(data) + if let debugDescription { + Logger.transport.info("📻 \(debugDescription, privacy: .public)") + } + } + + func didReceive(_ event: ConnectionEvent) { + packetsReceived += 1 + + switch event { + case .data(let fromRadio): + // Logger.transport.info("✅ [Accessory] didReceive: \(fromRadio.payloadVariant.debugDescription)") + self.processFromRadio(fromRadio) + Task { + await self.heartbeatResponseTimer?.cancel(withReason: "Data packet received") + await self.heartbeatTimer?.reset(delay: .seconds(60.0)) + } + + case .logMessage(let message): + self.didReceiveLog(message: message) + Task { + await self.heartbeatResponseTimer?.cancel(withReason: "Log message packet received") + await self.heartbeatTimer?.reset(delay: .seconds(60.0)) + } + + case .rssiUpdate(let rssi): + guard let deviceId = self.activeConnection?.device.id else { + Logger.transport.error("Could not update RSSI, no active connection") + return + } + updateDevice(deviceId: deviceId, key: \.rssi, value: rssi) + + case .error(let error), .errorWithoutReconnect(let error): + Task { + // Figure out if we'll reconnect + if case .errorWithoutReconnect = event { + shouldAutomaticallyConnectToPreferredPeripheral = false + } else { + shouldAutomaticallyConnectToPreferredPeripheral = true + } + + Logger.transport.info("🚨 [Accessory] didReceive with failure: \(error.localizedDescription) (willReconnect = \(self.shouldAutomaticallyConnectToPreferredPeripheral, privacy: .public))") + + lastConnectionError = error + + if let connectionStepper = self.connectionStepper { + // If we're in the midst of a connection process, tell the stepper that something happened + // This cancels retry connection attempts if we've been asked not to reconnect + await connectionStepper.cancelCurrentlyExecutingStep(withError: error, cancelFullProcess: !shouldAutomaticallyConnectToPreferredPeripheral) + } else { + // Normal processing. Expose the error and disconnect + try? await self.closeConnection() + + // If we were actively reconnecting, then don't update the status because + // we're in the midst of a reconnection flow + if !(await self.connectionStepper?.isRunning ?? false) { + updateState(.discovering) + } + } + } + + case .disconnected: + Task { + // This is user-initatied, so don't reconnect + shouldAutomaticallyConnectToPreferredPeripheral = false + try? await self.closeConnection() + updateState(.discovering) + } + Logger.transport.info("[Accessory] Connection reported user-initiated disconnect.") + } + } + + func didReceiveLog(message: String) { + var log = message + /// Debug Log Level + if log.starts(with: "DEBUG |") { + do { + let logString = log + if let coordsMatch = try CommonRegex.COORDS_REGEX.firstMatch(in: logString) { + log = "\(log.replacingOccurrences(of: "DEBUG |", with: "").trimmingCharacters(in: .whitespaces))" + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.debug("🛰️ \(log.prefix(upTo: coordsMatch.range.lowerBound), privacy: .public) \(coordsMatch.0.replacingOccurrences(of: "[,]", with: "", options: .regularExpression), privacy: .private(mask: .none)) \(log.suffix(from: coordsMatch.range.upperBound), privacy: .public)") + } else { + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.debug("🕵🏻‍♂️ \(log.replacingOccurrences(of: "DEBUG |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)") + } + } catch { + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.debug("🕵🏻‍♂️ \(log.replacingOccurrences(of: "DEBUG |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)") + } + } else if log.starts(with: "INFO |") { + do { + let logString = log + if let coordsMatch = try CommonRegex.COORDS_REGEX.firstMatch(in: logString) { + log = "\(log.replacingOccurrences(of: "INFO |", with: "").trimmingCharacters(in: .whitespaces))" + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.info("🛰️ \(log.prefix(upTo: coordsMatch.range.lowerBound), privacy: .public) \(coordsMatch.0.replacingOccurrences(of: "[,]", with: "", options: .regularExpression), privacy: .private) \(log.suffix(from: coordsMatch.range.upperBound), privacy: .public)") + } else { + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.info("📢 \(log.replacingOccurrences(of: "INFO |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)") + } + } catch { + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.info("📢 \(log.replacingOccurrences(of: "INFO |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)") + } + } else if log.starts(with: "WARN |") { + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.warning("⚠️ \(log.replacingOccurrences(of: "WARN |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)") + } else if log.starts(with: "ERROR |") { + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.error("💥 \(log.replacingOccurrences(of: "ERROR |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)") + } else if log.starts(with: "CRIT |") { + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.critical("🧨 \(log.replacingOccurrences(of: "CRIT |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)") + } else { + log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression) + Logger.radio.debug("📟 \(log, privacy: .public)") + } + } + + private func processFromRadio(_ decodedInfo: FromRadio) { + switch decodedInfo.payloadVariant { + case .mqttClientProxyMessage(let mqttClientProxyMessage): + handleMqttClientProxyMessage(mqttClientProxyMessage) + + case .clientNotification(let clientNotification): + handleClientNotification(clientNotification) + + case .myInfo(let myNodeInfo): + handleMyInfo(myNodeInfo) + + case .packet(let packet): + if case let .decoded(data) = packet.payloadVariant { + switch data.portnum { + case .textMessageApp, .detectionSensorApp, .alertApp: + handleTextMessageAppPacket(packet) + case .remoteHardwareApp: + Logger.mesh.info("🕸️ MESH PACKET received for Remote Hardware App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + case .positionApp: + upsertPositionPacket(packet: packet, context: context) + case .waypointApp: + waypointPacket(packet: packet, context: context) + case .nodeinfoApp: + guard let connectedNodeNum = self.activeDeviceNum else { + Logger.mesh.error("🕸️ Unable to determine connectedNodeNum for node info upsert.") + return + } + if packet.from != connectedNodeNum { + upsertNodeInfoPacket(packet: packet, context: context) + } else { + Logger.mesh.error("🕸️ Received a node info packet from ourselves over the mesh. Dropping.") + } + case .routingApp: + guard let deviceNum = activeConnection?.device.num else { + Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for routingPacket.") + return + } + routingPacket(packet: packet, connectedNodeNum: deviceNum, context: context) + case .adminApp: + adminAppPacket(packet: packet, context: context) + case .replyApp: + Logger.mesh.info("🕸️ MESH PACKET received for Reply App handling as a text message") + guard let deviceNum = activeConnection?.device.num else { + Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for replyApp.") + return + } + textMessageAppPacket(packet: packet, wantRangeTestPackets: wantRangeTestPackets, connectedNode: deviceNum, context: context, appState: appState) + case .ipTunnelApp: + Logger.mesh.info("🕸️ MESH PACKET received for IP Tunnel App UNHANDLED UNHANDLED") + case .serialApp: + Logger.mesh.info("🕸️ MESH PACKET received for Serial App UNHANDLED UNHANDLED") + case .storeForwardApp: + guard let deviceNum = activeConnection?.device.num else { + Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for storeAndForward.") + return + } + storeAndForwardPacket(packet: decodedInfo.packet, connectedNodeNum: deviceNum) + case .rangeTestApp: + guard let deviceNum = activeConnection?.device.num else { + Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for rangeTestApp.") + return + } + if wantRangeTestPackets { + textMessageAppPacket( + packet: packet, + wantRangeTestPackets: true, + connectedNode: deviceNum, + context: context, + appState: appState + ) + } else { + Logger.mesh.info("🕸️ MESH PACKET received for Range Test App Range testing is disabled.") + } + case .telemetryApp: + guard let deviceNum = activeConnection?.device.num else { + Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for telemetryApp.") + return + } + telemetryPacket(packet: packet, connectedNode: deviceNum, context: context) + case .textMessageCompressedApp: + Logger.mesh.info("🕸️ MESH PACKET received for Text Message Compressed App UNHANDLED") + case .zpsApp: + Logger.mesh.info("🕸️ MESH PACKET received for Zero Positioning System App UNHANDLED") + case .privateApp: + Logger.mesh.info("🕸️ MESH PACKET received for Private App UNHANDLED UNHANDLED") + case .atakForwarder: + Logger.mesh.info("🕸️ MESH PACKET received for ATAK Forwarder App UNHANDLED UNHANDLED") + case .simulatorApp: + Logger.mesh.info("🕸️ MESH PACKET received for Simulator App UNHANDLED UNHANDLED") + case .audioApp: + Logger.mesh.info("🕸️ MESH PACKET received for Audio App UNHANDLED UNHANDLED") + case .tracerouteApp: + handleTraceRouteApp(packet) + case .neighborinfoApp: + if let neighborInfo = try? NeighborInfo(serializedBytes: decodedInfo.packet.decoded.payload) { + Logger.mesh.info("🕸️ MESH PACKET received for Neighbor Info App UNHANDLED \((try? neighborInfo.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + } + case .paxcounterApp: + paxCounterPacket(packet: decodedInfo.packet, context: context) + case .mapReportApp: + Logger.mesh.info("🕸️ MESH PACKET received Map Report App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + case .UNRECOGNIZED: + Logger.mesh.info("🕸️ MESH PACKET received UNRECOGNIZED App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + case .max: + Logger.services.info("MAX PORT NUM OF 511") + case .atakPlugin: + Logger.mesh.info("🕸️ MESH PACKET received for ATAK Plugin App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + case .powerstressApp: + Logger.mesh.info("🕸️ MESH PACKET received for Power Stress App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + case .reticulumTunnelApp: + Logger.mesh.info("🕸️ MESH PACKET received for Reticulum Tunnel App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + case .keyVerificationApp: + Logger.mesh.warning("🕸️ MESH PACKET received for Key Verification App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + case .unknownApp: + Logger.mesh.warning("🕸️ MESH PACKET received for unknown App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + case .cayenneApp: + Logger.mesh.info("🕸️ MESH PACKET received Cayenne App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + } + } + + case .nodeInfo(let nodeInfo): + handleNodeInfo(nodeInfo) + + case .channel(let channel): + handleChannel(channel) + + case .config(let config): + handleConfig(config) + + case .moduleConfig(let moduleConfig): + handleModuleConfig(moduleConfig) + + case .metadata(let metadata): + handleDeviceMetadata(metadata) + + case .deviceuiConfig: +#if DEBUG + Logger.mesh.info("🕸️ MESH PACKET received for deviceUIConfig UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") +#endif + case .fileInfo: +#if DEBUG + Logger.mesh.info("🕸️ MESH PACKET received for fileInfo UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") +#endif + case .queueStatus: +#if DEBUG + Logger.mesh.info("🕸️ MESH PACKET received for queueStatus \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") +#else + Logger.mesh.info("🕸️ MESH PACKET received for heartbeat response") +#endif + case .logRecord(let record): + didReceiveLog(message: record.stringRepresentation) + + case .configCompleteID(let configCompleteID): + // Not sure if we want to do anythign here directly? The continuation stuff lets you + // do the next step right in the connection flow. + + // switch configCompleteID { + // case UInt32(NONCE_ONLY_CONFIG): + // break; + // case UInt32(NONCE_ONLY_DB): + // case UInt32(NONCE_ONLY_DB): + // break; + // break: + // Logger.mesh.error("✅ [Accessory] Unknown UNHANDLED confligCompleteID: \(configCompleteID)") + // } + + Logger.transport.info("✅ [Accessory] Notifying completions that have completed for configCompleteID: \(configCompleteID)") + switch configCompleteID { + case UInt32(NONCE_ONLY_CONFIG): + if let continuation = wantConfigContinuation { + continuation.resume() + } + + case UInt32(NONCE_ONLY_DB): + // Open the gate for the wantDatabaseContinuation + Task { await wantDatabaseGate.open() } + + // If we get the "done" for NONCE_ONLY_DB, but are still waiting for the first NodeInfo, + // Then the database is probably empty, and can continue + if let firstDatabaseNodeInfoContinuation { + firstDatabaseNodeInfoContinuation.resume() + self.firstDatabaseNodeInfoContinuation = nil + } + + default: + Logger.transport.error("[Accessory] Unknown nonce completed: \(configCompleteID)") + } + + case .rebooted: + // If we had an existing connection, then we can probably get away with just a wantConfig? + if state == .subscribed { + Task { try? await sendWantConfig() } + } + + default: + Logger.mesh.error("Unknown FromRadio variant: \(decodedInfo.payloadVariant.debugDescription)") + } + + } +} + +extension AccessoryManager { + var connectedVersion: String? { + return activeConnection?.device.firmwareVersion + } + + func checkIsVersionSupported(forVersion: String) -> Bool { + let myVersion = connectedVersion ?? "0.0.0" + let supportedVersion = UserDefaults.firmwareVersion == "0.0.0" || + forVersion.compare(myVersion, options: .numeric) == .orderedAscending || + forVersion.compare(myVersion, options: .numeric) == .orderedSame + return supportedVersion + } +} + +extension AccessoryManager { + func setupPeriodicHeartbeat() async { + if heartbeatTimer != nil { + Logger.transport.debug("💓 [Heartbeat] Cancelling existing heartbeat timer") + await self.heartbeatTimer?.cancel(withReason: "Duplicate setup, cancelling previous timer") + self.heartbeatTimer = nil + } + self.heartbeatTimer = ResettableTimer(isRepeating: true, debugName: "Send Heartbeat") { + Logger.transport.debug("💓 [Heartbeat] Sending periodic heartbeat") + try? await self.sendHeartbeat() + } + + // We can send heartbeats for older versions just fine, but only 2.7.4 and up will respond with + // a definite queueStatus packet. + if self.checkIsVersionSupported(forVersion: "2.7.4") { + self.heartbeatResponseTimer = ResettableTimer(isRepeating: false, debugName: "Heartbeat Timeout") { @MainActor in + Logger.transport.error("💓 [Heartbeat] Connection Timeout: Did not receive a packet after heartbeat.") + // If we're in the middle of a connection cancel it. + await self.connectionStepper?.cancel() + + // Close out the connection + if let activeConnection = self.activeConnection { + try? await activeConnection.connection.disconnect(withError: AccessoryError.timeout, shouldReconnect: true) + } else { + self.lastConnectionError = AccessoryError.timeout + try? await self.closeConnection() + } + } + } + await self.heartbeatTimer?.reset(delay: .seconds(60.0)) + } +} + +enum PossiblyAlreadyDoneContinuation { + case alreadyDone + case notDone(CheckedContinuation) +} + +extension AccessoryManager { + func appDidEnterBackground() { + if self.state == .uninitialized { return } + if let connection = self.activeConnection?.connection { + Logger.transport.info("[AccessoryManager] informing active connection that we are entering the background") + Task { await connection.appDidEnterBackground() } + } else { + Logger.transport.info("[AccessoryManager] suspending scanning while in the background") + stopDiscovery() + } + } + + func appDidBecomeActive() { + if self.state == .uninitialized { return } + if let connection = self.activeConnection?.connection { + Logger.transport.info("[AccessoryManager] informing previously active connection that we are active again") + Task { await connection.appDidBecomeActive() } + } else { + if self.discoveryTask == nil { + Logger.transport.info("[AccessoryManager] Previosuly in the background but not scanning, starting scanning again") + self.startDiscovery() + } + } + } +} diff --git a/Meshtastic/Accessory/Helpers/AsyncGate.swift b/Meshtastic/Accessory/Helpers/AsyncGate.swift new file mode 100644 index 00000000..7f20ce83 --- /dev/null +++ b/Meshtastic/Accessory/Helpers/AsyncGate.swift @@ -0,0 +1,59 @@ +// +// AsyncGate.swift +// Meshtastic +// +// Created by Jake on 8/20/25. +// + +import Foundation + +actor AsyncGate { + private var waiters: [UUID: CheckedContinuation] = [:] + private var isOpen = false + + /// Wait until the gate is opened. Respects cancellation. + func wait() async throws { + if isOpen { return } + + let id = UUID() + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + waiters[id] = cont + } + } onCancel: { + Task { [weak self] in + await self?.cancelWaiter(id: id) + } + } + } + + /// Opens the gate, resuming all current waiters. + func open() { + isOpen = true + for (_, cont) in waiters { + cont.resume() + } + waiters.removeAll() + } + + /// Cancels all current waiters with `CancellationError`. + func cancelAll() { + for (_, cont) in waiters { + cont.resume(throwing: CancellationError()) + } + waiters.removeAll() + } + + /// Resets the gate back to closed. + /// Future waiters will suspend again until `open()` is called. + func reset() { + isOpen = false + } + + private func cancelWaiter(id: UUID) { + if let cont = waiters.removeValue(forKey: id) { + cont.resume(throwing: CancellationError()) + } + } +} diff --git a/Meshtastic/Accessory/Helpers/LogRecord+StringRepresentation.swift b/Meshtastic/Accessory/Helpers/LogRecord+StringRepresentation.swift new file mode 100644 index 00000000..340bc557 --- /dev/null +++ b/Meshtastic/Accessory/Helpers/LogRecord+StringRepresentation.swift @@ -0,0 +1,30 @@ +// +// LogRecord+StringRepresentation.swift +// Meshtastic +// +// Created by Jake Bordens on 7/29/25. +// + +import Foundation +import MeshtasticProtobufs + +extension LogRecord { + var stringRepresentation: String { + var message = self.source.isEmpty ? self.message : "[\(self.source)] \(self.message)" + switch self.level { + case .debug: + message = "DEBUG | \(message)" + case .info: + message = "INFO | \(message)" + case .warning: + message = "WARN | \(message)" + case .error: + message = "ERROR | \(message)" + case .critical: + message = "CRIT | \(message)" + default: + message = "DEBUG | \(message)" + } + return message + } +} diff --git a/Meshtastic/Accessory/Helpers/ResettableTimer.swift b/Meshtastic/Accessory/Helpers/ResettableTimer.swift new file mode 100644 index 00000000..c1410d2b --- /dev/null +++ b/Meshtastic/Accessory/Helpers/ResettableTimer.swift @@ -0,0 +1,67 @@ +// +// ResettableTimer.swift +// Meshtastic +// +// Created by jake on 8/16/25. +// + +import Foundation // For Duration and Task (though often implicit in Swift environments) +import OSLog + +/// A resettable timer implemented using Swift concurrency. +/// The timer can optionally be set to repeat, executing the closure repeatedly at the specified interval. +/// Calling `reset` cancels any ongoing timer and starts a new one with the given delay. +/// For repeating timers, it will continue firing until explicitly cancelled. +actor ResettableTimer { + private var currentTask: Task? + private let action: @Sendable () async -> Void + private let isRepeating: Bool + private let debugName: String? + /// Initializes the timer with the closure to execute and whether it should repeat. + /// - Parameters: + /// - isRepeating: If true, the timer will repeat indefinitely until cancelled. Defaults to false for one-shot behavior. + /// - action: The closure to run after the delay elapses (and repeatedly if repeating). + init(isRepeating: Bool = false, debugName: String? = nil, action: @Sendable @escaping () async -> Void) { + self.isRepeating = isRepeating + self.action = action + self.debugName = debugName + } + + /// Resets the timer to a new delay, cancelling any previous scheduled execution. + /// - Parameter delay: The new delay duration before executing the action. + func reset(delay: Duration, withReason reason: String? = nil) { + if let debugName { + if let reason { + Logger.services.debug("⏱️ [\(debugName)] Resettable timer reset with new duration \(delay): \(reason)") + } else { + Logger.services.debug("⏱️ [\(debugName)] Resettable timer reset with new duration \(delay)") + } + } + currentTask?.cancel() + currentTask = Task { + repeat { + do { + try await Task.sleep(for: delay) + if Task.isCancelled { break } + await action() + } catch { + // Timer was cancelled or sleep interrupted; exit the loop. + break + } + } while isRepeating + } + } + + /// Cancels the timer without starting a new one. For repeating timers, this stops future executions. + func cancel(withReason reason: String? = nil) { + if let debugName { + if let reason { + Logger.services.debug("⏱️ [\(debugName)] Resettable timer cancelled: \(reason)") + } else { + Logger.services.debug("⏱️ [\(debugName)] Resettable timer cancelled") + } + } + currentTask?.cancel() + currentTask = nil + } +} diff --git a/Meshtastic/Accessory/Protocols/Connection.swift b/Meshtastic/Accessory/Protocols/Connection.swift new file mode 100644 index 00000000..afc087c5 --- /dev/null +++ b/Meshtastic/Accessory/Protocols/Connection.swift @@ -0,0 +1,42 @@ +// +// Connection.swift +// Meshtastic +// +// Created by Jake Bordens on 7/10/25. +// + +import Foundation +import MeshtasticProtobufs + +protocol Connection: Actor { + var type: TransportType { get } + + var isConnected: Bool { get } + func send(_ data: ToRadio) async throws + func connect() async throws -> AsyncStream + func disconnect(withError: Error?, shouldReconnect: Bool) throws + func drainPendingPackets() async throws + func startDrainPendingPackets() throws + + func appDidEnterBackground() + func appDidBecomeActive() +} + +enum ConnectionEvent { + case data(FromRadio) + case logMessage(String) + case rssiUpdate(Int) + case error(Error) + case errorWithoutReconnect(Error) + case disconnected(shouldReconnect: Bool) +} + +enum ConnectionState: Equatable { + case disconnected + case connecting + case connected +} + +enum ConnectionError: Error, LocalizedError { + case ioError(String) +} diff --git a/Meshtastic/Accessory/Protocols/Device.swift b/Meshtastic/Accessory/Protocols/Device.swift new file mode 100644 index 00000000..1f35d3b1 --- /dev/null +++ b/Meshtastic/Accessory/Protocols/Device.swift @@ -0,0 +1,53 @@ +// +// Device.swift +// Meshtastic +// +// Created by Jake Bordens on 7/10/25. +// + +import Foundation + +struct Device: Identifiable, Hashable { + let id: UUID + var name: String + var transportType: TransportType + var identifier: String // e.g., UUID for BLE, IP:port for TCP, port path for Serial + + var num: Int64? + var shortName: String? + var longName: String? + var firmwareVersion: String? + var rssi: Int? + var lastUpdate: Date? + + var connectionState: ConnectionState + + init(id: UUID, name: String, transportType: TransportType, identifier: String, connectionState: ConnectionState = .disconnected, rssi: Int? = nil) { + self.id = id + self.name = name + self.transportType = transportType + self.identifier = identifier + self.connectionState = connectionState + self.rssi = rssi + } + + var rssiString: String { + if let rssi { + return "\(rssi) dBm" + } else { + return "n/a" + } + } + + func getSignalStrength() -> BLESignalStrength? { + guard let rssi else { return nil } + if NSNumber(value: rssi).compare(NSNumber(-65)) == ComparisonResult.orderedDescending { + return BLESignalStrength.strong + } else if NSNumber(value: rssi).compare(NSNumber(-85)) == ComparisonResult.orderedDescending { + return BLESignalStrength.normal + } else { + return BLESignalStrength.weak + } + } + +} diff --git a/Meshtastic/Accessory/Protocols/Transport.swift b/Meshtastic/Accessory/Protocols/Transport.swift new file mode 100644 index 00000000..0c5cce08 --- /dev/null +++ b/Meshtastic/Accessory/Protocols/Transport.swift @@ -0,0 +1,84 @@ +// +// Transport.swift +// Meshtastic +// +// Created by Jake Bordens on 7/10/25. +// + +import Foundation +import CommonCrypto +import SwiftUI + +enum TransportType: String, CaseIterable { + case ble = "BLE" + case tcp = "TCP" + case serial = "Serial" + + var icon: Image { + switch self { + case .ble: + Image("custom.bluetooth") + case .tcp: + Image(systemName: "network") + case .serial: + Image(systemName: "cable.connector.horizontal") + } + } +} + +enum TransportStatus: Equatable { + case uninitialized + case ready + case discovering + case error(String) +} + +enum DiscoveryEvent { + case deviceFound(Device) + case deviceUpdated(Device) + case deviceLost(UUID) + case deviceReportedRssi(UUID, Int) +} + +protocol Transport { + var type: TransportType { get } + var status: TransportStatus { get } + + // Discovers devices asynchronously. For ongoing scans (e.g., BLE), this can yield via AsyncStream. + func discoverDevices() -> AsyncStream + + // Connects to a device and returns a Connection. + func connect(to device: Device) async throws -> any Connection + + var requiresPeriodicHeartbeat: Bool { get } + var supportsManualConnection: Bool { get } + + func manuallyConnect(withConnectionString: String) async throws +} + +// Used to make stable-ish ID's for accessories that don't have a UUID +extension String { + func toUUIDFormatHash() -> UUID? { + // Convert string to data + guard let data = self.data(using: .utf8) else { return nil } + + // Create buffer for SHA-256 hash (32 bytes) + var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + + // Perform SHA-256 hashing + _ = data.withUnsafeBytes { buffer in + CC_SHA256(buffer.baseAddress, CC_LONG(buffer.count), &digest) + } + + // Take first 16 bytes (128 bits) for UUID + let uuidBytes = Array(digest.prefix(16)) + + // Create UUID from bytes + return UUID(uuid: ( + uuidBytes[0], uuidBytes[1], uuidBytes[2], uuidBytes[3], + uuidBytes[4], uuidBytes[5], uuidBytes[6], uuidBytes[7], + uuidBytes[8], uuidBytes[9], uuidBytes[10], uuidBytes[11], + uuidBytes[12], uuidBytes[13], uuidBytes[14], uuidBytes[15] + )) + } +} diff --git a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEAuthorizationHelper.swift b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEAuthorizationHelper.swift new file mode 100644 index 00000000..010711ad --- /dev/null +++ b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEAuthorizationHelper.swift @@ -0,0 +1,75 @@ +// +// BluetoothAuthorizationHelper.swift +// Meshtastic +// +// Created by Jake Bordens on 7/31/25. +// + +import Foundation +import CoreBluetooth + +/// A helper class to manage the CoreBluetooth delegate callbacks. +/// This is necessary because CBCentralManagerDelegate requires an NSObject. +class BluetoothAuthorizationHelper: NSObject, CBCentralManagerDelegate { + + /// The continuation to resume when the authorization status is determined. + private var continuation: CheckedContinuation? + + /// The CoreBluetooth central manager. + private var centralManager: CBCentralManager? + + /// Requests Bluetooth authorization and awaits the user's response. + func requestAuthorization() async -> Bool { + await withCheckedContinuation { continuation in + self.continuation = continuation + + // Initializing the CBCentralManager triggers the permission prompt if needed. + // The delegate method will be called with the result. + // The manager must be retained for the delegate callbacks to occur. + self.centralManager = CBCentralManager(delegate: self, queue: nil) + } + } + + /// The delegate method that receives state updates from the CBCentralManager. + func centralManagerDidUpdateState(_ central: CBCentralManager) { + switch central.state { + case .poweredOn: + // Success: User has granted permission and Bluetooth is on. + continuation?.resume(returning: true) + + case .unauthorized: + // Failure: User has explicitly denied permission. + continuation?.resume(returning: false) + + case .poweredOff: + // Failure: User needs to turn on Bluetooth in Settings. + // For the purpose of this function, the app cannot use BLE. + continuation?.resume(returning: false) + + case .unsupported: + // Failure: This device does not support Bluetooth Low Energy. + continuation?.resume(returning: false) + + case .resetting, .unknown: + // The state is temporary or unknown. We wait for the next state update. + // Do nothing and let the continuation live. + break + + @unknown default: + // Handle any future cases gracefully. + continuation?.resume(returning: false) + } + + // Clean up to prevent resuming more than once. + self.continuation = nil + } + + /// A static function to provide a clean call site. + static func requestBluetoothAuthorization() async -> Bool { + // Create an instance of the helper class. + // The instance will be retained until the async operation completes. + let authorizer = BluetoothAuthorizationHelper() + return await authorizer.requestAuthorization() + } + +} diff --git a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift new file mode 100644 index 00000000..a4381433 --- /dev/null +++ b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift @@ -0,0 +1,513 @@ +// +// BLEConnection.swift +// Meshtastic +// +// Created by Jake Bordens on 7/10/25. +// + +import Foundation +@preconcurrency import CoreBluetooth +import OSLog +import MeshtasticProtobufs + +let meshtasticServiceCBUUID = CBUUID(string: "0x6BA1B218-15A8-461F-9FA8-5DCAE273EAFD") +let TORADIO_UUID = CBUUID(string: "0xF75C76D2-129E-4DAD-A1DD-7866124401E7") +let FROMRADIO_UUID = CBUUID(string: "0x2C55E69E-4993-11ED-B878-0242AC120002") +let FROMNUM_UUID = CBUUID(string: "0xED9DA18C-A800-4F66-A670-AA7547E34453") +let LOGRADIO_UUID = CBUUID(string: "0x5a3d6e49-06e6-4423-9944-e9de8cdf9547") + +extension CBCharacteristic { + + var meshtasticCharacteristicName: String { + switch self.uuid { + case TORADIO_UUID: + return "TORADIO" + case FROMRADIO_UUID: + return "FROMRADIO" + case FROMNUM_UUID: + return "FROMNUM" + case LOGRADIO_UUID: + return "LOGRADIO" + default: + return "UNKNOWN (\(self.uuid.uuidString))" + } + } +} + +actor BLEConnection: Connection { + let type = TransportType.ble + + var delegate: BLEConnectionDelegate + var peripheral: CBPeripheral + var central: CBCentralManager + private var needsDrain: Bool = false + private var isDraining: Bool = false + + fileprivate var TORADIO_characteristic: CBCharacteristic? + fileprivate var FROMRADIO_characteristic: CBCharacteristic? + fileprivate var FROMNUM_characteristic: CBCharacteristic? + fileprivate var LOGRADIO_characteristic: CBCharacteristic? + + private var connectionStreamContinuation: AsyncStream.Continuation? + + private var connectContinuation: CheckedContinuation? + private var writeContinuations: [CheckedContinuation] + private var readContinuations: [CheckedContinuation] + + private var rssiTask: Task? + + var isConnected: Bool { peripheral.state == .connected } + var transport: BLETransport? + + init(peripheral: CBPeripheral, central: CBCentralManager, transport: BLETransport) { + self.peripheral = peripheral + self.central = central + self.transport = transport + self.delegate = BLEConnectionDelegate(peripheral: peripheral) + self.writeContinuations = [] + self.readContinuations = [] + self.delegate.setConnection(self) + } + + func disconnect(withError error: Error? = nil, shouldReconnect: Bool) throws { + if peripheral.state == .connected { + if let characteristic = FROMRADIO_characteristic { + peripheral.setNotifyValue(false, for: characteristic) + } + if let characteristic = FROMNUM_characteristic { + peripheral.setNotifyValue(false, for: characteristic) + } + if let characteristic = LOGRADIO_characteristic { + peripheral.setNotifyValue(false, for: characteristic) + } + } + + transport?.connectionDidDisconnect() + + central.cancelPeripheralConnection(peripheral) + peripheral.delegate = nil + + while !writeContinuations.isEmpty { + let writeContinuation = writeContinuations.removeFirst() + writeContinuation.resume(throwing: AccessoryError.disconnected("Unknown error")) + } + + while !readContinuations.isEmpty { + let readContinuation = readContinuations.removeFirst() + readContinuation.resume(throwing: AccessoryError.disconnected("Unknown error")) + } + + if let error { + // Inform the AccessoryManager of the error and intent to reconnect + if shouldReconnect { + connectionStreamContinuation?.yield(.error(error)) + } else { + connectionStreamContinuation?.yield(.errorWithoutReconnect(error)) + } + } else { + connectionStreamContinuation?.yield(.disconnected(shouldReconnect: shouldReconnect)) + } + + connectionStreamContinuation?.finish() + connectionStreamContinuation = nil + + rssiTask?.cancel() + rssiTask = nil + } + + func startDrainPendingPackets() throws { + guard isConnected else { + throw AccessoryError.ioFailed("Not connected") + } + needsDrain = true + if !isDraining { + Task { + isDraining = true + defer { isDraining = false } + while needsDrain { + needsDrain = false + do { + try await drainPendingPackets() + } catch { + // Handle or log error as needed; for now, just continue to allow retry on next notification + } + } + } + } + } + + func drainPendingPackets() async throws { + guard isConnected else { + throw AccessoryError.ioFailed("Not connected") + } + repeat { + do { + let data = try await read() + + if data.count == 0 { + break + } + + let decodedInfo = try FromRadio(serializedBytes: data) + connectionStreamContinuation?.yield(.data(decodedInfo)) + } catch { + try? await self.disconnect(withError: error, shouldReconnect: true) + throw error // Re-throw to propagate up to the caller for handling + } + } while true + } + + func didReceiveLogMessage(_ logMessage: String) { + self.connectionStreamContinuation?.yield(.logMessage(logMessage)) + } + + func didUpdateRssi(_ rssi: Int) { + self.connectionStreamContinuation?.yield(.rssiUpdate(rssi)) + } + + func getPacketStream() -> AsyncStream { + AsyncStream { continuation in + self.connectionStreamContinuation = continuation + } + } + + func discoverServices() async throws { + try await withCheckedThrowingContinuation { cont in + self.connectContinuation = cont + peripheral.discoverServices([meshtasticServiceCBUUID]) + } + } + + func connect() async throws -> AsyncStream { + if self.peripheral.state != .connected { + throw AccessoryError.ioFailed("BLE peripheral not connected") + } + return try await withTaskCancellationHandler { + try await discoverServices() + startRSSITask() + return self.getPacketStream() + } onCancel: { + Task { + await continueConnectionProcess(throwing: CancellationError()) + await self.transport?.connectionDidDisconnect() + } + } + } + + private func continueConnectionProcess(throwing error: Error? = nil) { + if let error { + self.connectContinuation?.resume(throwing: error) + } else { + self.connectContinuation?.resume() + } + self.connectContinuation = nil + } + + func startRSSITask() { + if let task = self.rssiTask { + task.cancel() + } + self.rssiTask = Task { + do { + while !Task.isCancelled { + try await Task.sleep(for: .seconds(10)) + peripheral.readRSSI() + } + } catch { + + } + } + } + + func didDiscoverServices(error: Error? ) { + if let error = error { + self.continueConnectionProcess(throwing: error) + return + } + + guard let services = peripheral.services else { + self.continueConnectionProcess(throwing: AccessoryError.discoveryFailed("No services found")) + return + } + + var foundMeshtasticService = false + for service in services where service.uuid == meshtasticServiceCBUUID { + foundMeshtasticService = true + peripheral.discoverCharacteristics([TORADIO_UUID, FROMRADIO_UUID, FROMNUM_UUID, LOGRADIO_UUID], for: service) + Logger.transport.info("🛜 [BLE] Service for Meshtastic discovered by \(self.peripheral.name ?? "Unknown", privacy: .public)") + } + + if !foundMeshtasticService { + self.continueConnectionProcess(throwing: AccessoryError.discoveryFailed("Meshtastic service not found")) + } + } + + func didDiscoverCharacteristicsFor(service: CBService, error: Error?) { + if let error = error { + self.continueConnectionProcess(throwing: error) + return + } + guard let characteristics = service.characteristics else { + self.continueConnectionProcess(throwing: AccessoryError.discoveryFailed("No characteristics")) + return + } + + for characteristic in characteristics { + switch characteristic.uuid { + case TORADIO_UUID: + Logger.transport.info("🛜 [BLE] did discover TORADIO characteristic for Meshtastic by \(self.peripheral.name ?? "Unknown", privacy: .public)") + TORADIO_characteristic = characteristic + + case FROMRADIO_UUID: + Logger.transport.info("🛜 [BLE] did discover FROMRADIO characteristic for Meshtastic by \(self.peripheral.name ?? "Unknown", privacy: .public)") + FROMRADIO_characteristic = characteristic + self.peripheral.setNotifyValue(true, for: characteristic) + + case FROMNUM_UUID: + Logger.transport.info("🛜 [BLE] did discover FROMNUM (Notify) characteristic for Meshtastic by \(self.peripheral.name ?? "Unknown", privacy: .public)") + FROMNUM_characteristic = characteristic + self.peripheral.setNotifyValue(true, for: characteristic) + + case LOGRADIO_UUID: + Logger.transport.info("🛜 [BLE] did discover LOGRADIO (Notify) characteristic for Meshtastic by \(self.peripheral.name ?? "Unknown", privacy: .public)") + LOGRADIO_characteristic = characteristic + self.peripheral.setNotifyValue(true, for: characteristic) + + default: + Logger.transport.info("🛜 [BLE] did discover unsupported \(characteristic.uuid) characteristic for Meshtastic by \(self.peripheral.name ?? "Unknown", privacy: .public)") + } + } + + if TORADIO_characteristic != nil && FROMRADIO_characteristic != nil && FROMNUM_characteristic != nil { + Logger.transport.info("🛜 [BLE] characteristics ready") + self.continueConnectionProcess() + + // Read initial RSSI on ready + peripheral.readRSSI() + } else { + Logger.transport.info("🛜 [BLE] Missing required characteristics") + self.continueConnectionProcess(throwing: AccessoryError.discoveryFailed("Missing required characteristics")) + } + } + + func didUpdateValueFor(characteristic: CBCharacteristic, error: Error?) { + if let error = error { + if characteristic.uuid == FROMRADIO_UUID { + Logger.transport.debug("🛜 [BLE] Error updating value for \(characteristic.meshtasticCharacteristicName, privacy: .public): \(error)") + if !readContinuations.isEmpty { + let readContinuation = self.readContinuations.removeFirst() + readContinuation.resume(throwing: error) + } + } + Task { try await self.handlePeripheralError(error: error) } + return + } + Logger.transport.debug("🛜 [BLE] Did update value for \(characteristic.meshtasticCharacteristicName, privacy: .public)=\(characteristic.value ?? Data(), privacy: .public)") + + guard let value = characteristic.value else { return } + + switch characteristic.uuid { + case FROMRADIO_UUID: + if !readContinuations.isEmpty { + let readContinuation = self.readContinuations.removeFirst() + readContinuation.resume(returning: value) + } + case FROMNUM_UUID: + try? startDrainPendingPackets() + + case LOGRADIO_UUID: + if let value = characteristic.value, + let logRecord = try? LogRecord(serializedBytes: value) { + self.didReceiveLogMessage(logRecord.stringRepresentation) + } + + default: + break + } + } + + func didWriteValueFor(characteristic: CBCharacteristic, error: Error?) { + guard characteristic.uuid == TORADIO_UUID else { + Logger.transport.error("🛜 [BLE] didWriteValueFor a characteristic other than TORADIO_UUID. Should not happen!") + return + } + guard !writeContinuations.isEmpty else { + Logger.transport.error("🛜 [BLE] didWriteValueFor with no waiting continuations. Should not happen!") + return + } + + let writeContinuation = writeContinuations.removeFirst() + + if let error = error { + Logger.transport.error("🛜 [BLE] Did write for \(characteristic.meshtasticCharacteristicName, privacy: .public) with error \(error, privacy: .public)") + writeContinuation.resume(throwing: error) + Task { try await self.handlePeripheralError(error: error) } + } else { + #if DEBUG + // Too much logging to report every write. + Logger.transport.error("🛜 [BLE] Did write for \(characteristic.meshtasticCharacteristicName, privacy: .public)") + #endif + writeContinuation.resume() + } + } + + func didReadRSSI(RSSI: NSNumber, error: Error?) { + if let error = error { + Logger.transport.error("🛜 [BLE] Error reading RSSI: \(error.localizedDescription)") + return + } + connectionStreamContinuation?.yield(.rssiUpdate(RSSI.intValue)) + } + + func send(_ data: ToRadio) async throws { + guard let characteristic = TORADIO_characteristic, isConnected else { + throw AccessoryError.ioFailed("Not connected or characteristic not found") + } + guard let binaryData = try? data.serializedData() else { + throw AccessoryError.ioFailed("Failed to serialize data") + } + guard characteristic.properties.contains(.write) || + characteristic.properties.contains(.writeWithoutResponse) else { + throw AccessoryError.ioFailed("Characteristic does not support write") + } + + let writeType: CBCharacteristicWriteType = characteristic.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse + try await withCheckedThrowingContinuation { newWriteContinuation in + if writeType == .withoutResponse { + peripheral.writeValue(binaryData, for: characteristic, type: writeType) + newWriteContinuation.resume() + } else { + writeContinuations.append(newWriteContinuation) + peripheral.writeValue(binaryData, for: characteristic, type: writeType) + } + } + } + + func read() async throws -> Data { + guard let FROMRADIO_characteristic else { + throw AccessoryError.ioFailed("No FROMRADIO_characteristic ") + } + let data: Data = try await withCheckedThrowingContinuation { newReadContinuation in + readContinuations.append(newReadContinuation) + peripheral.readValue(for: FROMRADIO_characteristic) + } + if data.isEmpty { + Logger.transport.debug("🛜 [BLE] Received empty data, ending drain operation.") + } + return data + } + + func handlePeripheralError(error: Error) async throws { + var shouldReconnect = false + switch error { + case let cbError as CBError: + switch cbError.code { + case .unknown: // 0 + Logger.transport.error("🛜 [BLEConnection] Disconnected due to unknown error.") + case .invalidParameters: // 1 + Logger.transport.error("🛜 [BLEConnection] Disconnected due to invalid parameters.") + case .invalidHandle: // 2 + Logger.transport.error("🛜 [BLEConnection] Disconnected due to invalid handle.") + case .notConnected: // 3 + Logger.transport.error("🛜 [BLEConnection] Disconnected because device was not connected.") + case .outOfSpace: // 4 + Logger.transport.error("🛜 [BLEConnection] Disconnected due to out of space.") + case .operationCancelled: // 5 + Logger.transport.error("🛜 [BLEConnection] Disconnected due to operation cancelled.") + case .connectionTimeout: // 6 + // Should disconnect, show error, and retry when re-advertised + Logger.transport.error("🛜 [BLEConnection] Disconnected due to connection timeout.") + shouldReconnect = true + case .peripheralDisconnected: // 7 + // Likely prompting for a PIN + // Should disconnect, show error, and retry when re-advertised + Logger.transport.error("🛜 [BLEConnection] Disconnected by peripheral.") + shouldReconnect = true + case .uuidNotAllowed: // 8 + Logger.transport.error("🛜 [BLEConnection] Disconnected due to UUID not allowed.") + case .alreadyAdvertising: // 9 + Logger.transport.error("🛜 [BLEConnection] Disconnected because already advertising.") + case .connectionFailed: // 10 + Logger.transport.error("🛜 [BLEConnection] Disconnected due to connection failure.") + case .connectionLimitReached: // 11 + Logger.transport.error("🛜 [BLEConnection] Disconnected due to connection limit reached.") + case .unknownDevice, .unkownDevice: // 12 + Logger.transport.error("🛜 [BLEConnection] Disconnected due to unknown device.") + case .operationNotSupported: // 13 + Logger.transport.error("🛜 [BLEConnection] Disconnected due to operation not supported.") + case .peerRemovedPairingInformation: // 14 + // Should disconnect and not retry + Logger.transport.error("🛜 [BLEConnection] Disconnected because peer removed pairing information.") + case .encryptionTimedOut: // 15 + Logger.transport.error("🛜 [BLEConnection] Disconnected due to encryption timeout.") + case .tooManyLEPairedDevices: // 16 + Logger.transport.error("🛜 [BLEConnection] Disconnected due to too many LE paired devices.") + + // leGatt cases are watchOS only + case .leGattExceededBackgroundNotificationLimit: // 17 + Logger.transport.error("🛜 [BLEConnection] Disconnected due to exceeding LE GATT background notification limit.") + case .leGattNearBackgroundNotificationLimit: // 18 + Logger.transport.error("🛜 [BLEConnection] Disconnected due to nearing LE GATT background notification limit.") + + @unknown default: + Logger.transport.error("🛜 [BLEConnection] Disconnected due to unknown future error code: \(cbError.code.rawValue)") + } + case let otherError: + Logger.transport.error("🛜 [BLEConnection] Disconnected with non-CBError: \(otherError.localizedDescription)") + } + + // Inform the active connection that there was an error and it should disconnect + try self.disconnect(withError: error, shouldReconnect: shouldReconnect) + } + + func appDidEnterBackground() { + if let task = self.rssiTask { + Logger.transport.info("🛜 [BLE] App is entering the background, suspending RSSI reports.") + task.cancel() + self.rssiTask = nil + } + } + + func appDidBecomeActive() { + if self.rssiTask == nil { + Logger.transport.info("🛜 [BLE] App is active, restarting RSSI reports.") + self.startRSSITask() + } + } +} + +class BLEConnectionDelegate: NSObject, CBPeripheralDelegate { + private weak var connection: BLEConnection? + let peripheral: CBPeripheral + + init(peripheral: CBPeripheral) { + self.peripheral = peripheral + super.init() + peripheral.delegate = self + } + + func setConnection(_ connection: BLEConnection) { + self.connection = connection + } + + // MARK: CBPeripheralDelegate + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + Task { await connection?.didDiscoverServices(error: error) } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + Task { await connection?.didDiscoverCharacteristicsFor(service: service, error: error) } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + Task { await connection?.didUpdateValueFor(characteristic: characteristic, error: error) } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + Task { await connection?.didWriteValueFor(characteristic: characteristic, error: error) } + } + + func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) { + Task { await connection?.didReadRSSI(RSSI: RSSI, error: error) } + } +} diff --git a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift new file mode 100644 index 00000000..0f8e7333 --- /dev/null +++ b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift @@ -0,0 +1,394 @@ +// +// BLETransport.swift +// Meshtastic +// +// Created by Jake Bordens on 7/10/25. +// + +import Foundation +@preconcurrency import CoreBluetooth +import SwiftUI +import OSLog + +class BLETransport: Transport { + + let meshtasticServiceCBUUID = CBUUID(string: "0x6BA1B218-15A8-461F-9FA8-5DCAE273EAFD") + + let type: TransportType = .ble + private var centralManager: CBCentralManager? + private var discoveredPeripherals: [UUID: (peripheral: CBPeripheral, lastSeen: Date)] = [:] + private var discoveredDeviceContinuation: AsyncStream.Continuation? + private let delegate: BLEDelegate + private var connectingPeripheral: CBPeripheral? + private var activeConnection: BLEConnection? + private var connectContinuation: CheckedContinuation? + private var setupCompleteContinuation: CheckedContinuation? + + var status: TransportStatus = .uninitialized + + private var cleanupTask: Task? + + // Transport properties + var supportsManualConnection: Bool = false + let requiresPeriodicHeartbeat = false + + init() { + self.centralManager = nil + self.discoveredPeripherals = [:] + self.discoveredDeviceContinuation = nil + self.delegate = BLEDelegate() + self.delegate.setTransport(self) + } + + nonisolated func discoverDevices() -> AsyncStream { + AsyncStream { cont in + Task { + self.discoveredDeviceContinuation = cont + if self.centralManager == nil { + try await self.setupCentralManager() + } + centralManager?.scanForPeripherals(withServices: [meshtasticServiceCBUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]) + + setupCleanupTask() + } + cont.onTermination = { _ in + Logger.transport.error("🛜 [BLE] Discovery event stream has been canecelled.") + self.stopScanning() + } + } + } + + private func setupCleanupTask() { + if let task = self.cleanupTask { + task.cancel() + } + self.cleanupTask = Task { + while !Task.isCancelled { + var keysToRemove: [UUID] = [] + for (deviceId, discoveryEntry) in self.discoveredPeripherals + where Date().timeIntervalSince(discoveryEntry.lastSeen) > 30 { + keysToRemove.append(deviceId) + } + for deviceId in keysToRemove { + self.discoveredDeviceContinuation?.yield(.deviceLost(deviceId)) + self.discoveredPeripherals.removeValue(forKey: deviceId) + } + + try? await Task.sleep(for: .seconds(15)) // Cleanup every 15 seconds + } + Logger.transport.debug("🛜 [BLE] Discovery clean up task has been canecelled.") + } + } + + private func setupCentralManager() async throws { + try await withCheckedThrowingContinuation { cont in + self.setupCompleteContinuation = cont + centralManager = CBCentralManager(delegate: delegate, queue: .global()) + } + } + + private func stopScanning() { + Logger.transport.debug("🛜 [BLE] Stop Scanning: BLE Discovery has been stopped.") + centralManager?.stopScan() + discoveredPeripherals.removeAll() + discoveredDeviceContinuation = nil + if let state = centralManager?.state, state == .poweredOn { + status = .ready + } else { + status = .uninitialized + } + centralManager = nil + cleanupTask?.cancel() + cleanupTask = nil + } + + func handleCentralState(_ state: CBManagerState, central: CBCentralManager) { + Logger.transport.error("🛜 [BLE] State has transitioned to: \(cbManagerStateDescription(state), privacy: .public)") + switch state { + case .poweredOn: + if activeConnection != nil { + Logger.transport.info("🛜 [BLE] CBManager has poweredOn with an already active connection") + } + status = .discovering + self.setupCompleteContinuation?.resume() + self.setupCompleteContinuation = nil + + if self.discoveredDeviceContinuation != nil { + // We have someone already subscribed to our discovery event stream. + // Likely a powerOff event occcurred and need to now restore scanning. + central.scanForPeripherals(withServices: [meshtasticServiceCBUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]) + } + + case .poweredOff: + status = .error("Bluetooth is powered off") + if let connection = activeConnection { + Task { + Logger.transport.error("🛜 [BLE] Bluetooth has powered off during active connection. Cleaning up.") + try await connection.disconnect(withError: AccessoryError.disconnected("Bluetooth powered off"), shouldReconnect: true) + self.activeConnection = nil + } + } + status = .ready + self.setupCompleteContinuation?.resume(throwing: AccessoryError.connectionFailed("Bluetooth is powered off")) + self.setupCompleteContinuation = nil + + case .unauthorized: + status = .error("Bluetooth access is unauthorized") + self.setupCompleteContinuation?.resume(throwing: AccessoryError.connectionFailed("Bluetooth is unauthorized")) + self.setupCompleteContinuation = nil + + case .unsupported: + status = .error("Bluetooth is unsupported on this device") + self.setupCompleteContinuation?.resume(throwing: AccessoryError.connectionFailed("Bluetooth is unsupported")) + self.setupCompleteContinuation = nil + + case .resetting: + status = .error("Bluetooth is resetting") + // Perhaps don't finish, wait for next state + + case .unknown: + status = .error("Bluetooth state is unknown") + // Perhaps wait + @unknown default: + status = .error("Unknown Bluetooth state") + self.setupCompleteContinuation?.resume(throwing: AccessoryError.connectionFailed("Unknown Bluetooth State")) + self.setupCompleteContinuation = nil + } + } + + func didDiscover(peripheral: CBPeripheral, rssi: NSNumber) { + let id = peripheral.identifier + let isNew = discoveredPeripherals[id] == nil + if isNew { + discoveredPeripherals[id] = (peripheral, Date()) + } + let device = Device(id: id, + name: peripheral.name ?? "Unknown", + transportType: .ble, + identifier: id.uuidString, + rssi: rssi.intValue) + if isNew { + Logger.transport.debug("🛜 [BLE] Did Discover new device: \(peripheral.name ?? "Unknown", privacy: .public) (\(peripheral.identifier, privacy: .public))") + discoveredDeviceContinuation?.yield(.deviceFound(device)) + } else { + let rssiVal = rssi.intValue + let deviceId = id + discoveredPeripherals[id]?.lastSeen = Date() + discoveredDeviceContinuation?.yield(.deviceReportedRssi(deviceId, rssiVal)) + } + } + + func connect(to device: Device) async throws -> any Connection { + guard let peripheral = discoveredPeripherals[UUID(uuidString: device.identifier)!] else { + throw AccessoryError.connectionFailed("Peripheral not found") + } + guard let cm = centralManager else { + throw AccessoryError.connectionFailed("Central manager not available") + } + + if await self.activeConnection?.peripheral.state == .disconnected { + Logger.transport.error("🛜 [BLE] Connect request while an active (but disconnected)") + throw AccessoryError.connectionFailed("Connect request while an active connection exists") + } + + let returnConnection = try await withTaskCancellationHandler { + let newConnection: BLEConnection = try await withCheckedThrowingContinuation { cont in + if self.connectContinuation != nil || self.activeConnection != nil { + cont.resume(throwing: AccessoryError.connectionFailed("BLE transport is busy: already connecting or connected")) + return + } + self.connectContinuation = cont + self.connectingPeripheral = peripheral.peripheral + cm.connect(peripheral.peripheral) + } + self.activeConnection = newConnection + return newConnection + } onCancel: { + self.connectContinuation?.resume(throwing: CancellationError()) + self.connectContinuation = nil + self.activeConnection = nil + self.connectingPeripheral = nil + } + Logger.transport.debug("🛜 [BLE] Connect complete.") + return returnConnection + } + + func handlePeripheralDisconnect(peripheral: CBPeripheral) { + if let connection = self.activeConnection { + discoveredPeripherals.removeValue(forKey: peripheral.identifier) + discoveredDeviceContinuation?.yield(.deviceLost(peripheral.identifier)) + Task { + if await connection.peripheral.identifier == peripheral.identifier { + try await connection.disconnect(withError: AccessoryError.disconnected("BLE connection lost"), shouldReconnect: true) + self.activeConnection = nil + } + } + } + } + + func handlePeripheralDisconnectError(peripheral: CBPeripheral, error: Error) { + var shouldReconnect = false + switch error { + case let cbError as CBError: + switch cbError.code { + case .unknown: // 0 + Logger.transport.error("🛜 [BLETransport] Disconnected due to unknown error.") + case .invalidParameters: // 1 + Logger.transport.error("🛜 [BLETransport] Disconnected due to invalid parameters.") + case .invalidHandle: // 2 + Logger.transport.error("🛜 [BLETransport] Disconnected due to invalid handle.") + case .notConnected: // 3 + Logger.transport.error("🛜 [BLETransport] Disconnected because device was not connected.") + case .outOfSpace: // 4 + Logger.transport.error("🛜 [BLETransport] Disconnected due to out of space.") + case .operationCancelled: // 5 + Logger.transport.error("🛜 [BLETransport] Disconnected due to operation cancelled.") + case .connectionTimeout: // 6 + // Should disconnect, show error, and retry when re-advertised + Logger.transport.error("🛜 [BLETransport] Disconnected due to connection timeout.") + shouldReconnect = true + case .peripheralDisconnected: // 7 + // Likely prompting for a PIN + // Should disconnect, show error, and retry when re-advertised + Logger.transport.error("🛜 [BLETransport] Disconnected by peripheral.") + shouldReconnect = true + case .uuidNotAllowed: // 8 + Logger.transport.error("🛜 [BLETransport] Disconnected due to UUID not allowed.") + case .alreadyAdvertising: // 9 + Logger.transport.error("🛜 [BLETransport] Disconnected because already advertising.") + case .connectionFailed: // 10 + Logger.transport.error("🛜 [BLETransport] Disconnected due to connection failure.") + case .connectionLimitReached: // 11 + Logger.transport.error("🛜 [BLETransport] Disconnected due to connection limit reached.") + case .unknownDevice, .unkownDevice: // 12 + Logger.transport.error("🛜 [BLETransport] Disconnected due to unknown device.") + case .operationNotSupported: // 13 + Logger.transport.error("🛜 [BLETransport] Disconnected due to operation not supported.") + case .peerRemovedPairingInformation: // 14 + // Should disconnect and not retry + Logger.transport.error("🛜 [BLETransport] Disconnected because peer removed pairing information.") + case .encryptionTimedOut: // 15 + Logger.transport.error("🛜 [BLETransport] Disconnected due to encryption timeout.") + case .tooManyLEPairedDevices: // 16 + Logger.transport.error("🛜 [BLETransport] Disconnected due to too many LE paired devices.") + + // leGatt cases are watchOS only + case .leGattExceededBackgroundNotificationLimit: // 17 + Logger.transport.error("🛜 [BLETransport] Disconnected due to exceeding LE GATT background notification limit.") + case .leGattNearBackgroundNotificationLimit: // 18 + Logger.transport.error("🛜 [BLETransport] Disconnected due to nearing LE GATT background notification limit.") + + @unknown default: + Logger.transport.error("🛜 [BLETransport] Disconnected due to unknown future error code: \(cbError.code.rawValue)") + } + case let otherError: + Logger.transport.error("🛜 [BLETransport] Disconnected with non-CBError: \(otherError.localizedDescription)") + } + + if let continuation = self.connectContinuation { + Logger.transport.debug("🛜 [BLETransport] Error while connecting. Resuming connection continuation with error.") + continuation.resume(throwing: error) + self.connectContinuation = nil + } else if let activeConnection = self.activeConnection { + // Inform the active connection that there was an error and it should disconnect + Logger.transport.debug("🛜 [BLETransport] Error while connecting. Disconnecting the active connection.") + Task { + try? await activeConnection.disconnect(withError: error, shouldReconnect: shouldReconnect) + self.activeConnection = nil + } + } else { + Logger.transport.error("🚨 [BLETransport] unhandled error. May be in an inconsistent state.") + } + } + + func handleDidConnect(peripheral: CBPeripheral, central: CBCentralManager) { + Logger.transport.debug("🛜 [BLE] Handle Did Connect Connected to peripheral \(peripheral.name ?? "Unknown", privacy: .public)") + guard let cont = connectContinuation, + let connPeripheral = connectingPeripheral, + peripheral.identifier == connPeripheral.identifier else { + return + } + let connection = BLEConnection(peripheral: peripheral, central: central, transport: self) + cont.resume(returning: connection) + self.connectContinuation = nil + self.connectingPeripheral = nil + } + + func handleDidFailToConnect(peripheral: CBPeripheral, error: Error?) { + guard let cont = connectContinuation, + let connPeripheral = connectingPeripheral, + peripheral.identifier == connPeripheral.identifier else { + return + } + cont.resume(throwing: error ?? AccessoryError.connectionFailed("Connection failed")) + self.connectContinuation = nil + self.connectingPeripheral = nil + } + + func handleWillRestoreState(dict: [String: Any]) { + Logger.transport.debug("🛜 [BLE] Will Restore State was called, unhandled. \(dict, privacy: .public)") + } + + func manuallyConnect(withConnectionString: String) async throws { + Logger.transport.error("🛜 [BLE] This transport does not support manual connections") + } + + // BLETransport handles portions of the connection process, so it needs to be informed that we've closed up shop. + func connectionDidDisconnect() { + self.activeConnection = nil + self.connectingPeripheral = nil + } +} + +class BLEDelegate: NSObject, CBCentralManagerDelegate { + private weak var transport: BLETransport? + + override init() { + super.init() + } + + func setTransport(_ transport: BLETransport) { + self.transport = transport + } + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + transport?.handleCentralState(central.state, central: central) + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { + transport?.didDiscover(peripheral: peripheral, rssi: RSSI) + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + transport?.handleDidConnect(peripheral: peripheral, central: central) + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + transport?.handleDidFailToConnect(peripheral: peripheral, error: error) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + if let error = error as? NSError { + transport?.handlePeripheralDisconnectError(peripheral: peripheral, error: error) + } else { + transport?.handlePeripheralDisconnect(peripheral: peripheral) + } + } + +// func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { +// self.transport?.handleWillRestoreState(dict: dict) +// } +} + +/// Returns a human-readable description for a CBManagerState value. +private func cbManagerStateDescription(_ state: CBManagerState) -> String { + switch state { + case .unknown: return "unknown" + case .resetting: return "resetting" + case .unsupported: return "unsupported" + case .unauthorized: return "unauthorized" + case .poweredOff: return "poweredOff" + case .poweredOn: return "poweredOn" + @unknown default: return "unhandled state" + } +} diff --git a/Meshtastic/Accessory/Transports/Serial/SerialConnection.swift b/Meshtastic/Accessory/Transports/Serial/SerialConnection.swift new file mode 100644 index 00000000..6b538b5e --- /dev/null +++ b/Meshtastic/Accessory/Transports/Serial/SerialConnection.swift @@ -0,0 +1,263 @@ +// +// SerialConnection.swift +// Meshtastic +// +// Created by Jake Bordens on 7/22/25. +// +#if targetEnvironment(macCatalyst) +import Foundation +import OSLog +import MeshtasticProtobufs +import Darwin.POSIX.termios + +/// Custom error type for serial connection handling. +private enum SerialError: Error, LocalizedError { + case eof + case ioFailed(String) + case notConnected + case invalidPacketLength(UInt16) + + var errorDescription: String? { + switch self { + case .eof: + return "End of file reached." + case .ioFailed(let reason): + return "I/O Error: \(reason)" + case .notConnected: + return "Serial port not connected." + case .invalidPacketLength(let length): + return "Invalid packet length received: \(length)." + } + } +} + +actor SerialConnection: Connection { + let type = TransportType.serial + private let path: String + private var fd: Int32 = -1 + private var fileHandle: FileHandle? + private var isOpen: Bool = false + + // For DispatchSourceRead implementation + private var readSource: DispatchSourceRead? + private let readQueue = DispatchQueue(label: "com.meshtastic.serial.read") + private var readBuffer = Data() + + private var eventStreamContinuation: AsyncStream.Continuation? + + var isConnected: Bool { isOpen } + + init(path: String) { + self.path = path + } + + // MARK: - Reading Logic (DispatchSourceRead Implementation) + + /// Processes the internal buffer to find and yield complete packets. + /// This method is always called on the actor's context. + private func processBuffer() { + let startOfFrame: [UInt8] = [0x94, 0xc3] + + while !readBuffer.isEmpty { + guard let startIndex = readBuffer.firstRange(of: startOfFrame)?.lowerBound else { + readBuffer.removeAll() + return + } + + if startIndex > readBuffer.startIndex { + readBuffer.removeSubrange(readBuffer.startIndex..= 4 else { return } + + let lengthBytes = readBuffer.subdata(in: 2..<4) + let length = lengthBytes.withUnsafeBytes { $0.load(as: UInt16.self).bigEndian } + + let totalPacketLength = 4 + Int(length) + + guard readBuffer.count >= totalPacketLength else { return } + + let payload = readBuffer.subdata(in: 4.. 0 { + await self?.handleDataAvailable(bytesAvailable: Int(bytesAvailable)) + } else { + await self?.handleReaderEOF() + } + } + } + + // The cancellation handler also hops back to the actor to clean up. + source.setCancelHandler { [weak self] in + Task { + try? await self?.disconnect(withError: AccessoryError.disconnected("Serial connection lost"), shouldReconnect: true) + } + } + + source.resume() + } + + /// Reads available data from the file handle and processes it. + /// This method is always called on the actor's context via a Task. + private func handleDataAvailable(bytesAvailable: Int) { + guard isOpen, let fileHandle = self.fileHandle else { + readSource?.cancel() + return + } + + do { + if let data = try fileHandle.read(upToCount: bytesAvailable) { + if !data.isEmpty { + appendAndProcess(data: data) + } else { + handleReaderEOF() + } + } + } catch { + Logger.transport.error("🔱 [Serial] Read error: \(error, privacy: .public)") + handleReaderEOF() + } + } + + // Actor-isolated methods to be called from other actor-isolated methods. + private func appendAndProcess(data: Data) { + readBuffer.append(data) + processBuffer() + } + + private func handleReaderEOF() { + Logger.transport.info("🔱 [Serial] Reached end of file. Closing connection.") + readSource?.cancel() + } + + // MARK: - Connection Lifecycle + + func connect() async throws -> AsyncStream { + fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK) + if fd == -1 { + throw POSIXError(POSIXErrorCode(rawValue: errno)!) + } + + var term = termios() + if tcgetattr(fd, &term) == -1 { + close(fd) + throw POSIXError(POSIXErrorCode(rawValue: errno)!) + } + + cfmakeraw(&term) + + term.c_cflag = UInt((CS8 | CREAD | CLOCAL)) + term.c_oflag = 0 + term.c_iflag = 0 + term.c_lflag = 0 + + term.c_cc.16 = 0 // VMIN + term.c_cc.17 = 1 // VTIME (1 decisecond = 100ms) + + if cfsetspeed(&term, 115200) == -1 { + close(fd) + throw POSIXError(POSIXErrorCode(rawValue: errno)!) + } + + if tcsetattr(fd, TCSANOW, &term) == -1 { + close(fd) + throw POSIXError(POSIXErrorCode(rawValue: errno)!) + } + + self.fileHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + self.isOpen = true + + startReader() + return getPacketStream() + } + + func disconnect(withError error: Error? = nil, shouldReconnect: Bool) throws { + if let error { + // Inform the AccessoryManager of the error and intent to reconnect + if shouldReconnect { + eventStreamContinuation?.yield(.error(error)) + } else { + eventStreamContinuation?.yield(.errorWithoutReconnect(error)) + } + } else { + eventStreamContinuation?.yield(.disconnected(shouldReconnect: shouldReconnect)) + } + eventStreamContinuation?.finish() + eventStreamContinuation = nil + + if isOpen { + isOpen = false + try? fileHandle?.close() + fileHandle = nil + fd = -1 + readSource?.cancel() + readSource = nil + } + } + + // MARK: - Sending Data + + func send(_ data: ToRadio) async throws { + guard isOpen, let fileHandle = self.fileHandle else { + throw SerialError.notConnected + } + let serialized = try data.serializedData() + var buffer = Data([0x94, 0xc3]) + var len: UInt16 = UInt16(serialized.count).bigEndian + buffer.append(Data(bytes: &len, count: 2)) + buffer.append(serialized) + + do { + try fileHandle.write(contentsOf: buffer) + } catch { + throw SerialError.ioFailed(error.localizedDescription) + } + } + + // MARK: - Stream Management + private func getPacketStream() -> AsyncStream { + AsyncStream { continuation in + self.eventStreamContinuation = continuation + continuation.onTermination = { _ in + Task { + await self.readSource?.cancel() + } + } + } + } + + // These methods are part of the Connection protocol but are not needed + // for a continuously-reading serial connection. + func drainPendingPackets() async throws {} + func startDrainPendingPackets() throws {} + + func appDidEnterBackground() { + + } + + func appDidBecomeActive() { + + } +} +#endif diff --git a/Meshtastic/Accessory/Transports/Serial/SerialTransport.swift b/Meshtastic/Accessory/Transports/Serial/SerialTransport.swift new file mode 100644 index 00000000..920dd538 --- /dev/null +++ b/Meshtastic/Accessory/Transports/Serial/SerialTransport.swift @@ -0,0 +1,125 @@ +// +// SerialTransport.swift +// Meshtastic +// +// Created by Jake Bordens on 7/22/25. +// + +#if targetEnvironment(macCatalyst) + +import Foundation +import OSLog +import IOKit.serial +import SwiftUI + +class SerialTransport: Transport { + + let type: TransportType = .serial + var status: TransportStatus = .uninitialized + + // Transport Properties + let requiresPeriodicHeartbeat = true + let supportsManualConnection = false + + var portsAlreadyNotified = [String]() + var discoveryTask: Task? + + func discoverDevices() -> AsyncStream { + AsyncStream { cont in + self.status = .discovering + self.discoveryTask = Task { + while !Task.isCancelled { + let ports = self.getSerialPorts() + for port in ports { + let id = port.toUUIDFormatHash() ?? UUID() + if !portsAlreadyNotified.contains(port) { + Logger.transport.info("🔱 [Serial] Port \(port, privacy: .public) found.") + let newDevice = Device(id: id, + name: port.components(separatedBy: "/").last ?? port, + transportType: .serial, + identifier: port) + cont.yield(.deviceFound(newDevice)) + portsAlreadyNotified.append(port) + } + } + for knownPort in portsAlreadyNotified where !ports.contains(knownPort) { + // Previosuly seen port is no longer available + Logger.transport.info("🔱 [Serial] Port \(knownPort, privacy: .public) is no longer connected.") + if let uuid = knownPort.toUUIDFormatHash() { + cont.yield(.deviceLost(uuid)) + } + portsAlreadyNotified.removeAll(where: {$0 == knownPort}) + } + try? await Task.sleep(for: .seconds(5)) + } + } + cont.onTermination = { _ in + self.discoveryTask?.cancel() + self.discoveryTask = nil + self.portsAlreadyNotified.removeAll() + self.status = .ready + } + } + } + +// DEPRICATED: old approach is just matching filenames +// private func getSerialPorts() -> [String] { +// do { +// let dev = "/dev" +// let contents = try FileManager.default.contentsOfDirectory(atPath: dev) +// return contents.filter { $0.hasPrefix("cu.") || $0.hasPrefix("tty.") }.map { dev + "/" + $0 } +// } catch { +// Logger.transport.error("[Serial] Error listing /dev: \(error, privacy: .public)") +// return [] +// } +// } + + // New approach, return only specific USB serial devices + private func getSerialPorts() -> [String] { + var serialPortIterator: io_iterator_t = 0 + var paths: [String] = [] + + // Create a matching dictionary for all serial BSD services + guard let matchingDict = IOServiceMatching(kIOSerialBSDServiceValue) as? [String: Any] else { + return [] + } + _ = matchingDict.merging([kIOSerialBSDTypeKey: kIOSerialBSDAllTypes]) { _, new in new } + + // Get the iterator for matching services + let result = IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict as CFDictionary, &serialPortIterator) + if result != KERN_SUCCESS { + return [] + } + defer { IOObjectRelease(serialPortIterator) } + + // Iterate through services and extract callout paths (/dev/cu.xxx) only if they have a USB Serial Number property + var serialService: io_object_t = 0 + let usbSerialKey = "USB Serial Number" as CFString + let searchOptions: IOOptionBits = UInt32(kIORegistryIterateRecursively | kIORegistryIterateParents) + + repeat { + serialService = IOIteratorNext(serialPortIterator) + if serialService != 0 { + // Check for USB Serial Number in the service or its parents + if IORegistryEntrySearchCFProperty(serialService, kIOServicePlane, usbSerialKey, kCFAllocatorDefault, searchOptions) != nil { + // Property exists, so this is a USB serial device; get the path + if let path = IORegistryEntryCreateCFProperty(serialService, kIOCalloutDeviceKey as CFString, kCFAllocatorDefault, 0).takeRetainedValue() as? String { + paths.append(path) + } + } + IOObjectRelease(serialService) + } + } while serialService != 0 + + return paths.sorted() // Sort for consistent UX + } + + func connect(to device: Device) async throws -> any Connection { + return SerialConnection(path: device.identifier) + } + + func manuallyConnect(withConnectionString: String) async throws { + Logger.transport.error("🔱 [USB] This transport does not support manual connections") + } +} +#endif diff --git a/Meshtastic/Accessory/Transports/TCP/TCPConnection.swift b/Meshtastic/Accessory/Transports/TCP/TCPConnection.swift new file mode 100644 index 00000000..906e6dc3 --- /dev/null +++ b/Meshtastic/Accessory/Transports/TCP/TCPConnection.swift @@ -0,0 +1,227 @@ +// +// TCPConnection.swift +// Meshtastic +// +// Created by Jake Bordens on 7/19/25. +// + +import Foundation +import Network +import OSLog +import MeshtasticProtobufs + +actor TCPConnection: Connection { + let type = TransportType.tcp + + private var connection: NWConnection? + private let queue = DispatchQueue(label: "tcp.connection") + private var readerTask: Task? + private let nwHost: NWEndpoint.Host + private let nwPort: NWEndpoint.Port + + private var connectionStreamContinuation: AsyncStream.Continuation? + + var isConnected: Bool { + connection?.state == .ready + } + + init(host: String, port: Int) async throws { + self.nwHost = NWEndpoint.Host(host) + self.nwPort = NWEndpoint.Port(integerLiteral: UInt16(port)) + } + + private func waitForMagicBytes() async throws -> Bool { + let startOfFrame: [UInt8] = [0x94, 0xc3] + var waitingOnByte = 0 + while true { + let data = try await receiveData(min: 1, max: 1) + if data.count != 1 { + // End of stream + return false + } + + if data[0] == startOfFrame[waitingOnByte] { + waitingOnByte += 1 + } else { + waitingOnByte = 0 + } + + if waitingOnByte > 1 { + return true + } + } + } + + private func readInteger() async throws -> UInt16? { + let data = try await receiveData(min: 2, max: 2) + if data.count == 2 { + let value = data.withUnsafeBytes { $0.load(as: UInt16.self).bigEndian } + return value + } + return nil + } + + private func startReader() { + // TODO: @MainActor here because packets come into AccessoryManager out of order otherwise. Need to figure out the concurrency + readerTask = Task { @MainActor in + while await isConnected { + do { + if try await waitForMagicBytes() == false { + Logger.transport.debug("🌐 [TCP] startReader: EOF while waiting for magic bytes") + continue + } + // Logger.transport.debug("[TCP] startReader: Found magic byte, waiting for length") + + if let length = try? await readInteger() { + let payload = try await receiveData(min: Int(length), max: Int(length)) + if let fromRadio = try? FromRadio(serializedBytes: payload) { + await connectionStreamContinuation?.yield(.data(fromRadio)) + } else { + try await self.disconnect(withError: AccessoryError.disconnected("Network connection dropped"), shouldReconnect: true) + } + } else { + Logger.transport.debug("🌐 [TCP] startReader: EOF while waiting for length") + } + } catch { + Logger.transport.error("🌐 [TCP] startReader: Error reading from TCP: \(error, privacy: .public)") + try? await self.disconnect(withError: error, shouldReconnect: true) + break + } + } + // Logger.services.error("End of TCP reading task: isConnected:\(self.isConnected)") + } + } + + private func receiveData(min: Int, max: Int) async throws -> Data { + try await withCheckedThrowingContinuation { cont in + connection?.receive(minimumIncompleteLength: min, maximumLength: max) { content, _, isComplete, error in + if let error = error { + cont.resume(throwing: error) + return + } + if isComplete { + // cont.resume(returning: Data()) + cont.resume(throwing: AccessoryError.disconnected("Error while receiving data")) + return + } + cont.resume(returning: content ?? Data()) + } + } + } + + func send(_ data: ToRadio) async throws { + let serialized = try data.serializedData() + var buffer = Data() + buffer.append(0x94) + buffer.append(0xc3) + var len = UInt16(serialized.count).bigEndian + withUnsafeBytes(of: &len) { buffer.append(contentsOf: $0) } + buffer.append(serialized) + + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection?.send(content: buffer, completion: .contentProcessed { error in + if let error = error { + cont.resume(throwing: error) + } else { + cont.resume() + } + }) + } + } + + func disconnect(withError error: Error? = nil, shouldReconnect: Bool) throws { + Logger.transport.debug("🌐 [TCP] Disconnecting from TCP connection") + readerTask?.cancel() + readerTask = nil + + connection?.cancel() + connection = nil + + if let error { + // Inform the AccessoryManager of the error and intent to reconnect + if shouldReconnect { + connectionStreamContinuation?.yield(.error(error)) + } else { + connectionStreamContinuation?.yield(.errorWithoutReconnect(error)) + } + } else { + connectionStreamContinuation?.yield(.disconnected(shouldReconnect: shouldReconnect)) + } + + connectionStreamContinuation?.finish() + connectionStreamContinuation = nil + } + + func drainPendingPackets() async throws { + // For TCP, since reader is always running, no need to drain separately + } + + func startDrainPendingPackets() throws { + // For TCP, reader is already started + } + + private func getPacketStream() -> AsyncStream { + AsyncStream { continuation in + self.connectionStreamContinuation = continuation + continuation.onTermination = { _ in + Task { try await self.disconnect(withError: AccessoryError.eventStreamCancelled, shouldReconnect: true) } + } + } + } + + func connect() async throws -> AsyncStream { + let newConnection = NWConnection(host: nwHost, port: nwPort, using: .tcp) + self.connection = newConnection + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { cont in + newConnection.stateUpdateHandler = { state in + switch state { + case .ready: + cont.resume() + case .failed(let error): + cont.resume(throwing: error) + case .cancelled: + cont.resume(throwing: CancellationError()) + default: + break + } + } + newConnection.start(queue: queue) + } + } onCancel: { + newConnection.cancel() + } + + // We've gotten here past the connection and since we haven't thrown, the + // connection is in the ready state. + + // Update the state connection handler for in-progress monitoring of state + // changes while connected. + newConnection.stateUpdateHandler = { state in + switch state { + case .failed(let error): + Logger.transport.error("🌐 [TCP] Connection failed after ready: \(error, privacy: .public)") + Task { + try? await self.disconnect(withError: error, shouldReconnect: true) + } + case .cancelled: + Logger.transport.debug("🌐 [TCP] Connection cancelled") + default: + break + } + } + + startReader() + return getPacketStream() + + } + + func appDidEnterBackground() { + + } + + func appDidBecomeActive() { + + } +} diff --git a/Meshtastic/Accessory/Transports/TCP/TCPTransport.swift b/Meshtastic/Accessory/Transports/TCP/TCPTransport.swift new file mode 100644 index 00000000..d753fc76 --- /dev/null +++ b/Meshtastic/Accessory/Transports/TCP/TCPTransport.swift @@ -0,0 +1,249 @@ +// +// TCPTransport.swift +// Meshtastic +// +// Created by Jake Bordens on 7/19/25. +// + +import Foundation +import Network +import OSLog +import MeshtasticProtobufs +import SwiftUI + +let MESHTASTIC_SERVICE_TYPE = "_meshtastic._tcp." +let MESHTASTIC_DOMAIN = "local." + +class TCPTransport: NSObject, Transport, NetServiceBrowserDelegate, NetServiceDelegate { + + let type: TransportType = .tcp + var status: TransportStatus = .uninitialized + // TODO: Move to NWBrowser (NetServiceBrowser is depricated) + private var browser: NetServiceBrowser? + private var services: [String: ResolvedService] = [:] // Key: service.name + private var continuation: AsyncStream.Continuation? + + private var service: NetService? + + // Transport Properties + let requiresPeriodicHeartbeat = true + let supportsManualConnection = true + + struct ResolvedService { + let id: UUID + let service: NetService + let host: String + let port: Int + } + + override init() { + super.init() + browser = NetServiceBrowser() + browser?.delegate = self + } + + func discoverDevices() -> AsyncStream { + AsyncStream { cont in + self.continuation = cont + self.status = .discovering + Task { + self.browser?.searchForServices(ofType: MESHTASTIC_SERVICE_TYPE, inDomain: MESHTASTIC_DOMAIN) + } + cont.onTermination = { _ in + self.browser?.stop() + self.services.removeAll() + self.continuation = nil + self.status = .ready + } + } + } + + func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) { + self.service = service + service.delegate = self + service.resolve(withTimeout: 5) + } + + func netServiceDidResolveAddress(_ service: NetService) { + guard let host = service.hostName else { + Logger.transport.error("🌐 [TCP] Failed to resolve host for service \(service.name, privacy: .public)") + return + } + let port = service.port + let ip = service.ipv4Address ?? "Unknown IP" + + // Use a mishmash of things and hash for stable? ID. + let idString = "\(service.name):\(host):\(ip):\(port)".toUUIDFormatHash() ?? UUID() + + // Save the resolved service locally for later + services[service.name] = ResolvedService(id: idString, service: service, host: host, port: port) + + let device = Device(id: idString, + name: "\(service.name) (\(ip))", + transportType: .tcp, + identifier: "\(host):\(port)") + continuation?.yield(.deviceFound(device)) + } + + func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { + Logger.transport.error("🌐 [TCP] Failed to resolve service \(sender.name, privacy: .public): \(errorDict, privacy: .public)") + } + + func connect(to device: Device) async throws -> any Connection { + Logger.transport.debug("🌐 [TCP] Connect to device: \(device.name, privacy: .public) with identifier: \(device.identifier, privacy: .public)") + let parts = device.identifier.split(separator: ":") + + var host: String? + var port: Int? + + switch parts.count { + case 1: + // host & default port + host = String(parts[0]) + port = 4403 + case 2: + // host & port + host = String(parts[0]) + port = Int(parts[1]) + default: + throw AccessoryError.connectionFailed("Invalid identifier format") + } + guard let host, let port else { + throw AccessoryError.connectionFailed("Invalid identifier format") + } + + return try await TCPConnection(host: host, port: port) + } + + func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) { + guard let leavingService = services[service.name] else { + Logger.transport.error("🌐 [TCP] Service \(service.name, privacy: .public) not found in resolved services") + return + } + + // Notify the downstream + self.continuation?.yield(.deviceLost(leavingService.id)) + + // Clean up the resolved services list + var keysToRemove = [String]() + for (key, value) in services where value.service == service { + keysToRemove.append(key) + } + for removeKey in keysToRemove { + services.removeValue(forKey: removeKey) + } + } + + func manuallyConnect(withConnectionString: String) async throws { + let hashedIdentifier = withConnectionString.toUUIDFormatHash() ?? UUID() + let manualDevice = Device(id: hashedIdentifier, + name: "\(withConnectionString) (Manual)", + transportType: .tcp, identifier: withConnectionString) + try await AccessoryManager.shared.connect(to: manualDevice) + } + +} + +extension NetService { + var ipv4Address: String? { + for addressData in addresses ?? [] { + // sockaddr_in is typically 16 bytes; skip if too small + guard addressData.count >= 16 else { continue } + + // Byte 1: sin_family (AF_INET == 2 for IPv4) + let family = addressData[1] + guard family == UInt8(AF_INET) else { continue } + + // Bytes 4-7: sin_addr.s_addr (IPv4 address in network byte order) + let ipBytes = addressData[4..<8] + + // Convert each byte to string and join with dots + return ipBytes.map { String($0) }.joined(separator: ".") + } + return nil + } +} + +extension TCPTransport { + static func requestLocalNetworkAuthorization() async -> Bool { + await withCheckedContinuation { continuation in + var resumeContinuation: CheckedContinuation? = continuation + let resumeOnce: (Bool) -> Void = { result in + resumeContinuation?.resume(returning: result) + resumeContinuation = nil + } + + let queue = DispatchQueue(label: "com.meshtastic.localNetworkAuth") + + let listener: NWListener + do { + listener = try NWListener(using: .tcp) + } catch { + Logger.transport.error("🌐 [TCP Permissions] Failed to create NWListener: \(error)") + resumeOnce(false) + return + } + + // Use a unique name to avoid conflicts + let uniqueName = UUID().uuidString + listener.service = NWListener.Service(name: uniqueName, type: MESHTASTIC_SERVICE_TYPE, domain: MESHTASTIC_DOMAIN) + + listener.newConnectionHandler = { _ in } // Required to avoid errors + + listener.stateUpdateHandler = { state in + switch state { + case .setup, .waiting, .ready, .cancelled: + // No-op + break + case .failed(let error): + Logger.transport.error("🌐 [TCP Permissions] Authorization NWListener failed: \(error)") + resumeOnce(false) + listener.cancel() + @unknown default: + Logger.transport.debug("🌐 [TCP Permissions] Authorization NWListener unknown state") + } + } + + listener.start(queue: queue) + + let parameters = NWParameters.tcp + parameters.includePeerToPeer = true + + let browser = NWBrowser(for: .bonjour(type: MESHTASTIC_SERVICE_TYPE, domain: MESHTASTIC_DOMAIN ?? "local."), using: parameters) + + browser.stateUpdateHandler = { state in + switch state { + case .setup, .ready, .cancelled: + // No-op + break + case .waiting(let error): + Logger.transport.debug("🌐 [TCP Permissions] Authorization NWBrowser waiting: \(error)") + if case .dns(let dnsError) = error, dnsError == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) { // Or check rawValue == -72003 + resumeOnce(false) + browser.cancel() + listener.cancel() + } + case .failed(let error): + Logger.transport.error("🌐 [TCP Permissions] Authorization NWBrowser failed: \(error)") + resumeOnce(false) + browser.cancel() + listener.cancel() + @unknown default: + Logger.transport.debug("🌐 [TCP] Authorization NWBrowser unknown state") + } + } + + // Key addition: Detect success when the browser finds the service (permission granted) + browser.browseResultsChangedHandler = { results, _ in + if !results.isEmpty { + Logger.transport.debug("🌐 [TCP Permissions] Authorization NWBrowser found results, permission granted") + resumeOnce(true) + browser.cancel() + listener.cancel() + } + } + + browser.start(queue: queue) + } + } +} diff --git a/Meshtastic/AppIntents/AddContactIntent.swift b/Meshtastic/AppIntents/AddContactIntent.swift index e68ac4a3..cd35e98a 100644 --- a/Meshtastic/AppIntents/AddContactIntent.swift +++ b/Meshtastic/AppIntents/AddContactIntent.swift @@ -9,8 +9,8 @@ import AppIntents import MeshtasticProtobufs struct AddContactIntent: AppIntent { - static var title: LocalizedStringResource = "Add Contact" - static var description: IntentDescription = "Takes a Meshtastic contact URL and saves it to the nodes database" + static let title: LocalizedStringResource = "Add Contact" + static let description: IntentDescription = "Takes a Meshtastic contact URL and saves it to the nodes database" @Parameter(title: "Contact URL", description: "The URL for the node to add") var contactUrl: URL @@ -18,7 +18,7 @@ struct AddContactIntent: AppIntent { // Define the function that performs the main logic func perform() async throws -> some IntentResult { // Ensure the BLE Manager is connected - if !BLEManager.shared.isConnected { + if !(await AccessoryManager.shared.isConnected) { throw AppIntentErrors.AppIntentError.notConnected } @@ -27,15 +27,11 @@ struct AddContactIntent: AppIntent { // Extract contact information from the URL if let contactData = components.last { let decodedString = contactData.base64urlToBase64() - if let decodedData = Data(base64Encoded: decodedString) { + if let _ = Data(base64Encoded: decodedString) { do { - let success = BLEManager.shared.addContactFromURL(base64UrlString: contactData) - if !success { - throw AppIntentErrors.AppIntentError.message("Failed to add contact") - } - + try await AccessoryManager.shared.addContactFromURL(base64UrlString: contactData) } catch { - throw AppIntentErrors.AppIntentError.message("Failed to parse contact data: \(error.localizedDescription)") + throw AppIntentErrors.AppIntentError.message("Failed to add/parse contact data: \(error.localizedDescription)") } } } diff --git a/Meshtastic/AppIntents/DisconnectNodeIntent.swift b/Meshtastic/AppIntents/DisconnectNodeIntent.swift index 4f3b4b33..28d0e0ab 100644 --- a/Meshtastic/AppIntents/DisconnectNodeIntent.swift +++ b/Meshtastic/AppIntents/DisconnectNodeIntent.swift @@ -9,19 +9,19 @@ import Foundation import AppIntents struct DisconnectNodeIntent: AppIntent { - static var title: LocalizedStringResource = "Disconnect Node" + static let title: LocalizedStringResource = "Disconnect Node" - static var description: IntentDescription = "Disconnect the currently connected node" + static let description: IntentDescription = "Disconnect the currently connected node" func perform() async throws -> some IntentResult { - if !BLEManager.shared.isConnected { + let isConnected = await AccessoryManager.shared.isConnected + if !isConnected { throw AppIntentErrors.AppIntentError.notConnected } - if let connectedPeripheral = BLEManager.shared.connectedPeripheral, - connectedPeripheral.peripheral.state == .connected { - BLEManager.shared.disconnectPeripheral(reconnect: false) - } else { + do { + try await AccessoryManager.shared.disconnect() + } catch { throw AppIntentErrors.AppIntentError.message("Error disconnecting node") } diff --git a/Meshtastic/AppIntents/FactoryResetNodeIntent.swift b/Meshtastic/AppIntents/FactoryResetNodeIntent.swift index 4510874a..870002cc 100644 --- a/Meshtastic/AppIntents/FactoryResetNodeIntent.swift +++ b/Meshtastic/AppIntents/FactoryResetNodeIntent.swift @@ -9,8 +9,8 @@ import Foundation import AppIntents struct FactoryResetNodeIntent: AppIntent { - static var title: LocalizedStringResource = "Factory Reset" - static var description: IntentDescription = "Perform a factory reset on the node you are connected to" + static let title: LocalizedStringResource = "Factory Reset" + static let description: IntentDescription = "Perform a factory reset on the node you are connected to" @Parameter(title: "Hard Reset", description: "In addition to Config, Keys and BLE bonds will be wiped", default: false) var hardReset: Bool @Parameter(title: "Provide Confirmation", description: "Show a confirmation dialog before performing the factory reset", default: true) @@ -24,18 +24,20 @@ struct FactoryResetNodeIntent: AppIntent { } // Ensure the node is connected - if !BLEManager.shared.isConnected { + if !(await AccessoryManager.shared.isConnected) { throw AppIntentErrors.AppIntentError.notConnected } // Safely unwrap the connected node information - if let connectedPeripheralNum = BLEManager.shared.connectedPeripheral?.num, + if let connectedPeripheralNum = await AccessoryManager.shared.activeDeviceNum, let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: PersistenceController.shared.container.viewContext), let fromUser = connectedNode.user, let toUser = connectedNode.user { // Attempt to send a factory reset command, throw an error if it fails - if !BLEManager.shared.sendFactoryReset(fromUser: fromUser, toUser: toUser, resetDevice: hardReset) { + do { + try await AccessoryManager.shared.sendFactoryReset(fromUser: fromUser, toUser: toUser, resetDevice: hardReset) + } catch { throw AppIntentErrors.AppIntentError.message("Failed to perform factory reset") } } else { diff --git a/Meshtastic/AppIntents/MessageChannelIntent.swift b/Meshtastic/AppIntents/MessageChannelIntent.swift index aa9ea47a..501237fc 100644 --- a/Meshtastic/AppIntents/MessageChannelIntent.swift +++ b/Meshtastic/AppIntents/MessageChannelIntent.swift @@ -9,9 +9,9 @@ import Foundation import AppIntents struct MessageChannelIntent: AppIntent { - static var title: LocalizedStringResource = "Send a Group Message" + static let title: LocalizedStringResource = "Send a Group Message" - static var description: IntentDescription = "Send a message to a certain meshtastic channel" + static let description: IntentDescription = "Send a message to a certain meshtastic channel" @Parameter(title: "Message") var messageContent: String @@ -23,7 +23,7 @@ struct MessageChannelIntent: AppIntent { Summary("Send \(\.$messageContent) to \(\.$channelNumber)") } func perform() async throws -> some IntentResult { - if !BLEManager.shared.isConnected { + if !(await AccessoryManager.shared.isConnected) { throw AppIntentErrors.AppIntentError.notConnected } @@ -41,7 +41,9 @@ struct MessageChannelIntent: AppIntent { throw $messageContent.needsValueError("Message content exceeds 200 bytes.") } - if !BLEManager.shared.sendMessage(message: messageContent, toUserNum: 0, channel: Int32(channelNumber), isEmoji: false, replyID: 0) { + do { + try await AccessoryManager.shared.sendMessage(message: messageContent, toUserNum: 0, channel: Int32(channelNumber), isEmoji: false, replyID: 0) + } catch { throw AppIntentErrors.AppIntentError.message("Failed to send message") } diff --git a/Meshtastic/AppIntents/MessageNodeIntent.swift b/Meshtastic/AppIntents/MessageNodeIntent.swift index 089530bf..9b6fb714 100644 --- a/Meshtastic/AppIntents/MessageNodeIntent.swift +++ b/Meshtastic/AppIntents/MessageNodeIntent.swift @@ -23,7 +23,7 @@ struct MessageNodeIntent: AppIntent { Summary("Send \(\.$messageContent) to \(\.$nodeNumber)") } func perform() async throws -> some IntentResult { - if !BLEManager.shared.isConnected { + if !AccessoryManager.shared.isConnected { throw AppIntentErrors.AppIntentError.notConnected } @@ -36,7 +36,9 @@ struct MessageNodeIntent: AppIntent { throw $messageContent.needsValueError("Message content exceeds 200 bytes.") } - if !BLEManager.shared.sendMessage(message: messageContent, toUserNum: Int64(nodeNumber), channel: 0, isEmoji: false, replyID: 0) { + do { + try await AccessoryManager.shared.sendMessage(message: messageContent, toUserNum: Int64(nodeNumber), channel: 0, isEmoji: false, replyID: 0) + } catch { throw AppIntentErrors.AppIntentError.message("Failed to send message") } diff --git a/Meshtastic/AppIntents/NodePositionIntent.swift b/Meshtastic/AppIntents/NodePositionIntent.swift index 1e052eb9..e942db04 100644 --- a/Meshtastic/AppIntents/NodePositionIntent.swift +++ b/Meshtastic/AppIntents/NodePositionIntent.swift @@ -15,11 +15,11 @@ struct NodePositionIntent: AppIntent { @Parameter(title: "Node Number") var nodeNum: Int - static var title: LocalizedStringResource = "Get Node Position" - static var description: IntentDescription = "Fetch the latest position of a cetain node" + static let title: LocalizedStringResource = "Get Node Position" + static let description: IntentDescription = "Fetch the latest position of a cetain node" func perform() async throws -> some IntentResult & ReturnsValue { - if !BLEManager.shared.isConnected { + if !(await AccessoryManager.shared.isConnected) { throw AppIntentErrors.AppIntentError.notConnected } let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest(entityName: "NodeInfoEntity") diff --git a/Meshtastic/AppIntents/RestartNodeIntent.swift b/Meshtastic/AppIntents/RestartNodeIntent.swift index d1fe922b..8e325480 100644 --- a/Meshtastic/AppIntents/RestartNodeIntent.swift +++ b/Meshtastic/AppIntents/RestartNodeIntent.swift @@ -9,23 +9,25 @@ import Foundation import AppIntents struct RestartNodeIntent: AppIntent { - static var title: LocalizedStringResource = "Restart" + static let title: LocalizedStringResource = "Restart" - static var description: IntentDescription = "Restart to the node you are connected to" + static let description: IntentDescription = "Restart to the node you are connected to" func perform() async throws -> some IntentResult { - if !BLEManager.shared.isConnected { + if !(await AccessoryManager.shared.isConnected) { throw AppIntentErrors.AppIntentError.notConnected } // Safely unwrap the connectedNode using if let - if let connectedPeripheralNum = BLEManager.shared.connectedPeripheral?.num, + if let connectedPeripheralNum = await AccessoryManager.shared.activeDeviceNum, let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: PersistenceController.shared.container.viewContext), let fromUser = connectedNode.user, let toUser = connectedNode.user { // Attempt to send shutdown, throw an error if it fails - if !BLEManager.shared.sendReboot(fromUser: fromUser, toUser: toUser) { + do { + try await AccessoryManager.shared.sendReboot(fromUser: fromUser, toUser: toUser) + } catch { throw AppIntentErrors.AppIntentError.message("Failed to restart") } } else { diff --git a/Meshtastic/AppIntents/SaveChannelSettingsIntent.swift b/Meshtastic/AppIntents/SaveChannelSettingsIntent.swift index 095112ff..2d660924 100644 --- a/Meshtastic/AppIntents/SaveChannelSettingsIntent.swift +++ b/Meshtastic/AppIntents/SaveChannelSettingsIntent.swift @@ -11,8 +11,8 @@ import AppIntents // Define the AppIntent for saving channel settings from a URL struct SaveChannelSettingsIntent: AppIntent { // Define a title and description for the intent - static var title: LocalizedStringResource = "Save Channel Settings" - static var description: IntentDescription = "Takes a Meshtastic channel URL and saves the channel settings." + static let title: LocalizedStringResource = "Save Channel Settings" + static let description: IntentDescription = "Takes a Meshtastic channel URL and saves the channel settings." // Define the input for the intent (the channel URL) @Parameter(title: "Channel URL", description: "The URL for the channel settings") @@ -21,7 +21,7 @@ struct SaveChannelSettingsIntent: AppIntent { // Define the function that performs the main logic func perform() async throws -> some IntentResult { // Ensure the BLE Manager is connected - if !BLEManager.shared.isConnected { + if !(await AccessoryManager.shared.isConnected) { throw AppIntentErrors.AppIntentError.notConnected } @@ -39,10 +39,13 @@ struct SaveChannelSettingsIntent: AppIntent { // If valid channel settings are extracted, attempt to save them if let channelSettings = channelSettings { - // Call the BLEManager to save the channel settings - let saveResult = BLEManager.shared.saveChannelSet(base64UrlString: channelSettings, addChannels: addChannels) - if !saveResult { - throw AppIntentErrors.AppIntentError.message("Failed to save the channel settings.") + Task { + do { + // Call the AcessoryManager to save the channel settings + try await AccessoryManager.shared.saveChannelSet(base64UrlString: channelSettings, addChannels: addChannels) + } catch { + throw AppIntentErrors.AppIntentError.message("Failed to save the channel settings.") + } } } else { throw AppIntentErrors.AppIntentError.message("Invalid Channel URL: Unable to extract settings.") diff --git a/Meshtastic/AppIntents/SendWaypointIntent.swift b/Meshtastic/AppIntents/SendWaypointIntent.swift index ba589ee6..c547e8ce 100644 --- a/Meshtastic/AppIntents/SendWaypointIntent.swift +++ b/Meshtastic/AppIntents/SendWaypointIntent.swift @@ -14,7 +14,7 @@ struct SendWaypointIntent: AppIntent { var defaultDate = Date.now.addingTimeInterval(60 * 480) - static var title = LocalizedStringResource("Send a Waypoint") + static let title = LocalizedStringResource("Send a Waypoint") @Parameter(title: "Name", default: "Dropped Pin") var nameParameter: String? @@ -39,7 +39,7 @@ struct SendWaypointIntent: AppIntent { var expiration: Date? func perform() async throws -> some IntentResult { - if !BLEManager.shared.isConnected { + if !(await AccessoryManager.shared.isConnected) { throw AppIntentErrors.AppIntentError.notConnected } @@ -87,14 +87,16 @@ struct SendWaypointIntent: AppIntent { newWaypoint.expire = UInt32(expirationDate.timeIntervalSince1970) } if isLocked { - if let connectedPeripheral = BLEManager.shared.connectedPeripheral { - newWaypoint.lockedTo = UInt32(connectedPeripheral.num) + if let deviceNum = await AccessoryManager.shared.activeDeviceNum { + newWaypoint.lockedTo = UInt32(deviceNum) } else { throw AppIntentErrors.AppIntentError.notConnected } } - if !BLEManager.shared.sendWaypoint(waypoint: newWaypoint) { + do { + try await AccessoryManager.shared.sendWaypoint(waypoint: newWaypoint) + } catch { throw AppIntentErrors.AppIntentError.message("Failed to Send Waypoint") } return .result() diff --git a/Meshtastic/AppIntents/ShutDownNodeIntent.swift b/Meshtastic/AppIntents/ShutDownNodeIntent.swift index 7f5acc57..594c1f43 100644 --- a/Meshtastic/AppIntents/ShutDownNodeIntent.swift +++ b/Meshtastic/AppIntents/ShutDownNodeIntent.swift @@ -9,25 +9,27 @@ import Foundation import AppIntents struct ShutDownNodeIntent: AppIntent { - static var title: LocalizedStringResource = "Shut Down" + static let title: LocalizedStringResource = "Shut Down" - static var description: IntentDescription = "Send a shutdown to the node you are connected to" + static let description: IntentDescription = "Send a shutdown to the node you are connected to" func perform() async throws -> some IntentResult { try await requestConfirmation(result: .result(dialog: "Shut Down Node?")) - if !BLEManager.shared.isConnected { + if !(await AccessoryManager.shared.isConnected) { throw AppIntentErrors.AppIntentError.notConnected } // Safely unwrap the connectedNode using if let - if let connectedPeripheralNum = BLEManager.shared.connectedPeripheral?.num, + if let connectedPeripheralNum = await AccessoryManager.shared.activeDeviceNum, let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: PersistenceController.shared.container.viewContext), let fromUser = connectedNode.user, let toUser = connectedNode.user { // Attempt to send shutdown, throw an error if it fails - if !BLEManager.shared.sendShutdown(fromUser: fromUser, toUser: toUser) { + do { + try await AccessoryManager.shared.sendShutdown(fromUser: fromUser, toUser: toUser) + } catch { throw AppIntentErrors.AppIntentError.message("Failed to shut down") } } else { diff --git a/Meshtastic/AppState.swift b/Meshtastic/AppState.swift index 72f0d23a..674c343f 100644 --- a/Meshtastic/AppState.swift +++ b/Meshtastic/AppState.swift @@ -2,19 +2,14 @@ import Combine import SwiftUI class AppState: ObservableObject { - @Published - var router: Router - @Published - var unreadChannelMessages: Int - - @Published - var unreadDirectMessages: Int + @Published var router: Router + @Published var unreadChannelMessages: Int + @Published var unreadDirectMessages: Int var totalUnreadMessages: Int { unreadChannelMessages + unreadDirectMessages } - private var cancellables: Set = [] init(router: Router) { diff --git a/Meshtastic/Assets.xcassets/Symbol.symbolset/Contents.json b/Meshtastic/Assets.xcassets/Symbol.symbolset/Contents.json new file mode 100644 index 00000000..2f415ce6 --- /dev/null +++ b/Meshtastic/Assets.xcassets/Symbol.symbolset/Contents.json @@ -0,0 +1,11 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "idiom" : "universal" + } + ] +} diff --git a/Meshtastic/Assets.xcassets/custom.bluetooth.symbolset/Contents.json b/Meshtastic/Assets.xcassets/custom.bluetooth.symbolset/Contents.json new file mode 100644 index 00000000..d79f4b98 --- /dev/null +++ b/Meshtastic/Assets.xcassets/custom.bluetooth.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "custom.bluetooth.svg", + "idiom" : "universal" + } + ] +} diff --git a/Meshtastic/Assets.xcassets/custom.bluetooth.symbolset/custom.bluetooth.svg b/Meshtastic/Assets.xcassets/custom.bluetooth.symbolset/custom.bluetooth.svg new file mode 100644 index 00000000..c4a793f6 --- /dev/null +++ b/Meshtastic/Assets.xcassets/custom.bluetooth.symbolset/custom.bluetooth.svg @@ -0,0 +1,101 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.6.0 + Requires Xcode 16 or greater + Generated from custom.bluetooth + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Meshtastic/Assets.xcassets/custom.link.slash.symbolset/Contents.json b/Meshtastic/Assets.xcassets/custom.link.slash.symbolset/Contents.json new file mode 100644 index 00000000..13b4b879 --- /dev/null +++ b/Meshtastic/Assets.xcassets/custom.link.slash.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "custom.link.slash.svg", + "idiom" : "universal" + } + ] +} diff --git a/Meshtastic/Assets.xcassets/custom.link.slash.symbolset/custom.link.slash.svg b/Meshtastic/Assets.xcassets/custom.link.slash.symbolset/custom.link.slash.svg new file mode 100644 index 00000000..4c90e2e5 --- /dev/null +++ b/Meshtastic/Assets.xcassets/custom.link.slash.symbolset/custom.link.slash.svg @@ -0,0 +1,115 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.6.0 + Requires Xcode 16 or greater + Generated from + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Meshtastic/Export/CsvDocument.swift b/Meshtastic/Export/CsvDocument.swift index 7133633e..0dac16f6 100644 --- a/Meshtastic/Export/CsvDocument.swift +++ b/Meshtastic/Export/CsvDocument.swift @@ -10,7 +10,7 @@ import UniformTypeIdentifiers struct CsvDocument: FileDocument { - static var readableContentTypes = [UTType.commaSeparatedText] + static let readableContentTypes = [UTType.commaSeparatedText] @State var csvData: String diff --git a/Meshtastic/Extensions/CoreData/UserEntityExtension.swift b/Meshtastic/Extensions/CoreData/UserEntityExtension.swift index c909d344..bcfe2361 100644 --- a/Meshtastic/Extensions/CoreData/UserEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/UserEntityExtension.swift @@ -71,7 +71,7 @@ extension UserEntity { return "TLORAC6" case "TLORAT3S3EPAPER": return "TLORAT3S3EPAPER" - case "TLORAT3S3V1", "TLORAT3S3" : + case "TLORAT3S3V1", "TLORAT3S3": return "TLORAT3S3V1" case "TLORAV211P6": return "TLORAV211P6" diff --git a/Meshtastic/Extensions/Logger.swift b/Meshtastic/Extensions/Logger.swift index 402c0c1b..a67f32d1 100644 --- a/Meshtastic/Extensions/Logger.swift +++ b/Meshtastic/Extensions/Logger.swift @@ -10,7 +10,7 @@ import OSLog extension Logger { /// The logger's subsystem. - private static var subsystem = Bundle.main.bundleIdentifier! + private static let subsystem = Bundle.main.bundleIdentifier! /// All admin messages static let admin = Logger(subsystem: subsystem, category: "🏛 Admin") @@ -33,6 +33,9 @@ extension Logger { /// All logs related to tracking and analytics. static let statistics = Logger(subsystem: subsystem, category: "📊 Stats") + /// All logs related to the transport layer + static let transport = Logger(subsystem: subsystem, category: "🚚 Transport") + /// Fetch from the logstore static public func fetch(predicateFormat: String) async throws -> [OSLogEntryLog] { diff --git a/Meshtastic/Extensions/UserDefaults.swift b/Meshtastic/Extensions/UserDefaults.swift index 9ef77a18..0e605b12 100644 --- a/Meshtastic/Extensions/UserDefaults.swift +++ b/Meshtastic/Extensions/UserDefaults.swift @@ -78,6 +78,7 @@ extension UserDefaults { case firstLaunch case showDeviceOnboarding case usageDataAndCrashReporting + case autoconnectOnDiscovery case testIntEnum } @@ -172,6 +173,9 @@ extension UserDefaults { @UserDefault(.showDeviceOnboarding, defaultValue: false) static var showDeviceOnboarding: Bool + @UserDefault(.autoconnectOnDiscovery, defaultValue: true) + static var autoconnectOnDiscovery: Bool + @UserDefault(.testIntEnum, defaultValue: .one) static var testIntEnum: TestIntEnum } diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift deleted file mode 100644 index 58395c37..00000000 --- a/Meshtastic/Helpers/BLEManager.swift +++ /dev/null @@ -1,3665 +0,0 @@ -import Foundation -import CoreData -import CoreBluetooth -import SwiftUI -import MapKit -import MeshtasticProtobufs -import CocoaMQTT -import OSLog - -// --------------------------------------------------------------------------------------- -// Meshtastic BLE Device Manager -// --------------------------------------------------------------------------------------- -class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate, ObservableObject { - static var shared: BLEManager! // Singleton instance - - let appState: AppState - - let context: NSManagedObjectContext - - private var centralManager: CBCentralManager! - - @Published var peripherals: [Peripheral] = [] - @Published var connectedPeripheral: Peripheral! - @Published var lastConnectionError: String - @Published var invalidVersion = false - @Published var isSwitchedOn: Bool = false - @Published var automaticallyReconnect: Bool = true - @Published var mqttProxyConnected: Bool = false - @Published var mqttError: String = "" - public var minimumVersion = "2.3.15" - public var connectedVersion: String - public var isConnecting: Bool = false - public var isConnected: Bool = false - public var isSubscribed: Bool = false - public var allowDisconnect: Bool = false - private var configNonce: UInt32 = 1 - var timeoutTimer: Timer? - var timeoutTimerCount = 0 - var positionTimer: Timer? - var maintenanceTimer: Timer? - let mqttManager = MqttClientProxyManager.shared - var wantRangeTestPackets = false - var wantStoreAndForwardPackets = false - /* Meshtastic Service Details */ - var TORADIO_characteristic: CBCharacteristic! - var FROMRADIO_characteristic: CBCharacteristic! - var FROMNUM_characteristic: CBCharacteristic! - var LEGACY_LOGRADIO_characteristic: CBCharacteristic! - var LOGRADIO_characteristic: CBCharacteristic! - let meshtasticServiceCBUUID = CBUUID(string: "0x6BA1B218-15A8-461F-9FA8-5DCAE273EAFD") - let TORADIO_UUID = CBUUID(string: "0xF75C76D2-129E-4DAD-A1DD-7866124401E7") - let FROMRADIO_UUID = CBUUID(string: "0x2C55E69E-4993-11ED-B878-0242AC120002") - let EOL_FROMRADIO_UUID = CBUUID(string: "0x8BA2BCC2-EE02-4A55-A531-C525C5E454D5") - let FROMNUM_UUID = CBUUID(string: "0xED9DA18C-A800-4F66-A670-AA7547E34453") - let LEGACY_LOGRADIO_UUID = CBUUID(string: "0x6C6FD238-78FA-436B-AACF-15C5BE1EF2E2") - let LOGRADIO_UUID = CBUUID(string: "0x5a3d6e49-06e6-4423-9944-e9de8cdf9547") - @AppStorage("purgeStaleNodeDays") var purgeStaleNodeDays: Double = 0 - - let NONCE_ONLY_CONFIG = 69420 - let NONCE_ONLY_DB = 69421 - private var isWaitingForWantConfigResponse = false - - private var wantConfigTimer: Timer? - private var wantConfigRetryCount = 0 - private let maxWantConfigRetries = 6 - private let wantConfigTimeoutInterval: TimeInterval = 6.0 - - // MARK: init - private override init() { - // Default initialization should not be used - fatalError("Use setup(appState:context:) to initialize the singleton") - } - - static func setup(appState: AppState, context: NSManagedObjectContext) { - guard shared == nil else { - Logger.services.warning("[BLE] BLEManager already initialized") - return - } - shared = BLEManager(appState: appState, context: context) - } - - private init(appState: AppState, context: NSManagedObjectContext) { - self.appState = appState - self.context = context - self.lastConnectionError = "" - self.connectedVersion = "0.0.0" - super.init() - centralManager = CBCentralManager(delegate: self, queue: nil) - mqttManager.delegate = self - // Run clearStaleNodes every hour - maintenanceTimer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true, block: { _ in - let result = clearStaleNodes(nodeExpireDays: Int(self.purgeStaleNodeDays), context: self.context) - // If you are connected and the clear worked, pull nodes back from the node in case we have deleted anything from that app that is in the device nodedb - if result && self.isSubscribed { - self.sendWantConfig() - } - }) - } - - // MARK: Scanning for BLE Devices - // Scan for nearby BLE devices using the Meshtastic BLE service ID - func startScanning() { - if isSwitchedOn { - centralManager.scanForPeripherals(withServices: [meshtasticServiceCBUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) - Logger.services.info("✅ [BLE] Scanning Started") - } - } - - // Stop Scanning For BLE Devices - func stopScanning() { - if centralManager.isScanning { - centralManager.stopScan() - Logger.services.info("🛑 [BLE] Stopped Scanning") - } - } - - // MARK: BLE Connect functions - /// The action after the timeout-timer has fired - /// - /// - Parameters: - /// - timer: The time that fired the event - /// - @objc func timeoutTimerFired(timer: Timer) { - guard let timerContext = timer.userInfo as? [String: String] else { return } - let name: String = timerContext["name", default: "Unknown"] - - self.timeoutTimerCount += 1 - self.lastConnectionError = "" - - if timeoutTimerCount == 10 { - if connectedPeripheral != nil { - self.centralManager?.cancelPeripheralConnection(connectedPeripheral.peripheral) - } - connectedPeripheral = nil - if self.timeoutTimer != nil { - - self.timeoutTimer!.invalidate() - } - self.isConnected = false - self.isConnecting = false - self.lastConnectionError = "🚨 " + String.localizedStringWithFormat("Connection failed after %d attempts to connect to %@. You may need to forget your device under Settings > Bluetooth.".localized, timeoutTimerCount, name) - Logger.services.error("\(self.lastConnectionError, privacy: .public)") - self.timeoutTimerCount = 0 - self.startScanning() - } else { - Logger.services.info("🚨 [BLE] Connecting 2 Second Timeout Timer Fired \(self.timeoutTimerCount, privacy: .public) Time(s): \(name, privacy: .public)") - } - } - - // Connect to a specific peripheral - func connectTo(peripheral: CBPeripheral) { - stopScanning() - DispatchQueue.main.async { - self.isConnecting = true - self.lastConnectionError = "" - self.automaticallyReconnect = true - } - if connectedPeripheral != nil { - Logger.services.info("ℹ️ [BLE] Disconnecting from: \(self.connectedPeripheral.name, privacy: .public) to connect to \(peripheral.name ?? "Unknown", privacy: .public)") - disconnectPeripheral() - } - - centralManager?.connect(peripheral) - // Invalidate any existing timer - if timeoutTimer != nil { - timeoutTimer!.invalidate() - } - // Use a timer to keep track of connecting peripherals, context to pass the radio name with the timer and the RunLoop to prevent - // the timer from running on the main UI thread - let context = ["name": "\(peripheral.name ?? "Unknown")"] - timeoutTimer = Timer.scheduledTimer(timeInterval: 1.5, target: self, selector: #selector(timeoutTimerFired), userInfo: context, repeats: true) - RunLoop.current.add(timeoutTimer!, forMode: .common) - Logger.services.info("ℹ️ BLE Connecting: \(peripheral.name ?? "Unknown", privacy: .public)") - } - - // Disconnect Connected Peripheral - func cancelPeripheralConnection() { - - if mqttProxyConnected { - mqttManager.mqttClientProxy?.disconnect() - } - FROMRADIO_characteristic = nil - isConnecting = false - isConnected = false - isSubscribed = false - allowDisconnect = false - self.connectedPeripheral = nil - invalidVersion = false - connectedVersion = "0.0.0" - connectedPeripheral = nil - if timeoutTimer != nil { - timeoutTimer!.invalidate() - } - automaticallyReconnect = false - stopScanning() - startScanning() - } - - // Disconnect Connected Peripheral - func disconnectPeripheral(reconnect: Bool = true) { - // Ensure all operations run on the main thread - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - guard let connectedPeripheral = self.connectedPeripheral else { return } - if self.mqttProxyConnected { - self.mqttManager.mqttClientProxy?.disconnect() - } - self.isWaitingForWantConfigResponse = false - if wantConfigTimer != nil { - self.wantConfigTimer?.invalidate() - } - self.wantConfigTimer = nil - self.wantConfigRetryCount = 0 - self.automaticallyReconnect = reconnect - self.centralManager?.cancelPeripheralConnection(connectedPeripheral.peripheral) - self.FROMRADIO_characteristic = nil - self.isConnected = false - self.isSubscribed = false - self.allowDisconnect = false - self.invalidVersion = false - self.connectedVersion = "0.0.0" - self.stopScanning() - self.startScanning() - } - } - - // Called each time a peripheral is connected - func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - isConnecting = false - isConnected = true - if UserDefaults.preferredPeripheralId.count < 1 { - UserDefaults.preferredPeripheralId = peripheral.identifier.uuidString - } - // Invalidate and reset connection timer count - timeoutTimerCount = 0 - if timeoutTimer != nil { - timeoutTimer!.invalidate() - } - - // remove any connection errors - self.lastConnectionError = "" - // Map the peripheral to the connectedPeripheral ObservedObjects - connectedPeripheral = peripherals.filter({ $0.peripheral.identifier == peripheral.identifier }).first - if connectedPeripheral != nil { - connectedPeripheral.peripheral.delegate = self - } else { - // we are null just disconnect and start over - lastConnectionError = "🚫 [BLE] Bluetooth connection error, please try again." - disconnectPeripheral() - return - } - // Discover Services - peripheral.discoverServices([meshtasticServiceCBUUID]) - Logger.services.info("✅ [BLE] Connected: \(peripheral.name ?? "Unknown", privacy: .public)") - } - - // Called when a Peripheral fails to connect - func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - if let e = error { - // https://developer.apple.com/documentation/corebluetooth/cberror/code - let errorCode = (e as NSError).code - cancelPeripheralConnection() - if errorCode == 14 { // Peer removed pairing information - // Forgetting and reconnecting seems to be necessary so we need to show the user an error telling them to do that - lastConnectionError = "🚨 " + String.localizedStringWithFormat("%@ This error usually cannot be fixed without forgetting the device under Settings > Bluetooth and re pairing the radio.".localized, e.localizedDescription) - Logger.services.error("🚨 [BLE] Failed to connect: \(peripheral.name ?? "Unknown".localized) Error Code: \(errorCode, privacy: .public) Error: \(self.lastConnectionError, privacy: .public)") - } else { - lastConnectionError = "🚨 \(e.localizedDescription)" - Logger.services.error("🚨 [BLE] Failed to connect: \(peripheral.name ?? "Unknown".localized, privacy: .public) Error Code: \(errorCode, privacy: .public) Error: \(e.localizedDescription, privacy: .public)") - } - } - } - - // Disconnect Peripheral Event - func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - resetWantConfigRetries() - self.connectedPeripheral = nil - self.isConnecting = false - self.isConnected = false - self.isSubscribed = false - let manager = LocalNotificationManager() - if let e = error { - // https://developer.apple.com/documentation/corebluetooth/cberror/code - let errorCode = (e as NSError).code - if errorCode == 6 { // CBError.Code.connectionTimeout The connection has timed out unexpectedly. - // Happens when device is manually reset / powered off - lastConnectionError = "🚨" + String.localizedStringWithFormat("%@ The app will automatically reconnect to the preferred radio if it comes back in range.".localized, e.localizedDescription) - Logger.services.error("🚨 [BLE] Disconnected: \(peripheral.name ?? "Unknown".localized, privacy: .public) Error Code: \(errorCode, privacy: .public) Error: \(e.localizedDescription, privacy: .public)") - } else if errorCode == 7 { // CBError.Code.peripheralDisconnected The specified device has disconnected from us. - // Seems to be what is received when a tbeam sleeps, immediately recconnecting does not work. - if UserDefaults.preferredPeripheralId == peripheral.identifier.uuidString { - manager.notifications = [ - Notification( - id: (peripheral.identifier.uuidString), - title: "Radio Disconnected".localized, - subtitle: "\(peripheral.name ?? "Unknown".localized)", - content: e.localizedDescription, - target: "bluetooth", - path: "meshtastic:///bluetooth" - ) - ] - manager.schedule() - } - lastConnectionError = "🚨 \("The specified device has disconnected from us".localized)" - Logger.services.error("🚨 [BLE] Disconnected: \(peripheral.name ?? "Unknown".localized, privacy: .public) Error Code: \(errorCode, privacy: .public) Error: \(e.localizedDescription, privacy: .public)") - } else if errorCode == 14 { // Peer removed pairing information - // Forgetting and reconnecting seems to be necessary so we need to show the user an error telling them to do that - lastConnectionError = "🚨 " + String.localizedStringWithFormat("%@ This error usually cannot be fixed without forgetting the device under Settings > Bluetooth and re-connecting to the radio.".localized, e.localizedDescription) - Logger.services.error("🚨 [BLE] Disconnected: \(peripheral.name ?? "Unknown".localized) Error Code: \(errorCode, privacy: .public) Error: \(self.lastConnectionError, privacy: .public)") - } else { - if UserDefaults.preferredPeripheralId == peripheral.identifier.uuidString { - manager.notifications = [ - Notification( - id: (peripheral.identifier.uuidString), - title: "Radio Disconnected".localized, - subtitle: "\(peripheral.name ?? "Unknown".localized)", - content: e.localizedDescription, - target: "bluetooth", - path: "meshtastic:///bluetooth" - ) - ] - manager.schedule() - } - lastConnectionError = "🚨 \(e.localizedDescription)" - Logger.services.error("🚨 [BLE] Disconnected: \(peripheral.name ?? "Unknown".localized, privacy: .public) Error Code: \(errorCode, privacy: .public) Error: \(e.localizedDescription, privacy: .public)") - } - } else { - // Disconnected without error which indicates user intent to disconnect - // Happens when swiping to disconnect - Logger.services.info("ℹ️ [BLE] Disconnected: \(peripheral.name ?? "Unknown".localized, privacy: .public): \(String(describing: "User Initiated Disconnect".localized), privacy: .public)") - } - // Start a scan so the disconnected peripheral is moved to the peripherals[] if it is awake - self.startScanning() - } - - // MARK: Peripheral Services functions - func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - if let error { - Logger.services.error("🚫 [BLE] Discover Services error \(error.localizedDescription, privacy: .public)") - } - guard let services = peripheral.services else { return } - for service in services where service.uuid == meshtasticServiceCBUUID { - peripheral.discoverCharacteristics([TORADIO_UUID, FROMRADIO_UUID, FROMNUM_UUID, LEGACY_LOGRADIO_UUID, LOGRADIO_UUID], for: service) - Logger.services.info("✅ [BLE] Service for Meshtastic discovered by \(peripheral.name ?? "Unknown", privacy: .public)") - } - } - - // MARK: Discover Characteristics Event - func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - - if let error { - Logger.services.error("🚫 [BLE] Discover Characteristics error for \(peripheral.name ?? "Unknown", privacy: .public) \(error.localizedDescription, privacy: .public) disconnecting device") - // Try and stop crashes when this error occurs - disconnectPeripheral() - return - } - - guard let characteristics = service.characteristics else { return } - - for characteristic in characteristics { - switch characteristic.uuid { - - case TORADIO_UUID: - Logger.services.info("✅ [BLE] did discover TORADIO characteristic for Meshtastic by \(peripheral.name ?? "Unknown", privacy: .public)") - TORADIO_characteristic = characteristic - - case FROMRADIO_UUID: - Logger.services.info("✅ [BLE] did discover FROMRADIO characteristic for Meshtastic by \(peripheral.name ?? "Unknown", privacy: .public)") - FROMRADIO_characteristic = characteristic - peripheral.readValue(for: FROMRADIO_characteristic) - - case FROMNUM_UUID: - Logger.services.info("✅ [BLE] did discover FROMNUM (Notify) characteristic for Meshtastic by \(peripheral.name ?? "Unknown", privacy: .public)") - FROMNUM_characteristic = characteristic - peripheral.setNotifyValue(true, for: characteristic) - - case LEGACY_LOGRADIO_UUID: - Logger.services.info("✅ [BLE] did discover legacy LOGRADIO (Notify) characteristic for Meshtastic by \(peripheral.name ?? "Unknown", privacy: .public)") - LEGACY_LOGRADIO_characteristic = characteristic - peripheral.setNotifyValue(true, for: characteristic) - - case LOGRADIO_UUID: - Logger.services.info("✅ [BLE] did discover LOGRADIO (Notify) characteristic for Meshtastic by \(peripheral.name ?? "Unknown", privacy: .public)") - LOGRADIO_characteristic = characteristic - peripheral.setNotifyValue(true, for: characteristic) - - default: - break - } - } - if ![FROMNUM_characteristic, TORADIO_characteristic].contains(nil) { - if mqttProxyConnected { - mqttManager.mqttClientProxy?.disconnect() - } - sendWantConfig() - } - } - - // MARK: MqttClientProxyManagerDelegate Methods - func onMqttConnected() { - mqttProxyConnected = true - mqttError = "" - Logger.services.info("📲 [MQTT Client Proxy] onMqttConnected now subscribing to \(self.mqttManager.topic, privacy: .public).") - mqttManager.mqttClientProxy?.subscribe(mqttManager.topic) - } - - func onMqttDisconnected() { - mqttProxyConnected = false - Logger.services.info("📲 MQTT Disconnected") - } - - func onMqttMessageReceived(message: CocoaMQTTMessage) { - - 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 - guard let binaryData: Data = try? toRadio.serializedData() else { - return - } - if connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected { - connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse) - } - } - - func onMqttError(message: String) { - mqttProxyConnected = false - mqttError = message - Logger.services.info("📲 [MQTT Client Proxy] onMqttError: \(message, privacy: .public)") - } - - // MARK: Protobuf Methods - func requestDeviceMetadata(fromUser: UserEntity, toUser: UserEntity, context: NSManagedObjectContext) -> Int64 { - - guard connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected else { return 0 } - - var adminPacket = AdminMessage() - adminPacket.getDeviceMetadataRequest = true - var meshPacket: MeshPacket = MeshPacket() - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var success = false - guard connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected else { return success } - - let fromNodeNum = connectedPeripheral.num - let routePacket = RouteDiscovery() - var meshPacket = MeshPacket() - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. 0 { - let myInfo = myInfoPacket(myInfo: decodedInfo.myInfo, peripheralId: self.connectedPeripheral.id, context: context) - - if myInfo != nil { - UserDefaults.preferredPeripheralNum = Int(myInfo?.myNodeNum ?? 0) - connectedPeripheral.num = myInfo?.myNodeNum ?? 0 - connectedPeripheral.name = myInfo?.bleName ?? "Unknown".localized - connectedPeripheral.longName = myInfo?.bleName ?? "Unknown".localized - let newConnection = Int64(UserDefaults.preferredPeripheralNum) != Int64(decodedInfo.myInfo.myNodeNum) - if newConnection { - // Onboard a new device connection here - } - } - tryClearExistingChannels() - } - // NodeInfo - if decodedInfo.nodeInfo.num > 0 { - onWantConfigResponseReceived() - nowKnown = true - if let nodeInfo = nodeInfoPacket(nodeInfo: decodedInfo.nodeInfo, channel: decodedInfo.packet.channel, context: context) { - if self.connectedPeripheral != nil && self.connectedPeripheral.num == nodeInfo.num { - if nodeInfo.user != nil { - connectedPeripheral.shortName = nodeInfo.user?.shortName ?? "?" - connectedPeripheral.longName = nodeInfo.user?.longName ?? "Unknown".localized - UserDefaults.hardwareModel = nodeInfo.user?.hwModel ?? "Unset".localized - } - } - } - } - guard let cp = connectedPeripheral else { - return - } - // Channels - if decodedInfo.channel.isInitialized { - nowKnown = true - channelPacket(channel: decodedInfo.channel, fromNum: Int64(truncatingIfNeeded: cp.num), context: context) - } - // Config - if decodedInfo.config.isInitialized && !invalidVersion && cp.num != 0 { - nowKnown = true - localConfig(config: decodedInfo.config, context: context, nodeNum: Int64(truncatingIfNeeded: cp.num), nodeLongName: cp.longName) - } - // Module Config - if decodedInfo.moduleConfig.isInitialized && !invalidVersion && cp.num != 0 { - onWantConfigResponseReceived() - nowKnown = true - moduleConfig(config: decodedInfo.moduleConfig, context: context, nodeNum: Int64(truncatingIfNeeded: cp.num), nodeLongName: cp.longName) - if decodedInfo.moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.cannedMessage(decodedInfo.moduleConfig.cannedMessage) { - _ = self.getCannedMessageModuleMessages(destNum: cp.num, wantResponse: true) - } - if decodedInfo.config.payloadVariant == Config.OneOf_PayloadVariant.device(decodedInfo.config.device) { - var dc = decodedInfo.config.device - if dc.tzdef.isEmpty { - dc.tzdef = TimeZone.current.posixDescription - _ = self.saveTimeZone(config: dc, user: cp.num) - } - } - } - // Device Metadata - if decodedInfo.metadata.firmwareVersion.count > 0 && !invalidVersion { - nowKnown = true - deviceMetadataPacket(metadata: decodedInfo.metadata, fromNum: cp.num, context: context) - connectedPeripheral.firmwareVersion = decodedInfo.metadata.firmwareVersion - let lastDotIndex = decodedInfo.metadata.firmwareVersion.lastIndex(of: ".") - if lastDotIndex == nil { - invalidVersion = true - connectedVersion = "0.0.0" - } else { - let version = decodedInfo.metadata.firmwareVersion[...(lastDotIndex ?? String.Index(utf16Offset: 6, in: decodedInfo.metadata.firmwareVersion))] - nowKnown = true - connectedVersion = String(version.dropLast()) - UserDefaults.firmwareVersion = connectedVersion - } - let supportedVersion = connectedVersion == "0.0.0" || self.minimumVersion.compare(connectedVersion, options: .numeric) == .orderedAscending || minimumVersion.compare(connectedVersion, options: .numeric) == .orderedSame - if !supportedVersion { - invalidVersion = true - lastConnectionError = "🚨" + "Update Your Firmware".localized - return - } - } - // Log any other unknownApp calls - if !nowKnown { Logger.mesh.info("🕸️ MESH PACKET received for Unknown App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") } - case .textMessageApp, .detectionSensorApp: - textMessageAppPacket( - packet: decodedInfo.packet, - wantRangeTestPackets: wantRangeTestPackets, - connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), - context: context, - appState: appState - ) - case .alertApp: - textMessageAppPacket( - packet: decodedInfo.packet, - wantRangeTestPackets: wantRangeTestPackets, - critical: true, - connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), - context: context, - appState: appState - ) - case .remoteHardwareApp: - Logger.mesh.info("🕸️ MESH PACKET received for Remote Hardware App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") - case .positionApp: - upsertPositionPacket(packet: decodedInfo.packet, context: context) - case .waypointApp: - waypointPacket(packet: decodedInfo.packet, context: context) - case .nodeinfoApp: - guard let peripheral = self.connectedPeripheral else { - Logger.mesh.error("🕸️ connectedPeripheral is nil. Unable to determine connectedNodeNum for node info upsert.") - return - } - if Int64(truncatingIfNeeded: decodedInfo.packet.from) != peripheral.num { - upsertNodeInfoPacket(packet: decodedInfo.packet, context: context) - } - case .routingApp: - if !invalidVersion { - guard let peripheral = self.connectedPeripheral else { - Logger.mesh.error("🕸️ connectedPeripheral is nil. Unable to determine connectedNodeNum for routingPacket.") - return - } - routingPacket(packet: decodedInfo.packet, connectedNodeNum: peripheral.num, context: context) - } - case .adminApp: - adminAppPacket(packet: decodedInfo.packet, context: context) - case .replyApp: - Logger.mesh.info("🕸️ MESH PACKET received for Reply App handling as a text message") - textMessageAppPacket(packet: decodedInfo.packet, wantRangeTestPackets: wantRangeTestPackets, connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context, appState: appState) - case .ipTunnelApp: - Logger.mesh.info("🕸️ MESH PACKET received for IP Tunnel App UNHANDLED UNHANDLED") - case .serialApp: - Logger.mesh.info("🕸️ MESH PACKET received for Serial App UNHANDLED UNHANDLED") - case .storeForwardApp: - storeAndForwardPacket(packet: decodedInfo.packet, connectedNodeNum: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context) - case .rangeTestApp: - if wantRangeTestPackets { - textMessageAppPacket( - packet: decodedInfo.packet, - wantRangeTestPackets: true, - connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), - context: context, - appState: appState - ) - } else { - Logger.mesh.info("🕸️ MESH PACKET received for Range Test App Range testing is disabled.") - } - case .telemetryApp: - if !invalidVersion { telemetryPacket(packet: decodedInfo.packet, connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context) } - case .textMessageCompressedApp: - Logger.mesh.info("🕸️ MESH PACKET received for Text Message Compressed App UNHANDLED") - case .zpsApp: - Logger.mesh.info("🕸️ MESH PACKET received for Zero Positioning System App UNHANDLED") - case .privateApp: - Logger.mesh.info("🕸️ MESH PACKET received for Private App UNHANDLED UNHANDLED") - case .atakForwarder: - Logger.mesh.info("🕸️ MESH PACKET received for ATAK Forwarder App UNHANDLED UNHANDLED") - case .simulatorApp: - Logger.mesh.info("🕸️ MESH PACKET received for Simulator App UNHANDLED UNHANDLED") - case .audioApp: - Logger.mesh.info("🕸️ MESH PACKET received for Audio App UNHANDLED UNHANDLED") - case .tracerouteApp: - if let routingMessage = try? RouteDiscovery(serializedBytes: decodedInfo.packet.decoded.payload) { - let traceRoute = getTraceRoute(id: Int64(decodedInfo.packet.decoded.requestID), context: context) - traceRoute?.response = true - guard let connectedNode = getNodeInfo(id: Int64(connectedPeripheral.num), context: context) else { - return - } - var hopNodes: [TraceRouteHopEntity] = [] - let connectedHop = TraceRouteHopEntity(context: context) - connectedHop.time = Date() - connectedHop.num = connectedPeripheral.num - connectedHop.name = connectedNode.user?.longName ?? "???" - // If nil, set to unknown, INT8_MIN (-128) then divide by 4 - connectedHop.snr = Float(routingMessage.snrBack.last ?? -128) / 4 - if let mostRecent = traceRoute?.node?.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! { - connectedHop.altitude = mostRecent.altitude - connectedHop.latitudeI = mostRecent.latitudeI - connectedHop.longitudeI = mostRecent.longitudeI - traceRoute?.hasPositions = true - } - var routeString = "\(connectedNode.user?.longName ?? "???") --> " - hopNodes.append(connectedHop) - traceRoute?.hopsTowards = Int32(routingMessage.route.count) - for (index, node) in routingMessage.route.enumerated() { - var hopNode = getNodeInfo(id: Int64(node), context: context) - if hopNode == nil && hopNode?.num ?? 0 > 0 && node != 4294967295 { - hopNode = createNodeInfo(num: Int64(node), context: context) - } - let traceRouteHop = TraceRouteHopEntity(context: context) - traceRouteHop.time = Date() - if routingMessage.snrTowards.count >= index + 1 { - traceRouteHop.snr = Float(routingMessage.snrTowards[index]) / 4 - } else { - // If no snr in route, set unknown - traceRouteHop.snr = -32 - } - if let hn = hopNode, hn.hasPositions { - if let mostRecent = hn.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! { - traceRouteHop.altitude = mostRecent.altitude - traceRouteHop.latitudeI = mostRecent.latitudeI - traceRouteHop.longitudeI = mostRecent.longitudeI - traceRoute?.hasPositions = true - } - } - traceRouteHop.num = hopNode?.num ?? 0 - if hopNode != nil { - if decodedInfo.packet.rxTime > 0 { - hopNode?.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(decodedInfo.packet.rxTime))) - } - } - hopNodes.append(traceRouteHop) - - let hopName = hopNode?.user?.longName ?? (node == 4294967295 ? "Repeater" : String(hopNode?.num.toHex() ?? "Unknown".localized)) - let mqttLabel = hopNode?.viaMqtt ?? false ? "MQTT " : "" - let snrLabel = (traceRouteHop.snr != -32) ? String(traceRouteHop.snr) : "unknown ".localized - routeString += "\(hopName) \(mqttLabel)(\(snrLabel)dB) --> " - } - let destinationHop = TraceRouteHopEntity(context: context) - destinationHop.name = traceRoute?.node?.user?.longName ?? "Unknown".localized - destinationHop.time = Date() - // If nil, set to unknown, INT8_MIN (-128) then divide by 4 - destinationHop.snr = Float(routingMessage.snrTowards.last ?? -128) / 4 - destinationHop.num = traceRoute?.node?.num ?? 0 - if let mostRecent = traceRoute?.node?.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! { - destinationHop.altitude = mostRecent.altitude - destinationHop.latitudeI = mostRecent.latitudeI - destinationHop.longitudeI = mostRecent.longitudeI - traceRoute?.hasPositions = true - } - hopNodes.append(destinationHop) - /// Add the destination node to the end of the route towards string and the beginning of the route back string - routeString += "\(traceRoute?.node?.user?.longName ?? "Unknown".localized) \((traceRoute?.node?.num ?? 0).toHex()) (\(destinationHop.snr != -32 ? String(destinationHop.snr) : "unknown ".localized)dB)" - traceRoute?.routeText = routeString - // Default to -1 only fill in if routeBack is valid below - traceRoute?.hopsBack = -1 - // Only if hopStart is set and there is an SNR entry - if decodedInfo.packet.hopStart > 0 && routingMessage.snrBack.count > 0 { - traceRoute?.hopsBack = Int32(routingMessage.routeBack.count) - var routeBackString = "\(traceRoute?.node?.user?.longName ?? "Unknown".localized) \((traceRoute?.node?.num ?? 0).toHex()) --> " - for (index, node) in routingMessage.routeBack.enumerated() { - var hopNode = getNodeInfo(id: Int64(node), context: context) - if hopNode == nil && hopNode?.num ?? 0 > 0 && node != 4294967295 { - hopNode = createNodeInfo(num: Int64(node), context: context) - } - let traceRouteHop = TraceRouteHopEntity(context: context) - traceRouteHop.time = Date() - traceRouteHop.back = true - if routingMessage.snrBack.count >= index + 1 { - traceRouteHop.snr = Float(routingMessage.snrBack[index]) / 4 - } else { - // If no snr in route, set to unknown - traceRouteHop.snr = -32 - } - if let hn = hopNode, hn.hasPositions { - if let mostRecent = hn.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! { - traceRouteHop.altitude = mostRecent.altitude - traceRouteHop.latitudeI = mostRecent.latitudeI - traceRouteHop.longitudeI = mostRecent.longitudeI - traceRoute?.hasPositions = true - } - } - traceRouteHop.num = hopNode?.num ?? 0 - if hopNode != nil { - if decodedInfo.packet.rxTime > 0 { - hopNode?.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(decodedInfo.packet.rxTime))) - } - } - hopNodes.append(traceRouteHop) - - let hopName = hopNode?.user?.longName ?? (node == 4294967295 ? "Repeater" : String(hopNode?.num.toHex() ?? "Unknown".localized)) - let mqttLabel = hopNode?.viaMqtt ?? false ? "MQTT " : "" - let snrLabel = (traceRouteHop.snr != -32) ? String(traceRouteHop.snr) : "unknown ".localized - routeBackString += "\(hopName) \(mqttLabel)(\(snrLabel)dB) --> " - } - // If nil, set to unknown, INT8_MIN (-128) then divide by 4 - let snrBackLast = Float(routingMessage.snrBack.last ?? -128) / 4 - routeBackString += "\(connectedNode.user?.longName ?? String(connectedNode.num.toHex())) (\(snrBackLast != -32 ? String(snrBackLast) : "unknown ".localized)dB)" - traceRoute?.routeBackText = routeBackString - } - traceRoute?.hops = NSOrderedSet(array: hopNodes) - traceRoute?.time = Date() - - if let tr = traceRoute { - let manager = LocalNotificationManager() - manager.notifications = [ - Notification( - id: (UUID().uuidString), - title: "Traceroute Complete", - subtitle: "TR received back from \(destinationHop.name ?? "unknown")", - content: "Hops from: \(tr.hopsTowards), Hops back: \(tr.hopsBack)\n\(tr.routeText ?? "Unknown".localized)\n\(tr.routeBackText ?? "Unknown".localized)", - target: "nodes", - path: "meshtastic:///nodes?nodenum=\(tr.node?.num ?? 0)" - ) - ] - manager.schedule() - } - - do { - try context.save() - Logger.data.info("💾 Saved Trace Route") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Error Updating Core Data TraceRouteHop: \(nsError, privacy: .public)") - } - let logString = String.localizedStringWithFormat("Trace Route request returned: %@".localized, routeString) - Logger.mesh.info("🪧 \(logString, privacy: .public)") - } - case .neighborinfoApp: - if let neighborInfo = try? NeighborInfo(serializedBytes: decodedInfo.packet.decoded.payload) { - Logger.mesh.info("🕸️ MESH PACKET received for Neighbor Info App UNHANDLED \((try? neighborInfo.jsonString()) ?? "JSON Decode Failure", privacy: .public)") - } - case .paxcounterApp: - paxCounterPacket(packet: decodedInfo.packet, context: context) - case .mapReportApp: - Logger.mesh.info("🕸️ MESH PACKET received Map Report App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") - case .UNRECOGNIZED: - Logger.mesh.info("🕸️ MESH PACKET received UNRECOGNIZED App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") - case .max: - Logger.services.info("MAX PORT NUM OF 511") - case .atakPlugin: - Logger.mesh.info("🕸️ MESH PACKET received for ATAK Plugin App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") - case .powerstressApp: - Logger.mesh.info("🕸️ MESH PACKET received for Power Stress App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") - case .reticulumTunnelApp: - Logger.mesh.info("🕸️ MESH PACKET received for Reticulum Tunnel App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") - case .keyVerificationApp: - Logger.mesh.warning("🕸️ MESH PACKET received for Key Verification App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") - case .cayenneApp: - Logger.mesh.info("🕸️ MESH PACKET received Cayenne App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") - } - - if decodedInfo.configCompleteID != 0 && decodedInfo.configCompleteID == NONCE_ONLY_CONFIG { - invalidVersion = false - lastConnectionError = "" - isSubscribed = true - allowDisconnect = true - Logger.mesh.info("🤜 [BLE] Want Config Complete. ID:\(decodedInfo.configCompleteID, privacy: .public)") - if sendTime() { - } - peripherals.removeAll(where: { $0.peripheral.state == CBPeripheralState.disconnected }) - // Config conplete returns so we don't read the characteristic again - - /// MQTT Client Proxy and RangeTest and Store and Forward interest - if connectedPeripheral.num > 0 { - - let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(connectedPeripheral.num)) - do { - let fetchedNodeInfo = try context.fetch(fetchNodeInfoRequest) - if fetchedNodeInfo.count == 1 { - // Subscribe to Mqtt Client Proxy if enabled - if fetchedNodeInfo[0].mqttConfig != nil && fetchedNodeInfo[0].mqttConfig?.enabled ?? false && fetchedNodeInfo[0].mqttConfig?.proxyToClientEnabled ?? false { - mqttManager.connectFromConfigSettings(node: fetchedNodeInfo[0]) - } else { - if mqttProxyConnected { - mqttManager.mqttClientProxy?.disconnect() - } - } - // Set initial unread message badge states - appState.unreadChannelMessages = fetchedNodeInfo[0].myInfo?.unreadMessages ?? 0 - appState.unreadDirectMessages = fetchedNodeInfo[0].user?.unreadMessages ?? 0 - } - if fetchedNodeInfo.count == 1 && fetchedNodeInfo[0].rangeTestConfig?.enabled == true { - wantRangeTestPackets = true - } - if fetchedNodeInfo.count == 1 && fetchedNodeInfo[0].storeForwardConfig?.enabled == true { - wantStoreAndForwardPackets = true - } - - } catch { - Logger.data.error("Failed to find a node info for the connected node \(error.localizedDescription, privacy: .public)") - } - Logger.mesh.info("🤜 [BLE] Want Config Complete. ID:\(decodedInfo.configCompleteID, privacy: .public)") - sendWantConfig() - - } - // 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 - if UserDefaults.provideLocation { - let interval = UserDefaults.provideLocationInterval >= 10 ? UserDefaults.provideLocationInterval : 30 - positionTimer = Timer.scheduledTimer(timeInterval: TimeInterval(interval), target: self, selector: #selector(positionTimerFired), userInfo: context, repeats: true) - if positionTimer != nil { - RunLoop.current.add(positionTimer!, forMode: .common) - } - } - return - } - if decodedInfo.configCompleteID != 0 && decodedInfo.configCompleteID == NONCE_ONLY_DB { - Logger.mesh.info("🤜 [BLE] Want Config DB Complete. ID:\(decodedInfo.configCompleteID, privacy: .public)") - } - case FROMNUM_UUID: - Logger.services.info("🗞️ [BLE] (Notify) characteristic value will be read next") - default: - Logger.services.error("🚫 Unhandled Characteristic UUID: \(characteristic.uuid, privacy: .public)") - } - if FROMRADIO_characteristic != nil { - // Either Read the config complete value or from num notify value - peripheral.readValue(for: FROMRADIO_characteristic) - } - } - - public func sendMessage(message: String, toUserNum: Int64, channel: Int32, isEmoji: Bool, replyID: Int64) -> Bool { - var success = false - - // Return false if we are not properly connected to a device, handle retry logic in the view for now - if connectedPeripheral == nil || connectedPeripheral!.peripheral.state != CBPeripheralState.connected { - - self.disconnectPeripheral() - self.startScanning() - - // Try and connect to the preferredPeripherial first - let preferredPeripheral = peripherals.filter({ $0.peripheral.identifier.uuidString == UserDefaults.preferredPeripheralId as String }).first - if preferredPeripheral != nil && preferredPeripheral?.peripheral != nil { - connectTo(peripheral: preferredPeripheral!.peripheral) - } - let nodeName = connectedPeripheral?.peripheral.name ?? "Unknown".localized - let logString = String.localizedStringWithFormat("Message Send Failed, not properly connected to %@".localized, nodeName) - Logger.mesh.info("🚫 \(logString, privacy: .public)") - - success = false - } else if message.count < 1 { - - // Don't send an empty message - Logger.mesh.info("🚫 Don't Send an Empty Message") - success = false - - } else { - guard let fromUserNum = self.connectedPeripheral?.num else { - Logger.mesh.error("🚫 Connected peripheral user number is nil, cannot send message.") - return false - } - - let messageUsers = UserEntity.fetchRequest() - messageUsers.predicate = NSPredicate(format: "num IN %@", [fromUserNum, Int64(toUserNum)]) - - do { - - let fetchedUsers = try context.fetch(messageUsers) - if fetchedUsers.isEmpty { - - Logger.data.error("🚫 Message Users Not Found, Fail") - success = false - } else if fetchedUsers.count >= 1 { - - let newMessage = MessageEntity(context: context) - newMessage.messageId = Int64(UInt32.random(in: UInt32(UInt8.max).. 0 { - newMessage.toUser = fetchedUsers.first(where: { $0.num == toUserNum }) - newMessage.toUser?.lastMessage = Date() - if newMessage.toUser?.pkiEncrypted ?? false { - newMessage.publicKey = newMessage.toUser?.publicKey - newMessage.pkiEncrypted = true - } - } - newMessage.fromUser = fetchedUsers.first(where: { $0.num == fromUserNum }) - newMessage.isEmoji = isEmoji - newMessage.admin = false - newMessage.channel = channel - if replyID > 0 { - newMessage.replyID = replyID - } - newMessage.messagePayload = message - newMessage.messagePayloadMarkdown = generateMessageMarkdown(message: message) - newMessage.read = true - - let dataType = PortNum.textMessageApp - var messageQuotesReplaced = message.replacingOccurrences(of: "’", with: "'") - messageQuotesReplaced = message.replacingOccurrences(of: "”", with: "\"") - let payloadData: Data = messageQuotesReplaced.data(using: String.Encoding.utf8)! - - var dataMessage = DataMessage() - dataMessage.payload = payloadData - dataMessage.portnum = dataType - - var meshPacket = MeshPacket() - if newMessage.toUser?.pkiEncrypted ?? false { - meshPacket.pkiEncrypted = true - meshPacket.publicKey = newMessage.toUser?.publicKey ?? Data() - // Auto Favorite nodes you DM so they don't roll out of the nodedb - if !(newMessage.toUser?.userNode?.favorite ?? true) { - newMessage.toUser?.userNode?.favorite = true - do { - try context.save() - if let connectedPeripheral = self.connectedPeripheral { - Logger.data.info("💾 Auto favorited node based on sending a message \(connectedPeripheral.num.toHex(), privacy: .public) to \(toUserNum.toHex(), privacy: .public)") - } else { - Logger.data.warning("⚠️ connectedPeripheral is nil while attempting to log auto-favoriting a node.") - } - guard let userNode = newMessage.toUser?.userNode else { - Logger.data.warning("⚠️ Unable to set favorite node: userNode is nil.") - return false - } - _ = self.setFavoriteNode(node: userNode, connectedNodeNum: fromUserNum) - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Unresolved Core Data error when auto favoriting in Send Message Function. Error: \(nsError, privacy: .public)") - } - } - } - meshPacket.id = UInt32(newMessage.messageId) - if toUserNum > 0 { - meshPacket.to = UInt32(toUserNum) - } else { - meshPacket.to = Constants.maximumNodeNum - } - meshPacket.channel = UInt32(channel) - meshPacket.from = UInt32(fromUserNum) - meshPacket.decoded = dataMessage - meshPacket.decoded.emoji = isEmoji ? 1 : 0 - if replyID > 0 { - meshPacket.decoded.replyID = UInt32(replyID) - } - meshPacket.wantAck = true - - var toRadio: ToRadio! - toRadio = ToRadio() - toRadio.packet = meshPacket - guard let binaryData: Data = try? toRadio.serializedData() else { - return false - } - if connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected { - connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse) - let logString = String.localizedStringWithFormat("Sent message %@ from %@ to %@".localized, String(newMessage.messageId), fromUserNum.toHex(), toUserNum.toHex()) - - Logger.mesh.info("💬 \(logString, privacy: .public)") - do { - try context.save() - Logger.data.info("💾 Saved a new sent message from \(self.connectedPeripheral?.num.toHex() ?? "0", privacy: .public) to \(toUserNum.toHex(), privacy: .public)") - success = true - - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Unresolved Core Data error in Send Message Function your database is corrupted running a node db reset should clean up the data. Error: \(nsError, privacy: .public)") - } - } - } - } catch { - Logger.data.error("💥 Send message failure \(self.connectedPeripheral?.num.toHex() ?? "0", privacy: .public) to \(toUserNum.toHex(), privacy: .public)") - } - } - return success - } - - public func sendWaypoint(waypoint: Waypoint) -> Bool { - if waypoint.latitudeI == 0 && waypoint.longitudeI == 0 { - return false - } - var success = false - let fromNodeNum = UInt32(connectedPeripheral.num) - var meshPacket = MeshPacket() - meshPacket.to = Constants.maximumNodeNum - meshPacket.from = fromNodeNum - meshPacket.wantAck = true - var dataMessage = DataMessage() - do { - dataMessage.payload = try waypoint.serializedData() - } catch { - // Could not serialiaze the payload - return false - } - - dataMessage.portnum = PortNum.waypointApp - meshPacket.decoded = dataMessage - var toRadio: ToRadio! - toRadio = ToRadio() - toRadio.packet = meshPacket - guard let binaryData: Data = try? toRadio.serializedData() else { - return false - } - let logString = String.localizedStringWithFormat("Sent a Waypoint Packet from: %@".localized, String(fromNodeNum)) - Logger.mesh.info("📍 \(logString, privacy: .public)") - 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) - wayPointEntity.id = Int64(waypoint.id) - wayPointEntity.name = waypoint.name.count >= 1 ? waypoint.name : "Dropped Pin" - wayPointEntity.longDescription = waypoint.description_p - wayPointEntity.icon = Int64(waypoint.icon) - wayPointEntity.latitudeI = waypoint.latitudeI - wayPointEntity.longitudeI = waypoint.longitudeI - if waypoint.expire > 1 { - wayPointEntity.expire = Date.init(timeIntervalSince1970: Double(waypoint.expire)) - } else { - wayPointEntity.expire = nil - } - if waypoint.lockedTo > 0 { - wayPointEntity.locked = Int64(waypoint.lockedTo) - } else { - wayPointEntity.locked = 0 - } - if wayPointEntity.created == nil { - wayPointEntity.created = Date() - } else { - wayPointEntity.lastUpdated = Date() - } - do { - try context.save() - Logger.data.info("💾 Updated Waypoint from Waypoint App Packet From: \(fromNodeNum.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Error Saving NodeInfoEntity from WAYPOINT_APP \(nsError, privacy: .public)") - } - } - return success - } - - @MainActor - public func getPositionFromPhoneGPS(destNum: Int64, fixedPosition: Bool) -> Position? { - var positionPacket = Position() - - guard let lastLocation = LocationsHandler.shared.locationsArray.last else { - return nil - } - - if lastLocation == CLLocation(latitude: 0, longitude: 0) { - return nil - } - - positionPacket.latitudeI = Int32(lastLocation.coordinate.latitude * 1e7) - positionPacket.longitudeI = Int32(lastLocation.coordinate.longitude * 1e7) - let timestamp = lastLocation.timestamp - positionPacket.time = UInt32(timestamp.timeIntervalSince1970) - positionPacket.timestamp = UInt32(timestamp.timeIntervalSince1970) - positionPacket.altitude = Int32(lastLocation.altitude) - positionPacket.satsInView = UInt32(LocationsHandler.satsInView) - let currentSpeed = lastLocation.speed - if currentSpeed > 0 && (!currentSpeed.isNaN || !currentSpeed.isInfinite) { - positionPacket.groundSpeed = UInt32(currentSpeed) - } - let currentHeading = lastLocation.course - if (currentHeading > 0 && currentHeading <= 360) && (!currentHeading.isNaN || !currentHeading.isInfinite) { - positionPacket.groundTrack = UInt32(currentHeading) - } - /// Set location source for time - if !fixedPosition { - /// From GPS treat time as good - positionPacket.locationSource = Position.LocSource.locExternal - } else { - /// From GPS, but time can be old and have drifted - positionPacket.locationSource = Position.LocSource.locManual - } - return positionPacket - } - - @MainActor - public func setFixedPosition(fromUser: UserEntity, channel: Int32) -> Bool { - var adminPacket = AdminMessage() - guard let positionPacket = getPositionFromPhoneGPS(destNum: fromUser.num, fixedPosition: true) else { - return false - } - adminPacket.setFixedPosition = positionPacket - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(fromUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - var adminPacket = AdminMessage() - adminPacket.removeFixedPosition = true - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(fromUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - let fromNodeNum = connectedPeripheral.num - guard let positionPacket = getPositionFromPhoneGPS(destNum: destNum, fixedPosition: false) else { - Logger.services.error("Unable to get position data from device GPS to send to node") - return false - } - - var meshPacket = MeshPacket() - meshPacket.to = UInt32(destNum) - meshPacket.channel = UInt32(channel) - meshPacket.from = UInt32(fromNodeNum) - var dataMessage = DataMessage() - if let serializedData: Data = try? positionPacket.serializedData() { - dataMessage.payload = serializedData - dataMessage.portnum = PortNum.positionApp - dataMessage.wantResponse = wantResponse - meshPacket.decoded = dataMessage - } else { - Logger.services.error("Failed to serialize position packet data") - return false - } - - var toRadio: ToRadio! - toRadio = ToRadio() - toRadio.packet = meshPacket - guard let binaryData: Data = try? toRadio.serializedData() else { - Logger.services.error("Failed to serialize position packet") - return false - } - if connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected { - connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse) - let logString = String.localizedStringWithFormat("Sent a Position Packet from the Apple device GPS to node: %@".localized, String(fromNodeNum)) - Logger.services.debug("📍 \(logString, privacy: .public)") - return true - } else { - Logger.services.error("Device no longer connected. Unable to send position information.") - return false - } - } - - @MainActor - @objc func positionTimerFired(timer: Timer) { - // Check for connected node - if connectedPeripheral != nil { - // Send a position out to the mesh if "share location with the mesh" is enabled in settings - if UserDefaults.provideLocation { - _ = sendPosition(channel: 0, destNum: connectedPeripheral.num, wantResponse: false) - } - } - } - - public func sendTime() -> Bool { - if self.connectedPeripheral?.num ?? 0 <= 0 { - Logger.mesh.error("🚫 Unable to send time, connected node is disconnected or invalid") - return false - } - var adminPacket = AdminMessage() - adminPacket.setTimeOnly = UInt32(Date().timeIntervalSince1970) - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(self.connectedPeripheral.num) - meshPacket.from = UInt32(self.connectedPeripheral.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - var adminPacket = AdminMessage() - adminPacket.shutdownSeconds = 5 - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - var adminPacket = AdminMessage() - adminPacket.rebootSeconds = 5 - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - var adminPacket = AdminMessage() - adminPacket.rebootOtaSeconds = 5 - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - var adminPacket = AdminMessage() - adminPacket.enterDfuModeRequest = true - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - var adminPacket = AdminMessage() - if resetDevice { - adminPacket.factoryResetDevice = 5 - } else { - adminPacket.factoryResetConfig = 5 - } - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - var adminPacket = AdminMessage() - adminPacket.nodedbReset = 5 - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = 0 // UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - var success = false - // Return false if we are not properly connected to a device, handle retry logic in the view for now - if connectedPeripheral == nil || connectedPeripheral!.peripheral.state != CBPeripheralState.connected { - self.disconnectPeripheral() - self.startScanning() - // Try and connect to the preferredPeripherial first - let preferredPeripheral = peripherals.filter({ $0.peripheral.identifier.uuidString == UserDefaults.standard.object(forKey: "preferredPeripheralId") as? String ?? "" }).first - if preferredPeripheral != nil && preferredPeripheral?.peripheral != nil { - connectTo(peripheral: preferredPeripheral!.peripheral) - success = true - } - } else if connectedPeripheral != nil && isSubscribed { - success = true - } - return success - } - - public func getChannel(channel: Channel, fromUser: UserEntity, toUser: UserEntity) -> Int64 { - - var adminPacket = AdminMessage() - adminPacket.getChannelRequest = UInt32(channel.index + 1) - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setChannel = channel - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - if isConnected { - - var i: Int32 = 0 - var myInfo: MyInfoEntity - // Before we get started delete the existing channels from the myNodeInfo - if !addChannels { - tryClearExistingChannels() - } - - let decodedString = base64UrlString.base64urlToBase64() - if let decodedData = Data(base64Encoded: decodedString) { - do { - let channelSet: ChannelSet = try ChannelSet(serializedBytes: decodedData) - for cs in channelSet.settings { - if addChannels { - // We are trying to add a channel so lets get the last index - let fetchMyInfoRequest = MyInfoEntity.fetchRequest() - fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(connectedPeripheral.num)) - do { - let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) - if fetchedMyInfo.count == 1 { - i = Int32(fetchedMyInfo[0].channels?.count ?? -1) - myInfo = fetchedMyInfo[0] - // Bail out if the index is negative or bigger than our max of 8 - if i < 0 || i > 8 { - return false - } - // Bail out if there are no channels or if the same channel name already exists - guard let mutableChannels = myInfo.channels!.mutableCopy() as? NSMutableOrderedSet else { - return false - } - if mutableChannels.first(where: {($0 as AnyObject).name == cs.name }) is ChannelEntity { - return false - } - } - } catch { - Logger.data.error("Failed to find a node MyInfo to save these channels to: \(error.localizedDescription, privacy: .public)") - } - } - - var chan = Channel() - if i == 0 { - chan.role = Channel.Role.primary - } else { - chan.role = Channel.Role.secondary - } - chan.settings = cs - chan.index = i - i += 1 - - var adminPacket = AdminMessage() - adminPacket.setChannel = chan - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(connectedPeripheral.num) - meshPacket.from = UInt32(connectedPeripheral.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - if isConnected { - - let decodedString = base64UrlString.base64urlToBase64() - if let decodedData = Data(base64Encoded: decodedString) { - do { - let contact: SharedContact = try SharedContact(serializedBytes: decodedData) - var adminPacket = AdminMessage() - adminPacket.addContact = contact - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(connectedPeripheral.num) - meshPacket.from = UInt32(connectedPeripheral.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - var adminPacket = AdminMessage() - adminPacket.setOwner = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - var adminPacket = AdminMessage() - adminPacket.removeByNodenum = UInt32(node.num) - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(connectedNodeNum) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. 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).. Bool { - var adminPacket = AdminMessage() - adminPacket.setIgnoredNode = UInt32(node.num) - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(connectedNodeNum) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - var adminPacket = AdminMessage() - adminPacket.removeIgnoredNode = 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 - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - var adminPacket = AdminMessage() - adminPacket.setConfig.bluetooth = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setConfig.device = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setConfig.device = config - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(user) - meshPacket.from = UInt32(user) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - var adminPacket = AdminMessage() - adminPacket.setConfig.display = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setConfig.lora = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setConfig.position = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setConfig.power = config - - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setConfig.network = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setConfig.security = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setModuleConfig.ambientLighting = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setModuleConfig.cannedMessage = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setCannedMessageModuleMessages = messages - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setModuleConfig.detectionSensor = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setModuleConfig.externalNotification = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setModuleConfig.paxcounter = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setRingtoneMessage = ringtone - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setModuleConfig.mqtt = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setModuleConfig.rangeTest = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setModuleConfig.serial = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setModuleConfig.storeForward = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Int64 { - - var adminPacket = AdminMessage() - adminPacket.setModuleConfig.telemetry = config - if fromUser != toUser { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getChannelRequest = channelIndex - - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getCannedMessageModuleMessagesRequest = true - - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(destNum) - meshPacket.from = UInt32(connectedPeripheral.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getConfigRequest = AdminMessage.ConfigType.bluetoothConfig - if UserDefaults.enableAdministration { - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - } - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getConfigRequest = AdminMessage.ConfigType.deviceConfig - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getConfigRequest = AdminMessage.ConfigType.displayConfig - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getConfigRequest = AdminMessage.ConfigType.loraConfig - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getConfigRequest = AdminMessage.ConfigType.networkConfig - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getConfigRequest = AdminMessage.ConfigType.positionConfig - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getConfigRequest = AdminMessage.ConfigType.powerConfig - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getConfigRequest = AdminMessage.ConfigType.securityConfig - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.ambientlightingConfig - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.cannedmsgConfig - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.extnotifConfig - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.paxcounterConfig - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getRingtoneRequest = true - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.rangetestConfig - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.mqttConfig - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.detectionsensorConfig - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.serialConfig - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.storeforwardConfig - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var adminPacket = AdminMessage() - adminPacket.getModuleConfigRequest = AdminMessage.ModuleConfigType.telemetryConfig - adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data() - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { - - var toRadio: ToRadio! - toRadio = ToRadio() - toRadio.packet = meshPacket - guard let binaryData: Data = try? toRadio.serializedData() else { - return false - } - - if connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected { - connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse) - Logger.mesh.debug("\(adminDescription, privacy: .public)") - return true - } - return false - } - - public func requestStoreAndForwardClientHistory(fromUser: UserEntity, toUser: UserEntity) -> Bool { - - /// send a request for ClientHistory with a time period matching the heartbeat - var sfPacket = StoreAndForward() - sfPacket.rr = StoreAndForward.RequestResponse.clientHistory - sfPacket.history.window = UInt32(toUser.userNode?.storeForwardConfig?.historyReturnWindow ?? 120) - sfPacket.history.lastRequest = UInt32(toUser.userNode?.storeForwardConfig?.lastRequest ?? 0) - var meshPacket: MeshPacket = MeshPacket() - meshPacket.to = UInt32(toUser.num) - meshPacket.from = UInt32(fromUser.num) - meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. String { } func localConfig (config: Config, context: NSManagedObjectContext, nodeNum: Int64, nodeLongName: String) { - - if config.payloadVariant == Config.OneOf_PayloadVariant.bluetooth(config.bluetooth) { + switch config.payloadVariant { + case .bluetooth: upsertBluetoothConfigPacket(config: config.bluetooth, nodeNum: nodeNum, context: context) - } else if config.payloadVariant == Config.OneOf_PayloadVariant.device(config.device) { + case .device: upsertDeviceConfigPacket(config: config.device, nodeNum: nodeNum, context: context) - } else if config.payloadVariant == Config.OneOf_PayloadVariant.display(config.display) { + case .display: upsertDisplayConfigPacket(config: config.display, nodeNum: nodeNum, context: context) - } else if config.payloadVariant == Config.OneOf_PayloadVariant.lora(config.lora) { + case .lora: upsertLoRaConfigPacket(config: config.lora, nodeNum: nodeNum, context: context) - } else if config.payloadVariant == Config.OneOf_PayloadVariant.network(config.network) { + case .network: upsertNetworkConfigPacket(config: config.network, nodeNum: nodeNum, context: context) - } else if config.payloadVariant == Config.OneOf_PayloadVariant.position(config.position) { + case .position: upsertPositionConfigPacket(config: config.position, nodeNum: nodeNum, context: context) - } else if config.payloadVariant == Config.OneOf_PayloadVariant.power(config.power) { + case .power: upsertPowerConfigPacket(config: config.power, nodeNum: nodeNum, context: context) - } else if config.payloadVariant == Config.OneOf_PayloadVariant.security(config.security) { + case .security: upsertSecurityConfigPacket(config: config.security, nodeNum: nodeNum, context: context) + default: +#if DEBUG + Logger.services.error("⁉️ Unknown Config variant UNHANDLED \(config.payloadVariant.debugDescription, privacy: .public)") +#endif } } func moduleConfig (config: ModuleConfig, context: NSManagedObjectContext, nodeNum: Int64, nodeLongName: String) { - - if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.ambientLighting(config.ambientLighting) { + switch config.payloadVariant { + case .ambientLighting: upsertAmbientLightingModuleConfigPacket(config: config.ambientLighting, nodeNum: nodeNum, context: context) - } else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.cannedMessage(config.cannedMessage) { + case .cannedMessage: upsertCannedMessagesModuleConfigPacket(config: config.cannedMessage, nodeNum: nodeNum, context: context) - } else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.detectionSensor(config.detectionSensor) { + case .detectionSensor: upsertDetectionSensorModuleConfigPacket(config: config.detectionSensor, nodeNum: nodeNum, context: context) - } else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.externalNotification(config.externalNotification) { + case .externalNotification: upsertExternalNotificationModuleConfigPacket(config: config.externalNotification, nodeNum: nodeNum, context: context) - } else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.mqtt(config.mqtt) { + case .mqtt: upsertMqttModuleConfigPacket(config: config.mqtt, nodeNum: nodeNum, context: context) - } else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.paxcounter(config.paxcounter) { + case .paxcounter: upsertPaxCounterModuleConfigPacket(config: config.paxcounter, nodeNum: nodeNum, context: context) - } else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.rangeTest(config.rangeTest) { + case .rangeTest: upsertRangeTestModuleConfigPacket(config: config.rangeTest, nodeNum: nodeNum, context: context) - } else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.serial(config.serial) { + case .serial: upsertSerialModuleConfigPacket(config: config.serial, nodeNum: nodeNum, context: context) - } else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.telemetry(config.telemetry) { + case .telemetry: upsertTelemetryModuleConfigPacket(config: config.telemetry, nodeNum: nodeNum, context: context) - } else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.storeForward(config.storeForward) { + case .storeForward: upsertStoreForwardModuleConfigPacket(config: config.storeForward, nodeNum: nodeNum, context: context) + default: +#if DEBUG + Logger.services.error("⁉️ Unknown Module Config variant UNHANDLED \(config.payloadVariant.debugDescription, privacy: .public)") +#endif } } @@ -258,7 +266,7 @@ func deviceMetadataPacket (metadata: DeviceMetadata, fromNum: Int64, sessionPass func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObjectContext) -> NodeInfoEntity? { - let logString = String.localizedStringWithFormat("Node info received for: %@".localized, String(nodeInfo.num)) + let logString = String.localizedStringWithFormat("[NodeInfo] received for: %@".localized, String(nodeInfo.num)) Logger.mesh.info("📟 \(logString, privacy: .public)") guard nodeInfo.num > 0 else { return nil } @@ -716,139 +724,139 @@ func routingPacket (packet: MeshPacket, connectedNodeNum: Int64, context: NSMana } func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManagedObjectContext) { - - if let telemetryMessage = try? Telemetry(serializedBytes: packet.decoded.payload) { - let logString = String.localizedStringWithFormat("Telemetry received for: %@".localized, String(packet.from)) - Logger.mesh.info("📈 \(logString, privacy: .public)") - if telemetryMessage.variant != Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) && telemetryMessage.variant != Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) { - /// Other unhandled telemetry packets - return - } - let telemetry = TelemetryEntity(context: context) - let fetchNodeTelemetryRequest = NodeInfoEntity.fetchRequest() - fetchNodeTelemetryRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) - do { - let fetchedNode = try context.fetch(fetchNodeTelemetryRequest) - if fetchedNode.count == 1 { - /// Currently only Device Metrics and Environment Telemetry are supported in the app - if telemetryMessage.variant == Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) { - // Device Metrics - telemetry.airUtilTx = telemetryMessage.deviceMetrics.hasAirUtilTx.then(telemetryMessage.deviceMetrics.airUtilTx) - telemetry.channelUtilization = telemetryMessage.deviceMetrics.hasChannelUtilization.then(telemetryMessage.deviceMetrics.channelUtilization) - telemetry.batteryLevel = telemetryMessage.deviceMetrics.hasBatteryLevel.then(Int32(telemetryMessage.deviceMetrics.batteryLevel)) - telemetry.voltage = telemetryMessage.deviceMetrics.hasVoltage.then(telemetryMessage.deviceMetrics.voltage) - telemetry.uptimeSeconds = telemetryMessage.deviceMetrics.hasUptimeSeconds.then(Int32(telemetryMessage.deviceMetrics.uptimeSeconds)) - telemetry.metricsType = 0 - Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.deviceMetrics.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.deviceMetrics.airUtilTx, privacy: .public) for Node: \(packet.from.toHex(), privacy: .public)") - } else if telemetryMessage.variant == Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) { - // Environment Metrics - telemetry.barometricPressure = telemetryMessage.environmentMetrics.hasBarometricPressure.then(telemetryMessage.environmentMetrics.barometricPressure) - telemetry.current = telemetryMessage.environmentMetrics.hasCurrent.then(telemetryMessage.environmentMetrics.current) - telemetry.iaq = telemetryMessage.environmentMetrics.hasIaq.then(Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.iaq)) - telemetry.gasResistance = telemetryMessage.environmentMetrics.hasGasResistance.then(telemetryMessage.environmentMetrics.gasResistance) - telemetry.relativeHumidity = telemetryMessage.environmentMetrics.hasRelativeHumidity.then(telemetryMessage.environmentMetrics.relativeHumidity) - telemetry.temperature = telemetryMessage.environmentMetrics.hasTemperature.then(telemetryMessage.environmentMetrics.temperature) - telemetry.current = telemetryMessage.environmentMetrics.hasCurrent.then(telemetryMessage.environmentMetrics.current) - telemetry.voltage = telemetryMessage.environmentMetrics.hasVoltage.then(telemetryMessage.environmentMetrics.voltage) - telemetry.weight = telemetryMessage.environmentMetrics.hasWeight.then(telemetryMessage.environmentMetrics.weight) - telemetry.distance = telemetryMessage.environmentMetrics.hasDistance.then(telemetryMessage.environmentMetrics.distance) - telemetry.windSpeed = telemetryMessage.environmentMetrics.hasWindSpeed.then(telemetryMessage.environmentMetrics.windSpeed) - telemetry.windGust = telemetryMessage.environmentMetrics.hasWindGust.then(telemetryMessage.environmentMetrics.windGust) - telemetry.windLull = telemetryMessage.environmentMetrics.hasWindLull.then(telemetryMessage.environmentMetrics.windLull) - telemetry.windDirection = telemetryMessage.environmentMetrics.hasWindDirection.then(Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.windDirection)) - telemetry.irLux = telemetryMessage.environmentMetrics.hasIrLux.then(telemetryMessage.environmentMetrics.irLux) - telemetry.lux = telemetryMessage.environmentMetrics.hasLux.then(telemetryMessage.environmentMetrics.lux) - telemetry.whiteLux = telemetryMessage.environmentMetrics.hasWhiteLux.then(telemetryMessage.environmentMetrics.whiteLux) - telemetry.uvLux = telemetryMessage.environmentMetrics.hasUvLux.then(telemetryMessage.environmentMetrics.uvLux) - telemetry.radiation = telemetryMessage.environmentMetrics.hasRadiation.then(telemetryMessage.environmentMetrics.radiation) - telemetry.rainfall1H = telemetryMessage.environmentMetrics.hasRainfall1H.then(telemetryMessage.environmentMetrics.rainfall1H) - telemetry.rainfall24H = telemetryMessage.environmentMetrics.hasRainfall24H.then(telemetryMessage.environmentMetrics.rainfall24H) - telemetry.soilTemperature = telemetryMessage.environmentMetrics.hasSoilTemperature.then(telemetryMessage.environmentMetrics.soilTemperature) - telemetry.soilMoisture = telemetryMessage.environmentMetrics.hasSoilMoisture.then(telemetryMessage.environmentMetrics.soilMoisture) - telemetry.metricsType = 1 - } else if telemetryMessage.variant == Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) { - // Local Stats for Live activity - telemetry.uptimeSeconds = Int32(telemetryMessage.localStats.uptimeSeconds) - telemetry.channelUtilization = telemetryMessage.localStats.channelUtilization - telemetry.airUtilTx = telemetryMessage.localStats.airUtilTx - telemetry.numPacketsTx = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsTx) - telemetry.numPacketsRx = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsRx) - telemetry.numPacketsRxBad = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsRxBad) - telemetry.numRxDupe = Int32(truncatingIfNeeded: telemetryMessage.localStats.numRxDupe) - telemetry.numTxRelay = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTxRelay) - telemetry.numTxRelayCanceled = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTxRelayCanceled) - telemetry.numOnlineNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numOnlineNodes) - telemetry.numTotalNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTotalNodes) - telemetry.metricsType = 4 - Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.localStats.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.localStats.airUtilTx, privacy: .public) Packets Sent: \(telemetryMessage.localStats.numPacketsTx, privacy: .public) Packets Received: \(telemetryMessage.localStats.numPacketsRx, privacy: .public) Bad Packets Received: \(telemetryMessage.localStats.numPacketsRxBad, privacy: .public) Nodes Online: \(telemetryMessage.localStats.numOnlineNodes, privacy: .public) of \(telemetryMessage.localStats.numTotalNodes, privacy: .public) nodes for Node: \(packet.from.toHex(), privacy: .public)") - } else if telemetryMessage.variant == Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) { - Logger.data.info("📈 [Power Metrics] Received for Node: \(packet.from.toHex(), privacy: .public)") - telemetry.powerCh1Voltage = telemetryMessage.powerMetrics.hasCh1Voltage.then(telemetryMessage.powerMetrics.ch1Voltage) - telemetry.powerCh1Current = telemetryMessage.powerMetrics.hasCh1Current.then(telemetryMessage.powerMetrics.ch1Current) - telemetry.powerCh2Voltage = telemetryMessage.powerMetrics.hasCh2Voltage.then(telemetryMessage.powerMetrics.ch2Voltage) - telemetry.powerCh2Current = telemetryMessage.powerMetrics.hasCh1Current.then(telemetryMessage.powerMetrics.ch2Current) - telemetry.powerCh3Voltage = telemetryMessage.powerMetrics.hasCh3Voltage.then(telemetryMessage.powerMetrics.ch3Voltage) - telemetry.powerCh3Current = telemetryMessage.powerMetrics.hasCh3Current.then(telemetryMessage.powerMetrics.ch3Current) - telemetry.metricsType = 2 - } - telemetry.snr = packet.rxSnr - telemetry.rssi = packet.rxRssi - telemetry.time = Date(timeIntervalSince1970: TimeInterval(Int64(truncatingIfNeeded: telemetryMessage.time))) - guard let mutableTelemetries = fetchedNode[0].telemetries!.mutableCopy() as? NSMutableOrderedSet else { - return - } - mutableTelemetries.add(telemetry) - if packet.rxTime > 0 { - fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(packet.rxTime)) - } else { - fetchedNode[0].lastHeard = Date() - } - fetchedNode[0].telemetries = mutableTelemetries.copy() as? NSOrderedSet + Task { @MainActor in + if let telemetryMessage = try? Telemetry(serializedBytes: packet.decoded.payload) { + if telemetryMessage.variant != Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) && telemetryMessage.variant != Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) { + /// Other unhandled telemetry packets + return } - try context.save() - Logger.data.info("💾 [TelemetryEntity] of type \(MetricsTypes(rawValue: Int(telemetry.metricsType))?.name ?? "Unknown Metrics Type", privacy: .public) Saved for Node: \(packet.from.toHex(), privacy: .public)") - if telemetry.metricsType == 0 { - // Connected Device Metrics - // ------------------------ - // Low Battery notification - if connectedNode == Int64(packet.from) { - let batteryLevel = telemetry.batteryLevel ?? 0 - if UserDefaults.lowBatteryNotifications && batteryLevel > 0 && batteryLevel < 4 { - let manager = LocalNotificationManager() - manager.notifications = [ - Notification( - id: ("notification.id.\(UUID().uuidString)"), - title: "Critically Low Battery!", - subtitle: "AKA \(telemetry.nodeTelemetry?.user?.shortName ?? "UNK")", - content: "Time to charge your radio, there is \(telemetry.batteryLevel?.formatted(.number) ?? Constants.nilValueIndicator)% battery remaining.", - target: "nodes", - path: "meshtastic:///nodes?nodenum=\(telemetry.nodeTelemetry?.num ?? 0)" - ) - ] - manager.schedule() + let telemetry = TelemetryEntity(context: context) + let fetchNodeTelemetryRequest = NodeInfoEntity.fetchRequest() + fetchNodeTelemetryRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + do { + let fetchedNode = try context.fetch(fetchNodeTelemetryRequest) + if fetchedNode.count == 1 { + /// Currently only Device Metrics and Environment Telemetry are supported in the app + if telemetryMessage.variant == Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) { + // Device Metrics + Logger.data.info("📈 [Telemetry] Device Metrics Received for Node: \(packet.from.toHex(), privacy: .public)") + telemetry.airUtilTx = telemetryMessage.deviceMetrics.hasAirUtilTx.then(telemetryMessage.deviceMetrics.airUtilTx) + telemetry.channelUtilization = telemetryMessage.deviceMetrics.hasChannelUtilization.then(telemetryMessage.deviceMetrics.channelUtilization) + telemetry.batteryLevel = telemetryMessage.deviceMetrics.hasBatteryLevel.then(Int32(telemetryMessage.deviceMetrics.batteryLevel)) + telemetry.voltage = telemetryMessage.deviceMetrics.hasVoltage.then(telemetryMessage.deviceMetrics.voltage) + telemetry.uptimeSeconds = telemetryMessage.deviceMetrics.hasUptimeSeconds.then(Int32(telemetryMessage.deviceMetrics.uptimeSeconds)) + telemetry.metricsType = 0 + Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.deviceMetrics.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.deviceMetrics.airUtilTx, privacy: .public) for Node: \(packet.from.toHex(), privacy: .public)") + } else if telemetryMessage.variant == Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) { + // Environment Metrics + Logger.data.info("📈 [Telemetry] Environment Metrics Received for Node: \(packet.from.toHex(), privacy: .public)") + telemetry.barometricPressure = telemetryMessage.environmentMetrics.hasBarometricPressure.then(telemetryMessage.environmentMetrics.barometricPressure) + telemetry.current = telemetryMessage.environmentMetrics.hasCurrent.then(telemetryMessage.environmentMetrics.current) + telemetry.iaq = telemetryMessage.environmentMetrics.hasIaq.then(Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.iaq)) + telemetry.gasResistance = telemetryMessage.environmentMetrics.hasGasResistance.then(telemetryMessage.environmentMetrics.gasResistance) + telemetry.relativeHumidity = telemetryMessage.environmentMetrics.hasRelativeHumidity.then(telemetryMessage.environmentMetrics.relativeHumidity) + telemetry.temperature = telemetryMessage.environmentMetrics.hasTemperature.then(telemetryMessage.environmentMetrics.temperature) + telemetry.current = telemetryMessage.environmentMetrics.hasCurrent.then(telemetryMessage.environmentMetrics.current) + telemetry.voltage = telemetryMessage.environmentMetrics.hasVoltage.then(telemetryMessage.environmentMetrics.voltage) + telemetry.weight = telemetryMessage.environmentMetrics.hasWeight.then(telemetryMessage.environmentMetrics.weight) + telemetry.distance = telemetryMessage.environmentMetrics.hasDistance.then(telemetryMessage.environmentMetrics.distance) + telemetry.windSpeed = telemetryMessage.environmentMetrics.hasWindSpeed.then(telemetryMessage.environmentMetrics.windSpeed) + telemetry.windGust = telemetryMessage.environmentMetrics.hasWindGust.then(telemetryMessage.environmentMetrics.windGust) + telemetry.windLull = telemetryMessage.environmentMetrics.hasWindLull.then(telemetryMessage.environmentMetrics.windLull) + telemetry.windDirection = telemetryMessage.environmentMetrics.hasWindDirection.then(Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.windDirection)) + telemetry.irLux = telemetryMessage.environmentMetrics.hasIrLux.then(telemetryMessage.environmentMetrics.irLux) + telemetry.lux = telemetryMessage.environmentMetrics.hasLux.then(telemetryMessage.environmentMetrics.lux) + telemetry.whiteLux = telemetryMessage.environmentMetrics.hasWhiteLux.then(telemetryMessage.environmentMetrics.whiteLux) + telemetry.uvLux = telemetryMessage.environmentMetrics.hasUvLux.then(telemetryMessage.environmentMetrics.uvLux) + telemetry.radiation = telemetryMessage.environmentMetrics.hasRadiation.then(telemetryMessage.environmentMetrics.radiation) + telemetry.rainfall1H = telemetryMessage.environmentMetrics.hasRainfall1H.then(telemetryMessage.environmentMetrics.rainfall1H) + telemetry.rainfall24H = telemetryMessage.environmentMetrics.hasRainfall24H.then(telemetryMessage.environmentMetrics.rainfall24H) + telemetry.soilTemperature = telemetryMessage.environmentMetrics.hasSoilTemperature.then(telemetryMessage.environmentMetrics.soilTemperature) + telemetry.soilMoisture = telemetryMessage.environmentMetrics.hasSoilMoisture.then(telemetryMessage.environmentMetrics.soilMoisture) + telemetry.metricsType = 1 + } else if telemetryMessage.variant == Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) { + // Local Stats for Live activity + telemetry.uptimeSeconds = Int32(telemetryMessage.localStats.uptimeSeconds) + telemetry.channelUtilization = telemetryMessage.localStats.channelUtilization + telemetry.airUtilTx = telemetryMessage.localStats.airUtilTx + telemetry.numPacketsTx = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsTx) + telemetry.numPacketsRx = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsRx) + telemetry.numPacketsRxBad = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsRxBad) + telemetry.numRxDupe = Int32(truncatingIfNeeded: telemetryMessage.localStats.numRxDupe) + telemetry.numTxRelay = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTxRelay) + telemetry.numTxRelayCanceled = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTxRelayCanceled) + telemetry.numOnlineNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numOnlineNodes) + telemetry.numTotalNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTotalNodes) + telemetry.metricsType = 4 + Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.localStats.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.localStats.airUtilTx, privacy: .public) Packets Sent: \(telemetryMessage.localStats.numPacketsTx, privacy: .public) Packets Received: \(telemetryMessage.localStats.numPacketsRx, privacy: .public) Bad Packets Received: \(telemetryMessage.localStats.numPacketsRxBad, privacy: .public) Nodes Online: \(telemetryMessage.localStats.numOnlineNodes, privacy: .public) of \(telemetryMessage.localStats.numTotalNodes, privacy: .public) nodes for Node: \(packet.from.toHex(), privacy: .public)") + } else if telemetryMessage.variant == Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) { + Logger.data.info("📈 [Telemetry] Power Metrics Received for Node: \(packet.from.toHex(), privacy: .public)") + telemetry.powerCh1Voltage = telemetryMessage.powerMetrics.hasCh1Voltage.then(telemetryMessage.powerMetrics.ch1Voltage) + telemetry.powerCh1Current = telemetryMessage.powerMetrics.hasCh1Current.then(telemetryMessage.powerMetrics.ch1Current) + telemetry.powerCh2Voltage = telemetryMessage.powerMetrics.hasCh2Voltage.then(telemetryMessage.powerMetrics.ch2Voltage) + telemetry.powerCh2Current = telemetryMessage.powerMetrics.hasCh1Current.then(telemetryMessage.powerMetrics.ch2Current) + telemetry.powerCh3Voltage = telemetryMessage.powerMetrics.hasCh3Voltage.then(telemetryMessage.powerMetrics.ch3Voltage) + telemetry.powerCh3Current = telemetryMessage.powerMetrics.hasCh3Current.then(telemetryMessage.powerMetrics.ch3Current) + telemetry.metricsType = 2 } + telemetry.snr = packet.rxSnr + telemetry.rssi = packet.rxRssi + telemetry.time = Date(timeIntervalSince1970: TimeInterval(Int64(truncatingIfNeeded: telemetryMessage.time))) + guard let mutableTelemetries = fetchedNode[0].telemetries!.mutableCopy() as? NSMutableOrderedSet else { + return + } + mutableTelemetries.add(telemetry) + if packet.rxTime > 0 { + fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(packet.rxTime)) + } else { + fetchedNode[0].lastHeard = Date() + } + fetchedNode[0].telemetries = mutableTelemetries.copy() as? NSOrderedSet } - } else if telemetry.metricsType == 4 { - // Update our live activity if there is one running, not available on mac + try context.save() + Logger.data.info("💾 [TelemetryEntity] of type \(MetricsTypes(rawValue: Int(telemetry.metricsType))?.name ?? "Unknown Metrics Type", privacy: .public) Saved for Node: \(packet.from.toHex(), privacy: .public)") + if telemetry.metricsType == 0 { + // Connected Device Metrics + // ------------------------ + // Low Battery notification + if connectedNode == Int64(packet.from) { + let batteryLevel = telemetry.batteryLevel ?? 0 + if UserDefaults.lowBatteryNotifications && batteryLevel > 0 && batteryLevel < 4 { + let manager = LocalNotificationManager() + manager.notifications = [ + Notification( + id: ("notification.id.\(UUID().uuidString)"), + title: "Critically Low Battery!", + subtitle: "AKA \(telemetry.nodeTelemetry?.user?.shortName ?? "UNK")", + content: "Time to charge your radio, there is \(telemetry.batteryLevel?.formatted(.number) ?? Constants.nilValueIndicator)% battery remaining.", + target: "nodes", + path: "meshtastic:///nodes?nodenum=\(telemetry.nodeTelemetry?.num ?? 0)" + ) + ] + manager.schedule() + } + } + } else if telemetry.metricsType == 4 { + // Update our live activity if there is one running, not available on mac #if !targetEnvironment(macCatalyst) #if canImport(ActivityKit) - let fifteenMinutesLater = Calendar.current.date(byAdding: .minute, value: (Int(15) ), to: Date())! - let date = Date.now...fifteenMinutesLater - let updatedMeshStatus = MeshActivityAttributes.MeshActivityStatus(uptimeSeconds: telemetry.uptimeSeconds.map { UInt32($0) }, - channelUtilization: telemetry.channelUtilization, - airtime: telemetry.airUtilTx, - sentPackets: UInt32(telemetry.numPacketsTx), - receivedPackets: UInt32(telemetry.numPacketsRx), - badReceivedPackets: UInt32(telemetry.numPacketsRxBad), - dupeReceivedPackets: UInt32(telemetry.numRxDupe), - packetsSentRelay: UInt32(telemetry.numTxRelay), - packetsCanceledRelay: UInt32(telemetry.numTxRelayCanceled), - nodesOnline: UInt32(telemetry.numOnlineNodes), - totalNodes: UInt32(telemetry.numTotalNodes), - timerRange: date) + let fifteenMinutesLater = Calendar.current.date(byAdding: .minute, value: (Int(15) ), to: Date())! + let date = Date.now...fifteenMinutesLater + let updatedMeshStatus = MeshActivityAttributes.MeshActivityStatus(uptimeSeconds: telemetry.uptimeSeconds.map { UInt32($0) }, + channelUtilization: telemetry.channelUtilization, + airtime: telemetry.airUtilTx, + sentPackets: UInt32(telemetry.numPacketsTx), + receivedPackets: UInt32(telemetry.numPacketsRx), + badReceivedPackets: UInt32(telemetry.numPacketsRxBad), + dupeReceivedPackets: UInt32(telemetry.numRxDupe), + packetsSentRelay: UInt32(telemetry.numTxRelay), + packetsCanceledRelay: UInt32(telemetry.numTxRelayCanceled), + nodesOnline: UInt32(telemetry.numOnlineNodes), + totalNodes: UInt32(telemetry.numTotalNodes), + timerRange: date) - let alertConfiguration = AlertConfiguration(title: "Mesh activity update", body: "Updated Node Stats Data.", sound: .default) + let alertConfiguration = AlertConfiguration(title: "Mesh activity update", body: "Updated Node Stats Data.", sound: .default) let updatedContent = ActivityContent(state: updatedMeshStatus, staleDate: nil) let meshActivity = Activity.activities.first(where: { $0.attributes.nodeNum == connectedNode }) @@ -861,14 +869,15 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage } #endif #endif + } + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 Error Saving Telemetry for Node \(packet.from, privacy: .public) Error: \(nsError, privacy: .public)") } - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 Error Saving Telemetry for Node \(packet.from, privacy: .public) Error: \(nsError, privacy: .public)") + } else { + Logger.data.error("💥 Error Fetching NodeInfoEntity for Node \(packet.from.toHex(), privacy: .public)") } - } else { - Logger.data.error("💥 Error Fetching NodeInfoEntity for Node \(packet.from.toHex(), privacy: .public)") } } @@ -879,7 +888,7 @@ func textMessageAppPacket( connectedNode: Int64, storeForward: Bool = false, context: NSManagedObjectContext, - appState: AppState + appState: AppState? ) { var messageText = String(bytes: packet.decoded.payload, encoding: .utf8) let rangeRef = Reference(Int.self) @@ -1015,7 +1024,7 @@ func textMessageAppPacket( if newMessage.fromUser != nil && newMessage.toUser != nil { // Set Unread Message Indicators if packet.to == connectedNode { - appState.unreadDirectMessages = newMessage.toUser?.unreadMessages ?? 0 + appState?.unreadDirectMessages = newMessage.toUser?.unreadMessages ?? 0 } if !(newMessage.fromUser?.mute ?? false) { // Create an iOS Notification for the received DM message @@ -1043,7 +1052,7 @@ func textMessageAppPacket( do { let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) if !fetchedMyInfo.isEmpty { - appState.unreadChannelMessages = fetchedMyInfo[0].unreadMessages + appState?.unreadChannelMessages = fetchedMyInfo[0].unreadMessages for channel in (fetchedMyInfo[0].channels?.array ?? []) as? [ChannelEntity] ?? [] { if channel.index == newMessage.channel { context.refresh(channel, mergeChanges: true) diff --git a/Meshtastic/Info.plist b/Meshtastic/Info.plist index 3c6cc130..307df86a 100644 --- a/Meshtastic/Info.plist +++ b/Meshtastic/Info.plist @@ -100,9 +100,15 @@ NSBluetoothAlwaysUsageDescription We use bluetooth to connect to nearby Meshtastic Devices NSBluetoothPeripheralUsageDescription - Bluetooth is used to connect an iPhone to a user's meshtastic device to allow text messaging and location data for the mesh network. + Bluetooth is used to connect an iPhone to a user's meshtastic device to allow text messaging and location data for the mesh network. + NSBonjourServices + + _meshtastic._tcp + NSCameraUsageDescription We use the camera to share channels using a QR Code + NSLocalNetworkUsageDescription + We use local networking to connect to network-based nodes. NSLocationAlwaysAndWhenInUseUsageDescription We use your location to display it on the mesh map as well as to have GPS coordinates to send to the connected device. Route Recording uses location in the background. NSLocationAlwaysUsageDescription diff --git a/Meshtastic/Meshtastic.entitlements b/Meshtastic/Meshtastic.entitlements index 3689ef3e..4dbdb836 100644 --- a/Meshtastic/Meshtastic.entitlements +++ b/Meshtastic/Meshtastic.entitlements @@ -17,6 +17,10 @@ com.apple.security.device.bluetooth + com.apple.security.device.serial + + com.apple.security.device.usb + com.apple.security.files.user-selected.read-write com.apple.security.network.client diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index 4134f551..aa16b57a 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -10,7 +10,9 @@ import DatadogCrashReporting import DatadogRUM import DatadogTrace import DatadogLogs - +#if DEBUG +import DatadogSessionReplay +#endif @main struct MeshtasticAppleApp: App { @@ -19,7 +21,7 @@ struct MeshtasticAppleApp: App { @ObservedObject var appState: AppState private let persistenceController: PersistenceController - + private let accessoryManager: AccessoryManager @Environment(\.scenePhase) var scenePhase @State var saveChannels = false @State var incomingUrl: URL? @@ -27,7 +29,9 @@ struct MeshtasticAppleApp: App { @State var addChannels = false init() { + let persistenceController = PersistenceController.shared + let appState = AppState( router: Router() ) @@ -35,9 +39,13 @@ struct MeshtasticAppleApp: App { // RUM Client Tokens are NOT secret let appID = "79fe92a9-74c9-4c8f-ba63-6308384ecfa9" let clientToken = "pub4427bea20dbdb08a6af68034de22cd3b" - let environment = "testflight" + var environment = "AppStore" #if !targetEnvironment(macCatalyst) + +#if DEBUG + environment = "TestFlight" +#endif Datadog.initialize( with: Datadog.Configuration( clientToken: clientToken, @@ -67,10 +75,24 @@ struct MeshtasticAppleApp: App { "hardware_model": UserDefaults.hardwareModel ] RUMMonitor.shared().addAttributes(attributes) +#if DEBUG + SessionReplay.enable( + with: SessionReplay.Configuration( + replaySampleRate: 100, + textAndInputPrivacyLevel: .maskSensitiveInputs, + imagePrivacyLevel: .maskNone, + touchPrivacyLevel: .show, + startRecordingImmediately: true, + featureFlags: [.swiftui: true] + ) + ) #endif +#endif + accessoryManager = AccessoryManager.shared + accessoryManager.appState = appState + self._appState = ObservedObject(wrappedValue: appState) - // Initialize the BLEManager singleton with the necessary dependencies - BLEManager.setup(appState: appState, context: persistenceController.container.viewContext) + self.persistenceController = persistenceController // Wire up router self.appDelegate.router = appState.router @@ -81,6 +103,13 @@ struct MeshtasticAppleApp: App { // Show tips in development try? Tips.resetDatastore() #endif + if !UserDefaults.firstLaunch { + // If this is first launch, we will show onboarding screens which + // Step through the authorization process. Do not start discovery + // unitl this workflow completes, otherwise the discovery process + // may trigger permission dialogs too soon. + accessoryManager.startDiscovery() + } } var body: some Scene { WindowGroup { @@ -102,8 +131,7 @@ struct MeshtasticAppleApp: App { SaveChannelQRCode( channelSetLink: channelSettings ?? "Empty Channel URL", addChannels: addChannels, - bleManager: BLEManager.shared - ) + accessoryManager: accessoryManager ) .presentationDetents([.large]) .presentationDragIndicator(.visible) } @@ -112,7 +140,7 @@ struct MeshtasticAppleApp: App { self.incomingUrl = userActivity.webpageURL self.saveChannels = false if self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/v/#") == true { - ContactURLHandler.handleContactUrl(url: self.incomingUrl!, bleManager: BLEManager.shared) + ContactURLHandler.handleContactUrl(url: self.incomingUrl!, accessoryManager: accessoryManager) } else if self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/e/") == true { if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") { self.addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false @@ -140,7 +168,7 @@ struct MeshtasticAppleApp: App { Logger.mesh.debug("Some sort of URL was received \(url, privacy: .public)") self.incomingUrl = url if url.absoluteString.lowercased().contains("meshtastic.org/v/#") { - ContactURLHandler.handleContactUrl(url: url, bleManager: BLEManager.shared) + ContactURLHandler.handleContactUrl(url: url, accessoryManager: accessoryManager) } else if url.absoluteString.lowercased().contains("meshtastic.org/e/") { if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") { self.addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false @@ -179,8 +207,8 @@ struct MeshtasticAppleApp: App { switch newScenePhase { case .background: Logger.services.info("🎬 [App] Scene is in the background") + accessoryManager.appDidEnterBackground() do { - try persistenceController.container.viewContext.save() Logger.services.info("💾 [App] Saved CoreData ViewContext when the app went to the background.") @@ -192,13 +220,14 @@ struct MeshtasticAppleApp: App { Logger.services.info("🎬 [App] Scene is inactive") case .active: Logger.services.info("🎬 [App] Scene is active") + accessoryManager.appDidBecomeActive() @unknown default: Logger.services.error("🍎 [App] Apple must have changed something") } } .environment(\.managedObjectContext, persistenceController.container.viewContext) .environmentObject(appState) - .environmentObject(BLEManager.shared) + .environmentObject(accessoryManager) } } diff --git a/Meshtastic/MeshtasticAppDelegate.swift b/Meshtastic/MeshtasticAppDelegate.swift index bfa2ed1e..19521601 100644 --- a/Meshtastic/MeshtasticAppDelegate.swift +++ b/Meshtastic/MeshtasticAppDelegate.swift @@ -50,45 +50,58 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat case "messageNotification.thumbsUpAction": if let channel = userInfo["channel"] as? Int32, let replyID = userInfo["messageId"] as? Int64 { - let tapbackResponse = !BLEManager.shared.sendMessage( - message: Tapbacks.thumbsUp.emojiString, - toUserNum: userInfo["userNum"] as? Int64 ?? 0, - channel: channel, - isEmoji: true, - replyID: replyID - ) - Logger.services.info("Tapback response sent") - } else { - Logger.services.error("Failed to retrieve channel or messageId from userInfo") + Task { + do { + try await AccessoryManager.shared.sendMessage( + message: Tapbacks.thumbsUp.emojiString, + toUserNum: userInfo["userNum"] as? Int64 ?? 0, + channel: channel, + isEmoji: true, + replyID: replyID + ) + Logger.services.info("Tapback response sent") + } catch { + Logger.services.error("Failed to retrieve channel or messageId from userInfo") + } + } } case "messageNotification.thumbsDownAction": if let channel = userInfo["channel"] as? Int32, let replyID = userInfo["messageId"] as? Int64 { - let tapbackResponse = !BLEManager.shared.sendMessage( - message: Tapbacks.thumbsDown.emojiString, - toUserNum: userInfo["userNum"] as? Int64 ?? 0, - channel: channel, - isEmoji: true, - replyID: replyID - ) - Logger.services.info("Tapback response sent") - } else { - Logger.services.error("Failed to retrieve channel or messageId from userInfo") + Task { + do { + try await AccessoryManager.shared.sendMessage( + message: Tapbacks.thumbsDown.emojiString, + toUserNum: userInfo["userNum"] as? Int64 ?? 0, + channel: channel, + isEmoji: true, + replyID: replyID + ) + Logger.services.info("Tapback response sent") + } catch { + Logger.services.error("Failed to retrieve channel or messageId from userInfo") + } + } } case "messageNotification.replyInputAction": if let userInput = (response as? UNTextInputNotificationResponse)?.userText, let channel = userInfo["channel"] as? Int32, let replyID = userInfo["messageId"] as? Int64 { - let tapbackResponse = !BLEManager.shared.sendMessage( - message: userInput, - toUserNum: userInfo["userNum"] as? Int64 ?? 0, - channel: channel, - isEmoji: false, - replyID: replyID - ) - Logger.services.info("Actionable notification reply sent") - } else { - Logger.services.error("Failed to retrieve user input, channel, or messageId from userInfo") + Task { + do { + try await AccessoryManager.shared.sendMessage( + message: userInput, + toUserNum: userInfo["userNum"] as? Int64 ?? 0, + channel: channel, + isEmoji: false, + replyID: replyID + ) + + Logger.services.info("Actionable notification reply sent") + } catch { + Logger.services.error("Failed to retrieve user input, channel, or messageId from userInfo") + } + } } default: break diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index b69c8442..9dbb9a5a 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -167,7 +167,7 @@ public func clearCoreDataDatabase(context: NSManagedObjectContext, includeRoutes func upsertNodeInfoPacket (packet: MeshPacket, context: NSManagedObjectContext) { - let logString = String.localizedStringWithFormat("Node info received for: %@".localized, packet.from.toHex()) + let logString = String.localizedStringWithFormat("[NodeInfo] received for: %@".localized, packet.from.toHex()) Logger.mesh.info("📟 \(logString, privacy: .public)") guard packet.from > 0 else { return } @@ -403,7 +403,7 @@ func upsertNodeInfoPacket (packet: MeshPacket, context: NSManagedObjectContext) func upsertPositionPacket (packet: MeshPacket, context: NSManagedObjectContext) { - let logString = String.localizedStringWithFormat("Position Packet received from node: %@".localized, String(packet.from)) + let logString = String.localizedStringWithFormat("[Position] received from node: %@".localized, String(packet.from)) Logger.mesh.info("📍 \(logString, privacy: .public)") let fetchNodePositionRequest = NodeInfoEntity.fetchRequest() diff --git a/Meshtastic/Resources/DeviceHardware.json b/Meshtastic/Resources/DeviceHardware.json index e2cfca54..b38eeae9 100644 --- a/Meshtastic/Resources/DeviceHardware.json +++ b/Meshtastic/Resources/DeviceHardware.json @@ -27,7 +27,7 @@ "platformioTarget": "tlora-v2-1-1_6", "architecture": "esp32", "activelySupported": true, - "supportLevel": 1, + "supportLevel": 3, "displayName": "LILYGO T-LoRa V2.1-1.6", "tags": [ "LilyGo" @@ -42,7 +42,7 @@ "platformioTarget": "tbeam", "architecture": "esp32", "activelySupported": true, - "supportLevel": 1, + "supportLevel": 3, "displayName": "LILYGO T-Beam", "tags": [ "LilyGo" @@ -163,6 +163,7 @@ "platformioTarget": "rak11200", "architecture": "esp32", "activelySupported": true, + "supportLevel": 3, "displayName": "RAK WisBlock 11200", "tags": [ "RAK" @@ -189,7 +190,7 @@ "platformioTarget": "tlora-v2-1-1_8", "architecture": "esp32", "activelySupported": true, - "supportLevel": 2, + "supportLevel": 3, "displayName": "LILYGO T-LoRa V2.1-1.8", "tags": [ "LilyGo", @@ -266,7 +267,7 @@ "platformioTarget": "wio-tracker-wm1110", "architecture": "nrf52840", "activelySupported": true, - "supportLevel": 1, + "supportLevel": 3, "displayName": "Seeed Wio WM1110 Tracker", "tags": [ "Seeed" @@ -536,7 +537,7 @@ "platformioTarget": "t-watch-s3", "architecture": "esp32-s3", "activelySupported": true, - "supportLevel": 1, + "supportLevel": 3, "displayName": "LILYGO T-Watch S3", "tags": [ "LilyGo" @@ -766,7 +767,7 @@ "platformioTarget": "seeed-xiao-s3", "architecture": "esp32-s3", "activelySupported": true, - "supportLevel": 1, + "supportLevel": 3, "displayName": "Seeed Xiao ESP32-S3", "tags": [ "Seeed" @@ -777,6 +778,22 @@ "requiresDfu": true, "partitionScheme": "8MB" }, + { + "hwModel": 105, + "hwModelSlug": "WISMESH_TAG", + "platformioTarget": "rak_wismeshtag", + "architecture": "nrf52840", + "activelySupported": true, + "supportLevel": 1, + "displayName": "RAK WisMesh Tag", + "tags": [ + "RAK" + ], + "images": [ + "rak_wismesh_tag.svg" + ], + "requiresDfu": true + }, { "hwModel": 84, "hwModelSlug": "WISMESH_TAP", @@ -858,6 +875,23 @@ ], "hasInkHud": true }, + { + "hwModel": 107, + "hwModelSlug": "THINKNODE_M5", + "platformioTarget": "thinknode_m5", + "architecture": "esp32-s3", + "activelySupported": true, + "supportLevel": 1, + "displayName": "ThinkNode M5", + "tags": [ + "Elecrow" + ], + "requiresDfu": false, + "images": [ + "thinknode_m1.svg" + ], + "hasInkHud": true + }, { "hwModel": 90, "hwModelSlug": "THINKNODE_M2", @@ -992,5 +1026,23 @@ ], "partitionScheme": "16MB", "hasMui": true + }, + { + "hwModel": 102, + "hwModelSlug": "T_DECK_PRO", + "platformioTarget": "t-deck-pro", + "architecture": "esp32-s3", + "activelySupported": false, + "supportLevel": 1, + "displayName": "LILYGO T-Deck Pro", + "tags": [ + "LilyGo" + ], + "images": [ + "tdeck_pro.svg" + ], + "requiresDfu": true, + "hasMui": false, + "partitionScheme": "16MB" } ] diff --git a/Meshtastic/Router/NavigationState.swift b/Meshtastic/Router/NavigationState.swift index d74d2a3f..48a97b93 100644 --- a/Meshtastic/Router/NavigationState.swift +++ b/Meshtastic/Router/NavigationState.swift @@ -57,13 +57,13 @@ enum SettingsNavigationState: String { struct NavigationState: Hashable { enum Tab: String, Hashable { case messages - case bluetooth + case connect case nodes case map case settings } - var selectedTab: Tab = .bluetooth + var selectedTab: Tab = .connect var messages: MessagesNavigationState? var nodeListSelectedNodeNum: Int64? var map: MapNavigationState? diff --git a/Meshtastic/Router/Router.swift b/Meshtastic/Router/Router.swift index 8ba39609..b5274966 100644 --- a/Meshtastic/Router/Router.swift +++ b/Meshtastic/Router/Router.swift @@ -13,30 +13,30 @@ class Router: ObservableObject { init( navigationState: NavigationState = NavigationState( - selectedTab: .bluetooth + selectedTab: .connect ) ) { self.navigationState = navigationState $navigationState.sink { destination in - Logger.services.info("🛣 Routed to \(String(describing: destination), privacy: .public)") + Logger.services.info("🛣 [App] Routed to \(destination.selectedTab.rawValue, privacy: .public)") }.store(in: &cancellables) } func route(url: URL) { guard url.scheme == "meshtastic" else { - Logger.services.error("🛣 Received routing URL \(url, privacy: .public) with invalid scheme. Ignoring route.") + Logger.services.error("🛣 [App] Received routing URL \(url, privacy: .public) with invalid scheme. Ignoring route.") return } guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - Logger.services.error("🛣 Received routing URL \(url, privacy: .public) with invalid host path. Ignoring route.") + Logger.services.error("🛣 [App] Received routing URL \(url, privacy: .public) with invalid host path. Ignoring route.") return } if components.path == "/messages" { routeMessages(components) - } else if components.path == "/bluetooth" { - navigationState.selectedTab = .bluetooth + } else if components.path == "/connect" { + navigationState.selectedTab = .connect } else if components.path == "/nodes" { routeNodes(components) } else if components.path == "/map" { @@ -44,7 +44,7 @@ class Router: ObservableObject { } else if components.path.hasPrefix("/settings") { routeSettings(components) } else { - Logger.services.warning("Failed to route url: \(url, privacy: .public)") + Logger.services.warning("🛣 [App] Failed to route url: \(url, privacy: .public)") } } diff --git a/Meshtastic/ShowTime.swift b/Meshtastic/ShowTime.swift new file mode 100644 index 00000000..e69de29b diff --git a/Meshtastic/Tips/BluetoothTips.swift b/Meshtastic/Tips/BluetoothTips.swift index 72c22108..72a6e8e1 100644 --- a/Meshtastic/Tips/BluetoothTips.swift +++ b/Meshtastic/Tips/BluetoothTips.swift @@ -7,16 +7,16 @@ import SwiftUI import TipKit -struct BluetoothConnectionTip: Tip { +struct ConnectionTip: Tip { var id: String { - return "tip.bluetooth.connect" + return "tip.connect" } var title: Text { Text("Connected Radio") } var message: Text? { - Text("Shows information for the Lora radio connected via bluetooth. You can swipe left to disconnect the radio and long press to start the live activity.") + Text("Shows information for the connected Lora radio. You can swipe left to disconnect the radio and long press to start the live activity.") } var image: Image? { Image(systemName: "flipphone") diff --git a/Meshtastic/Views/Bluetooth/Connect.swift b/Meshtastic/Views/Bluetooth/Connect.swift deleted file mode 100644 index 8d154d8a..00000000 --- a/Meshtastic/Views/Bluetooth/Connect.swift +++ /dev/null @@ -1,379 +0,0 @@ -// -// Connect.swift -// Meshtastic Apple -// -// Copyright(c) Garth Vander Houwen 8/18/21. -// - -import SwiftUI -import MapKit -import CoreData -import CoreLocation -import CoreBluetooth -import OSLog -import TipKit -#if canImport(ActivityKit) -import ActivityKit -#endif - -struct Connect: View { - - @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager - @ObservedObject var router: Router - @State var node: NodeInfoEntity? - @State var isUnsetRegion = false - @State var invalidFirmwareVersion = false - @State var liveActivityStarted = false - @State var presentingSwitchPreferredPeripheral = false - @State var selectedPeripherialId = "" - - var body: some View { - NavigationStack { - VStack { - List { - if bleManager.isSwitchedOn { - Section { - if let connectedPeripheral = bleManager.connectedPeripheral, connectedPeripheral.peripheral.state == .connected { - TipView(BluetoothConnectionTip(), arrowEdge: .bottom) - .tipViewStyle(PersistentTip()) - VStack(alignment: .leading) { - HStack { - VStack(alignment: .center) { - CircleText(text: node?.user?.shortName ?? "?", color: Color(UIColor(hex: UInt32(node?.num ?? 0))), circleSize: 90) - .padding(.trailing, 5) - if node?.latestDeviceMetrics != nil { - BatteryCompact(batteryLevel: node?.latestDeviceMetrics?.batteryLevel ?? 0, font: .caption, iconFont: .callout, color: .accentColor) - .padding(.trailing, 5) - } - } - .padding(.trailing) - VStack(alignment: .leading) { - if node != nil { - Text(connectedPeripheral.longName.addingVariationSelectors).font(.title2) - } - Text("BLE Name").font(.callout)+Text(": \(bleManager.connectedPeripheral?.peripheral.name?.addingVariationSelectors ?? "Unknown".localized)") - .font(.callout).foregroundColor(Color.gray) - if node != nil { - Text("Firmware Version").font(.callout)+Text(": \(node?.metadata?.firmwareVersion ?? "Unknown".localized)") - .font(.callout).foregroundColor(Color.gray) - } - if bleManager.isSubscribed { - Text("Subscribed").font(.callout) - .foregroundColor(.green) - } else { - HStack { - Image(systemName: "square.stack.3d.down.forward") - .symbolRenderingMode(.multicolor) - .symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3)) - .foregroundColor(.orange) - Text("Communicating").font(.callout) - .foregroundColor(.orange) - } - } - } - } - } - .font(.caption) - .foregroundColor(Color.gray) - .padding([.top]) - .swipeActions { - if bleManager.allowDisconnect { - Button(role: .destructive) { - if let connectedPeripheral = bleManager.connectedPeripheral, - connectedPeripheral.peripheral.state == .connected { - bleManager.disconnectPeripheral(reconnect: false) - } - } label: { - Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash") - } - } - } - .contextMenu { - - if node != nil { - Label("\(String(node!.num))", systemImage: "number") - Label("BLE RSSI \(connectedPeripheral.rssi)", systemImage: "cellularbars") - #if !targetEnvironment(macCatalyst) - if bleManager.isSubscribed { - Button { - if !liveActivityStarted { - #if canImport(ActivityKit) - Logger.services.info("Start live activity.") - startNodeActivity() - #endif - } else { - #if canImport(ActivityKit) - Logger.services.info("Stop live activity.") - endActivity() - #endif - } - } label: { - Label("Mesh Live Activity", systemImage: liveActivityStarted ? "stop" : "play") - } - } - #endif - if bleManager.allowDisconnect { - Button(role: .destructive) { - if let connectedPeripheral = bleManager.connectedPeripheral, - connectedPeripheral.peripheral.state == .connected { - bleManager.disconnectPeripheral(reconnect: false) - } - } label: { - Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash") - } - Button(role: .destructive) { - if !bleManager.sendShutdown(fromUser: node!.user!, toUser: node!.user!) { - Logger.mesh.error("Shutdown Failed") - } - - } label: { - Label("Power Off", systemImage: "power") - } - } - } - } - if isUnsetRegion { - HStack { - NavigationLink { - LoRaConfig(node: node) - } label: { - Label("Set LoRa Region", systemImage: "globe.americas.fill") - .foregroundColor(.red) - .font(.title) - } - } - } - } else { - if bleManager.isConnecting { - HStack { - Image(systemName: "antenna.radiowaves.left.and.right") - .resizable() - .symbolRenderingMode(.hierarchical) - .foregroundColor(.orange) - .frame(width: 60, height: 60) - .padding(.trailing) - if bleManager.timeoutTimerCount == 0 { - Text("Connecting . .") - .font(.title2) - .foregroundColor(.orange) - } else { - VStack { - - Text("Connection Attempt \(bleManager.timeoutTimerCount) of 10") - .font(.callout) - .foregroundColor(.orange) - } - } - } - .padding() - .swipeActions { - Button(role: .destructive) { - bleManager.cancelPeripheralConnection() - } label: { - Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash") - } - } - - } else { - - if bleManager.lastConnectionError.count > 0 { - Text(bleManager.lastConnectionError).font(.callout).foregroundColor(.red) - } - HStack { - Image(systemName: "antenna.radiowaves.left.and.right.slash") - .resizable() - .symbolRenderingMode(.hierarchical) - .foregroundColor(.red) - .frame(width: 60, height: 60) - .padding(.trailing) - Text("No device connected").font(.title3) - } - .padding() - } - } - } - .textCase(nil) - - if !self.bleManager.isConnected { - Section(header: Text("Available Radios").font(.title)) { - ForEach(bleManager.peripherals.filter({ $0.peripheral.state == CBPeripheralState.disconnected }).sorted(by: { $0.name < $1.name })) { peripheral in - HStack { - if UserDefaults.preferredPeripheralId == peripheral.peripheral.identifier.uuidString { - Image(systemName: "star.fill") - .imageScale(.large).foregroundColor(.yellow) - .padding(.trailing) - } else { - Image(systemName: "circle.fill") - .imageScale(.large).foregroundColor(.gray) - .padding(.trailing) - } - Button(action: { - if UserDefaults.preferredPeripheralId.count > 0 && peripheral.peripheral.identifier.uuidString != UserDefaults.preferredPeripheralId { - if let connectedPeripheral = bleManager.connectedPeripheral, connectedPeripheral.peripheral.state == CBPeripheralState.connected { - bleManager.disconnectPeripheral() - } - presentingSwitchPreferredPeripheral = true - selectedPeripherialId = peripheral.peripheral.identifier.uuidString - } else { - self.bleManager.connectTo(peripheral: peripheral.peripheral) - } - }) { - Text(peripheral.name).font(.callout) - } - Spacer() - VStack { - SignalStrengthIndicator(signalStrength: peripheral.getSignalStrength()) - } - }.padding([.bottom, .top]) - } - } - .confirmationDialog("Connecting to a new radio will clear all app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) { - Button("Connect to new radio?", role: .destructive) { - UserDefaults.preferredPeripheralId = selectedPeripherialId - UserDefaults.preferredPeripheralNum = 0 - if bleManager.connectedPeripheral != nil && bleManager.connectedPeripheral.peripheral.state == CBPeripheralState.connected { - bleManager.disconnectPeripheral() - } - clearCoreDataDatabase(context: context, includeRoutes: false) - let radio = bleManager.peripherals.first(where: { $0.peripheral.identifier.uuidString == selectedPeripherialId }) - if radio != nil { - bleManager.connectTo(peripheral: radio!.peripheral) - } - } - } - .textCase(nil) - } - - } else { - Text("Bluetooth is off") - .foregroundColor(.red) - .font(.title) - } - } - - HStack(alignment: .center) { - Spacer() - #if targetEnvironment(macCatalyst) - if let connectedPeripheral = bleManager.connectedPeripheral { - Button(role: .destructive, action: { - if connectedPeripheral.peripheral.state == CBPeripheralState.connected { - bleManager.disconnectPeripheral(reconnect: false) - } - }) { - Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash") - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding() - } - if bleManager.isConnecting { - Button(role: .destructive, action: { - bleManager.cancelPeripheralConnection() - - }) { - Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash") - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding() - } - #endif - Spacer() - } - .padding(.bottom, 10) - } - .navigationTitle("Bluetooth") - .navigationBarItems( - leading: MeshtasticLogo(), - trailing: ZStack { - ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?", - mqttProxyConnected: bleManager.mqttProxyConnected, - mqttTopic: bleManager.mqttManager.topic - ) - } - ) - } - .sheet(isPresented: $invalidFirmwareVersion, onDismiss: didDismissSheet) { - InvalidVersion(minimumVersion: self.bleManager.minimumVersion, version: self.bleManager.connectedVersion) - .presentationDetents([.large]) - .presentationDragIndicator(.automatic) - } - .onChange(of: self.bleManager.invalidVersion) { - invalidFirmwareVersion = self.bleManager.invalidVersion - } - .onChange(of: self.bleManager.isSubscribed) { _, sub in - - if UserDefaults.preferredPeripheralId.count > 0 && sub { - - let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(bleManager.connectedPeripheral?.num ?? -1)) - - do { - node = try context.fetch(fetchNodeInfoRequest).first - if let loRaConfig = node?.loRaConfig, loRaConfig.regionCode == RegionCodes.unset.rawValue { - isUnsetRegion = true - } else { - isUnsetRegion = false - } - } catch { - Logger.data.error("💥 Error fetching node info: \(error.localizedDescription, privacy: .public)") - } - } - } - } -#if !targetEnvironment(macCatalyst) -#if canImport(ActivityKit) - func startNodeActivity() { - liveActivityStarted = true - // 15 Minutes Local Stats Interval - let timerSeconds = 900 - let localStats = node?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4")) - let mostRecent = localStats?.lastObject as? TelemetryEntity - - let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName?.addingVariationSelectors ?? "unknown") - - let future = Date(timeIntervalSinceNow: Double(timerSeconds)) - let initialContentState = MeshActivityAttributes.ContentState(uptimeSeconds: UInt32(mostRecent?.uptimeSeconds ?? 0), - channelUtilization: mostRecent?.channelUtilization ?? 0.0, - airtime: mostRecent?.airUtilTx ?? 0.0, - sentPackets: UInt32(mostRecent?.numPacketsTx ?? 0), - receivedPackets: UInt32(mostRecent?.numPacketsRx ?? 0), - badReceivedPackets: UInt32(mostRecent?.numPacketsRxBad ?? 0), - dupeReceivedPackets: UInt32(mostRecent?.numRxDupe ?? 0), - packetsSentRelay: UInt32(mostRecent?.numTxRelay ?? 0), - packetsCanceledRelay: UInt32(mostRecent?.numTxRelayCanceled ?? 0), - nodesOnline: UInt32(mostRecent?.numOnlineNodes ?? 0), - totalNodes: UInt32(mostRecent?.numTotalNodes ?? 0), - timerRange: Date.now...future) - - let activityContent = ActivityContent(state: initialContentState, staleDate: Calendar.current.date(byAdding: .minute, value: 15, to: Date())!) - - do { - let myActivity = try Activity.request(attributes: activityAttributes, content: activityContent, - pushType: nil) - Logger.services.info("Requested MyActivity live activity. ID: \(myActivity.id)") - } catch { - Logger.services.error("Error requesting live activity: \(error.localizedDescription, privacy: .public)") - } - } - - func endActivity() { - liveActivityStarted = false - Task { - for activity in Activity.activities where activity.attributes.nodeNum == node?.num ?? 0 { - await activity.end(nil, dismissalPolicy: .immediate) - } - } - } -#endif -#endif - func didDismissSheet() { - bleManager.disconnectPeripheral(reconnect: false) - } -} diff --git a/Meshtastic/Views/Connect/Connect.swift b/Meshtastic/Views/Connect/Connect.swift new file mode 100644 index 00000000..6dead473 --- /dev/null +++ b/Meshtastic/Views/Connect/Connect.swift @@ -0,0 +1,517 @@ +// +// Connect.swift +// Meshtastic Apple +// +// Copyright(c) Garth Vander Houwen 8/18/21. +// + +import SwiftUI +import MapKit +import CoreData +import CoreLocation +import CoreBluetooth +import OSLog +import TipKit +#if canImport(ActivityKit) +import ActivityKit +#endif + +struct Connect: View { + + @Environment(\.managedObjectContext) var context + @EnvironmentObject var accessoryManager: AccessoryManager + @State var router: Router + @State var node: NodeInfoEntity? + @State var isUnsetRegion = false + @State var invalidFirmwareVersion = false + @State var liveActivityStarted = false + @State var presentingSwitchPreferredPeripheral = false + @State var selectedPeripherialId = "" + + var body: some View { + NavigationStack { + VStack { + List { + Section { + if let connectedDevice = accessoryManager.activeConnection?.device, + accessoryManager.isConnected || accessoryManager.isConnecting { + TipView(ConnectionTip(), arrowEdge: .bottom) + .tipViewStyle(PersistentTip()) + VStack(alignment: .leading) { + HStack { + VStack(alignment: .center) { + CircleText(text: node?.user?.shortName?.addingVariationSelectors ?? "?", color: Color(UIColor(hex: UInt32(node?.num ?? 0))), circleSize: 90) + .padding(.trailing, 5) + if node?.latestDeviceMetrics != nil { + BatteryCompact(batteryLevel: node?.latestDeviceMetrics?.batteryLevel ?? 0, font: .caption, iconFont: .callout, color: .accentColor) + .padding(.trailing, 5) + } + } + .padding(.trailing) + VStack(alignment: .leading) { + if node != nil { + Text(connectedDevice.longName?.addingVariationSelectors ?? "Unknown".localized).font(.title2) + } + Text("Connection Name").font(.callout)+Text(": \(connectedDevice.name.addingVariationSelectors)") + .font(.callout).foregroundColor(Color.gray) + HStack(alignment: .firstTextBaseline) { + TransportIcon(transportType: connectedDevice.transportType) + if connectedDevice.transportType == .ble { + connectedDevice.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0, width: 5, height: 20) } + } + Spacer() + } + .padding(0) + if node != nil { + Text("Firmware Version").font(.callout)+Text(": \(node?.metadata?.firmwareVersion ?? "Unknown".localized)") + .font(.callout).foregroundColor(Color.gray) + } + switch accessoryManager.state { + case .subscribed: + Text("Subscribed").font(.callout) + .foregroundColor(.green) + case .retrievingDatabase(let nodeCount): + HStack { + Image(systemName: "square.stack.3d.down.forward") + .symbolRenderingMode(.multicolor) + .symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3)) + .foregroundColor(.teal) + if let expectedNodeDBSize = accessoryManager.expectedNodeDBSize { + if UIDevice.current.userInterfaceIdiom == .phone { + VStack(alignment: .leading, spacing: 2.0) { + Text("Retrieving nodes").font(.callout) + .foregroundColor(.teal) + ProgressView(value: Double(nodeCount), total: Double(expectedNodeDBSize)) + } + } else { + // iPad/Mac with more space, show progress bar AFTER the label + HStack { + Text("Retrieving nodes").font(.callout) + .foregroundColor(.teal) + ProgressView(value: Double(nodeCount), total: Double(expectedNodeDBSize)) + } + } + + } else { + Text("Retrieving nodes \(nodeCount)").font(.callout) + .foregroundColor(.teal) + } + } + case .communicating: + HStack { + Image(systemName: "square.stack.3d.down.forward") + .symbolRenderingMode(.multicolor) + .symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3)) + .foregroundColor(.orange) + Text("Communicating").font(.callout) + .foregroundColor(.orange) + } + case .retrying(let attempt): + HStack { + Image(systemName: "square.stack.3d.down.forward") + .symbolRenderingMode(.multicolor) + .symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3)) + .foregroundColor(.orange) + Text("Retrying (attempt \(attempt))").font(.callout) + .foregroundColor(.orange) + } + default: + EmptyView() + } + } + } + } + .font(.caption) + .foregroundColor(Color.gray) + .padding([.top]) + .swipeActions { + if accessoryManager.allowDisconnect { + Button(role: .destructive) { + Task { + try await accessoryManager.disconnect() + } + } label: { + Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash") + }.disabled(!accessoryManager.allowDisconnect) + } + } + .contextMenu { + + if node != nil { + Label("\(String(node!.num))", systemImage: "number") +#if !targetEnvironment(macCatalyst) + if accessoryManager.state == .subscribed { + Button { + if !liveActivityStarted { +#if canImport(ActivityKit) + Logger.services.info("Start live activity.") + startNodeActivity() +#endif + } else { +#if canImport(ActivityKit) + Logger.services.info("Stop live activity.") + endActivity() +#endif + } + } label: { + Label("Mesh Live Activity", systemImage: liveActivityStarted ? "stop" : "play") + } + } +#endif + if accessoryManager.allowDisconnect { + Button(role: .destructive) { + if accessoryManager.allowDisconnect { + Task { + try await accessoryManager.disconnect() + } + } + } label: { + Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash") + } + Button(role: .destructive) { + Task { + do { + try await accessoryManager.sendShutdown(fromUser: node!.user!, toUser: node!.user!) + } catch { + Logger.mesh.error("Shutdown Failed: \(error)") + } + } + + } label: { + Label("Power Off", systemImage: "power") + } + } + } + } + if isUnsetRegion { + HStack { + NavigationLink { + LoRaConfig(node: node) + } label: { + Label("Set LoRa Region", systemImage: "globe.americas.fill") + .foregroundColor(.red) + .font(.title) + } + } + } + } else { + if accessoryManager.isConnecting { + HStack { + Image(systemName: "antenna.radiowaves.left.and.right") + .resizable() + .symbolRenderingMode(.hierarchical) + .foregroundColor(.orange) + .frame(width: 60, height: 60) + .padding(.trailing) + switch accessoryManager.state { + case .connecting, .communicating: + Text("Connecting . .") + .font(.title2) + .foregroundColor(.orange) + case .retrievingDatabase: + Text("Retreiving nodes . .") + .font(.callout) + .foregroundColor(.orange) + case .retrying(let attempt): + Text("Connection Attempt \(attempt) of 10") + .font(.callout) + .foregroundColor(.orange) + default: + EmptyView() + } + } + .padding() + .swipeActions { + if accessoryManager.allowDisconnect { + Button(role: .destructive) { + Task { + try await accessoryManager.disconnect() + } + } label: { + Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash") + }.disabled(!accessoryManager.allowDisconnect) + } + } + + } else { + + if let lastError = accessoryManager.lastConnectionError as? Error { + Text(lastError.localizedDescription).font(.callout).foregroundColor(.red) + } + HStack { + Image("custom.link.slash") + .resizable() + .symbolRenderingMode(.hierarchical) + .foregroundColor(.red) + .frame(width: 60, height: 60) + .padding(.trailing) + Text("No device connected").font(.title3) + } + .padding() + } + } + } + .textCase(nil) + + if !(accessoryManager.isConnected || accessoryManager .isConnecting) { + Section(header: HStack { + Text("Available Radios").font(.title) + Spacer() + ManualConnectionMenu() + }) { + ForEach(accessoryManager.devices.sorted(by: { $0.name < $1.name })) { device in + HStack { + if UserDefaults.preferredPeripheralId == device.id.uuidString { + Image(systemName: "star.fill") + .imageScale(.large).foregroundColor(.yellow) + .padding(.trailing) + } else { + Image(systemName: "circle.fill") + .imageScale(.large).foregroundColor(.gray) + .padding(.trailing) + } + VStack(alignment: .leading) { + Button(action: { + if UserDefaults.preferredPeripheralId.count > 0 && device.id.uuidString != UserDefaults.preferredPeripheralId { + if accessoryManager.allowDisconnect { + Task { try await accessoryManager.disconnect() } + } + presentingSwitchPreferredPeripheral = true + selectedPeripherialId = device.id.uuidString + } else { + Task { + try? await accessoryManager.connect(to: device) + } + } + }) { + Text(device.name).font(.callout) + } + // Show transport type + TransportIcon(transportType: device.transportType) + } + Spacer() + VStack { + device.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0) } + } + }.padding([.bottom, .top]) + } + } + .confirmationDialog("Connecting to a new radio will clear all app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) { + Button("Connect to new radio?", role: .destructive) { + UserDefaults.preferredPeripheralId = selectedPeripherialId + UserDefaults.preferredPeripheralNum = 0 + if accessoryManager.allowDisconnect { + Task { try await accessoryManager.disconnect() } + } + clearCoreDataDatabase(context: context, includeRoutes: false) + if let radio = accessoryManager.devices.first(where: { $0.id.uuidString == selectedPeripherialId }) { + Task { + try await accessoryManager.connect(to: radio) + } + } + } + } + .textCase(nil) + } + } + + HStack(alignment: .center) { + Spacer() +#if targetEnvironment(macCatalyst) + // TODO: should this be allowDisconnect? + if accessoryManager.allowDisconnect { + Button(role: .destructive, action: { + if accessoryManager.allowDisconnect { + Task { + try await accessoryManager.disconnect() + } + } + }) { + Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + } +#endif + Spacer() + } + .padding(.bottom, 10) + } + .navigationTitle("Connect") + .navigationBarItems( + leading: MeshtasticLogo(), + trailing: ZStack { + ConnectedDevice( + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?", + mqttProxyConnected: accessoryManager.mqttProxyConnected, + mqttTopic: accessoryManager.mqttManager.topic + + ) + } + ) + + } + // TODO: REMOVING VERSION STUFF? + // .sheet(isPresented: $invalidFirmwareVersion, onDismiss: didDismissSheet) { + // InvalidVersion(minimumVersion: accessoryManager.minimumVersion, version: accessoryManager.activeConnection?.device.firmwareVersion ?? "?.?.?") + // .presentationDetents([.large]) + // .presentationDragIndicator(.automatic) + // } + // .onChange(of: accessoryManager) { + // invalidFirmwareVersion = self.bleManager.invalidVersion + // } + .onChange(of: self.accessoryManager.state) { _, state in + + if let deviceNum = accessoryManager.activeDeviceNum, UserDefaults.preferredPeripheralId.count > 0 && state == .subscribed { + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", deviceNum) + + do { + node = try context.fetch(fetchNodeInfoRequest).first + if let loRaConfig = node?.loRaConfig, loRaConfig.regionCode == RegionCodes.unset.rawValue { + isUnsetRegion = true + } else { + isUnsetRegion = false + } + } catch { + Logger.data.error("💥 Error fetching node info: \(error.localizedDescription, privacy: .public)") + } + } + } + } +#if !targetEnvironment(macCatalyst) +#if canImport(ActivityKit) + func startNodeActivity() { + liveActivityStarted = true + // 15 Minutes Local Stats Interval + let timerSeconds = 900 + let localStats = node?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4")) + let mostRecent = localStats?.lastObject as? TelemetryEntity + + let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName?.addingVariationSelectors ?? "unknown") + + let future = Date(timeIntervalSinceNow: Double(timerSeconds)) + let initialContentState = MeshActivityAttributes.ContentState(uptimeSeconds: UInt32(mostRecent?.uptimeSeconds ?? 0), + channelUtilization: mostRecent?.channelUtilization ?? 0.0, + airtime: mostRecent?.airUtilTx ?? 0.0, + sentPackets: UInt32(mostRecent?.numPacketsTx ?? 0), + receivedPackets: UInt32(mostRecent?.numPacketsRx ?? 0), + badReceivedPackets: UInt32(mostRecent?.numPacketsRxBad ?? 0), + dupeReceivedPackets: UInt32(mostRecent?.numRxDupe ?? 0), + packetsSentRelay: UInt32(mostRecent?.numTxRelay ?? 0), + packetsCanceledRelay: UInt32(mostRecent?.numTxRelayCanceled ?? 0), + nodesOnline: UInt32(mostRecent?.numOnlineNodes ?? 0), + totalNodes: UInt32(mostRecent?.numTotalNodes ?? 0), + timerRange: Date.now...future) + + let activityContent = ActivityContent(state: initialContentState, staleDate: Calendar.current.date(byAdding: .minute, value: 15, to: Date())!) + + do { + let myActivity = try Activity.request(attributes: activityAttributes, content: activityContent, + pushType: nil) + Logger.services.info("Requested MyActivity live activity. ID: \(myActivity.id)") + } catch { + Logger.services.error("Error requesting live activity: \(error.localizedDescription, privacy: .public)") + } + } + + func endActivity() { + liveActivityStarted = false + Task { + for activity in Activity.activities where activity.attributes.nodeNum == node?.num ?? 0 { + await activity.end(nil, dismissalPolicy: .immediate) + } + } + } +#endif +#endif + func didDismissSheet() { + // bleManager.disconnectPeripheral(reconnect: false) + Task { + try await accessoryManager.disconnect() + } + } +} + +struct TransportIcon: View { + var transportType: TransportType + @EnvironmentObject var accessoryManager: AccessoryManager + + var body: some View { + let transport = accessoryManager.transportForType(transportType) + return HStack(spacing: 3.0) { + if let icon = transport?.type.icon { + icon + .font(.title2) + .foregroundColor(transport?.type == .ble ? Color.accentColor : Color.primary) + } else { + Image(systemName: "questionmark") + .font(.title2) + } + Text(transport?.type.rawValue ?? "Unknown".localized) + .font(.title3) + } + } +} + +struct ManualConnectionMenu: View { + private struct IterableTransport: Identifiable { + let id: UUID + let icon: Image + let title: String + let transport: any Transport + } + + private var transports: [IterableTransport] + + init() { + self.transports = AccessoryManager.shared.transports.filter { $0.supportsManualConnection}.map { transport in + IterableTransport(id: UUID(), icon: transport.type.icon, title: transport.type.rawValue, transport: transport) + } + } + + @State private var selectedTransport: IterableTransport? + @State private var showAlert: Bool = false + @State private var connectionString = "" + + var body: some View { + Menu { + ForEach(transports) { transport in + Button { + self.selectedTransport = transport + self.showAlert = true + } label: { + Label(title: { Text(transport.title)}, icon: { transport.icon }) + } + } + } label: { + Label("Manual", systemImage: "plus") + }.alert("Manual connection string", isPresented: $showAlert, presenting: selectedTransport) { selectedTransport in + // This continues to be quick and dirty. A better system is needed. + TextField("Enter hostname[:port]", text: $connectionString) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + .onChange(of: connectionString) { _, newValue in + // Filter to only allow valid characters for hostname/IP:port + let allowedCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-:") + let filtered = String(newValue.unicodeScalars.filter { allowedCharacters.contains($0) }) + if filtered != newValue { + connectionString = filtered + } + } + + Button("OK", action: { + if !connectionString.isEmpty { + Task { + try await selectedTransport.transport.manuallyConnect(withConnectionString: connectionString) + } + } + }) + } + } +} diff --git a/Meshtastic/Views/Bluetooth/InvalidVersion.swift b/Meshtastic/Views/Connect/InvalidVersion.swift similarity index 100% rename from Meshtastic/Views/Bluetooth/InvalidVersion.swift rename to Meshtastic/Views/Connect/InvalidVersion.swift diff --git a/Meshtastic/Views/ContentView.swift b/Meshtastic/Views/ContentView.swift index 5c1b285b..ac18b9a4 100644 --- a/Meshtastic/Views/ContentView.swift +++ b/Meshtastic/Views/ContentView.swift @@ -6,8 +6,8 @@ import SwiftUI struct ContentView: View { @ObservedObject var appState: AppState - - @ObservedObject var router: Router + @EnvironmentObject var accessoryManager: AccessoryManager + @State var router: Router @State var isShowingDeviceOnboardingFlow: Bool = false init(appState: AppState, router: Router) { @@ -33,9 +33,9 @@ struct ContentView: View { router: appState.router ) .tabItem { - Label("Bluetooth", systemImage: "antenna.radiowaves.left.and.right") + Label("Connect", systemImage: "link") } - .tag(NavigationState.Tab.bluetooth) + .tag(NavigationState.Tab.connect) NodeList( router: appState.router @@ -63,6 +63,7 @@ struct ContentView: View { isPresented: $isShowingDeviceOnboardingFlow, onDismiss: { UserDefaults.firstLaunch = false + accessoryManager.startDiscovery() }, content: { DeviceOnboarding() } diff --git a/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift b/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift index 2e8d5c53..a68b3597 100644 --- a/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift +++ b/Meshtastic/Views/Helpers/BLESignalStrengthIndicator.swift @@ -45,7 +45,8 @@ struct SignalStrengthIndicator: View { } let signalStrength: BLESignalStrength - + var width: CGFloat = 8 + var height: CGFloat = 40 var body: some View { Group { HStack { @@ -53,7 +54,7 @@ struct SignalStrengthIndicator: View { RoundedRectangle(cornerRadius: 3) .divided(amount: (CGFloat(bar) + 1) / CGFloat(3)) .fill(getColor().opacity(bar <= signalStrength.rawValue ? 1 : 0.3)) - .frame(width: 8, height: 40) + .frame(width: width, height: height) } } } diff --git a/Meshtastic/Views/Helpers/BatteryCompact.swift b/Meshtastic/Views/Helpers/BatteryCompact.swift index f04a5c99..60c1307a 100644 --- a/Meshtastic/Views/Helpers/BatteryCompact.swift +++ b/Meshtastic/Views/Helpers/BatteryCompact.swift @@ -106,7 +106,7 @@ struct BatteryCompact: View { return "Charging".localized } else { // Normal battery level - return String(format: NSLocalizedString("Battery Level %", comment: "VoiceOver value for battery level"), Int(level)) + return String(format: NSLocalizedString("Battery Level %d", comment: "VoiceOver value for battery level"), Int(level)) } } ?? "Unknown") } diff --git a/Meshtastic/Views/Helpers/BatteryGauge.swift b/Meshtastic/Views/Helpers/BatteryGauge.swift index addbc97c..dfd24f04 100644 --- a/Meshtastic/Views/Helpers/BatteryGauge.swift +++ b/Meshtastic/Views/Helpers/BatteryGauge.swift @@ -53,7 +53,7 @@ struct BatteryGauge: View { } } .accessibilityLabel(NSLocalizedString("Battery Level", comment: "VoiceOver label for battery gauge")) - .accessibilityValue(String(format: NSLocalizedString("Battery Level %", comment: "VoiceOver value for battery level"), Int(batteryLevel))) + .accessibilityValue(String(format: NSLocalizedString("Battery Level %d", comment: "VoiceOver value for battery level"), Int(batteryLevel))) .tint(gradient) .gaugeStyle(.accessoryCircular) } diff --git a/Meshtastic/Views/Helpers/ConnectedDevice.swift b/Meshtastic/Views/Helpers/ConnectedDevice.swift index f0a9fa2a..eb0308cc 100644 --- a/Meshtastic/Views/Helpers/ConnectedDevice.swift +++ b/Meshtastic/Views/Helpers/ConnectedDevice.swift @@ -1,83 +1,86 @@ /* - Abstract: - A view that draws the indicator used in the upper right corner for views using BLE - */ +Abstract: +A view draws the indicator used in the upper right corner for views using BLE +*/ import SwiftUI struct ConnectedDevice: View { - var bluetoothOn: Bool - var deviceConnected: Bool - var name: String - var mqttProxyConnected: Bool = false - var mqttUplinkEnabled: Bool = false - var mqttDownlinkEnabled: Bool = false - var mqttTopic: String = "" - var phoneOnly: Bool = false + @EnvironmentObject var accessoryManager: AccessoryManager + var deviceConnected: Bool + var name: String + var mqttProxyConnected: Bool = false + var mqttUplinkEnabled: Bool = false + var mqttDownlinkEnabled: Bool = false + var mqttTopic: String = "" + var phoneOnly: Bool = false + var showActivityLights: Bool - var body: some View { + init(deviceConnected: Bool, name: String, mqttProxyConnected: Bool = false, mqttUplinkEnabled: Bool = false, mqttDownlinkEnabled: Bool = false, mqttTopic: String = "", phoneOnly: Bool = false, showActivityLights: Bool = true) { + self.deviceConnected = deviceConnected + self.name = name + self.mqttProxyConnected = mqttProxyConnected + self.mqttUplinkEnabled = mqttUplinkEnabled + self.mqttDownlinkEnabled = mqttDownlinkEnabled + self.mqttTopic = mqttTopic + self.phoneOnly = phoneOnly + self.showActivityLights = showActivityLights + } + + var body: some View { HStack { - if (phoneOnly && UIDevice.current.userInterfaceIdiom == .phone) || !phoneOnly { - if bluetoothOn { - if deviceConnected { - // Create an HStack for connected state with proper accessibility - HStack { - if mqttUplinkEnabled || mqttDownlinkEnabled { - MQTTIcon(connected: mqttProxyConnected, uplink: mqttUplinkEnabled, downlink: mqttDownlinkEnabled, topic: mqttTopic) - .accessibilityHidden(true) - } - Image(systemName: "antenna.radiowaves.left.and.right.circle.fill") - .imageScale(.large) - .foregroundColor(.green) - .symbolRenderingMode(.hierarchical) - .accessibilityHidden(true) - Text(name.addingVariationSelectors) - .font(name.isEmoji() ? .title : .callout) - .foregroundColor(.gray) - .accessibilityHidden(true) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("Connected to Bluetooth device".localized + ", " + name.formatNodeNameForVoiceOver()) - } else { - // Create a container for disconnected state - HStack { - Image(systemName: "antenna.radiowaves.left.and.right.slash") - .imageScale(.medium) - .foregroundColor(.red) - .symbolRenderingMode(.hierarchical) - .accessibilityHidden(true) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("No Bluetooth device connected".localized) - } - } else { - // Create a container for Bluetooth off state + if showActivityLights { + RXTXIndicatorWidget() + } + if (phoneOnly && UIDevice.current.userInterfaceIdiom == .phone) || !phoneOnly { + if deviceConnected { + // Create an HStack for connected state with proper accessibility HStack { - Text("Bluetooth is off".localized) - .font(.subheadline) - .foregroundColor(.red) + if mqttUplinkEnabled || mqttDownlinkEnabled { + MQTTIcon(connected: mqttProxyConnected, uplink: mqttUplinkEnabled, downlink: mqttDownlinkEnabled, topic: mqttTopic) + .accessibilityHidden(true) + } + Image(systemName: "link.circle.fill") + .imageScale(.large) + .foregroundColor(.green) + .symbolRenderingMode(.hierarchical) + .accessibilityHidden(true) + Text(name.addingVariationSelectors) + .font(name.isEmoji() ? .title : .callout) + .foregroundColor(.gray) .accessibilityHidden(true) } .accessibilityElement(children: .ignore) - .accessibilityLabel("Bluetooth is off".localized) + .accessibilityLabel("Connected to Bluetooth device".localized + ", " + name.formatNodeNameForVoiceOver()) + } else { + // Create a container for disconnected state + HStack { + Image("custom.link.slash") + .imageScale(.medium) + .foregroundColor(.red) + .symbolRenderingMode(.hierarchical) + .accessibilityHidden(true) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("No Bluetooth device connected".localized) } - } - } - } + } + }.iOS26Modifier { $0.padding(.horizontal, 5.0) } + } } struct ConnectedDevice_Previews: PreviewProvider { - static var previews: some View { - VStack(alignment: .trailing) { - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: true) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: true, mqttTopic: "msh/US/2/e/#") - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: false) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: false) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: false, mqttDownlinkEnabled: true) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: false, mqttDownlinkEnabled: true) - ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true) - ConnectedDevice(bluetoothOn: true, deviceConnected: false, name: "MEMO", mqttProxyConnected: false) - }.previewLayout(.fixed(width: 150, height: 275)) - } + static var previews: some View { + VStack(alignment: .trailing) { + ConnectedDevice(deviceConnected: true, name: "MEMO", mqttProxyConnected: true) + ConnectedDevice(deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: true) + ConnectedDevice(deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: true, mqttTopic: "msh/US/2/e/#") + ConnectedDevice(deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: false) + ConnectedDevice(deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: false) + ConnectedDevice(deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: false, mqttDownlinkEnabled: true) + ConnectedDevice(deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: false, mqttDownlinkEnabled: true) + ConnectedDevice(deviceConnected: true, name: "MEMO", mqttProxyConnected: true) + ConnectedDevice(deviceConnected: false, name: "MEMO", mqttProxyConnected: false) + }.previewLayout(.fixed(width: 150, height: 275)) + } } diff --git a/Meshtastic/Views/Helpers/MQTTIcon.swift b/Meshtastic/Views/Helpers/MQTTIcon.swift index 7d6c653a..0aa6775a 100644 --- a/Meshtastic/Views/Helpers/MQTTIcon.swift +++ b/Meshtastic/Views/Helpers/MQTTIcon.swift @@ -25,11 +25,10 @@ struct MQTTIcon: View { .imageScale(.large) .foregroundColor(connected ? .green : .secondary) .symbolRenderingMode(.hierarchical) - }.popover(isPresented: self.$isPopoverOpen, arrowEdge: .bottom, content: { + }.popover(isPresented: self.$isPopoverOpen, content: { VStack(spacing: 0.5) { Text("Topic: \(topic)".localized) .padding(20) - Button("Close", action: { self.isPopoverOpen = false }).padding([.bottom], 20) } .presentationCompactAdaptation(.popover) }) diff --git a/Meshtastic/Views/Helpers/MeshtasticLogo.swift b/Meshtastic/Views/Helpers/MeshtasticLogo.swift index c6906f15..800793c8 100644 --- a/Meshtastic/Views/Helpers/MeshtasticLogo.swift +++ b/Meshtastic/Views/Helpers/MeshtasticLogo.swift @@ -13,20 +13,36 @@ struct MeshtasticLogo: View { var body: some View { #if targetEnvironment(macCatalyst) VStack { - Image("logo-white") - .resizable() - .foregroundColor(.accentColor) - .scaledToFit() + if #available(iOS 26.0, macOS 26.0, *) { + Image(colorScheme == .dark ? "logo-white" : "logo-black") + .resizable() + .foregroundColor(.accentColor) + .scaledToFit() + } else { + Image("logo-white") + .resizable() + .foregroundColor(.accentColor) + .scaledToFit() + } + } .padding(.bottom, 5) .padding(.top, 5) #else + if #available(iOS 26.0, macOS 26.0, *) { + VStack { + Image(colorScheme == .dark ? "logo-white" : "logo-black") + .resizable() + .scaledToFit() + } + } else { VStack { Image(colorScheme == .dark ? "logo-white" : "logo-black") .resizable() .scaledToFit() } .padding(.bottom, 5) + } #endif } } diff --git a/Meshtastic/Views/Helpers/RXTXIndicatorView.swift b/Meshtastic/Views/Helpers/RXTXIndicatorView.swift new file mode 100644 index 00000000..b04cad89 --- /dev/null +++ b/Meshtastic/Views/Helpers/RXTXIndicatorView.swift @@ -0,0 +1,112 @@ +// +// RXTXIndicatorView.swift +// Meshtastic +// +// Created by jake on 8/5/25. +// + +import Foundation +import SwiftUI +import OSLog + +struct RXTXIndicatorWidget: View { + @EnvironmentObject var accessoryManager: AccessoryManager + @State private var isPopoverOpen = false + + let fontSize: CGFloat = 7.0 + var body: some View { + Button( action: { + if !isPopoverOpen && accessoryManager.isConnected { + Task { + // TODO: replace with a heartbeat when the heartbeat works + try await Task.sleep(for: .seconds(0.5)) // little delay for user affordance + if accessoryManager.checkIsVersionSupported(forVersion: "2.7.4") { + Logger.transport.debug("[RXTXIndicator] sending heartbeat (2.7.4+)") + try await accessoryManager.sendHeartbeat() + } else { + Logger.transport.debug("[RXTXIndicator] sending metadata request (pre 2.7.4 does not support heartbeat nonce)") + _ = try await accessoryManager.requestDeviceMetadata() + } + } + } + self.isPopoverOpen.toggle() + }) { + VStack(spacing: 3.0) { + HStack(spacing: 2.0) { + Image(systemName: "arrow.up") + .font(.system(size: fontSize)) + LEDIndicator(flash: $accessoryManager.packetsSent, color: .green) + }.frame(maxHeight: fontSize) + HStack(spacing: 2.0) { + Image(systemName: "arrow.down") + .font(.system(size: fontSize)) + LEDIndicator(flash: $accessoryManager.packetsReceived, color: .red) + }.frame(maxHeight: fontSize) + } + .contentShape(Rectangle()) // Make sure the whole thing is tappable + .popover(isPresented: self.$isPopoverOpen, + attachmentAnchor: .rect(.bounds), + arrowEdge: .top) { + Button(action: { + self.isPopoverOpen = false + }) { + VStack(spacing: 0.5) { + Text("Packet Count") + .font(.caption) + .bold() + .padding(2.0) + Divider() + VStack(alignment: .leading) { + HStack(spacing: 3.0) { + HStack(spacing: 2.0) { + LEDIndicator(flash: $accessoryManager.packetsSent, color: .green) + .frame(maxHeight: fontSize) + Image(systemName: "arrow.up") + .font(.system(size: fontSize)) + } + Text("To Radio (TX): \(accessoryManager.packetsSent)") + .font(.caption2) + Spacer() + } + HStack(spacing: 3.0) { + HStack(spacing: 2.0) { + LEDIndicator(flash: $accessoryManager.packetsReceived, color: .red) + .frame(maxHeight: fontSize) + Image(systemName: "arrow.down") + .font(.system(size: fontSize)) + } + Text("From Radio (RX): \(accessoryManager.packetsReceived)") + .font(.caption2) + Spacer() + } + }.padding(2.0) + }.padding(10) + .contentShape(Rectangle()) // Make sure the whole thing is tappable + }.buttonStyle(.plain) + .presentationCompactAdaptation(.popover) + } + }.buttonStyle(.borderless) + } +} + +struct LEDIndicator: View { + @Environment(\.colorScheme) var colorScheme + @Binding var flash: Int + let color: Color + + @State private var brightness: Double = 0.0 + + var body: some View { + Circle() + .foregroundColor(color.opacity(brightness)) + .overlay( + Circle() + .stroke(colorScheme == .light ? Color.black : Color.white, lineWidth: 0.5) + ).onChange(of: flash) { _, _ in + brightness = 1.0 + withAnimation(.easeOut(duration: 0.3)) { + brightness = 0.0 + } + } + } +} diff --git a/Meshtastic/Views/Helpers/View+iOS26Modifier.swift b/Meshtastic/Views/Helpers/View+iOS26Modifier.swift new file mode 100644 index 00000000..fb296a32 --- /dev/null +++ b/Meshtastic/Views/Helpers/View+iOS26Modifier.swift @@ -0,0 +1,33 @@ +// +// View+iOS26Modifier.swift +// Meshtastic +// +// Created by Jake Bordens on 7/29/25. +// + +import Foundation +import SwiftUI + +extension View { + @ViewBuilder + func iOS26Modifier( + _ contentBuilder: (@escaping (Self) -> some View) + ) -> some View { + if #available(iOS 26.0, macOS 26.0, *) { + contentBuilder(self) + } else { + self + } + } + + @ViewBuilder + func olderThaniOS26Modifier( + _ contentBuilder: (@escaping (Self) -> some View) + ) -> some View { + if #available(iOS 26.0, macOS 26.0, *) { + self + } else { + contentBuilder(self) + } + } +} diff --git a/Meshtastic/Views/Messages/ChannelList.swift b/Meshtastic/Views/Messages/ChannelList.swift index 835c662d..5359ac37 100644 --- a/Meshtastic/Views/Messages/ChannelList.swift +++ b/Meshtastic/Views/Messages/ChannelList.swift @@ -12,7 +12,7 @@ import OSLog struct ChannelList: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Binding var node: NodeInfoEntity? @@ -131,14 +131,22 @@ struct ChannelList: View { Button { channel.mute.toggle() do { - let adminMessageId = bleManager.saveChannel(channel: channel.protoBuf, fromUser: node.user!, toUser: node.user!) - if adminMessageId > 0 { - context.refresh(channel, mergeChanges: true) + Task { + do { + _ = try await accessoryManager.saveChannel(channel: channel.protoBuf, fromUser: node.user!, toUser: node.user!) + Task { @MainActor in + do { + context.refresh(channel, mergeChanges: true) + try context.save() + } catch { + context.rollback() + Logger.data.error("💥 Save Channel Mute Error") + } + } + } catch { + Logger.mesh.error("Unable to save channel") + } } - try context.save() - } catch { - context.rollback() - Logger.data.error("💥 Save Channel Mute Error") } } label: { Label(channel.mute ? "Show Alerts" : "Hide Alerts", systemImage: channel.mute ? "bell" : "bell.slash") @@ -160,7 +168,7 @@ struct ChannelList: View { } } } - .padding([.top, .bottom]) + .olderThaniOS26Modifier { $0.padding([.top, .bottom]) } .listStyle(.plain) .navigationTitle("Channels") } diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index 42bd5c12..bdd6c1e3 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -13,7 +13,7 @@ import SwiftUI struct ChannelMessageList: View { @EnvironmentObject var appState: AppState @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager // Keyboard State @FocusState var messageFieldFocused: Bool @ObservedObject var myInfo: MyInfoEntity @@ -25,7 +25,25 @@ struct ChannelMessageList: View { @State private var hasReachedBottom = false @State private var gotFirstUnreadMessage: Bool = false - @State private var messageToHighlight: Int64 = 0 + @State private var messageToHighlight: Int64 = 0 + + @FetchRequest private var allPrivateMessages: FetchedResults + + init(myInfo: MyInfoEntity, channel: ChannelEntity) { + self.myInfo = myInfo + self.channel = channel + + // Configure fetch request here + let request: NSFetchRequest = MessageEntity.fetchRequest() + request.sortDescriptors = [ + NSSortDescriptor(keyPath: \MessageEntity.messageTimestamp, ascending: true) + ] + request.predicate = NSPredicate( + format: "channel == %ld AND toUser == nil AND isEmoji == false", + channel.index + ) + _allPrivateMessages = FetchRequest(fetchRequest: request) + } var body: some View { VStack { @@ -33,9 +51,10 @@ struct ChannelMessageList: View { ZStack(alignment: .bottomTrailing) { ScrollView { LazyVStack { - ForEach(Array(channel.allPrivateMessages.enumerated()), id: \.element.id) { index, message in + ForEach(allPrivateMessages) { message in // Get the previous message, if it exists - let previousMessage = index > 0 ? channel.allPrivateMessages[index - 1] : nil + let thisMessageIndex = allPrivateMessages.firstIndex(of: message) ?? 0 + let previousMessage = thisMessageIndex > 0 ? allPrivateMessages[thisMessageIndex - 1] : nil let currentUser: Bool = (Int64(preferredPeripheralNum) == message.fromUser?.num ? true : false) if message.displayTimestamp(aboveMessage: previousMessage) { Text(message.timestamp.formatted(date: .abbreviated, time: .shortened)) @@ -43,7 +62,7 @@ struct ChannelMessageList: View { .foregroundColor(.gray) } if message.replyID > 0 { - let messageReply = channel.allPrivateMessages.first(where: { $0.messageId == message.replyID }) + let messageReply = allPrivateMessages.first(where: { $0.messageId == message.replyID }) HStack { Button { if let messageNum = messageReply?.messageId { @@ -130,7 +149,7 @@ struct ChannelMessageList: View { } } .padding(.bottom) - .id(channel.allPrivateMessages.firstIndex(of: message)) + .id(allPrivateMessages.firstIndex(of: message)) if !currentUser { Spacer(minLength: 50) @@ -149,7 +168,7 @@ struct ChannelMessageList: View { if !message.read { message.read = true do { - for unreadMessage in channel.allPrivateMessages.filter({ !$0.read }) { + for unreadMessage in allPrivateMessages.filter({ !$0.read }) { unreadMessage.read = true } try context.save() @@ -161,7 +180,7 @@ struct ChannelMessageList: View { } } // Check if we've reached the bottom message - if message.messageId == channel.allPrivateMessages.last?.messageId { + if message.messageId == allPrivateMessages.last?.messageId { hasReachedBottom = true showScrollToBottomButton = false } @@ -180,20 +199,22 @@ struct ChannelMessageList: View { } .scrollDismissesKeyboard(.interactively) .onFirstAppear { - if channel.unreadMessages == 0 { - withAnimation { - scrollView.scrollTo("bottomAnchor", anchor: .bottom) - hasReachedBottom = true - } - } else { - if let firstUnreadMessageId = channel.allPrivateMessages.first(where: { !$0.read })?.messageId { + DispatchQueue.main.async { + if channel.unreadMessages == 0 { withAnimation { - scrollView.scrollTo(firstUnreadMessageId, anchor: .top) - showScrollToBottomButton = true + scrollView.scrollTo("bottomAnchor", anchor: .bottom) + hasReachedBottom = true + } + } else { + if let firstUnreadMessageId = allPrivateMessages.first(where: { !$0.read })?.messageId { + withAnimation { + scrollView.scrollTo(firstUnreadMessageId, anchor: .top) + showScrollToBottomButton = true + } } } + gotFirstUnreadMessage = true } - gotFirstUnreadMessage = true } .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in withAnimation { @@ -202,7 +223,7 @@ struct ChannelMessageList: View { showScrollToBottomButton = false } } - .onChange(of: channel.allPrivateMessages) { + .onChange(of: allPrivateMessages.count) { if hasReachedBottom { withAnimation { scrollView.scrollTo("bottomAnchor", anchor: .bottom) @@ -248,18 +269,17 @@ struct ChannelMessageList: View { ToolbarItem(placement: .navigationBarTrailing) { ZStack { ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?", - + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?", // mqttProxyConnected defaults to false, so if it's not enabled it will still be false - mqttProxyConnected: bleManager.mqttProxyConnected && (channel.uplinkEnabled || channel.downlinkEnabled), + mqttProxyConnected: accessoryManager.mqttProxyConnected && (channel.uplinkEnabled || channel.downlinkEnabled), mqttUplinkEnabled: channel.uplinkEnabled, mqttDownlinkEnabled: channel.downlinkEnabled, - mqttTopic: bleManager.mqttManager.topic + mqttTopic: accessoryManager.mqttManager.topic ) } } } } } + diff --git a/Meshtastic/Views/Messages/MessageContextMenuItems.swift b/Meshtastic/Views/Messages/MessageContextMenuItems.swift index ca80f80e..ce27d808 100644 --- a/Meshtastic/Views/Messages/MessageContextMenuItems.swift +++ b/Meshtastic/Views/Messages/MessageContextMenuItems.swift @@ -1,9 +1,10 @@ import SwiftUI import CoreData +import OSLog struct MessageContextMenuItems: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager let message: MessageEntity let tapBackDestination: MessageDestination @@ -22,15 +23,21 @@ struct MessageContextMenuItems: View { Menu("Tapback") { ForEach(Tapbacks.allCases) { tb in Button { - let sentMessage = bleManager.sendMessage( - message: tb.emojiString, - toUserNum: tapBackDestination.userNum, - channel: tapBackDestination.channelNum, - isEmoji: true, - replyID: message.messageId - ) - if sentMessage { - self.context.refresh(tapBackDestination.managedObject, mergeChanges: true) + Task { + do { + try await accessoryManager.sendMessage( + message: tb.emojiString, + toUserNum: tapBackDestination.userNum, + channel: tapBackDestination.channelNum, + isEmoji: true, + replyID: message.messageId + ) + Task { @MainActor in + self.context.refresh(tapBackDestination.managedObject, mergeChanges: true) + } + } catch { + Logger.services.warning("Failed to send tapback.") + } } } label: { Text(tb.description) diff --git a/Meshtastic/Views/Messages/MessageText.swift b/Meshtastic/Views/Messages/MessageText.swift index b93f413f..24028b29 100644 --- a/Meshtastic/Views/Messages/MessageText.swift +++ b/Meshtastic/Views/Messages/MessageText.swift @@ -1,6 +1,7 @@ import MeshtasticProtobufs import OSLog import SwiftUI +import DatadogSessionReplay struct MessageText: View { static let linkBlue = Color(red: 0.4627, green: 0.8392, blue: 1) /* #76d6ff */ @@ -11,6 +12,8 @@ struct MessageText: View { ) static let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mm:ss:a") @Environment(\.managedObjectContext) var context + @EnvironmentObject var accessoryManager: AccessoryManager + let message: MessageEntity let tapBackDestination: MessageDestination let isCurrentUser: Bool @@ -20,132 +23,136 @@ struct MessageText: View { @State private var channelSettings: String? @State private var addChannels = false @State private var isShowingDeleteConfirmation = false - + var body: some View { - let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE")) - return Text(markdownText) - .tint(Self.linkBlue) - .padding(.vertical, 10) - .padding(.horizontal, 8) - .foregroundColor(.white) - .background(isCurrentUser ? .accentColor : Color(.gray)) - .cornerRadius(15) - .overlay { - /// Show the lock if the message is pki encrypted and has a real ack if sent by the current user, or is pki encrypted for incoming messages - if message.pkiEncrypted && message.realACK || !isCurrentUser && message.pkiEncrypted { - VStack(alignment: .trailing) { - Spacer() - HStack { + + SessionReplayPrivacyView(textAndInputPrivacy: .maskAll) { + + let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE")) + return Text(markdownText) + .tint(Self.linkBlue) + .padding(.vertical, 10) + .padding(.horizontal, 8) + .foregroundColor(.white) + .background(isCurrentUser ? .accentColor : Color(.gray)) + .cornerRadius(15) + .overlay { + /// Show the lock if the message is pki encrypted and has a real ack if sent by the current user, or is pki encrypted for incoming messages + if message.pkiEncrypted && message.realACK || !isCurrentUser && message.pkiEncrypted { + VStack(alignment: .trailing) { Spacer() - Image(systemName: "lock.circle.fill") - .symbolRenderingMode(.palette) - .foregroundStyle(.white, .green) - .font(.system(size: 20)) - .offset(x: 8, y: 8) + HStack { + Spacer() + Image(systemName: "lock.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .green) + .font(.system(size: 20)) + .offset(x: 8, y: 8) + } } } - } - let isStoreAndForward = message.portNum == Int32(PortNum.storeForwardApp.rawValue) - let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue) - if isStoreAndForward { - VStack(alignment: .trailing) { - Spacer() - HStack { + let isStoreAndForward = message.portNum == Int32(PortNum.storeForwardApp.rawValue) + let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue) + if isStoreAndForward { + VStack(alignment: .trailing) { Spacer() - Image(systemName: "envelope.circle.fill") - .symbolRenderingMode(.palette) - .foregroundStyle(.white, .gray) - .font(.system(size: 20)) - .offset(x: 8, y: 8) + HStack { + Spacer() + Image(systemName: "envelope.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .gray) + .font(.system(size: 20)) + .offset(x: 8, y: 8) + } } } - } - if tapBackDestination.overlaySensorMessage { - VStack { - isDetectionSensorMessage ? Image(systemName: "sensor.fill") - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .foregroundStyle(Color.orange) - .symbolRenderingMode(.multicolor) - .symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3)) - .offset(x: 20, y: -20) - : nil - } - } else { - EmptyView() - } - } - .contextMenu { - MessageContextMenuItems( - message: message, - tapBackDestination: tapBackDestination, - isCurrentUser: isCurrentUser, - isShowingDeleteConfirmation: $isShowingDeleteConfirmation, - onReply: onReply - ) - } - .environment(\.openURL, OpenURLAction { url in - channelSettings = nil - if url.absoluteString.lowercased().contains("meshtastic.org/v/#") { - // Handle contact URL - ContactURLHandler.handleContactUrl(url: url, bleManager: BLEManager.shared) - return .handled // Prevent default browser opening - } else if url.absoluteString.lowercased().contains("meshtastic.org/e/") { - // Handle channel URL - let components = url.absoluteString.components(separatedBy: "#") - guard !components.isEmpty, let lastComponent = components.last else { - Logger.services.error("No valid components found in channel URL: \(url.absoluteString, privacy: .public)") - return .discarded - } - self.addChannels = Bool(url.query?.contains("add=true") ?? false) - guard let lastComponent = components.last else { - Logger.services.error("Channel URL missing fragment component: \(url.absoluteString, privacy: .public)") - self.channelSettings = nil - return .discarded - } - self.channelSettings = lastComponent.components(separatedBy: "?").first ?? "" - Logger.services.debug("Add Channel: \(self.addChannels, privacy: .public)") - self.saveChannels = true - Logger.mesh.debug("Opening Channel Settings URL: \(url.absoluteString, privacy: .public)") - return .handled // Prevent default browser opening - } - return .systemAction // Open other URLs in browser - }) - // Display sheet for channel settings - .sheet(isPresented: Binding( - get: { - saveChannels && !(channelSettings == nil) - }, - set: { newValue in - saveChannels = newValue - if !newValue { - channelSettings = nil + if tapBackDestination.overlaySensorMessage { + VStack { + isDetectionSensorMessage ? Image(systemName: "sensor.fill") + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .foregroundStyle(Color.orange) + .symbolRenderingMode(.multicolor) + .symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3)) + .offset(x: 20, y: -20) + : nil + } + } else { + EmptyView() } } - )) { - SaveChannelQRCode( - channelSetLink: channelSettings ?? "Empty Channel URL", - addChannels: addChannels, - bleManager: BLEManager.shared - ) - .presentationDetents([.large]) - .presentationDragIndicator(.visible) - } - .confirmationDialog( - "Are you sure you want to delete this message?", - isPresented: $isShowingDeleteConfirmation, - titleVisibility: .visible - ) { - Button("Delete Message", role: .destructive) { - context.delete(message) - do { - try context.save() - } catch { - Logger.data.error("Failed to delete message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)") - } + .contextMenu { + MessageContextMenuItems( + message: message, + tapBackDestination: tapBackDestination, + isCurrentUser: isCurrentUser, + isShowingDeleteConfirmation: $isShowingDeleteConfirmation, + onReply: onReply + ) } - Button("Cancel", role: .cancel) {} - } + .environment(\.openURL, OpenURLAction { url in + channelSettings = nil + if url.absoluteString.lowercased().contains("meshtastic.org/v/#") { + // Handle contact URL + ContactURLHandler.handleContactUrl(url: url, accessoryManager: AccessoryManager.shared) + return .handled // Prevent default browser opening + } else if url.absoluteString.lowercased().contains("meshtastic.org/e/") { + // Handle channel URL + let components = url.absoluteString.components(separatedBy: "#") + guard !components.isEmpty, let lastComponent = components.last else { + Logger.services.error("No valid components found in channel URL: \(url.absoluteString, privacy: .public)") + return .discarded + } + self.addChannels = Bool(url.query?.contains("add=true") ?? false) + guard let lastComponent = components.last else { + Logger.services.error("Channel URL missing fragment component: \(url.absoluteString, privacy: .public)") + self.channelSettings = nil + return .discarded + } + self.channelSettings = lastComponent.components(separatedBy: "?").first ?? "" + Logger.services.debug("Add Channel: \(self.addChannels, privacy: .public)") + self.saveChannels = true + Logger.mesh.debug("Opening Channel Settings URL: \(url.absoluteString, privacy: .public)") + return .handled // Prevent default browser opening + } + return .systemAction // Open other URLs in browser + }) + // Display sheet for channel settings + .sheet(isPresented: Binding( + get: { + saveChannels && !(channelSettings == nil) + }, + set: { newValue in + saveChannels = newValue + if !newValue { + channelSettings = nil + } + } + )) { + SaveChannelQRCode( + channelSetLink: channelSettings ?? "Empty Channel URL", + addChannels: addChannels, + accessoryManager: accessoryManager + ) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + .confirmationDialog( + "Are you sure you want to delete this message?", + isPresented: $isShowingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete Message", role: .destructive) { + context.delete(message) + do { + try context.save() + } catch { + Logger.data.error("Failed to delete message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + Button("Cancel", role: .cancel) {} + } + } } } diff --git a/Meshtastic/Views/Messages/Messages.swift b/Meshtastic/Views/Messages/Messages.swift index 8a75faf7..1f3db5bb 100644 --- a/Meshtastic/Views/Messages/Messages.swift +++ b/Meshtastic/Views/Messages/Messages.swift @@ -13,7 +13,6 @@ import TipKit struct Messages: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager @ObservedObject var router: Router @@ -46,7 +45,6 @@ struct Messages: View { .font(.title2) .padding() } - } NavigationLink(value: MessagesNavigationState.directMessages()) { Label { diff --git a/Meshtastic/Views/Messages/RetryButton.swift b/Meshtastic/Views/Messages/RetryButton.swift index 6964f1b5..ab48072b 100644 --- a/Meshtastic/Views/Messages/RetryButton.swift +++ b/Meshtastic/Views/Messages/RetryButton.swift @@ -3,7 +3,7 @@ import OSLog struct RetryButton: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager let message: MessageEntity let destination: MessageDestination @@ -24,7 +24,7 @@ struct RetryButton: View { titleVisibility: .visible ) { Button("Try Again") { - guard bleManager.connectedPeripheral?.peripheral.state == .connected else { + guard accessoryManager.isConnected else { return } let messageID = message.messageId @@ -39,25 +39,23 @@ struct RetryButton: View { } catch { Logger.data.error("Failed to delete message \(messageID, privacy: .public): \(error.localizedDescription, privacy: .public)") } - if !bleManager.sendMessage( - message: payload, - toUserNum: userNum, - channel: channel, - isEmoji: isEmoji, - replyID: replyID - ) { - // Best effort, unlikely since we already checked BLE state - Logger.services.warning("Failed to resend message \(messageID, privacy: .public)") - } else { - switch destination { - case .user: - break - case let .channel(channel): - // We must refresh the channel to trigger a view update since its relationship - // to messages is via a weak fetched property which is not updated by - // `bleManager.sendMessage` unlike the user entity. - context.refresh(channel, mergeChanges: true) + Task { + do { + try await accessoryManager.sendMessage(message: payload, toUserNum: userNum, channel: channel, + isEmoji: isEmoji, replyID: replyID) + if case let .channel(channel) = destination { + // We must refresh the channel to trigger a view update since its relationship + // to messages is via a weak fetched property which is not updated by + // `bleManager.sendMessage` unlike the user entity. + Task { @MainActor in + context.refresh(channel, mergeChanges: true) + } + } + } catch { + // Best effort + Logger.services.warning("Failed to resend message \(messageID, privacy: .public)") } + } } Button("Cancel", role: .cancel) {} diff --git a/Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift b/Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift index 945b41c0..ef0fdd73 100644 --- a/Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift +++ b/Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift @@ -1,9 +1,10 @@ import SwiftUI import OSLog +import DatadogSessionReplay struct TextMessageField: View { static let maxbytes = 200 - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager let destination: MessageDestination @Binding var replyMessageId: Int64 @@ -15,118 +16,125 @@ struct TextMessageField: View { @State private var sendPositionWithMessage = false var body: some View { - VStack { - #if targetEnvironment(macCatalyst) - HStack { - if destination.showAlertButton { - Spacer() - AlertButton { typingMessage += "🔔 Alert Bell! \u{7}" } - } - Spacer() - RequestPositionButton(action: requestPosition) - TextMessageSize(maxbytes: Self.maxbytes, totalBytes: totalBytes).padding(.trailing) - } - #endif - - HStack(alignment: .top) { - if replyMessageId != 0 { - HStack { - Button { - withAnimation(.easeInOut(duration: 0.2)) { - replyMessageId = 0 - } - isFocused = false - } label: { - Image(systemName: "x.circle.fill") - } - Text("Reply") + SessionReplayPrivacyView(textAndInputPrivacy: .maskAllInputs) { + VStack { +#if targetEnvironment(macCatalyst) + HStack { + if destination.showAlertButton { + Spacer() + AlertButton { typingMessage += "🔔 Alert Bell! \u{7}" } } - .padding(.top) + Spacer() + RequestPositionButton(action: requestPosition) + TextMessageSize(maxbytes: Self.maxbytes, totalBytes: totalBytes).padding(.trailing) } - - ZStack { - TextField("Message", text: $typingMessage, axis: .vertical) - .onChange(of: typingMessage) { _, value in - totalBytes = value.utf8.count - while totalBytes > Self.maxbytes { - typingMessage = String(typingMessage.dropLast()) - totalBytes = typingMessage.utf8.count - } - } - .keyboardType(.default) - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Button("Dismiss") { - isFocused = false +#endif + + HStack(alignment: .top) { + if replyMessageId != 0 { + HStack { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + replyMessageId = 0 } - .font(.subheadline) - - if destination.showAlertButton { + isFocused = false + } label: { + Image(systemName: "x.circle.fill") + } + Text("Reply") + } + .padding(.top) + } + + ZStack { + TextField("Message", text: $typingMessage, axis: .vertical) + .onChange(of: typingMessage) { _, value in + totalBytes = value.utf8.count + while totalBytes > Self.maxbytes { + typingMessage = String(typingMessage.dropLast()) + totalBytes = typingMessage.utf8.count + } + } + .keyboardType(.default) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Button("Dismiss") { + isFocused = false + } + .font(.subheadline) + + if destination.showAlertButton { + Spacer() + AlertButton { typingMessage += "🔔 Alert Bell Character! \u{7}" } + } + Spacer() - AlertButton { typingMessage += "🔔 Alert Bell Character! \u{7}" } + RequestPositionButton(action: requestPosition) + TextMessageSize(maxbytes: Self.maxbytes, totalBytes: totalBytes) } - - Spacer() - RequestPositionButton(action: requestPosition) - TextMessageSize(maxbytes: Self.maxbytes, totalBytes: totalBytes) } - } - .padding(.horizontal, 8) - .focused($isFocused) - .multilineTextAlignment(.leading) - .frame(minHeight: 50) - .keyboardShortcut(.defaultAction) - .onSubmit { - #if targetEnvironment(macCatalyst) - sendMessage() - #endif - } - - Text(typingMessage) - .opacity(0) - .padding(.all, 0) - } - .overlay(RoundedRectangle(cornerRadius: 20).stroke(.tertiary, lineWidth: 1)) - .padding(.bottom, 15) - - Button(action: sendMessage) { - Image(systemName: "arrow.up.circle.fill") - .font(.largeTitle) - .foregroundColor(.accentColor) + .padding(.horizontal, 8) + .focused($isFocused) + .multilineTextAlignment(.leading) + .frame(minHeight: 50) + .keyboardShortcut(.defaultAction) + .onSubmit { +#if targetEnvironment(macCatalyst) + sendMessage() +#endif + } + + Text(typingMessage) + .opacity(0) + .padding(.all, 0) + } + .overlay(RoundedRectangle(cornerRadius: 20).stroke(.tertiary, lineWidth: 1)) + .padding(.bottom, 15) + + Button(action: sendMessage) { + Image(systemName: "arrow.up.circle.fill") + .font(.largeTitle) + .foregroundColor(.accentColor) + } } + .padding(.all, 15) } - .padding(.all, 15) } } private func requestPosition() { - let userLongName = bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral.longName : "Unknown" + let userLongName = accessoryManager.activeConnection?.device.longName ?? "Unknown" sendPositionWithMessage = true typingMessage = "📍 " + userLongName + " \(destination.positionShareMessage)." } private func sendMessage() { - let messageSent = bleManager.sendMessage( - message: typingMessage, - toUserNum: destination.userNum, - channel: destination.channelNum, - isEmoji: false, - replyID: replyMessageId - ) - if messageSent { - typingMessage = "" - isFocused = false - replyMessageId = 0 - onSubmit() - if sendPositionWithMessage { - let positionSent = bleManager.sendPosition( + Task { + do { + try await accessoryManager.sendMessage( + message: typingMessage, + toUserNum: destination.userNum, channel: destination.channelNum, - destNum: destination.positionDestNum, - wantResponse: destination.wantPositionResponse - ) - if positionSent { + isEmoji: false, + replyID: replyMessageId) + + // If nothing thrown, then successful. Reset for the next message + typingMessage = "" + isFocused = false + replyMessageId = 0 + onSubmit() + + if sendPositionWithMessage { + try await accessoryManager.sendPosition( + channel: destination.channelNum, + destNum: destination.positionDestNum, + wantResponse: destination.wantPositionResponse + ) + // If nothing thrown, then successful. Logger.mesh.info("Location Sent") } + } catch { + Logger.mesh.info("Error sending message") } } } diff --git a/Meshtastic/Views/Messages/UserList.swift b/Meshtastic/Views/Messages/UserList.swift index 6d20ec23..ae6be092 100644 --- a/Meshtastic/Views/Messages/UserList.swift +++ b/Meshtastic/Views/Messages/UserList.swift @@ -13,7 +13,7 @@ import TipKit struct UserList: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @State private var searchText = "" @State private var viaLora = true @State private var viaMqtt = true @@ -69,7 +69,7 @@ struct UserList: View { let lastMessageTime = Date(timeIntervalSince1970: TimeInterval(Int64((mostRecent?.messageTimestamp ?? 0 )))) let lastMessageDay = Calendar.current.dateComponents([.day], from: lastMessageTime).day ?? 0 let currentDay = Calendar.current.dateComponents([.day], from: Date()).day ?? 0 - if user.num != bleManager.connectedPeripheral?.num ?? 0 { + if user.num != accessoryManager.activeDeviceNum ?? 0 { NavigationLink(value: user) { ZStack { Image(systemName: "circle.fill") @@ -138,15 +138,15 @@ struct UserList: View { .contextMenu { Button { 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 ?? false) + user.userNode?.favorite = !(user.userNode?.favorite ?? false) + Task { + try await accessoryManager.setFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num)) Logger.data.info("Favorited a node") } } else { - let success = bleManager.removeFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num)) - if success { - user.userNode?.favorite = !(user.userNode?.favorite ?? false) + user.userNode?.favorite = !(user.userNode?.favorite ?? false) + Task { + try await accessoryManager.removeFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num)) Logger.data.info("Unfavorited a node") } } diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index 92b30d8e..15af373b 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -12,7 +12,7 @@ import OSLog struct UserMessageList: View { @EnvironmentObject var appState: AppState - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.managedObjectContext) var context // Keyboard State @FocusState var messageFieldFocused: Bool @@ -39,7 +39,7 @@ struct UserMessageList: View { .font(.caption) .foregroundColor(.gray) } - if user.num != bleManager.connectedPeripheral?.num ?? -1 { + if user.num != accessoryManager.activeDeviceNum ?? -1 { let currentUser: Bool = (Int64(UserDefaults.preferredPeripheralNum) == message.fromUser?.num ?? -1 ? true : false) if message.replyID > 0 { @@ -255,9 +255,8 @@ struct UserMessageList: View { ToolbarItem(placement: .navigationBarTrailing) { ZStack { ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?") } } } diff --git a/Meshtastic/Views/Nodes/DetectionSensorLog.swift b/Meshtastic/Views/Nodes/DetectionSensorLog.swift index e4c22e0d..f86b9acb 100644 --- a/Meshtastic/Views/Nodes/DetectionSensorLog.swift +++ b/Meshtastic/Views/Nodes/DetectionSensorLog.swift @@ -12,7 +12,7 @@ import OSLog struct DetectionSensorLog: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @State private var isPresentingClearLogConfirm: Bool = false @State var isExporting = false @State var exportString = "" @@ -122,7 +122,7 @@ struct DetectionSensorLog: View { .navigationBarTitleDisplayMode(.inline) .navigationBarItems(trailing: ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") }) .fileExporter( isPresented: $isExporting, diff --git a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift index e7b4c952..e21f9241 100644 --- a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift +++ b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift @@ -11,7 +11,7 @@ import OSLog struct DeviceMetricsLog: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @State private var isPresentingClearLogConfirm: Bool = false @@ -244,7 +244,7 @@ struct DeviceMetricsLog: View { .navigationBarTitleDisplayMode(.inline) .navigationBarItems(trailing: ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") }) .fileExporter( isPresented: $isExporting, diff --git a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift index 160f3ef7..be081e84 100644 --- a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift +++ b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift @@ -11,7 +11,7 @@ import OSLog struct EnvironmentMetricsLog: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @State private var isPresentingClearLogConfirm: Bool = false @State var isExporting = false @State var exportString = "" @@ -164,7 +164,7 @@ struct EnvironmentMetricsLog: View { .navigationBarTitleDisplayMode(.inline) .navigationBarItems(trailing: ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") }) .fileExporter( isPresented: $isExporting, diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/ClientHistoryButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/ClientHistoryButton.swift index 291eb336..624ca183 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/ClientHistoryButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/ClientHistoryButton.swift @@ -1,7 +1,7 @@ import SwiftUI - +import OSLog struct ClientHistoryButton: View { - var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager var connectedNode: NodeInfoEntity @@ -12,10 +12,19 @@ struct ClientHistoryButton: View { var body: some View { Button { - isPresentingAlert = bleManager.requestStoreAndForwardClientHistory( - fromUser: connectedNode.user!, - toUser: node.user! - ) + Task { + do { + try await accessoryManager.requestStoreAndForwardClientHistory( + fromUser: connectedNode.user!, + toUser: node.user! + ) + Task { @MainActor in + isPresentingAlert = true + } + } catch { + Logger.mesh.warning("Failed to send client history request: \(error)") + } + } } label: { Label( "Client History", diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/DeleteNodeButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/DeleteNodeButton.swift index c5495c10..70ffc217 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/DeleteNodeButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/DeleteNodeButton.swift @@ -4,8 +4,9 @@ import SwiftUI struct DeleteNodeButton: View { - var bleManager: BLEManager - var context: NSManagedObjectContext + @Environment(\.managedObjectContext) var context + @EnvironmentObject var accessoryManager: AccessoryManager + var connectedNode: NodeInfoEntity var node: NodeInfoEntity @Environment(\.dismiss) private var dismiss @@ -44,14 +45,19 @@ struct DeleteNodeButton: View { Logger.data.error("Unable to find node info to delete node \(node.num, privacy: .public)") return } - let success = bleManager.removeNode( - node: deleteNode, - connectedNodeNum: connectedNode.num - ) - if !success { - Logger.data.error("Failed to delete node \(deleteNode.user?.longName ?? "Unknown".localized, privacy: .public)") - } else { - dismiss() + + Task { + do { + try await accessoryManager.removeNode( + node: deleteNode, + connectedNodeNum: connectedNode.num + ) + Task {@MainActor in + dismiss() + } + } catch { + Logger.data.error("Failed to delete node \(deleteNode.user?.longName ?? "Unknown".localized, privacy: .public)") + } } } } diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/ExchangePositionsButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/ExchangePositionsButton.swift index f98bc999..2587550a 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/ExchangePositionsButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/ExchangePositionsButton.swift @@ -2,29 +2,35 @@ import CoreData import SwiftUI struct ExchangePositionsButton: View { - var bleManager: BLEManager - var node: NodeInfoEntity + @EnvironmentObject var accessoryManager: AccessoryManager + @State private var isPresentingPositionSentAlert: Bool = false @State private var isPresentingPositionFailedAlert: Bool = false var body: some View { Button { - let positionSent = bleManager.sendPosition( - channel: node.channel, - destNum: node.num, - wantResponse: true - ) - if positionSent { - isPresentingPositionSentAlert = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - isPresentingPositionSentAlert = false - } - } else { - isPresentingPositionFailedAlert = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - isPresentingPositionFailedAlert = false + Task { + do { + try await accessoryManager.sendPosition( + channel: node.channel, + destNum: node.num, + wantResponse: true + ) + Task { @MainActor in + isPresentingPositionSentAlert = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + isPresentingPositionSentAlert = false + } + } + } catch { + Task { @MainActor in + isPresentingPositionFailedAlert = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + isPresentingPositionFailedAlert = false + } + } } } diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/FavoriteNodeButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/FavoriteNodeButton.swift index 096674c7..4ca8009b 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/FavoriteNodeButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/FavoriteNodeButton.swift @@ -3,35 +3,44 @@ import OSLog import SwiftUI struct FavoriteNodeButton: View { - var bleManager: BLEManager - var context: NSManagedObjectContext - @ObservedObject - var node: NodeInfoEntity + @EnvironmentObject var accessoryManager: AccessoryManager + @Environment(\.managedObjectContext) var context + + @ObservedObject var node: NodeInfoEntity var body: some View { Button { - guard let connectedNodeNum = bleManager.connectedPeripheral?.num else { return } - let success = if node.favorite { - bleManager.removeFavoriteNode( - node: node, - connectedNodeNum: Int64(connectedNodeNum) - ) - } else { - bleManager.setFavoriteNode( - node: node, - connectedNodeNum: Int64(connectedNodeNum) - ) - } - if success { - node.favorite = !node.favorite + guard let connectedNodeNum = accessoryManager.activeDeviceNum else { return } + Task { do { - try context.save() + if node.favorite { + try await accessoryManager.removeFavoriteNode( + node: node, + connectedNodeNum: Int64(connectedNodeNum) + ) + } else { + try await accessoryManager.setFavoriteNode( + node: node, + connectedNodeNum: Int64(connectedNodeNum) + ) + } + + Task { @MainActor in + // Update CoreData + node.favorite = !node.favorite + + do { + try context.save() + } catch { + context.rollback() + Logger.data.error("Save Node Favorite Error") + } + Logger.data.debug("Favorited a node") + } } catch { - context.rollback() - Logger.data.error("Save Node Favorite Error") + } - Logger.data.debug("Favorited a node") } } label: { Label { diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift index 2d73d5c0..c15c69d1 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift @@ -3,35 +3,42 @@ import OSLog import SwiftUI struct IgnoreNodeButton: View { - var bleManager: BLEManager - var context: NSManagedObjectContext + @Environment(\.managedObjectContext) var context + @EnvironmentObject var accessoryManager: AccessoryManager @ObservedObject var node: NodeInfoEntity var body: some View { Button(role: .destructive) { - guard let connectedNodeNum = bleManager.connectedPeripheral?.num else { return } - let success = if node.ignored { - bleManager.removeIgnoredNode( - node: node, - connectedNodeNum: Int64(connectedNodeNum) - ) - } else { - bleManager.setIgnoredNode( - node: node, - connectedNodeNum: Int64(connectedNodeNum) - ) - } - if success { - node.ignored = !node.ignored + guard let connectedNodeNum = accessoryManager.activeDeviceNum else { return } + Task { do { - try context.save() + if node.ignored { + try await accessoryManager.removeIgnoredNode( + node: node, + connectedNodeNum: Int64(connectedNodeNum) + ) + } else { + try await accessoryManager.setIgnoredNode( + node: node, + connectedNodeNum: Int64(connectedNodeNum) + ) + } + Task {@MainActor in + // CoreData Stuff + node.ignored = !node.ignored + do { + try context.save() + } catch { + context.rollback() + Logger.data.error("Save Ignored Node Error") + } + } + Logger.data.debug("Ignored a node") } catch { - context.rollback() - Logger.data.error("Save Ignored Node Error") + Logger.mesh.error("Faile to Ignored/Un-ignore a node") } - Logger.data.debug("Ignored a node") } } label: { Label { diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/TraceRouteButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/TraceRouteButton.swift index 95489e95..11a14460 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/TraceRouteButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/TraceRouteButton.swift @@ -1,7 +1,7 @@ import SwiftUI - +import OSLog struct TraceRouteButton: View { - var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager var node: NodeInfoEntity @@ -10,10 +10,19 @@ struct TraceRouteButton: View { var body: some View { RateLimitedButton(key: "traceroute", rateLimit: 30.0) { - isPresentingTraceRouteSentAlert = bleManager.sendTraceRouteRequest( - destNum: node.user?.num ?? 0, - wantResponse: true - ) + Task { + do { + try await accessoryManager.sendTraceRouteRequest( + destNum: node.user?.num ?? 0, + wantResponse: true + ) + Task { + isPresentingTraceRouteSentAlert = true + } + } catch { + Logger.mesh.warning("Failed to send traceroute request: \(error)") + } + } } label: { completion in if let completion, completion.percentComplete > 0.0 { Label { diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapDataFiles.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapDataFiles.swift index 71df476d..89a7e1ad 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapDataFiles.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapDataFiles.swift @@ -4,7 +4,7 @@ import OSLog struct MapDataFiles: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @ObservedObject private var mapDataManager = MapDataManager.shared @State private var isShowingFilePicker = false @@ -58,10 +58,10 @@ struct MapDataFiles: View { let uploadedFiles = mapDataManager.getUploadedFiles() if uploadedFiles.isEmpty { - ContentUnavailableView ("No files uploaded", systemImage: "doc.text") + ContentUnavailableView("No files uploaded", systemImage: "doc.text") } else { ScrollView { - LazyVStack() { + LazyVStack { ForEach(Array(uploadedFiles.enumerated()), id: \.offset) { index, file in MapDataFileRow(file: file, showDivider: index < uploadedFiles.count - 1) { deleteFile(file) diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift index ec49fd52..d04cbd4f 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift @@ -187,7 +187,7 @@ struct MapSettingsForm: View { } } } else { - ContentUnavailableView ("No map data files uploaded", systemImage: "exclamationmark.triangle") + ContentUnavailableView("No map data files uploaded", systemImage: "exclamationmark.triangle") } } else if !hasUserData { // Upload prompt when no data available diff --git a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift index 34054376..67707590 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/NodeMapSwiftUI.swift @@ -11,7 +11,7 @@ import MapKit struct NodeMapSwiftUI: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager /// Parameters @ObservedObject var node: NodeInfoEntity @State var showUserLocation: Bool = false @@ -58,9 +58,8 @@ struct NodeMapSwiftUI: View { .navigationBarItems(trailing: ZStack { ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?") }) } diff --git a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift index 76e474b8..65cb56e0 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift @@ -12,7 +12,6 @@ struct PositionPopover: View { @ObservedObject var locationsHandler = LocationsHandler.shared @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @Environment(\.dismiss) private var dismiss var position: PositionEntity diff --git a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift index 2f74d51f..ea66a340 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift @@ -13,7 +13,7 @@ import SwiftUI struct WaypointForm: View { - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.managedObjectContext) var context @Environment(\.dismiss) private var dismiss @State var waypoint: WaypointEntity @@ -149,7 +149,11 @@ struct WaypointForm: View { .scrollDismissesKeyboard(.immediately) HStack { Button { - if bleManager.isConnected { + guard let deviceNum = accessoryManager.activeDeviceNum else { + Logger.mesh.warning("Send waypoint failed: No deviceNum") + return + } + if accessoryManager.isConnected { /// Send a new or exiting waypoint var newWaypoint = Waypoint() if waypoint.id == 0 { @@ -169,7 +173,7 @@ struct WaypointForm: View { newWaypoint.icon = unicode if locked { if lockedTo == 0 { - newWaypoint.lockedTo = UInt32(bleManager.connectedPeripheral!.num) + newWaypoint.lockedTo = UInt32(deviceNum) } else { newWaypoint.lockedTo = UInt32(lockedTo) } @@ -179,11 +183,17 @@ struct WaypointForm: View { } else { newWaypoint.expire = 0 } - if bleManager.sendWaypoint(waypoint: newWaypoint) { - dismiss() - } else { - Logger.mesh.warning("Send waypoint failed") - waypointFailedAlert = true + + Task { + do { + try await accessoryManager.sendWaypoint(waypoint: newWaypoint) + dismiss() + } catch { + Logger.mesh.warning("Send waypoint failed: \(error)") + Task { @MainActor in + waypointFailedAlert = true + } + } } } else { Logger.mesh.warning("Send waypoint failed, node not connected") @@ -194,7 +204,7 @@ struct WaypointForm: View { .buttonStyle(.bordered) .buttonBorderShape(.capsule) .controlSize(.regular) - .disabled(bleManager.connectedPeripheral == nil) + .disabled(!accessoryManager.isConnected) .padding(.bottom) Button(role: .cancel) { @@ -207,7 +217,7 @@ struct WaypointForm: View { .controlSize(.regular) .padding(.bottom) - if waypoint.id > 0 && bleManager.isConnected { + if waypoint.id > 0 && accessoryManager.isConnected { Menu { Button("For me", action: { @@ -219,6 +229,10 @@ struct WaypointForm: View { } dismiss() }) Button("For everyone", action: { + guard let deviceNum = accessoryManager.activeDeviceNum else { + Logger.mesh.error("Unable to set waypoint: No Device num") + return + } var newWaypoint = Waypoint() newWaypoint.id = UInt32(waypoint.id) newWaypoint.name = name.count > 0 ? name : "Dropped Pin" @@ -232,24 +246,30 @@ struct WaypointForm: View { newWaypoint.icon = unicode if locked { if lockedTo == 0 { - newWaypoint.lockedTo = UInt32(bleManager.connectedPeripheral!.num) + newWaypoint.lockedTo = UInt32(deviceNum) } else { newWaypoint.lockedTo = UInt32(lockedTo) } } newWaypoint.expire = UInt32(1) - if bleManager.sendWaypoint(waypoint: newWaypoint) { - - context.delete(waypoint) + Task { do { - try context.save() + try await accessoryManager.sendWaypoint(waypoint: newWaypoint) + Task { @MainActor in + context.delete(waypoint) + do { + try context.save() + } catch { + context.rollback() + } + dismiss() + } } catch { - context.rollback() + Logger.mesh.warning("Send waypoint failed") + Task {@MainActor in + waypointFailedAlert = true + } } - dismiss() - } else { - Logger.mesh.warning("Send waypoint failed") - waypointFailedAlert = true } }) } @@ -271,7 +291,7 @@ struct WaypointForm: View { Text(waypoint.name ?? "?") .font(.largeTitle) Spacer() - if waypoint.locked > 0 && waypoint.locked != UInt32(BLEManager.shared.connectedPeripheral?.num ?? 0) { + if waypoint.locked > 0 && waypoint.locked != UInt32(accessoryManager.activeDeviceNum ?? 0) { Image(systemName: "lock.fill") .font(.largeTitle) } else { diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index ae64393f..c6b9ff4a 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -20,7 +20,7 @@ struct NodeDetail: View { rawValue: UserDefaults.modemPreset ) ?? ModemPresets.longFast @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @State private var showingShutdownConfirm: Bool = false @State private var showingRebootConfirm: Bool = false @State private var dateFormatRelative: Bool = true @@ -34,7 +34,7 @@ struct NodeDetail: View { NavigationStack { List { let connectedNode = getNodeInfo( - id: bleManager.connectedPeripheral?.num ?? -1, + id: accessoryManager.activeDeviceNum ?? -1, context: context ) Section("Hardware") { @@ -115,7 +115,7 @@ struct NodeDetail: View { .textSelection(.enabled) } .accessibilityElement(children: .combine) - let connectedNode = getNodeInfo(id: BLEManager.shared.connectedPeripheral?.num ?? 0, context: context) + let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context) if let user = node.user, user.keyMatch { let publicKey = node.num == connectedNode?.num ? node.securityConfig?.publicKey?.base64EncodedString() ?? "" @@ -445,22 +445,17 @@ struct NodeDetail: View { } if let connectedNode { FavoriteNodeButton( - bleManager: bleManager, - context: context, node: node ) if connectedNode.num != node.num { ExchangePositionsButton( - bleManager: bleManager, node: node ) TraceRouteButton( - bleManager: bleManager, node: node ) if node.isStoreForwardRouter { ClientHistoryButton( - bleManager: bleManager, connectedNode: connectedNode, node: node ) @@ -469,13 +464,9 @@ struct NodeDetail: View { NavigateToButton(node: node) } IgnoreNodeButton( - bleManager: bleManager, - context: context, node: node ) DeleteNodeButton( - bleManager: bleManager, - context: context, connectedNode: connectedNode, node: node ) @@ -484,18 +475,22 @@ struct NodeDetail: View { } if let metadata = node.metadata, let connectedNode, - self.bleManager.connectedPeripheral != nil { + accessoryManager.isConnected { Section("Administration") { if UserDefaults.enableAdministration { Button { - let adminMessageId = bleManager.requestDeviceMetadata( - fromUser: connectedNode.user!, - toUser: node.user!, - context: context - ) - if adminMessageId > 0 { - Logger.mesh.info("Sent node metadata request from node details") + Task { + do { + _ = try await accessoryManager.requestDeviceMetadata( + fromUser: connectedNode.user!, + toUser: node.user!, + ) + Logger.mesh.info("Sent node metadata request from node details") + } catch { + Logger.mesh.error("Faild to send node metadata request from node details") + } } + } label: { Label { Text("Refresh device metadata") @@ -514,11 +509,15 @@ struct NodeDetail: View { isPresented: $showingShutdownConfirm ) { Button("Shutdown Node?", role: .destructive) { - if !bleManager.sendShutdown( - fromUser: connectedNode.user!, - toUser: node.user! - ) { - Logger.mesh.warning("Shutdown Failed") + Task { + do { + try await accessoryManager.sendShutdown( + fromUser: connectedNode.user!, + toUser: node.user! + ) + } catch { + Logger.mesh.warning("Shutdown Failed") + } } } } @@ -535,11 +534,14 @@ struct NodeDetail: View { isPresented: $showingRebootConfirm ) { Button("Reboot node?", role: .destructive) { - if !bleManager.sendReboot( - fromUser: connectedNode.user!, - toUser: node.user! - ) { - Logger.mesh.warning("Reboot Failed") + Task { + do { + try await accessoryManager.sendReboot( + fromUser: connectedNode.user!, + toUser: node.user! ) + } catch { + Logger.mesh.warning("Reboot Failed") + } } } } diff --git a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift index 1ceb7583..5c6e72ad 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift @@ -22,7 +22,7 @@ struct NodeListItem: View { } else { desc = "Unknown".localized + " " + "Node".localized } - if connected { + if isDirectlyConnected { desc += ", currently connected" } if node.favorite { @@ -57,7 +57,7 @@ struct NodeListItem: View { } } // Add distance and heading/bearing if available, but only for non-connected nodes - if !connected, let (lastPosition, myCoord) = locationData { + if !isDirectlyConnected, let (lastPosition, myCoord) = locationData { let nodeCoord = CLLocation(latitude: lastPosition.nodeCoordinate!.latitude, longitude: lastPosition.nodeCoordinate!.longitude) let metersAway = nodeCoord.distance(from: myCoord) // Distance information @@ -98,7 +98,7 @@ struct NodeListItem: View { } @ObservedObject var node: NodeInfoEntity - var connected: Bool + var isDirectlyConnected: Bool var connectedNode: Int64 var modemPreset: ModemPresets = ModemPresets(rawValue: UserDefaults.modemPreset) ?? ModemPresets.longFast @@ -159,7 +159,7 @@ struct NodeListItem: View { .symbolRenderingMode(.multicolor) } } - if connected { + if isDirectlyConnected { IconAndText(systemName: "antenna.radiowaves.left.and.right.circle.fill", imageColor: .green, text: "Connected".localized) @@ -318,6 +318,6 @@ struct IconAndText: View { user.shortName = "TU" nodeInfo.user = user return nodeInfo - }(), connected: true, connectedNode: 0, modemPreset: .longFast) + }(), isDirectlyConnected: true, connectedNode: 0, modemPreset: .longFast) } } diff --git a/Meshtastic/Views/Nodes/MeshMap.swift b/Meshtastic/Views/Nodes/MeshMap.swift index c72bab05..58f5aff6 100644 --- a/Meshtastic/Views/Nodes/MeshMap.swift +++ b/Meshtastic/Views/Nodes/MeshMap.swift @@ -15,7 +15,7 @@ import MapKit struct MeshMap: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @ObservedObject var router: Router @@ -194,7 +194,7 @@ struct MeshMap: View { } } .navigationBarItems(leading: MeshtasticLogo(), trailing: ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") }) .onFirstAppear { UIApplication.shared.isIdleTimerDisabled = true diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index fb0baf62..40c08fa3 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -11,13 +11,11 @@ import OSLog struct NodeList: View { @Environment(\.managedObjectContext) var context - - @EnvironmentObject - var bleManager: BLEManager - - @ObservedObject - var router: Router - + + @EnvironmentObject var accessoryManager: AccessoryManager + + @StateObject var router: Router + @State private var columnVisibility = NavigationSplitViewVisibility.all @State private var selectedNode: NodeInfoEntity? @State private var searchText = "" @@ -40,8 +38,8 @@ struct NodeList: View { @State private var isPresentingPositionFailedAlert = false @State private var isPresentingDeleteNodeAlert = false @State private var deleteNodeId: Int64 = 0 - @State private var shareContactNode: NodeInfoEntity? - + @State private var shareContactNode: NodeInfoEntity? + var boolFilters: [Bool] {[ isFavorite, isIgnored, @@ -51,11 +49,11 @@ struct NodeList: View { distanceFilter, roleFilter ]} - + @State var isEditingFilters = false - + @SceneStorage("selectedDetailView") var selectedDetailView: String? - + @FetchRequest( sortDescriptors: [ NSSortDescriptor(key: "ignored", ascending: true), @@ -66,11 +64,14 @@ struct NodeList: View { animation: .spring ) var nodes: FetchedResults - + var connectedNode: NodeInfoEntity? { - getNodeInfo(id: bleManager.connectedPeripheral?.num ?? 0, context: context) + if let num = accessoryManager.activeDeviceNum { + return getNodeInfo(id: num, context: context) + } + return nil } - + @ViewBuilder func contextMenuActions( node: NodeInfoEntity, @@ -89,45 +90,43 @@ struct NodeList: View { } if let connectedNode { /// Favoriting a node requires being connected - FavoriteNodeButton(bleManager: bleManager, context: context, node: node) + FavoriteNodeButton(node: node) /// Don't show message, trace route, position exchange or delete context menu items for the connected node if connectedNode.num != node.num { if !(node.user?.unmessagable ?? true) { Button(action: { if let url = URL(string: "meshtastic:///messages?userNum=\(node.num)") { - UIApplication.shared.open(url) + UIApplication.shared.open(url) } }) { Label("Message", systemImage: "message") } } TraceRouteButton( - bleManager: bleManager, node: node ) Button { - let positionSent = bleManager.sendPosition( - channel: node.channel, - destNum: node.num, - wantResponse: true - ) - if positionSent { - isPresentingPositionSentAlert = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - isPresentingPositionSentAlert = false - } - } else { - isPresentingPositionFailedAlert = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - isPresentingPositionFailedAlert = false + Task { + do { + try await accessoryManager.sendPosition( + channel: node.channel, + destNum: node.num, + wantResponse: true + ) + Task { @MainActor in + isPresentingPositionSentAlert = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + isPresentingPositionSentAlert = false + } + } + } catch { + Logger.mesh.warning("Failed to sendPosition") } } } label: { Label("Exchange Positions", systemImage: "arrow.triangle.2.circlepath") } IgnoreNodeButton( - bleManager: bleManager, - context: context, node: node ) Button(role: .destructive) { @@ -139,15 +138,15 @@ struct NodeList: View { } } } - + var body: some View { // Use forceRefreshID to completely rebuild the view when notifications update the selected node NavigationSplitView(columnVisibility: $columnVisibility) { List(nodes, id: \.self, selection: $selectedNode) { node in NodeListItem( node: node, - connected: bleManager.connectedPeripheral?.num ?? -1 == node.num, - connectedNode: bleManager.connectedPeripheral?.num ?? -1 + isDirectlyConnected: node.num == accessoryManager.activeDeviceNum, + connectedNode: accessoryManager.activeConnection?.device.num ?? -1 ) .contextMenu { contextMenuActions( @@ -199,56 +198,58 @@ struct NodeList: View { isPresented: $isPresentingPositionSentAlert) { Button("OK") { }.keyboardShortcut(.defaultAction) } message: { - Text("Your position has been sent with a request for a response with their position. You will receive a notification when a position is returned.") - } - .alert( - "Position Exchange Failed", - isPresented: $isPresentingPositionFailedAlert) { - Button("OK") { }.keyboardShortcut(.defaultAction) - } message: { - Text("Failed to get a valid position to exchange") - } - .alert( - "Trace Route Sent", - isPresented: $isPresentingTraceRouteSentAlert) { - Button("OK") { }.keyboardShortcut(.defaultAction) - } message: { - Text("This could take a while, response will appear in the trace route log for the node it was sent to.") - } - .confirmationDialog( - "Are you sure?", - isPresented: $isPresentingDeleteNodeAlert, - titleVisibility: .visible - ) { - Button("Delete Node") { - let deleteNode = getNodeInfo(id: deleteNodeId, context: context) - if connectedNode != nil { - if deleteNode != nil { - let success = bleManager.removeNode(node: deleteNode!, connectedNodeNum: Int64(bleManager.connectedPeripheral?.num ?? -1)) - if !success { - Logger.data.error("Failed to delete node \(deleteNode?.user?.longName ?? "Unknown".localized, privacy: .public)") + Text("Your position has been sent with a request for a response with their position. You will receive a notification when a position is returned.") + } + .alert( + "Position Exchange Failed", + isPresented: $isPresentingPositionFailedAlert) { + Button("OK") { }.keyboardShortcut(.defaultAction) + } message: { + Text("Failed to get a valid position to exchange") + } + .alert( + "Trace Route Sent", + isPresented: $isPresentingTraceRouteSentAlert) { + Button("OK") { }.keyboardShortcut(.defaultAction) + } message: { + Text("This could take a while, response will appear in the trace route log for the node it was sent to.") + } + .confirmationDialog( + "Are you sure?", + isPresented: $isPresentingDeleteNodeAlert, + titleVisibility: .visible + ) { + Button("Delete Node") { + let deleteNode = getNodeInfo(id: deleteNodeId, context: context) + if connectedNode != nil { + if deleteNode != nil { + Task { + do { + try await accessoryManager.removeNode(node: deleteNode!, connectedNodeNum: Int64(accessoryManager.activeDeviceNum ?? -1)) + } catch { + Logger.data.error("Failed to delete node \(deleteNode?.user?.longName ?? "Unknown".localized, privacy: .public)") + } + } + } + } } } - } - } - } - .sheet(item: $shareContactNode) { selectedNode in - ShareContactQRDialog(node: selectedNode.toProto()) - } - .navigationSplitViewColumnWidth(min: 100, ideal: 250, max: 500) - .navigationBarItems( - leading: MeshtasticLogo(), - trailing: ZStack { - ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?", - phoneOnly: true - ) - } - // Make sure the ZStack passes through accessibility to the ConnectedDevice component - .accessibilityElement(children: .contain) - ) + .sheet(item: $shareContactNode) { selectedNode in + ShareContactQRDialog(node: selectedNode.toProto()) + } + .navigationSplitViewColumnWidth(min: 100, ideal: 250, max: 500) + .navigationBarItems( + leading: MeshtasticLogo(), + trailing: ZStack { + ConnectedDevice( + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?", + phoneOnly: true + ) + } + // Make sure the ZStack passes through accessibility to the ConnectedDevice component + .accessibilityElement(children: .contain) + ) } content: { if let node = selectedNode { NavigationStack { @@ -259,29 +260,21 @@ struct NodeList: View { ) .edgesIgnoringSafeArea([.leading, .trailing]) .navigationBarItems( + leading: MeshtasticLogo(), trailing: ZStack { - if UIDevice.current.userInterfaceIdiom != .phone { - Button { - columnVisibility = .detailOnly - } label: { - Image(systemName: "rectangle") - } - .accessibilityLabel("Hide sidebar") - } ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?", + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?", phoneOnly: true ) } // Make sure the ZStack passes through accessibility to the ConnectedDevice component - .accessibilityElement(children: .contain) + .accessibilityElement(children: .contain) ) } - } else { + } else { ContentUnavailableView("Select Node", systemImage: "flipphone") - } + } } detail: { ContentUnavailableView("", systemImage: "line.3.horizontal") } @@ -343,7 +336,7 @@ struct NodeList: View { .onChange(of: router.navigationState) { if let selected = router.navigationState.nodeListSelectedNodeNum { // Force a complete view rebuild by generating a new UUID - Logger.services.info("Forcing view rebuild with new ID: \(self.forceRefreshID)") + Logger.services.info("👷‍♂️ [App] Forcing view rebuild with new ID: \(self.forceRefreshID, privacy: .public)") // First clear selection self.forceRefreshID = UUID() self.selectedNode = nil @@ -352,7 +345,7 @@ struct NodeList: View { // Generate another UUID to ensure view gets rebuilt self.forceRefreshID = UUID() self.selectedNode = getNodeInfo(id: selected, context: context) - Logger.services.info("Complete view refresh with node: \(selected, privacy: .public)") + Logger.services.info("👷‍♂️ [App] Complete view refresh with node: \(selected, privacy: .public)") } } else { self.selectedNode = nil @@ -377,7 +370,7 @@ struct NodeList: View { NotificationCenter.default.removeObserver(self, name: NSNotification.Name("ForceNavigationRefresh"), object: nil) } } - + private func searchNodeList() async { /// Case Insensitive Search Text Predicates let searchPredicates = ["user.userId", "user.numString", "user.hwModel", "user.hwDisplayName", "user.longName", "user.shortName"].map { property in @@ -446,7 +439,7 @@ struct NodeList: View { /// Distance if distanceFilter { let pointOfInterest = LocationsHandler.currentLocation - + if pointOfInterest.latitude != LocationsHandler.DefaultLocation.latitude && pointOfInterest.longitude != LocationsHandler.DefaultLocation.longitude { let d: Double = maxDistance * 1.1 let r: Double = 6371009 diff --git a/Meshtastic/Views/Nodes/PaxCounterLog.swift b/Meshtastic/Views/Nodes/PaxCounterLog.swift index 5be9db56..34285ddb 100644 --- a/Meshtastic/Views/Nodes/PaxCounterLog.swift +++ b/Meshtastic/Views/Nodes/PaxCounterLog.swift @@ -12,7 +12,7 @@ import OSLog struct PaxCounterLog: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @State private var isPresentingClearLogConfirm: Bool = false @State var isExporting = false @@ -203,7 +203,7 @@ struct PaxCounterLog: View { .navigationBarTitleDisplayMode(.inline) .navigationBarItems(trailing: ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") }) .fileExporter( isPresented: $isExporting, diff --git a/Meshtastic/Views/Nodes/PositionLog.swift b/Meshtastic/Views/Nodes/PositionLog.swift index 97c76b5a..b2be785d 100644 --- a/Meshtastic/Views/Nodes/PositionLog.swift +++ b/Meshtastic/Views/Nodes/PositionLog.swift @@ -9,7 +9,7 @@ import OSLog struct PositionLog: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.verticalSizeClass) var verticalSizeClass: UserInterfaceSizeClass? @Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass? var useGrid: Bool { @@ -174,7 +174,8 @@ struct PositionLog: View { .navigationBarItems( trailing: ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") + }) } } diff --git a/Meshtastic/Views/Nodes/PowerMetricsLog.swift b/Meshtastic/Views/Nodes/PowerMetricsLog.swift index b0afd67c..a555effd 100644 --- a/Meshtastic/Views/Nodes/PowerMetricsLog.swift +++ b/Meshtastic/Views/Nodes/PowerMetricsLog.swift @@ -13,7 +13,7 @@ import OSLog struct PowerMetricsLog: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @ObservedObject var node: NodeInfoEntity private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @State private var sortOrder = [KeyPathComparator(\TelemetryEntity.time, order: .reverse)] @@ -276,7 +276,7 @@ struct PowerMetricsLog: View { .navigationBarTitleDisplayMode(.inline) .navigationBarItems(trailing: ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") }) .fileExporter( isPresented: $isExporting, diff --git a/Meshtastic/Views/Nodes/TraceRouteLog.swift b/Meshtastic/Views/Nodes/TraceRouteLog.swift index 3d2df721..3b84aec3 100644 --- a/Meshtastic/Views/Nodes/TraceRouteLog.swift +++ b/Meshtastic/Views/Nodes/TraceRouteLog.swift @@ -14,7 +14,7 @@ struct TraceRouteLog: View { private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @ObservedObject var locationsHandler = LocationsHandler.shared @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @State private var isPresentingClearLogConfirm: Bool = false @State var isExporting = false @State var exportString = "" @@ -218,7 +218,7 @@ struct TraceRouteLog: View { } .navigationBarItems(trailing: ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") }) } @ViewBuilder func contents(animation: Animation? = nil) -> some View { diff --git a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift index c1a73ebc..05f65d82 100644 --- a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift +++ b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift @@ -8,9 +8,11 @@ struct DeviceOnboarding: View { enum SetupGuide: Hashable { case notifications case location + case localNetwork + case bluetooth } - - @EnvironmentObject var bleManager: BLEManager + + @EnvironmentObject var accessoryManager: AccessoryManager @State var navigationPath: [SetupGuide] = [] @State var locationStatus = LocationsHandler.shared.manager.authorizationStatus @AppStorage("provideLocation") private var provideLocation: Bool = false @@ -29,7 +31,7 @@ struct DeviceOnboarding: View { .fixedSize(horizontal: false, vertical: true) } } - + var welcomeView: some View { VStack { ScrollView(.vertical) { @@ -74,7 +76,7 @@ struct DeviceOnboarding: View { .buttonStyle(.borderedProminent) } } - + var notificationView: some View { VStack { ScrollView(.vertical) { @@ -132,7 +134,7 @@ struct DeviceOnboarding: View { .buttonStyle(.borderedProminent) } } - + var locationView: some View { VStack { ScrollView(.vertical) { @@ -201,7 +203,97 @@ struct DeviceOnboarding: View { .buttonStyle(.borderedProminent) } } - + + var localNetworkView: some View { + VStack { + ScrollView(.vertical) { + VStack { + Text("Local Network Access") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + VStack(alignment: .leading, spacing: 16) { + Text(createLocalNetworkString()) + .font(.body.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + makeRow( + icon: "network", + title: "Network-based Nodes".localized, + subtitle: "The Meshtastic App can connect to and manage network-enabled nodes.".localized + ) + makeRow( + icon: "person.and.background.dotted", + title: "Background Connections".localized, + subtitle: "Background network connections are not supported and may disconnect when you leave the app.".localized + ) + } + .padding() + } + Spacer() + Button { + Task { + await requestLocalNetworkPermissions() + await goToNextStep(after: .localNetwork) + } + } label: { + Text("Configure Local Network Access") + .frame(maxWidth: .infinity) + } + .padding() + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + .buttonStyle(.borderedProminent) + } + } + + var bluetoothView: some View { + VStack { + ScrollView(.vertical) { + VStack { + Text("Bluetooth Connectivity") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + VStack(alignment: .leading, spacing: 16) { + Text(createBluetoothString()) + .font(.body.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + makeRow( + icon: "network", + title: "Network-based Nodes".localized, + subtitle: "The Meshtastic App can connect to and manage network-enabled nodes.".localized + ) + makeRow( + icon: "person.and.background.dotted", + title: "Background Connections".localized, + subtitle: "Bluetooth Low Energy supports background connections. When possible, the applicaiton will remain connected to these accessories while the app is in the background".localized + ) + } + .padding() + } + Spacer() + Button { + Task { + await requestBluetoothPermissions() + await goToNextStep(after: .bluetooth) + } + } label: { + Text("Configure Bluetooth Connectivity") + .frame(maxWidth: .infinity) + } + .padding() + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + .buttonStyle(.borderedProminent) + } + } + var body: some View { NavigationStack(path: $navigationPath) { welcomeView @@ -211,12 +303,16 @@ struct DeviceOnboarding: View { notificationView case .location: locationView + case .bluetooth: + bluetoothView + case .localNetwork: + localNetworkView } } } .toolbar(.hidden) } - + @ViewBuilder func makeRow( icon: String, @@ -265,11 +361,16 @@ struct DeviceOnboarding: View { case .location: let status = LocationsHandler.shared.manager.authorizationStatus if status != .notDetermined && status != .restricted && status != .denied { - dismiss() + navigationPath.append(.localNetwork) } + case .localNetwork: + navigationPath.append(.bluetooth) + + case .bluetooth: + dismiss() } } - + // MARK: Formatting func createLocationString() -> AttributedString { var fullText = AttributedString("Meshtastic uses your phone's location to enable a number of features. You can update your location permissions at any time from settings.") @@ -279,7 +380,25 @@ struct DeviceOnboarding: View { } return fullText } - + + func createLocalNetworkString() -> AttributedString { + var fullText = AttributedString("Meshtastic accesses your local network to connect to TCP-based accessories. You can update the local network permissions at any time from settings.") + if let range = fullText.range(of: "settings") { + fullText[range].link = URL(string: UIApplication.openSettingsURLString)! + fullText[range].foregroundColor = .blue + } + return fullText + } + + func createBluetoothString() -> AttributedString { + var fullText = AttributedString("Meshtastic uses Bluetooth to connect to BLE-based accessories. You can update the permissions at any time from settings.") + if let range = fullText.range(of: "settings") { + fullText[range].link = URL(string: UIApplication.openSettingsURLString)! + fullText[range].foregroundColor = .blue + } + return fullText + } + // MARK: Permission Checks func requestNotificationsPermissions() async { let center = UNUserNotificationCenter.current() @@ -294,7 +413,7 @@ struct DeviceOnboarding: View { Logger.services.error("Notification permissions error: \(error.localizedDescription)") } } - + func requestLocationPermissions() async { locationStatus = await LocationsHandler.shared.requestLocationAlwaysPermissions() if locationStatus != .notDetermined { @@ -302,6 +421,15 @@ struct DeviceOnboarding: View { } else { Logger.services.info("Location permissions denied") } - dismiss() + await goToNextStep(after: .location) } + + func requestLocalNetworkPermissions() async { + _ = await TCPTransport.requestLocalNetworkAuthorization() + } + + func requestBluetoothPermissions() async { + _ = await BluetoothAuthorizationHelper.requestBluetoothAuthorization() + } + } diff --git a/Meshtastic/Views/Settings/AppData.swift b/Meshtastic/Views/Settings/AppData.swift index 4706b62e..aa7ac625 100644 --- a/Meshtastic/Views/Settings/AppData.swift +++ b/Meshtastic/Views/Settings/AppData.swift @@ -13,7 +13,7 @@ import Foundation struct AppData: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @State private var files = [URL]() private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } diff --git a/Meshtastic/Views/Settings/AppLog.swift b/Meshtastic/Views/Settings/AppLog.swift index 7a0154b6..08c09664 100644 --- a/Meshtastic/Views/Settings/AppLog.swift +++ b/Meshtastic/Views/Settings/AppLog.swift @@ -6,7 +6,7 @@ // import SwiftUI -import OSLog +@preconcurrency import OSLog struct AppLog: View { diff --git a/Meshtastic/Views/Settings/AppSettings.swift b/Meshtastic/Views/Settings/AppSettings.swift index eeddf8d0..d77445d0 100644 --- a/Meshtastic/Views/Settings/AppSettings.swift +++ b/Meshtastic/Views/Settings/AppSettings.swift @@ -8,7 +8,7 @@ import OSLog struct AppSettings: View { private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @State var totalDownloadedTileSize = "" @State private var isPresentingCoreDataResetConfirm = false @State private var isPresentingDeleteMapTilesConfirm = false @@ -17,6 +17,12 @@ struct AppSettings: View { @AppStorage("environmentEnableWeatherKit") private var environmentEnableWeatherKit: Bool = true @AppStorage("enableAdministration") private var enableAdministration: Bool = false @AppStorage("usageDataAndCrashReporting") private var usageDataAndCrashReporting: Bool = true + + let autoconnectBinding = Binding(get: { + return UserDefaults.autoconnectOnDiscovery + }, set: { newValue in + UserDefaults.autoconnectOnDiscovery = newValue + }) var body: some View { VStack { Form { @@ -30,24 +36,30 @@ struct AppSettings: View { Toggle(isOn: $enableAdministration) { Label("Administration", systemImage: "gearshape.2") } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .tint(.accentColor) Text("PKI based node administration, requires firmware version 2.5+") .foregroundStyle(.secondary) .font(.caption) Toggle(isOn: $usageDataAndCrashReporting) { Label("Usage and Crash Data", systemImage: "pencil.and.list.clipboard") } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .tint(.accentColor) Text("Provide anonymous usage statistics and crash reports.") .foregroundStyle(.secondary) .font(.caption) +#if DEBUG + Toggle(isOn: autoconnectBinding) { + Label("Automatically Connect", systemImage: "app.connected.to.app.below.fill") + } + .tint(.accentColor) +#endif } Section(header: Text("environment")) { VStack(alignment: .leading) { Toggle(isOn: $environmentEnableWeatherKit) { Label("Weather Conditions", systemImage: "cloud.sun") } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .tint(.accentColor) } } Section(header: Text("App Data")) { @@ -67,7 +79,7 @@ struct AppSettings: View { purgeStaleNodeDays = newValue ? purgeStaleNodeDays : 0 Logger.services.info("ℹ️ Purge Stale Nodes changed to \(purgeStaleNodeDays)") } - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .tint(.accentColor) .listRowSeparator(purgeStaleNodes ? .hidden : .visible) if purgeStaleNodes { @@ -96,7 +108,9 @@ struct AppSettings: View { titleVisibility: .visible ) { Button("Erase all app data?", role: .destructive) { - bleManager.disconnectPeripheral() + Task { + try await accessoryManager.disconnect() + } /// Delete any database backups too if var url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { url = url.appendingPathComponent("backup").appendingPathComponent(String(UserDefaults.preferredPeripheralNum)) @@ -133,7 +147,7 @@ struct AppSettings: View { .navigationTitle("App Settings") .navigationBarItems(trailing: ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") }) } } diff --git a/Meshtastic/Views/Settings/Channels.swift b/Meshtastic/Views/Settings/Channels.swift index b1a2518c..c9a4e705 100644 --- a/Meshtastic/Views/Settings/Channels.swift +++ b/Meshtastic/Views/Settings/Channels.swift @@ -23,7 +23,7 @@ func generateChannelKey(size: Int) -> String { struct Channels: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack @Environment(\.sizeCategory) var sizeCategory @@ -153,7 +153,7 @@ struct Channels: View { .presentationDetents([.large]) .presentationDragIndicator(.visible) .onFirstAppear { - supportedVersion = bleManager.connectedVersion == "0.0.0" || self.minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedAscending || minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedSame + supportedVersion = accessoryManager.checkIsVersionSupported(forVersion: minimumVersion) } HStack { Button { @@ -212,18 +212,20 @@ struct Channels: View { Logger.data.error("Unresolved Core Data error in the channel editor. Error: \(nsError, privacy: .public)") } } - let adminMessageId = bleManager.saveChannel(channel: channel, fromUser: node!.user!, toUser: node!.user!) + Task { + _ = try await accessoryManager.saveChannel(channel: channel, fromUser: node!.user!, toUser: node!.user!) - if adminMessageId > 0 { - selectedChannel = nil - channelName = "" - channelRole = 2 - hasChanges = false + Task { @MainActor in + selectedChannel = nil + channelName = "" + channelRole = 2 + hasChanges = false + } } } label: { Label("Save", systemImage: "square.and.arrow.down") } - .disabled(bleManager.connectedPeripheral == nil)// || !hasChanges)// !hasValidKey) + .disabled(!accessoryManager.isConnected)// || !hasChanges)// !hasValidKey) .buttonStyle(.bordered) .buttonBorderShape(.capsule) .controlSize(.large) @@ -307,7 +309,7 @@ struct Channels: View { .navigationTitle("Channels") .navigationBarItems(trailing: ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") }) } } diff --git a/Meshtastic/Views/Settings/Config/BluetoothConfig.swift b/Meshtastic/Views/Settings/Config/BluetoothConfig.swift index be6b9522..692e9281 100644 --- a/Meshtastic/Views/Settings/Config/BluetoothConfig.swift +++ b/Meshtastic/Views/Settings/Config/BluetoothConfig.swift @@ -11,7 +11,7 @@ import SwiftUI struct BluetoothConfig: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @State var hasChanges = false @@ -71,21 +71,24 @@ struct BluetoothConfig: View { } } } - .disabled(self.bleManager.connectedPeripheral == nil || node?.bluetoothConfig == nil) + .disabled(!accessoryManager.isConnected || node?.bluetoothConfig == nil) SaveConfigButton(node: node, hasChanges: $hasChanges) { - if let myNodeNum = bleManager.connectedPeripheral?.num, + if let myNodeNum = accessoryManager.activeDeviceNum, let connectedNode = getNodeInfo(id: myNodeNum, context: context) { var bc = Config.BluetoothConfig() bc.enabled = enabled bc.mode = BluetoothModes(rawValue: mode)?.protoEnumValue() ?? Config.BluetoothConfig.PairingMode.randomPin bc.fixedPin = UInt32(fixedPin) ?? 123456 - let adminMessageId = bleManager.saveBluetoothConfig(config: bc, fromUser: connectedNode.user!, toUser: node!.user!) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + Task { + // TODO: ADMINIndex? + _ = try await accessoryManager.saveBluetoothConfig(config: bc, fromUser: connectedNode.user!, toUser: node!.user!) + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + hasChanges = false + goBack() + } } } } @@ -93,25 +96,29 @@ struct BluetoothConfig: View { .navigationTitle("Bluetooth Config") .navigationBarItems( trailing: ZStack { - ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?" - ) + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") + } ) .onFirstAppear { // Need to request a BluetoothConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) - if let connectedNode { - if node.num != connectedNode.num { + if let deviceNum = accessoryManager.activeDeviceNum, let node { + if let connectedNode = getNodeInfo(id: deviceNum, context: context) { + if node.num != deviceNum { if UserDefaults.enableAdministration { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.bluetoothConfig == nil { - Logger.mesh.info("⚙️ Empty or expired bluetooth config requesting via PKI admin") - _ = bleManager.requestBluetoothConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired bluetooth config requesting via PKI admin") + // TODO: AdminIndex? + try await accessoryManager.requestBluetoothConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.info("🚨 Bluetooth config request failed") + } + } + } } else { /// Legacy Administration diff --git a/Meshtastic/Views/Settings/Config/ConfigHeader.swift b/Meshtastic/Views/Settings/Config/ConfigHeader.swift index cb4f7aee..1331235d 100644 --- a/Meshtastic/Views/Settings/Config/ConfigHeader.swift +++ b/Meshtastic/Views/Settings/Config/ConfigHeader.swift @@ -2,7 +2,7 @@ import SwiftUI import CoreData struct ConfigHeader: View { - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager let title: String let config: KeyPath @@ -10,12 +10,12 @@ struct ConfigHeader: View { let onAppear: () -> Void var body: some View { - if node != nil && node?.metadata == nil && node?.num ?? 0 != bleManager.connectedPeripheral?.num ?? 0 { + if node != nil && node?.metadata == nil && node?.num ?? 0 != accessoryManager.activeDeviceNum ?? 0 { Text("There has been no response to a request for device metadata via PKC admin for this node.") .font(.callout) .foregroundColor(.orange) - } else if node != nil && node?.num ?? 0 != bleManager.connectedPeripheral?.num ?? 0 { + } else if node != nil && node?.num ?? 0 != accessoryManager.activeDeviceNum ?? 0 { // Let users know what is going on if they are using remote admin and don't have the config yet let expiration = node?.sessionExpiration ?? Date() if node?[keyPath: config] == nil || expiration < node?.sessionExpiration ?? Date() { @@ -27,7 +27,7 @@ struct ConfigHeader: View { .onFirstAppear(onAppear) .font(.title3) } - } else if node != nil && node?.num ?? 0 == bleManager.connectedPeripheral?.num ?? -1 { + } else if node != nil && node?.num ?? 0 == accessoryManager.activeDeviceNum ?? -1 { Text("Configuration for: \(node?.user?.longName ?? "Unknown")") .onFirstAppear(onAppear) } else { diff --git a/Meshtastic/Views/Settings/Config/DeviceConfig.swift b/Meshtastic/Views/Settings/Config/DeviceConfig.swift index 8b8881b3..cc682e66 100644 --- a/Meshtastic/Views/Settings/Config/DeviceConfig.swift +++ b/Meshtastic/Views/Settings/Config/DeviceConfig.swift @@ -12,7 +12,7 @@ import SwiftUI struct DeviceConfig: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @@ -156,9 +156,9 @@ struct DeviceConfig: View { .pickerStyle(DefaultPickerStyle()) } } - .disabled(self.bleManager.connectedPeripheral == nil || node?.deviceConfig == nil) + .disabled(!accessoryManager.isConnected || node?.deviceConfig == nil) // Only show these buttons for the BLE connected node - if bleManager.connectedPeripheral != nil && node?.num ?? -1 == bleManager.connectedPeripheral.num { + if accessoryManager.isConnected, let device = accessoryManager.activeConnection?.device, node?.num ?? -1 == device.num { HStack { Button("Reset NodeDB", role: .destructive) { isPresentingNodeDBResetConfirm = true @@ -174,14 +174,15 @@ struct DeviceConfig: View { titleVisibility: .visible ) { Button("Erase all device and app data?", role: .destructive) { - if bleManager.sendNodeDBReset(fromUser: node!.user!, toUser: node!.user!) { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - bleManager.disconnectPeripheral() + Task { + do { + try await accessoryManager.sendNodeDBReset(fromUser: node!.user!, toUser: node!.user!) + try await Task.sleep(for: .seconds(1)) + try await accessoryManager.disconnect() clearCoreDataDatabase(context: context, includeRoutes: false) + } catch { + Logger.mesh.error("NodeDB Reset Failed") } - - } else { - Logger.mesh.error("NodeDB Reset Failed") } } } @@ -199,23 +200,27 @@ struct DeviceConfig: View { titleVisibility: .visible ) { Button("Delete all config? ", role: .destructive) { - if bleManager.sendFactoryReset(fromUser: node!.user!, toUser: node!.user!) { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - bleManager.disconnectPeripheral() + Task { + do { + try await accessoryManager.sendFactoryReset(fromUser: node!.user!, toUser: node!.user!) + try await Task.sleep(for: .seconds(1)) + try await accessoryManager.disconnect() clearCoreDataDatabase(context: context, includeRoutes: false) + } catch { + Logger.mesh.error("Factory Reset Failed") } - } else { - Logger.mesh.error("Factory Reset Failed") } } Button("Delete all config, keys and BLE bonds? ", role: .destructive) { - if bleManager.sendFactoryReset(fromUser: node!.user!, toUser: node!.user!, resetDevice: true) { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - bleManager.disconnectPeripheral() + Task { + do { + try await accessoryManager.sendFactoryReset(fromUser: node!.user!, toUser: node!.user!, resetDevice: true) + try? await Task.sleep(for: .seconds(1)) + try await accessoryManager.disconnect() clearCoreDataDatabase(context: context, includeRoutes: false) + } catch { + Logger.mesh.error("Factory Reset Failed") } - } else { - Logger.mesh.error("Factory Reset Failed") } } } @@ -223,8 +228,8 @@ struct DeviceConfig: View { } HStack { SaveConfigButton(node: node, hasChanges: $hasChanges) { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - if connectedNode != nil { + if let deviceNum = accessoryManager.activeDeviceNum, + let connectedNode = getNodeInfo(id: deviceNum, context: context) { var dc = Config.DeviceConfig() dc.role = DeviceRoles(rawValue: deviceRole)!.protoEnumValue() dc.buttonGpio = UInt32(buttonGPIO) @@ -235,12 +240,14 @@ struct DeviceConfig: View { dc.disableTripleClick = !tripleClickAsAdHocPing dc.tzdef = tzdef dc.ledHeartbeatDisabled = !ledHeartbeatEnabled - let adminMessageId = bleManager.saveDeviceConfig(config: dc, fromUser: connectedNode!.user!, toUser: node!.user!) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + Task { + _ = try await accessoryManager.saveDeviceConfig(config: dc, fromUser: connectedNode.user!, toUser: node!.user!) + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + hasChanges = false + goBack() + } } } } @@ -250,25 +257,28 @@ struct DeviceConfig: View { .navigationTitle("Device Config") .navigationBarItems( trailing: ZStack { - ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?" - ) + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") + } ) .onFirstAppear { // Need to request a DeviceConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) if let connectedNode { - if node.num != connectedNode.num { + if node.num != deviceNum { if UserDefaults.enableAdministration { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.deviceConfig == nil { - Logger.mesh.info("⚙️ Empty or expired device config requesting via PKI admin") - _ = bleManager.requestDeviceConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired device config requesting via PKI admin") + try await accessoryManager.requestDeviceConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.error("🚨 Device config request failed") + } + } } } else { if node.deviceConfig == nil { diff --git a/Meshtastic/Views/Settings/Config/DisplayConfig.swift b/Meshtastic/Views/Settings/Config/DisplayConfig.swift index 9a10f7bd..0729945b 100644 --- a/Meshtastic/Views/Settings/Config/DisplayConfig.swift +++ b/Meshtastic/Views/Settings/Config/DisplayConfig.swift @@ -12,7 +12,7 @@ import SwiftUI struct DisplayConfig: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @@ -127,11 +127,10 @@ struct DisplayConfig: View { .pickerStyle(DefaultPickerStyle()) } } - .disabled(self.bleManager.connectedPeripheral == nil || node?.displayConfig == nil) + .disabled(!accessoryManager.isConnected || node?.displayConfig == nil) SaveConfigButton(node: node, hasChanges: $hasChanges) { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - if connectedNode != nil { + if let deviceNum = accessoryManager.activeDeviceNum, let connectedNode = getNodeInfo(id: deviceNum, context: context) { var dc = Config.DisplayConfig() dc.screenOnSecs = UInt32(screenOnSeconds) dc.autoScreenCarouselSecs = UInt32(screenCarouselInterval) @@ -144,13 +143,14 @@ struct DisplayConfig: View { dc.use12HClock = use12HourClock dc.headingBold = headingBold - let adminMessageId = bleManager.saveDisplayConfig(config: dc, fromUser: connectedNode!.user!, toUser: node!.user!) - if adminMessageId > 0 { - - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + Task { + _ = try await accessoryManager.saveDisplayConfig(config: dc, fromUser: connectedNode.user!, toUser: node!.user!) + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + hasChanges = false + goBack() + } } } } @@ -158,25 +158,27 @@ struct DisplayConfig: View { .navigationTitle("Display Config") .navigationBarItems( trailing: ZStack { - ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?" - ) + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") + } ) .onFirstAppear { // Need to request a DisplayConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) - if let connectedNode { - if node.num != connectedNode.num { + if let deviceNum = accessoryManager.activeDeviceNum, let node { + if let connectedNode = getNodeInfo(id: deviceNum, context: context) { + if node.num != deviceNum { if UserDefaults.enableAdministration { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.displayConfig == nil { - Logger.mesh.info("⚙️ Empty or expired display config requesting via PKI admin") - _ = bleManager.requestDisplayConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired display config requesting via PKI admin") + try await accessoryManager.requestDisplayConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.error("🚨 Display config request failed") + } + } } } else { /// Legacy Administration diff --git a/Meshtastic/Views/Settings/Config/LoRaConfig.swift b/Meshtastic/Views/Settings/Config/LoRaConfig.swift index 3e8beb9a..d3945522 100644 --- a/Meshtastic/Views/Settings/Config/LoRaConfig.swift +++ b/Meshtastic/Views/Settings/Config/LoRaConfig.swift @@ -25,7 +25,7 @@ struct LoRaConfig: View { }() @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack @FocusState var focusedField: Field? @@ -195,11 +195,10 @@ struct LoRaConfig: View { } } } - .disabled(self.bleManager.connectedPeripheral == nil || node?.loRaConfig == nil) + .disabled(!accessoryManager.isConnected || node?.loRaConfig == nil) SaveConfigButton(node: node, hasChanges: $hasChanges) { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? 0, context: context) - if connectedNode != nil { + if let deviceNum = accessoryManager.activeDeviceNum, let connectedNode = getNodeInfo(id: deviceNum, context: context) { var lc = Config.LoRaConfig() lc.hopLimit = UInt32(hopLimit) lc.region = RegionCodes(rawValue: region)!.protoEnumValue() @@ -215,15 +214,17 @@ struct LoRaConfig: View { lc.overrideFrequency = overrideFrequency lc.ignoreMqtt = ignoreMqtt lc.configOkToMqtt = okToMqtt - if connectedNode?.num ?? -1 == node?.user?.num ?? 0 { + if connectedNode.num == node?.user?.num ?? 0 { UserDefaults.modemPreset = modemPreset } - let adminMessageId = bleManager.saveLoRaConfig(config: lc, fromUser: connectedNode!.user!, toUser: node!.user!) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + Task { + _ = try await accessoryManager.saveLoRaConfig(config: lc, fromUser: connectedNode.user!, toUser: node!.user!) + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + hasChanges = false + goBack() + } } } } @@ -231,26 +232,30 @@ struct LoRaConfig: View { .navigationTitle("LoRa Config") .navigationBarItems( trailing: ZStack { - ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?" - ) + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") + } ) .onFirstAppear { // Need to request a LoRaConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) - if let connectedNode { - if node.num != connectedNode.num { + if let deviceNum = accessoryManager.activeDeviceNum, let node { + if let connectedNode = getNodeInfo(id: deviceNum, context: context) { + if node.num != deviceNum { if UserDefaults.enableAdministration { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.loRaConfig == nil { - Logger.mesh.info("⚙️ Empty or expired lora config requesting via PKI admin") - if connectedNode.user != nil && node.user != nil { - _ = bleManager.requestLoRaConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + if connectedNode.user != nil && node.user != nil { + Logger.mesh.info("⚙️ Empty or expired lora config requesting via PKI admin") + _ = try await accessoryManager.requestLoRaConfig(fromUser: connectedNode.user!, toUser: node.user!) + } else { + Logger.mesh.info("🚫 No User or node for lora config request") + } + } catch { + Logger.mesh.info("🚨 Lora config request failed") + } } } } else { diff --git a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift index a02dd0ad..82f8d688 100644 --- a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift @@ -11,7 +11,7 @@ import OSLog struct AmbientLightingConfig: View { @Environment(\.self) var environment @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @@ -49,10 +49,13 @@ struct AmbientLightingConfig: View { } } } - .disabled(self.bleManager.connectedPeripheral == nil || node?.ambientLightingConfig == nil) + .disabled(!self.accessoryManager.isConnected || node?.ambientLightingConfig == nil) SaveConfigButton(node: node, hasChanges: $hasChanges) { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) + guard let deviceNum = accessoryManager.activeDeviceNum else { + return + } + let connectedNode = getNodeInfo(id: deviceNum, context: context) if connectedNode != nil { var al = ModuleConfig.AmbientLightingConfig() al.ledState = ledState @@ -64,37 +67,43 @@ struct AmbientLightingConfig: View { al.blue = UInt32(components.blue * 255) } - let adminMessageId = bleManager.saveAmbientLightingModuleConfig(config: al, fromUser: connectedNode!.user!, toUser: node!.user!) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + Task { + do { + _ = try await accessoryManager.saveAmbientLightingModuleConfig(config: al, fromUser: connectedNode!.user!, toUser: node!.user!) + Task { @MainActor in + hasChanges = false + goBack() + } + } catch { + Logger.mesh.warning("Unable to send ambient lighting module config") + } } } } .navigationTitle("Ambient Lighting Config") .navigationBarItems( trailing: ZStack { - ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?" - ) + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") } ) .onFirstAppear { // Need to request a Ambient Lighting Config from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) if let connectedNode { - if node.num != connectedNode.num { + if node.num != deviceNum { if UserDefaults.enableAdministration { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.ambientLightingConfig == nil { - Logger.mesh.info("⚙️ Empty or expired ambient lighting module config requesting via PKI admin") - _ = bleManager.requestAmbientLightingConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired ambient lighting module config requesting via PKI admin") + try await accessoryManager.requestAmbientLightingConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.info("🚨 Unable to send ambient lighting config request") + } + } } } else { /// Legacy Administration diff --git a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift index 13ab22b7..6004c2b7 100644 --- a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift @@ -10,7 +10,7 @@ import SwiftUI struct CannedMessagesConfig: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @State private var isPresentingSaveConfirm: Bool = false @@ -178,10 +178,10 @@ struct CannedMessagesConfig: View { .disabled(configPreset > 0) } .scrollDismissesKeyboard(.immediately) - .disabled(self.bleManager.connectedPeripheral == nil || node?.cannedMessageConfig == nil) + .disabled(!accessoryManager.isConnected || node?.cannedMessageConfig == nil) SaveConfigButton(node: node, hasChanges: $hasChanges) { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? -1, context: context) + let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? -1, context: context) if hasChanges { if connectedNode != nil { var cmc = ModuleConfig.CannedMessageConfig() @@ -204,17 +204,39 @@ struct CannedMessagesConfig: View { cmc.inputbrokerEventCw = InputEventChars(rawValue: inputbrokerEventCw)!.protoEnumValue() cmc.inputbrokerEventCcw = InputEventChars(rawValue: inputbrokerEventCcw)!.protoEnumValue() cmc.inputbrokerEventPress = InputEventChars(rawValue: inputbrokerEventPress)!.protoEnumValue() - let messagesAdminMessageId = bleManager.saveCannedMessageModuleMessages(messages: messages, fromUser: node!.user!, toUser: node!.user!) - if messagesAdminMessageId > 0 { - // Fire off the message update every time - hasMessagesChanges = false + Task { + do { + _ = try await accessoryManager.saveCannedMessageModuleConfig(config: cmc, fromUser: node!.user!, toUser: node!.user!) + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + hasChanges = false + goBack() + } + } catch { + Logger.mesh.error("Unable to save canned message module config") + } } - let adminMessageId = bleManager.saveCannedMessageModuleConfig(config: cmc, fromUser: node!.user!, toUser: node!.user!) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + } + } + if hasMessagesChanges { + Task { + do { + _ = try await accessoryManager.saveCannedMessageModuleMessages(messages: messages, fromUser: node!.user!, toUser: node!.user!) + + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + hasMessagesChanges = false + if !hasChanges { + Task { + Logger.transport.debug("[CannedMessagesConfig] sending wantConfig for save cannedMessagesConfig") + } + goBack() + } + } + } catch { + Logger.mesh.error("Unable to save canned message module messages") } } } @@ -223,24 +245,29 @@ struct CannedMessagesConfig: View { .navigationBarItems( trailing: ZStack { ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?" + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?" ) } ) .onFirstAppear { // Need to request a CannedMessagesModuleConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) if let connectedNode { - if node.num != connectedNode.num { + if node.num != deviceNum { if UserDefaults.enableAdministration && node.num != connectedNode.num { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.cannedMessageConfig == nil { - Logger.mesh.info("⚙️ Empty or expired canned messages module config requesting via PKI admin") - _ = bleManager.requestCannedMessagesModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired canned messages module config requesting via PKI admin") + try await accessoryManager.requestCannedMessagesModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.info("🚨 Unable to send canned message module config request") + } + } } } else { /// Legacy Administration diff --git a/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift b/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift index 9d4b61a4..38d4178c 100644 --- a/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift @@ -25,7 +25,7 @@ enum DetectionSensorRole: String, CaseIterable, Equatable, Decodable { struct DetectionSensorConfig: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @State private var isPresentingSaveConfirm: Bool = false @@ -158,10 +158,10 @@ struct DetectionSensorConfig: View { } } .scrollDismissesKeyboard(.interactively) - .disabled(self.bleManager.connectedPeripheral == nil || node?.detectionSensorConfig == nil) + .disabled(!accessoryManager.isConnected || node?.detectionSensorConfig == nil) SaveConfigButton(node: node, hasChanges: $hasChanges) { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? -1, context: context) + let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? -1, context: context) if connectedNode != nil { var dsc = ModuleConfig.DetectionSensorConfig() dsc.enabled = self.enabled @@ -172,12 +172,18 @@ struct DetectionSensorConfig: View { dsc.usePullup = self.usePullup dsc.minimumBroadcastSecs = UInt32(self.minimumBroadcastSecs) dsc.stateBroadcastSecs = UInt32(self.stateBroadcastSecs) - let adminMessageId = bleManager.saveDetectionSensorModuleConfig(config: dsc, fromUser: connectedNode!.user!, toUser: node!.user!) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + Task { + do { + _ = try await accessoryManager.saveDetectionSensorModuleConfig(config: dsc, fromUser: connectedNode!.user!, toUser: node!.user!) + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + hasChanges = false + goBack() + } + } catch { + Logger.mesh.error("Unable to save detection sensor module config") + } } } } @@ -185,24 +191,30 @@ struct DetectionSensorConfig: View { .navigationBarItems( trailing: ZStack { ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?" + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?" ) } ) .onFirstAppear { // Need to request a DetectionSensorModuleConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) if let connectedNode { - if node.num != connectedNode.num { + if node.num != deviceNum { if UserDefaults.enableAdministration && node.num != connectedNode.num { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.detectionSensorConfig == nil { - Logger.mesh.info("⚙️ Empty or expired detection sensor module config requesting via PKI admin") - _ = bleManager.requestDetectionSensorModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired detection sensor module config requesting via PKI admin") + try await accessoryManager.requestDetectionSensorModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.info("🚨 Unable to send detection sensor module config request") + } + } + } } else { /// Legacy Administration diff --git a/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift b/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift index d0c0b13b..643ec660 100644 --- a/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift @@ -11,7 +11,7 @@ import SwiftUI struct ExternalNotificationConfig: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @@ -158,11 +158,11 @@ struct ExternalNotificationConfig: View { } } } - .disabled(self.bleManager.connectedPeripheral == nil || node?.externalNotificationConfig == nil) + .disabled(!accessoryManager.isConnected || node?.externalNotificationConfig == nil) } SaveConfigButton(node: node, hasChanges: $hasChanges) { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? -1, context: context) + let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? -1, context: context) if connectedNode != nil { var enc = ModuleConfig.ExternalNotificationConfig() enc.enabled = enabled @@ -180,12 +180,16 @@ struct ExternalNotificationConfig: View { enc.outputMs = UInt32(outputMilliseconds) enc.usePwm = usePWM enc.useI2SAsBuzzer = useI2SAsBuzzer - let adminMessageId = bleManager.saveExternalNotificationModuleConfig(config: enc, fromUser: connectedNode!.user!, toUser: node!.user!) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + Task { + do { + _ = try await accessoryManager.saveExternalNotificationModuleConfig(config: enc, fromUser: connectedNode!.user!, toUser: node!.user!) + Task { @MainActor in + hasChanges = false + goBack() + } + } catch { + Logger.mesh.error("Unable to save external notiication module config") + } } } } @@ -193,24 +197,29 @@ struct ExternalNotificationConfig: View { .navigationBarItems( trailing: ZStack { ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?" + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?" ) } ) .onFirstAppear { // Need to request a ExternalNotificationModuleConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) if let connectedNode { - if node.num != connectedNode.num { + if node.num != deviceNum { if UserDefaults.enableAdministration && node.num != connectedNode.num { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.externalNotificationConfig == nil { - Logger.mesh.info("⚙️ Empty or expired external notificaiton module config requesting via PKI admin") - _ = bleManager.requestExternalNotificationModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired external notificaiton module config requesting via PKI admin") + try await accessoryManager.requestExternalNotificationModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.info("🚨 Unable to send external ntoification module config request") + } + } } } else { /// Legacy Administration diff --git a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift index 1e9ed3da..5914f4f8 100644 --- a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift @@ -12,7 +12,7 @@ import SwiftUI struct MQTTConfig: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @State private var isPresentingSaveConfirm: Bool = false @@ -68,8 +68,8 @@ struct MQTTConfig: View { if enabled && proxyToClientEnabled && node?.mqttConfig?.proxyToClientEnabled ?? false == true { Toggle(isOn: $mqttConnected) { Label("Connect to MQTT via Proxy", systemImage: "server.rack") - if bleManager.mqttError.count > 0 { - Text(bleManager.mqttError) + if accessoryManager.mqttError.count > 0 { + Text(accessoryManager.mqttError) .fixedSize(horizontal: false, vertical: true) .foregroundColor(.red) } @@ -250,10 +250,10 @@ struct MQTTConfig: View { .font(.callout) } .scrollDismissesKeyboard(.interactively) - .disabled(self.bleManager.connectedPeripheral == nil || node?.mqttConfig == nil) + .disabled(!accessoryManager.isConnected || node?.mqttConfig == nil) SaveConfigButton(node: node, hasChanges: $hasChanges) { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? -1, context: context) + let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? -1, context: context) if connectedNode != nil { var mqtt = ModuleConfig.MQTTConfig() mqtt.enabled = self.enabled @@ -268,12 +268,16 @@ struct MQTTConfig: View { mqtt.mapReportingEnabled = self.mapReportingEnabled mqtt.mapReportSettings.positionPrecision = UInt32(self.mapPositionPrecision) mqtt.mapReportSettings.publishIntervalSecs = UInt32(self.mapPublishIntervalSecs) - let adminMessageId = bleManager.saveMQTTConfig(config: mqtt, fromUser: connectedNode!.user!, toUser: node!.user!) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + Task { + do { + _ = try await accessoryManager.saveMQTTConfig(config: mqtt, fromUser: connectedNode!.user!, toUser: node!.user!) + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + hasChanges = false + goBack() + } + } } } }.onChange(of: enabled) { _, newEnabled in @@ -323,12 +327,12 @@ struct MQTTConfig: View { } .onChange(of: mqttConnected) { _, newMqttConnected in if newMqttConnected == false { - if bleManager.mqttProxyConnected { - bleManager.mqttManager.disconnect() + if accessoryManager.mqttProxyConnected { + accessoryManager.mqttManager.disconnect() } } else { - if !bleManager.mqttProxyConnected && node != nil { - bleManager.mqttManager.connectFromConfigSettings(node: node!) + if !accessoryManager.mqttProxyConnected && node != nil { + accessoryManager.mqttManager.connectFromConfigSettings(node: node!) } } } @@ -343,24 +347,29 @@ struct MQTTConfig: View { .navigationBarItems( trailing: ZStack { ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?" + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?" ) } ) .onFirstAppear { // Need to request a MqttModuleConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) if let connectedNode { - if node.num != connectedNode.num { - if UserDefaults.enableAdministration && node.num != connectedNode.num { + if node.num != deviceNum { + if UserDefaults.enableAdministration && node.num != deviceNum { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.mqttConfig == nil { - Logger.mesh.info("⚙️ Empty or expired mqtt module config requesting via PKI admin") - _ = bleManager.requestMqttModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired mqtt module config requesting via PKI admin") + try await accessoryManager.requestMqttModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.error("🚨 Mqtt module config request failed") + } + } } } else { /// Legacy Administration @@ -426,7 +435,7 @@ struct MQTTConfig: View { self.encryptionEnabled = node?.mqttConfig?.encryptionEnabled ?? false self.jsonEnabled = node?.mqttConfig?.jsonEnabled ?? false self.tlsEnabled = node?.mqttConfig?.tlsEnabled ?? false - self.mqttConnected = bleManager.mqttProxyConnected + self.mqttConnected = accessoryManager.mqttProxyConnected self.mapReportingEnabled = node?.mqttConfig?.mapReportingEnabled ?? false if node?.mqttConfig?.mapPublishIntervalSecs ?? 0 < 3600 { self.mapPublishIntervalSecs = 3600 diff --git a/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift b/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift index b0101f2b..108d9109 100644 --- a/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift @@ -11,7 +11,7 @@ import OSLog struct PaxCounterConfig: View { @Environment(\.managedObjectContext) private var context - @EnvironmentObject private var bleManager: BLEManager + @EnvironmentObject private var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack let node: NodeInfoEntity? @@ -49,27 +49,32 @@ struct PaxCounterConfig: View { Text("Options") } } - .disabled(self.bleManager.connectedPeripheral == nil || node?.powerConfig == nil) + .disabled(!accessoryManager.isConnected || node?.powerConfig == nil) .navigationTitle("PAX Counter Config") .navigationBarItems(trailing: ZStack { ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: "\(bleManager.connectedPeripheral?.shortName ?? "?")" + deviceConnected: accessoryManager.isConnected, + name: "\(accessoryManager.activeConnection?.device.shortName ?? "?")" ) }) .onFirstAppear { // Need to request a PaxCounterModuleConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) if let connectedNode { - if node.num != connectedNode.num { + if node.num != deviceNum { if UserDefaults.enableAdministration && node.num != connectedNode.num { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.paxCounterConfig == nil { - Logger.mesh.info("⚙️ Empty or expired pax counter module config requesting via PKI admin") - _ = bleManager.requestPaxCounterModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired pax counter module config requesting via PKI admin") + try await accessoryManager.requestPaxCounterModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.info("🚨 Request for pax counter module config failed") + } + } } } else { /// Legacy Administration @@ -87,7 +92,7 @@ struct PaxCounterConfig: View { } SaveConfigButton(node: node, hasChanges: $hasChanges) { - guard let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context), + guard let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? -1, context: context), let fromUser = connectedNode.user, let toUser = node?.user else { return @@ -97,16 +102,18 @@ struct PaxCounterConfig: View { config.enabled = enabled config.paxcounterUpdateInterval = UInt32(paxcounterUpdateInterval) - let adminMessageId = bleManager.savePaxcounterModuleConfig( - config: config, - fromUser: fromUser, - toUser: toUser - ) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + Task { + _ = try await accessoryManager.savePaxcounterModuleConfig( + config: config, + fromUser: fromUser, + toUser: toUser + ) + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + hasChanges = false + goBack() + } } } } diff --git a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift index a9b07c53..6cb6fb64 100644 --- a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift @@ -11,7 +11,7 @@ import SwiftUI struct RangeTestConfig: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @@ -53,21 +53,23 @@ struct RangeTestConfig: View { } } - .disabled(self.bleManager.connectedPeripheral == nil || node?.rangeTestConfig == nil) + .disabled(!accessoryManager.isConnected || node?.rangeTestConfig == nil) SaveConfigButton(node: node, hasChanges: $hasChanges) { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) + let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? -1, context: context) if connectedNode != nil { var rtc = ModuleConfig.RangeTestConfig() rtc.enabled = enabled rtc.save = save rtc.sender = UInt32(sender) - let adminMessageId = bleManager.saveRangeTestModuleConfig(config: rtc, fromUser: connectedNode!.user!, toUser: node!.user!) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + Task { + _ = try await accessoryManager.saveRangeTestModuleConfig(config: rtc, fromUser: connectedNode!.user!, toUser: node!.user!) + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + hasChanges = false + goBack() + } } } } @@ -75,24 +77,29 @@ struct RangeTestConfig: View { .navigationBarItems( trailing: ZStack { ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?" + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?" ) } ) .onFirstAppear { // Need to request a RangeTestModuleConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) if let connectedNode { - if node.num != connectedNode.num { + if node.num != deviceNum { if UserDefaults.enableAdministration && node.num != connectedNode.num { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.rangeTestConfig == nil { - Logger.mesh.info("⚙️ Empty or expired range test module config requesting via PKI admin") - _ = bleManager.requestRangeTestModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired range test module config requesting via PKI admin") + try await accessoryManager.requestRangeTestModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.error("🚨 Request Range test module config failed") + } + } } } else { /// Legacy Administration diff --git a/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift b/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift index ab1663a4..57167f1a 100644 --- a/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift @@ -10,7 +10,7 @@ import OSLog struct RtttlConfig: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @@ -48,17 +48,19 @@ struct RtttlConfig: View { .font(.callout) } } - .disabled(self.bleManager.connectedPeripheral == nil || node?.rtttlConfig == nil) + .disabled(!accessoryManager.isConnected || node?.rtttlConfig == nil) SaveConfigButton(node: node, hasChanges: $hasChanges) { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) + let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? -1, context: context) if connectedNode != nil { - let adminMessageId = bleManager.saveRtttlConfig(ringtone: ringtone.trimmingCharacters(in: .whitespacesAndNewlines), fromUser: connectedNode!.user!, toUser: node!.user!) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + Task { + _ = try await accessoryManager.saveRtttlConfig(ringtone: ringtone.trimmingCharacters(in: .whitespacesAndNewlines), fromUser: connectedNode!.user!, toUser: node!.user!) + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + hasChanges = false + goBack() + } } } } @@ -66,24 +68,29 @@ struct RtttlConfig: View { .navigationBarItems( trailing: ZStack { ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?" + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?" ) } ) .onFirstAppear { // Need to request a RtttlConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) if let connectedNode { - if node.num != connectedNode.num { + if node.num != deviceNum { if UserDefaults.enableAdministration && node.num != connectedNode.num { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.rtttlConfig == nil { - Logger.mesh.info("⚙️ Empty or expired ringtone module config requesting via PKI admin") - _ = bleManager.requestRtttlConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired ringtone module config requesting via PKI admin") + try await accessoryManager.requestRtttlConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.info("🚨 Request for ringtone module config failed") + } + } } } else { /// Legacy Administration diff --git a/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift b/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift index daa4c21e..1c587060 100644 --- a/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift @@ -11,7 +11,7 @@ import SwiftUI struct SerialConfig: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @@ -101,10 +101,10 @@ struct SerialConfig: View { .font(.callout) } } - .disabled(self.bleManager.connectedPeripheral == nil || node?.serialConfig == nil) + .disabled(!accessoryManager.isConnected || node?.serialConfig == nil) SaveConfigButton(node: node, hasChanges: $hasChanges) { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) + let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? -1, context: context) if connectedNode != nil { var sc = ModuleConfig.SerialConfig() sc.enabled = enabled @@ -116,13 +116,15 @@ struct SerialConfig: View { sc.overrideConsoleSerialPort = overrideConsoleSerialPort sc.mode = SerialModeTypes(rawValue: mode)!.protoEnumValue() - let adminMessageId = bleManager.saveSerialModuleConfig(config: sc, fromUser: connectedNode!.user!, toUser: node!.user!) + Task { + _ = try await accessoryManager.saveSerialModuleConfig(config: sc, fromUser: connectedNode!.user!, toUser: node!.user!) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + hasChanges = false + goBack() + } } } } @@ -130,24 +132,29 @@ struct SerialConfig: View { .navigationBarItems( trailing: ZStack { ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?" + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?" ) } ) .onFirstAppear { // Need to request a SerialModuleConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) if let connectedNode { - if node.num != connectedNode.num { - if UserDefaults.enableAdministration && node.num != connectedNode.num { + if node.num != deviceNum { + if UserDefaults.enableAdministration && node.num != deviceNum { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.serialConfig == nil { - Logger.mesh.info("⚙️ Empty or expired serial module config requesting via PKI admin") - _ = bleManager.requestSerialModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired serial module config requesting via PKI admin") + try await accessoryManager.requestSerialModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.info("🚨 Request for serial module config failed") + } + } } } else { /// Legacy Administration diff --git a/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift b/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift index 6f30ee33..504ad3a6 100644 --- a/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift @@ -11,7 +11,7 @@ import SwiftUI struct StoreForwardConfig: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @State private var isPresentingSaveConfirm: Bool = false @@ -94,11 +94,11 @@ struct StoreForwardConfig: View { } } .scrollDismissesKeyboard(.interactively) - .disabled(self.bleManager.connectedPeripheral == nil || node?.storeForwardConfig == nil) + .disabled(!accessoryManager.isConnected || node?.storeForwardConfig == nil) } SaveConfigButton(node: node, hasChanges: $hasChanges) { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? -1, context: context) + let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? -1, context: context) if connectedNode != nil { /// Let the user set isServer for the connected node, for nodes on the mesh set isServer based /// on receipt of a primary heartbeat @@ -118,12 +118,15 @@ struct StoreForwardConfig: View { sfc.records = UInt32(self.records) sfc.historyReturnMax = UInt32(self.historyReturnMax) sfc.historyReturnWindow = UInt32(self.historyReturnWindow) - let adminMessageId = bleManager.saveStoreForwardModuleConfig(config: sfc, fromUser: connectedNode!.user!, toUser: node!.user!) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + + Task { + _ = try await accessoryManager.saveStoreForwardModuleConfig(config: sfc, fromUser: connectedNode!.user!, toUser: node!.user!) + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + hasChanges = false + goBack() + } } } } @@ -131,24 +134,29 @@ struct StoreForwardConfig: View { .navigationBarItems( trailing: ZStack { ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?" + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?" ) } ) .onFirstAppear { // Need to request a StoreForwardModuleConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) if let connectedNode { - if node.num != connectedNode.num { - if UserDefaults.enableAdministration && node.num != connectedNode.num { + if node.num != deviceNum { + if UserDefaults.enableAdministration && node.num != deviceNum { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.storeForwardConfig == nil { - Logger.mesh.info("⚙️ Empty or expired store & forward module config requesting via PKI admin") - _ = bleManager.requestStoreAndForwardModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired store & forward module config requesting via PKI admin") + try await accessoryManager.requestStoreAndForwardModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.info("🚨 Request for store & forward module config failed") + } + } } } else { /// Legacy Administration diff --git a/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift b/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift index f87e7890..e614ba18 100644 --- a/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift @@ -11,7 +11,7 @@ import SwiftUI struct TelemetryConfig: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @@ -101,10 +101,10 @@ struct TelemetryConfig: View { .toggleStyle(SwitchToggleStyle(tint: .accentColor)) } } - .disabled(self.bleManager.connectedPeripheral == nil || node?.telemetryConfig == nil) + .disabled(!accessoryManager.isConnected || node?.telemetryConfig == nil) SaveConfigButton(node: node, hasChanges: $hasChanges) { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? -1, context: context) + let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? -1, context: context) if connectedNode != nil { var tc = ModuleConfig.TelemetryConfig() tc.deviceUpdateInterval = UInt32(deviceUpdateInterval) @@ -115,37 +115,43 @@ struct TelemetryConfig: View { tc.powerMeasurementEnabled = powerMeasurementEnabled tc.powerUpdateInterval = UInt32(powerUpdateInterval) tc.powerScreenEnabled = powerScreenEnabled - let adminMessageId = bleManager.saveTelemetryModuleConfig(config: tc, fromUser: connectedNode!.user!, toUser: node!.user!) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + + Task { + _ = try await accessoryManager.saveTelemetryModuleConfig(config: tc, fromUser: connectedNode!.user!, toUser: node!.user!) + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + hasChanges = false + goBack() + } } } } .navigationTitle("Telemetry Config") .navigationBarItems( trailing: ZStack { - ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?" - ) + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") + } ) .onFirstAppear { // Need to request a TelemetryModuleConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) if let connectedNode { - if node.num != connectedNode.num { - if UserDefaults.enableAdministration && node.num != connectedNode.num { + if node.num != deviceNum { + if UserDefaults.enableAdministration && node.num != deviceNum { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.telemetryConfig == nil { - Logger.mesh.info("⚙️ Empty or expired telemetry module config requesting via PKI admin") - _ = bleManager.requestTelemetryModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired telemetry module config requesting via PKI admin") + try await accessoryManager.requestTelemetryModuleConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.info("🚨 Telemetry module config request failed: \(error.localizedDescription)") + } + } } } else { /// Legacy Administration diff --git a/Meshtastic/Views/Settings/Config/NetworkConfig.swift b/Meshtastic/Views/Settings/Config/NetworkConfig.swift index 9d92ca03..8d32b189 100644 --- a/Meshtastic/Views/Settings/Config/NetworkConfig.swift +++ b/Meshtastic/Views/Settings/Config/NetworkConfig.swift @@ -11,7 +11,7 @@ import SwiftUI struct NetworkConfig: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @@ -37,7 +37,7 @@ struct NetworkConfig: View { Toggle(isOn: $wifiEnabled) { Label("Enabled", systemImage: "wifi") - Text("Enabling WiFi will disable the bluetooth connection to the app. TCP node connections are not available on Apple devices.") + Text("Enabling WiFi will disable the bluetooth connection to the app.") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) @@ -101,11 +101,10 @@ struct NetworkConfig: View { } } .scrollDismissesKeyboard(.interactively) - .disabled(self.bleManager.connectedPeripheral == nil || node?.networkConfig == nil) + .disabled(!accessoryManager.isConnected || node?.networkConfig == nil) SaveConfigButton(node: node, hasChanges: $hasChanges) { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - if connectedNode != nil { + if let deviceNum = accessoryManager.activeDeviceNum, let connectedNode = getNodeInfo(id: deviceNum, context: context) { var network = Config.NetworkConfig() network.wifiEnabled = self.wifiEnabled network.wifiSsid = self.wifiSsid @@ -113,13 +112,14 @@ struct NetworkConfig: View { network.ethEnabled = self.ethEnabled network.enabledProtocols = self.udpEnabled ? UInt32(Config.NetworkConfig.ProtocolFlags.udpBroadcast.rawValue) : UInt32(Config.NetworkConfig.ProtocolFlags.noBroadcast.rawValue) // network.addressMode = Config.NetworkConfig.AddressMode.dhcp - - let adminMessageId = bleManager.saveNetworkConfig(config: network, fromUser: connectedNode!.user!, toUser: node!.user!) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + Task { + _ = try await accessoryManager.saveNetworkConfig(config: network, fromUser: connectedNode.user!, toUser: node!.user!) + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + hasChanges = false + goBack() + } } } } @@ -127,35 +127,39 @@ struct NetworkConfig: View { .navigationTitle("Network Config") .navigationBarItems( trailing: ZStack { - ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: bleManager.connectedPeripheral?.shortName ?? "?" - ) + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") + } ) .onAppear { // Need to request a NetworkConfig from the remote node before allowing changes - if bleManager.connectedPeripheral != nil && node?.networkConfig == nil { + if accessoryManager.isConnected && node?.networkConfig == nil { Logger.mesh.info("empty network config") - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context) - if node != nil && connectedNode != nil { - _ = bleManager.requestNetworkConfig(fromUser: connectedNode!.user!, toUser: node!.user!) + if let deviceNum = accessoryManager.activeDeviceNum, let connectedNode = getNodeInfo(id: deviceNum, context: context), node != nil { + Task { + try await accessoryManager.requestNetworkConfig(fromUser: connectedNode.user!, toUser: node!.user!) + } } } } .onFirstAppear { // Need to request a NetworkConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) if let connectedNode { - if node.num != connectedNode.num { + if node.num != deviceNum { if UserDefaults.enableAdministration { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.networkConfig == nil { - Logger.mesh.info("⚙️ Empty or expired network config requesting via PKI admin") - _ = bleManager.requestNetworkConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired network config requesting via PKI admin") + try await accessoryManager.requestNetworkConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.error("🚨 Network config request failed") + } + } } } else { /// Legacy Administration diff --git a/Meshtastic/Views/Settings/Config/PositionConfig.swift b/Meshtastic/Views/Settings/Config/PositionConfig.swift index 537c1f26..8041d9d9 100644 --- a/Meshtastic/Views/Settings/Config/PositionConfig.swift +++ b/Meshtastic/Views/Settings/Config/PositionConfig.swift @@ -26,7 +26,7 @@ struct PositionFlags: OptionSet { struct PositionConfig: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @State var hasChanges = false @@ -160,7 +160,7 @@ struct PositionConfig: View { .font(.callout) } } - if (gpsMode != 1 && node?.num ?? 0 == bleManager.connectedPeripheral?.num ?? -1) || fixedPosition { + if (gpsMode != 1 && node?.num ?? 0 == accessoryManager.activeDeviceNum ?? -1) || fixedPosition { VStack(alignment: .leading) { Toggle(isOn: $fixedPosition) { Label("Fixed Position", systemImage: "location.square.fill") @@ -316,11 +316,11 @@ struct PositionConfig: View { var saveButton: some View { SaveConfigButton(node: node, hasChanges: $hasChanges) { if fixedPosition && !supportedVersion { - _ = bleManager.sendPosition(channel: 0, destNum: node?.num ?? 0, wantResponse: true) + Task { + try await accessoryManager.sendPosition(channel: 0, destNum: node?.num ?? 0, wantResponse: true) + } } - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral!.num, context: context) - - if connectedNode != nil { + if let deviceNum = accessoryManager.activeDeviceNum, let connectedNode = getNodeInfo(id: deviceNum, context: context) { var pc = Config.PositionConfig() pc.positionBroadcastSmartEnabled = smartPositionEnabled pc.gpsEnabled = gpsMode == 1 @@ -345,11 +345,13 @@ struct PositionConfig: View { if includeSpeed { pf.insert(.Speed) } if includeHeading { pf.insert(.Heading) } pc.positionFlags = UInt32(pf.rawValue) - let adminMessageId = bleManager.savePositionConfig(config: pc, fromUser: connectedNode!.user!, toUser: node!.user!) - if adminMessageId > 0 { - // Disable the button after a successful save - hasChanges = false - goBack() + Task { + _ = try await accessoryManager.savePositionConfig(config: pc, fromUser: connectedNode.user!, toUser: node!.user!) + Task { @MainActor in + // Disable the button after a successful save + hasChanges = false + goBack() + } } } } @@ -375,7 +377,7 @@ struct PositionConfig: View { advancedDeviceGPSSection } } - .disabled(self.bleManager.connectedPeripheral == nil || node?.positionConfig == nil) + .disabled(!accessoryManager.isConnected || node?.positionConfig == nil) .alert(setFixedAlertTitle, isPresented: $showingSetFixedAlert) { Button("Cancel", role: .cancel) { fixedPosition = !fixedPosition @@ -397,22 +399,28 @@ struct PositionConfig: View { .navigationTitle("Position Config") .navigationBarItems( trailing: ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: bleManager.connectedPeripheral?.shortName ?? "?") + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") } ) .onFirstAppear { - supportedVersion = bleManager.connectedVersion == "0.0.0" || self.minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedAscending || minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedSame + supportedVersion = accessoryManager.checkIsVersionSupported(forVersion: minimumVersion) // Need to request a NetworkConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) if let connectedNode { - if node.num != connectedNode.num { + if node.num != deviceNum { if UserDefaults.enableAdministration { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.positionConfig == nil { - Logger.mesh.info("⚙️ Empty or expired position config requesting via PKI admin") - _ = bleManager.requestPositionConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired position config requesting via PKI admin") + try await accessoryManager.requestPositionConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.info("🚨 Position config request failed") + } + } } } else { /// Legacy Administration @@ -511,10 +519,14 @@ struct PositionConfig: View { } private func setFixedPosition() { - guard let nodeNum = bleManager.connectedPeripheral?.num, + guard let nodeNum = accessoryManager.activeDeviceNum, nodeNum > 0 else { return } - if !bleManager.setFixedPosition(fromUser: node!.user!, channel: 0) { - Logger.mesh.error("Set Position Failed") + Task { + do { + try await accessoryManager.setFixedPosition(fromUser: node!.user!, channel: 0) + } catch { + Logger.mesh.error("Set Position Failed") + } } node?.positionConfig?.fixedPosition = true do { @@ -528,10 +540,14 @@ struct PositionConfig: View { } private func removeFixedPosition() { - guard let nodeNum = bleManager.connectedPeripheral?.num, + guard let nodeNum = accessoryManager.activeDeviceNum, nodeNum > 0 else { return } - if !bleManager.removeFixedPosition(fromUser: node!.user!, channel: 0) { - Logger.mesh.error("Remove Fixed Position Failed") + Task { + do { + try await accessoryManager.removeFixedPosition(fromUser: node!.user!, channel: 0) + } catch { + Logger.mesh.error("Remove Fixed Position Failed") + } } let mutablePositions = node?.positions?.mutableCopy() as? NSMutableOrderedSet mutablePositions?.removeAllObjects() diff --git a/Meshtastic/Views/Settings/Config/PowerConfig.swift b/Meshtastic/Views/Settings/Config/PowerConfig.swift index 6087fec6..c369275a 100644 --- a/Meshtastic/Views/Settings/Config/PowerConfig.swift +++ b/Meshtastic/Views/Settings/Config/PowerConfig.swift @@ -4,7 +4,7 @@ import OSLog struct PowerConfig: View { @Environment(\.managedObjectContext) private var context - @EnvironmentObject private var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack let node: NodeInfoEntity? @@ -100,14 +100,11 @@ struct PowerConfig: View { // } } } - .disabled(self.bleManager.connectedPeripheral == nil || node?.powerConfig == nil) + .disabled(!accessoryManager.isConnected || node?.powerConfig == nil) .navigationTitle("Power Config") .navigationBarItems(trailing: ZStack { - ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: "\(bleManager.connectedPeripheral?.shortName ?? "?")" - ) + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") + }) .toolbar { ToolbarItemGroup(placement: .keyboard) { @@ -129,17 +126,22 @@ struct PowerConfig: View { } } // Need to request a NetworkConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) + if let deviceNum = accessoryManager.activeDeviceNum, let node { + let connectedNode = getNodeInfo(id: deviceNum, context: context) if let connectedNode { - if node.num != connectedNode.num { + if node.num != deviceNum { if UserDefaults.enableAdministration { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.powerConfig == nil { - Logger.mesh.info("⚙️ Empty or expired power config requesting via PKI admin") - _ = bleManager.requestPowerConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired power config requesting via PKI admin") + try await accessoryManager.requestPowerConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.info("🚨 Power config request failed") + } + } } } else { /// Legacy Administration @@ -177,7 +179,8 @@ struct PowerConfig: View { } SaveConfigButton(node: node, hasChanges: $hasChanges) { - guard let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context), + guard let deviceNum = accessoryManager.activeDeviceNum, + let connectedNode = getNodeInfo(id: deviceNum, context: context), let fromUser = connectedNode.user, let toUser = node?.user else { return @@ -190,17 +193,18 @@ struct PowerConfig: View { config.waitBluetoothSecs = UInt32(waitBluetoothSecs) config.lsSecs = UInt32(lsSecs) config.minWakeSecs = UInt32(minWakeSecs) - - let adminMessageId = bleManager.savePowerConfig( - config: config, - fromUser: fromUser, - toUser: toUser - ) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - hasChanges = false - goBack() + Task { + _ = try await accessoryManager.savePowerConfig( + config: config, + fromUser: fromUser, + toUser: toUser + ) + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + hasChanges = false + goBack() + } } } } diff --git a/Meshtastic/Views/Settings/Config/SaveConfigButton.swift b/Meshtastic/Views/Settings/Config/SaveConfigButton.swift index 8e0c8701..dcad0515 100644 --- a/Meshtastic/Views/Settings/Config/SaveConfigButton.swift +++ b/Meshtastic/Views/Settings/Config/SaveConfigButton.swift @@ -1,8 +1,7 @@ import SwiftUI struct SaveConfigButton: View { - @EnvironmentObject var bleManager: BLEManager - + @EnvironmentObject var accessoryManager: AccessoryManager @State private var isPresentingSaveConfirm = false let node: NodeInfoEntity? @Binding var hasChanges: Bool @@ -14,7 +13,7 @@ struct SaveConfigButton: View { } label: { Label("Save", systemImage: "square.and.arrow.down") } - .disabled(bleManager.connectedPeripheral == nil || !hasChanges) + .disabled(!accessoryManager.isConnected || !hasChanges) .buttonStyle(.bordered) .buttonBorderShape(.capsule) .controlSize(.large) diff --git a/Meshtastic/Views/Settings/Config/SecurityConfig.swift b/Meshtastic/Views/Settings/Config/SecurityConfig.swift index 410c0b0d..280aee53 100644 --- a/Meshtastic/Views/Settings/Config/SecurityConfig.swift +++ b/Meshtastic/Views/Settings/Config/SecurityConfig.swift @@ -16,7 +16,7 @@ struct SecurityConfig: View { private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @@ -224,11 +224,8 @@ struct SecurityConfig: View { .scrollDismissesKeyboard(.immediately) .navigationTitle("Security Config") .navigationBarItems(trailing: ZStack { - ConnectedDevice( - bluetoothOn: bleManager.isSwitchedOn, - deviceConnected: bleManager.connectedPeripheral != nil, - name: "\(bleManager.connectedPeripheral?.shortName ?? "?")" - ) + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") + }) .onChange(of: node) { _, _ in setSecurityValues() @@ -290,16 +287,21 @@ struct SecurityConfig: View { } .onFirstAppear { // Need to request a SecurityConfig from the remote node before allowing changes - if let connectedPeripheral = bleManager.connectedPeripheral, let node { - let connectedNode = getNodeInfo(id: connectedPeripheral.num, context: context) - if let connectedNode { - if node.num != connectedNode.num { + if let deviceNum = accessoryManager.activeDeviceNum, let node { + if let connectedNode = getNodeInfo(id: deviceNum, context: context) { + if node.num != deviceNum { if UserDefaults.enableAdministration { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.securityConfig == nil { - Logger.mesh.info("⚙️ Empty or expired security config requesting via PKI admin") - _ = bleManager.requestSecurityConfig(fromUser: connectedNode.user!, toUser: node.user!) + Task { + do { + Logger.mesh.info("⚙️ Empty or expired security config requesting via PKI admin") + try await accessoryManager.requestSecurityConfig(fromUser: connectedNode.user!, toUser: node.user!) + } catch { + Logger.mesh.info("🚨 Security config request failed") + } + } } } else { if node.deviceConfig == nil { @@ -318,7 +320,8 @@ struct SecurityConfig: View { return } - guard let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral.num, context: context), + guard let deviceNum = accessoryManager.activeDeviceNum, + let connectedNode = getNodeInfo(id: deviceNum, context: context), let fromUser = connectedNode.user, let toUser = node?.user else { return @@ -332,32 +335,38 @@ struct SecurityConfig: View { config.debugLogApiEnabled = debugLogApiEnabled let keyUpdated = node?.securityConfig?.privateKey?.base64EncodedString() ?? "" != privateKey - let adminMessageId = bleManager.saveSecurityConfig( - config: config, - fromUser: fromUser, - toUser: toUser - ) - if adminMessageId > 0 { - // Should show a saved successfully alert once I know that to be true - // for now just disable the button after a successful save - if keyUpdated { - node?.user?.publicKey = Data(base64Encoded: publicKey) ?? Data() - do { - try context.save() - Logger.data.info("💾 Saved UserEntity Public Key to Core Data for \(node?.num ?? 0, privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Error Updating Core Data UserEntity: \(nsError, privacy: .public)") + Task { + _ = try await accessoryManager.saveSecurityConfig( + config: config, + fromUser: fromUser, + toUser: toUser + ) + Task { @MainActor in + // Should show a saved successfully alert once I know that to be true + // for now just disable the button after a successful save + if keyUpdated { + node?.user?.publicKey = Data(base64Encoded: publicKey) ?? Data() + do { + try context.save() + Logger.data.info("💾 Saved UserEntity Public Key to Core Data for \(node?.num ?? 0, privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Error Updating Core Data UserEntity: \(nsError, privacy: .public)") + } } } hasChanges = false if keyUpdated { - if !bleManager.sendReboot( - fromUser: fromUser, - toUser: toUser - ) { - Logger.mesh.warning("Reboot Failed") + Task { + do { + try await accessoryManager.sendReboot( + fromUser: fromUser, + toUser: toUser + ) + } catch { + Logger.mesh.warning("Reboot Failed") + } } } goBack() diff --git a/Meshtastic/Views/Settings/Firmware.swift b/Meshtastic/Views/Settings/Firmware.swift index f8225e73..5120b7cd 100644 --- a/Meshtastic/Views/Settings/Firmware.swift +++ b/Meshtastic/Views/Settings/Firmware.swift @@ -11,7 +11,7 @@ import OSLog struct Firmware: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager var node: NodeInfoEntity? @State var minimumVersion = "2.5.4" @State var version = "" @@ -20,8 +20,8 @@ struct Firmware: View { @State private var latestAlpha: FirmwareRelease? var body: some View { - - let supportedVersion = self.minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedAscending || minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedSame + let supportedVersion = accessoryManager.checkIsVersionSupported(forVersion: minimumVersion) + let connectedVersion = accessoryManager.activeConnection?.device.firmwareVersion ?? "Unknown" ScrollView { VStack(alignment: .leading) { let deviceString = currentDevice?.hwModelSlug.replacingOccurrences(of: "_", with: "") @@ -53,7 +53,7 @@ struct Firmware: View { .foregroundStyle(.green) .font(.title2) .padding(.bottom) - Text("Current Firmware Version: \(bleManager.connectedVersion)") + Text("Current Firmware Version: \(connectedVersion)") .fixedSize(horizontal: false, vertical: true) .font(.title3) .padding(.bottom) @@ -63,7 +63,7 @@ struct Firmware: View { .foregroundStyle(.red) .font(.title2) .padding(.bottom) - Text("Current Firmware Version: \(bleManager.connectedVersion), Latest Firmware Version: \(minimumVersion)") + Text("Current Firmware Version: \(connectedVersion), Latest Firmware Version: \(minimumVersion)") .fixedSize(horizontal: false, vertical: true) .font(.title3) .padding(.bottom) @@ -108,15 +108,18 @@ struct Firmware: View { .foregroundStyle(.gray) .font(.caption) Button { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? 0, context: context) + let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context) if connectedNode != nil { - - if bleManager.sendEnterDfuMode(fromUser: connectedNode!.user!, toUser: node!.user!) { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - bleManager.disconnectPeripheral(reconnect: false) + Task { + do { + try await accessoryManager.sendEnterDfuMode(fromUser: connectedNode!.user!, toUser: node!.user!) + Task { + try await Task.sleep(nanoseconds: 1 * 1_000_000_000) // 1 second + try await accessoryManager.disconnect() + } + } catch { + Logger.mesh.error("Enter DFU Failed") } - } else { - Logger.mesh.error("Enter DFU Failed") } } } label: { @@ -158,10 +161,14 @@ struct Firmware: View { HStack(alignment: .center) { Spacer() Button { - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? 0, context: context) + let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context) if connectedNode != nil { - if !bleManager.sendRebootOta(fromUser: connectedNode!.user!, toUser: node!.user!) { - Logger.mesh.error("Reboot Failed") + Task { + do { + try await accessoryManager.sendRebootOta(fromUser: connectedNode!.user!, toUser: node!.user!) + } catch { + Logger.mesh.error("Reboot Failed") + } } } } label: { diff --git a/Meshtastic/Views/Settings/Logs/AppLogFilter.swift b/Meshtastic/Views/Settings/Logs/AppLogFilter.swift index eadf8e41..179f8a4c 100644 --- a/Meshtastic/Views/Settings/Logs/AppLogFilter.swift +++ b/Meshtastic/Views/Settings/Logs/AppLogFilter.swift @@ -18,6 +18,7 @@ enum LogCategories: Int, CaseIterable, Identifiable { case radio = 4 case services = 5 case stats = 6 + case transport = 7 var id: Int { self.rawValue } var description: String { @@ -37,6 +38,8 @@ enum LogCategories: Int, CaseIterable, Identifiable { return "🍏 Services" case .stats: return "📊 Stats" + case .transport: + return "🚚 Transport" } } } @@ -108,17 +111,33 @@ struct AppLogFilter: View { NavigationStack { Form { - Section(header: Text("Categories")) { + Section(header: HStack { + Text("Categories") + Spacer() + Button { + categories.formUnion(LogCategories.allCases.map(\.id)) + } label: { + Text("All") + } + }) { VStack { List(LogCategories.allCases, selection: $categories) { cat in Text(cat.description) } .listStyle(.plain) .environment(\.editMode, $editMode) /// bind it here! - .frame(minHeight: 300, maxHeight: .infinity) + .frame(minHeight: 338, maxHeight: .infinity) } } - Section(header: Text("Log Levels")) { + Section(header: HStack { + Text("Log Levels") + Spacer() + Button { + levels.formUnion(LogLevels.allCases.map(\.id)) + } label: { + Text("All") + } + }) { VStack { List(LogLevels.allCases, selection: $levels) { level in Text(level.description) diff --git a/Meshtastic/Views/Settings/Routes.swift b/Meshtastic/Views/Settings/Routes.swift index 23c4fa18..d4e6e9c0 100644 --- a/Meshtastic/Views/Settings/Routes.swift +++ b/Meshtastic/Views/Settings/Routes.swift @@ -14,7 +14,7 @@ struct Routes: View { @State private var columnVisibility = NavigationSplitViewVisibility.doubleColumn @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @State private var selectedRoute: RouteEntity? @State private var importing = false @State private var isShowingBadFileAlert = false diff --git a/Meshtastic/Views/Settings/SaveChannelQRCode.swift b/Meshtastic/Views/Settings/SaveChannelQRCode.swift index 0a83c900..71a032b4 100644 --- a/Meshtastic/Views/Settings/SaveChannelQRCode.swift +++ b/Meshtastic/Views/Settings/SaveChannelQRCode.swift @@ -14,10 +14,11 @@ struct SaveChannelQRCode: View { @Environment(\.managedObjectContext) var context let channelSetLink: String var addChannels: Bool = false - var bleManager: BLEManager + var accessoryManager: AccessoryManager + @State private var showError: Bool = false @State private var errorMessage: String = "" - @State private var connectedToDevice: Bool = false + // @State private var connectedToDevice: Bool = false @State private var loraChanges: [String] = [] @State private var okToMQTT: Bool = false var body: some View { @@ -66,12 +67,19 @@ struct SaveChannelQRCode: View { } else { channelData = channelSetLink } - let success = bleManager.saveChannelSet(base64UrlString: channelData, addChannels: addChannels, okToMQTT: okToMQTT) - if success { - dismiss() - } else { - errorMessage = "Failed to save channel configuration" - showError = true + + Task { + do { + try await accessoryManager.saveChannelSet(base64UrlString: channelData, addChannels: addChannels, okToMQTT: okToMQTT) + Task { @MainActor in + dismiss() + } + } catch { + Task { @MainActor in + errorMessage = "Failed to save channel configuration" + showError = true + } + } } } label: { Label("Save", systemImage: "square.and.arrow.down") @@ -80,7 +88,7 @@ struct SaveChannelQRCode: View { .buttonBorderShape(.capsule) .controlSize(.large) .padding() - .disabled(!connectedToDevice) + .disabled(!accessoryManager.isConnected) #if targetEnvironment(macCatalyst) Button { @@ -108,7 +116,7 @@ struct SaveChannelQRCode: View { } .onAppear { Logger.data.info("Ch set link \(channelSetLink)") - connectedToDevice = bleManager.connectToPreferredPeripheral() + // connectedToDevice = accessoryManager.connectToPreferredDevice() fetchLoRaConfigChanges() } } @@ -153,7 +161,8 @@ struct SaveChannelQRCode: View { Logger.data.info("Processing channel data: \(channelData)") // Fetch current LoRa config from Core Data let fetchRequest = NodeInfoEntity.fetchRequest() - fetchRequest.predicate = NSPredicate(format: "num == %lld", Int64(bleManager.connectedPeripheral?.num ?? 0)) + fetchRequest.predicate = NSPredicate(format: "num == %lld", Int64(accessoryManager.activeDeviceNum ?? 0)) + do { let nodes = try context.fetch(fetchRequest) if let node = nodes.first { diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 426d95e9..329157c9 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -12,7 +12,7 @@ import MeshtasticProtobufs struct Settings: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @FetchRequest( sortDescriptors: [ NSSortDescriptor(key: "favorite", ascending: false), @@ -48,10 +48,10 @@ struct Settings: View { let node = nodes.first(where: { $0.num == preferredNodeNum }) if let node, let loRaConfig = node.loRaConfig, - let rc = RegionCodes(rawValue: Int(loRaConfig.regionCode)), + let rc = RegionCodes(rawValue: Int(loRaConfig.regionCode)), let user = node.user, !user.isLicensed, - rc.dutyCycle > 0 && rc.dutyCycle < 100 { + rc.dutyCycle > 0 && rc.dutyCycle < 100 { Label { Text("Hourly Duty Cycle") } icon: { @@ -377,7 +377,7 @@ struct Settings: View { } if !(node?.deviceConfig?.isManaged ?? false) { - if bleManager.connectedPeripheral != nil { + if accessoryManager.isConnected { Section("Configure") { if node?.canRemoteAdmin ?? false { Picker("Node", selection: $selectedNode) { @@ -386,11 +386,11 @@ struct Settings: View { } ForEach(nodes) { node in /// Connected Node - if node.num == bleManager.connectedPeripheral?.num ?? 0 { + if node.num == accessoryManager.activeDeviceNum ?? 0 { Label { - Text("BLE: \(node.user?.longName?.addingVariationSelectors ?? "Unknown".localized)") + Text("Connected") + Text(verbatim: ": \(node.user?.longName?.addingVariationSelectors ?? "Unknown".localized)") } icon: { - Image(systemName: "antenna.radiowaves.left.and.right") + accessoryManager.activeConnection?.device.transportType.icon ?? Image("questionmark.circle") } .tag(Int(node.num)) } else if node.canRemoteAdmin && UserDefaults.enableAdministration && node.sessionPasskey != nil { /// Nodes using the new PKI system @@ -427,13 +427,17 @@ struct Settings: View { } .pickerStyle(.navigationLink) .onChange(of: selectedNode) { _, newValue in - if selectedNode > 0 { - let node = nodes.first(where: { $0.num == newValue }) - let connectedNode = nodes.first(where: { $0.num == preferredNodeNum }) - preferredNodeNum = Int(connectedNode?.num ?? 0)// Int(bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral?.num ?? 0 : 0) - if connectedNode != nil && connectedNode?.user != nil && connectedNode?.myInfo != nil && node?.user != nil {// && node?.metadata == nil { - let adminMessageId = bleManager.requestDeviceMetadata(fromUser: connectedNode!.user!, toUser: node!.user!, context: context) - if adminMessageId > 0 { + if selectedNode > 0, + let destinationNode = nodes.first(where: { $0.num == newValue }), + let connectedNode = nodes.first(where: { $0.num == preferredNodeNum }), + let fromUser = connectedNode.user, + let _ = connectedNode.myInfo, // not sure why, but this check was present in the initial code. + let toUser = destinationNode.user { + + preferredNodeNum = Int(connectedNode.num) + Task { + _ = try await accessoryManager.requestDeviceMetadata(fromUser: fromUser, toUser: toUser) + Task { @MainActor in Logger.mesh.info("Sent node metadata request from node details") } } @@ -442,7 +446,7 @@ struct Settings: View { TipView(AdminChannelTip(), arrowEdge: .top) .tipViewStyle(PersistentTip()) } else { - if bleManager.connectedPeripheral != nil { + if accessoryManager.isConnected { Text("Connected Node \(node?.user?.longName?.addingVariationSelectors ?? "Unknown".localized)") } } @@ -522,25 +526,26 @@ struct Settings: View { } } .onChange(of: UserDefaults.preferredPeripheralNum ) { _, newConnectedNode in + // If the preferred node changes, then select the newly perferred node + // This should only happen during connect preferredNodeNum = newConnectedNode - if nodes.count > 1 { - if selectedNode == 0 { - self.selectedNode = Int(bleManager.connectedPeripheral != nil ? newConnectedNode : 0) - } - } else { - self.selectedNode = Int(bleManager.connectedPeripheral != nil ? newConnectedNode: 0) + setSelectedNode(to: newConnectedNode) + } + .onChange(of: accessoryManager.isConnected) { _, isConnectedNow in + // If we are on this screen, haven't iniatialized the selection yet, + // And we transition, to connected, then initialize the selection + if isConnectedNow, self.selectedNode == 0 { + self.preferredNodeNum = UserDefaults.preferredPeripheralNum + setSelectedNode(to: UserDefaults.preferredPeripheralNum) } } .onAppear { + // If the selection hasn't be initialized yet, try to initalize it. + // If we are not fully connected yet, then setSelectedNode will + // not select the node and it will remain 0 if self.preferredNodeNum <= 0 { self.preferredNodeNum = UserDefaults.preferredPeripheralNum - if nodes.count > 1 { - if selectedNode == 0 { - self.selectedNode = Int(bleManager.connectedPeripheral != nil ? UserDefaults.preferredPeripheralNum : 0) - } - } else { - self.selectedNode = Int(bleManager.connectedPeripheral != nil ? UserDefaults.preferredPeripheralNum : 0) - } + setSelectedNode(to: UserDefaults.preferredPeripheralNum) } } .navigationTitle("Settings") @@ -552,4 +557,14 @@ struct Settings: View { ) } } + + func setSelectedNode(to nodeNum: Int) { + if nodes.count > 1 { + if selectedNode == 0 { + self.selectedNode = Int(accessoryManager.isConnected ? nodeNum : 0) + } + } else { + self.selectedNode = Int(accessoryManager.isConnected ? nodeNum: 0) + } + } } diff --git a/Meshtastic/Views/Settings/ShareChannels.swift b/Meshtastic/Views/Settings/ShareChannels.swift index a3788ff0..d8d5d2d1 100644 --- a/Meshtastic/Views/Settings/ShareChannels.swift +++ b/Meshtastic/Views/Settings/ShareChannels.swift @@ -34,7 +34,7 @@ struct QrCodeImage { struct ShareChannels: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var dismiss @State var channelSet: ChannelSet = ChannelSet() @State var includeChannel0 = true @@ -248,7 +248,7 @@ struct ShareChannels: View { .navigationBarTitleDisplayMode(.inline) .navigationBarItems(trailing: ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") }) .onAppear { generateChannelSet() diff --git a/Meshtastic/Views/Settings/UserConfig.swift b/Meshtastic/Views/Settings/UserConfig.swift index 54001b7b..de2c2d79 100644 --- a/Meshtastic/Views/Settings/UserConfig.swift +++ b/Meshtastic/Views/Settings/UserConfig.swift @@ -11,7 +11,7 @@ import SwiftUI struct UserConfig: View { @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) private var goBack var node: NodeInfoEntity? @@ -59,6 +59,9 @@ struct UserConfig: View { totalBytes = newValue.utf8.count } longName = newValue + if longName.contains("📵") { + isUnmessagable = true + } } } .keyboardType(.default) @@ -97,7 +100,7 @@ struct UserConfig: View { Text("The last 4 of the device MAC address will be appended to the short name to set the device's BLE Name. Short name can be up to 4 bytes long.") .foregroundColor(.gray) .font(.callout) - let supportedVersion = UserDefaults.firmwareVersion == "0.0.0" || self.minimumVersion.compare(UserDefaults.firmwareVersion, options: .numeric) == .orderedAscending || minimumVersion.compare(UserDefaults.firmwareVersion, options: .numeric) == .orderedSame + let supportedVersion = accessoryManager.checkIsVersionSupported(forVersion: minimumVersion) Toggle(isOn: $isUnmessagable) { Label("Unmessagable", systemImage: "iphone.slash") Text("Used to identify unmonitored or infrastructure nodes so that messaging is not avaliable to nodes that will never respond.") @@ -107,7 +110,7 @@ struct UserConfig: View { .disabled(!supportedVersion) } // Only manage ham mode for the locally connected node - if node?.num ?? 0 > 0 && node?.num ?? 0 == bleManager.connectedPeripheral?.num ?? 0 { + if node?.num ?? 0 > 0 && node?.num ?? 0 == accessoryManager.activeDeviceNum ?? 0 { Toggle(isOn: $isLicensed) { Label("Licensed Operator", systemImage: "person.text.rectangle") } @@ -145,14 +148,14 @@ struct UserConfig: View { } } } - .disabled(bleManager.connectedPeripheral == nil) + .disabled(!accessoryManager.isConnected) HStack { Button { isPresentingSaveConfirm = true } label: { Label("Save", systemImage: "square.and.arrow.down") } - .disabled(bleManager.connectedPeripheral == nil || !hasChanges) + .disabled(!accessoryManager.isConnected || !hasChanges) .buttonStyle(.bordered) .buttonBorderShape(.capsule) .controlSize(.large) @@ -167,8 +170,8 @@ struct UserConfig: View { return } - let connectedUser = getUser(id: bleManager.connectedPeripheral?.num ?? -1, context: context) - let connectedNode = getNodeInfo(id: bleManager.connectedPeripheral?.num ?? -1, context: context) + let connectedUser = getUser(id: accessoryManager.activeDeviceNum ?? -1, context: context) + let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? -1, context: context) if node != nil && connectedNode != nil { if !isLicensed { @@ -176,10 +179,13 @@ struct UserConfig: View { u.shortName = shortName u.longName = longName u.isUnmessagable = isUnmessagable - let adminMessageId = bleManager.saveUser(config: u, fromUser: connectedUser, toUser: node!.user!) - if adminMessageId > 0 { - hasChanges = false - goBack() + + Task { + _ = try await accessoryManager.saveUser(config: u, fromUser: connectedUser, toUser: node!.user!) + Task { @MainActor in + hasChanges = false + goBack() + } } } else { var ham = HamParameters() @@ -188,10 +194,12 @@ struct UserConfig: View { ham.callSign = longName ham.txPower = Int32(txPower) ham.frequency = overrideFrequency - let adminMessageId = bleManager.saveLicensedUser(ham: ham, fromUser: connectedUser, toUser: node!.user!) - if adminMessageId > 0 { - hasChanges = false - goBack() + Task { + _ = try await accessoryManager.saveLicensedUser(ham: ham, fromUser: connectedUser, toUser: node!.user!) + Task { @MainActor in + hasChanges = false + goBack() + } } } } @@ -205,7 +213,7 @@ struct UserConfig: View { .navigationTitle("User Config") .navigationBarItems(trailing: ZStack { - ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") }) .onAppear { self.shortName = node?.user?.shortName ?? "" diff --git a/MeshtasticTests/RouterTests.swift b/MeshtasticTests/RouterTests.swift index a3ecee6d..96bd70af 100644 --- a/MeshtasticTests/RouterTests.swift +++ b/MeshtasticTests/RouterTests.swift @@ -8,7 +8,7 @@ final class RouterTests: XCTestCase { func testInitialState() async throws { let router = await Router() let tab = await router.navigationState.selectedTab - XCTAssertEqual(tab, .bluetooth) + XCTAssertEqual(tab, .connect) } func testRouteMessages() async throws { @@ -47,11 +47,11 @@ final class RouterTests: XCTestCase { ) } - func testRouteBluetooth() async throws { + func testRouteConnect() async throws { try await assertRoute( router: Router(), - "meshtastic:///bluetooth", - NavigationState(selectedTab: .bluetooth) + "meshtastic:///connect", + NavigationState(selectedTab: .connect) ) } diff --git a/Widgets/WidgetsLiveActivity.swift b/Widgets/WidgetsLiveActivity.swift index 656cc8a1..5f1e6d29 100644 --- a/Widgets/WidgetsLiveActivity.swift +++ b/Widgets/WidgetsLiveActivity.swift @@ -26,7 +26,7 @@ struct WidgetsLiveActivity: Widget { nodesOnline: context.state.nodesOnline, totalNodes: context.state.totalNodes, timerRange: context.state.timerRange) - .widgetURL(URL(string: "meshtastic:///bluetooth")) + .widgetURL(URL(string: "meshtastic:///connect")) } dynamicIsland: { context in DynamicIsland { @@ -111,7 +111,7 @@ struct WidgetsLiveActivity: Widget { .contentMargins(.trailing, 32, for: .expanded) .contentMargins([.leading, .top, .bottom], 6, for: .compactLeading) .contentMargins(.all, 6, for: .minimal) - .widgetURL(URL(string: "meshtastic:///bluetooth")) + .widgetURL(URL(string: "meshtastic:///connect")) } } } diff --git a/scripts/lint/lint-fix-changes.sh b/scripts/lint/lint-fix-changes.sh index 2de03ea2..808c5492 100755 --- a/scripts/lint/lint-fix-changes.sh +++ b/scripts/lint/lint-fix-changes.sh @@ -29,7 +29,7 @@ if [[ -e "${SWIFT_LINT}" ]]; then file_var="SCRIPT_INPUT_FILE_$i" file_path=${!file_var} echo "Fixing $file_path" - $SWIFT_LINT --fix "$file_path" + $SWIFT_LINT --config .swiftlint-precommit.yml --fix "$file_path" done # Add the fixed files back to staging @@ -43,7 +43,7 @@ if [[ -e "${SWIFT_LINT}" ]]; then # Optionally lint the fixed files echo "Linting fixed files..." - $SWIFT_LINT lint --use-script-input-files + $SWIFT_LINT lint --use-script-input-files --config .swiftlint-precommit.yml else exit 0 fi