diff --git a/Localizable.xcstrings b/Localizable.xcstrings index f665113c..bc96a6e1 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -5274,6 +5274,13 @@ } } } + }, + "BLE Device" : { + "comment" : "A label displayed under the name of a BLE device.", + "isCommentAutoGenerated" : true + }, + "BLE OTA Updating" : { + }, "BLE Pin must be 6 digits long." : { "localizations" : { @@ -13646,6 +13653,10 @@ } } }, + "ESP32 BLE Updater" : { + "comment" : "The title of the view.", + "isCommentAutoGenerated" : true + }, "ESP32 Device Firmware Update" : { "extractionState" : "stale", "localizations" : { @@ -13685,6 +13696,10 @@ "comment" : "The title of the view.", "isCommentAutoGenerated" : true }, + "ESP32 WiFi Updater" : { + "comment" : "The title of the view.", + "isCommentAutoGenerated" : true + }, "Ethernet Options" : { "localizations" : { "it" : { @@ -14904,6 +14919,9 @@ } } } + }, + "Firmware File" : { + }, "Firmware installation is not supported for this device architecture." : { "comment" : "An alert message displayed when attempting to install firmware on a device that is not supported.", @@ -18234,7 +18252,10 @@ } } }, - "If you device has the WiFi updater loaded into the OTA_1 partition, you can attempt to use the WiFi update process." : { + "If you device has the proper updater loaded into the OTA_1 partition, you can attempt to use the BLE update process." : { + + }, + "If you device has the proper updater loaded into the OTA_1 partition, you can attempt to use the WiFi update process." : { }, "Ignore MQTT" : { @@ -22772,6 +22793,9 @@ } } } + }, + "Network Location" : { + }, "Network Status Orange" : { "localizations" : { @@ -26368,6 +26392,9 @@ } } } + }, + "Please be sure this is correct before proceeding." : { + }, "Please connect to a device to see firmware updates." : { @@ -28514,8 +28541,8 @@ } } }, - "Reboot into OTA Update Mode" : { - "comment" : "A button that initiates a reboot of the connected device into OTA update mode.", + "Reboot into Wifi OTA Update Mode" : { + "comment" : "A button label that prompts the user to reboot their device into OTA update mode.", "isCommentAutoGenerated" : true }, "Reboot node?" : { @@ -41797,9 +41824,6 @@ } } } - }, - "WiFi Firmware Update" : { - }, "WiFi Options" : { "localizations" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index b7c97b01..c03baa67 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ 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 */; }; + 230A98402EF86A44004D87F1 /* AsyncCentral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 230A983F2EF86A44004D87F1 /* AsyncCentral.swift */; }; + 230A98422EF86AA9004D87F1 /* ESP32BLEOTAViewModel2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 230A98412EF86AA9004D87F1 /* ESP32BLEOTAViewModel2.swift */; }; 230BC3972E31071E0046BF2A /* AccessoryManager+Discovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */; }; 231251382E3BC96400E6ED07 /* BLEAuthorizationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231251372E3BC96400E6ED07 /* BLEAuthorizationHelper.swift */; }; 23148E302EE1CCE500F0DB2C /* MeshtasticAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23148E2F2EE1CCE500F0DB2C /* MeshtasticAPI.swift */; }; @@ -22,7 +24,7 @@ 2315D1A02EECB44800E0FAE7 /* UF2MassStorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2315D19F2EECB44800E0FAE7 /* UF2MassStorageView.swift */; }; 2315D1A52EED94E800E0FAE7 /* FirmwareFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2315D1A42EED94E800E0FAE7 /* FirmwareFile.swift */; }; 2315D1A82EEF2ED400E0FAE7 /* SwiftDraw in Frameworks */ = {isa = PBXBuildFile; productRef = 2315D1A72EEF2ED400E0FAE7 /* SwiftDraw */; }; - 23196A6E2EF1BA9100B1504B /* Esp32WifiOTAViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23196A6D2EF1BA9100B1504B /* Esp32WifiOTAViewModel.swift */; }; + 23196A6E2EF1BA9100B1504B /* ESP32WifiOTAViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23196A6D2EF1BA9100B1504B /* ESP32WifiOTAViewModel.swift */; }; 23196C702EF42D3D00B1504B /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23196C6F2EF42D3D00B1504B /* CircularProgressView.swift */; }; 231A53782E69ADB900216B99 /* NodeFilterParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231A53772E69ADB900216B99 /* NodeFilterParameters.swift */; }; 231B3F212D087A4C0069A07D /* MetricTableColumn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */; }; @@ -60,6 +62,10 @@ 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 */; }; + 23825FF52EF6CF0B00C25543 /* NWHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23825FF42EF6CF0B00C25543 /* NWHost.swift */; }; + 23825FF92EF6D9AA00C25543 /* ESP32WifiOTASheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23825FF82EF6D9AA00C25543 /* ESP32WifiOTASheet.swift */; }; + 23825FFD2EF6E70B00C25543 /* ESP32BLEOTAViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23825FFC2EF6E70B00C25543 /* ESP32BLEOTAViewModel.swift */; }; + 23825FFF2EF6E79C00C25543 /* ESP32BLEOTASheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23825FFE2EF6E79C00C25543 /* ESP32BLEOTASheet.swift */; }; 2388EC382EDF88E900F6F982 /* NordicDFU in Frameworks */ = {isa = PBXBuildFile; productRef = 2388EC372EDF88E900F6F982 /* NordicDFU */; }; 2388EC3A2EDF8A1400F6F982 /* DFUModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2388EC392EDF8A1400F6F982 /* DFUModel.swift */; }; 23A1AFB72E42BD2500E46C96 /* RXTXIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A1AFB62E42BD2500E46C96 /* RXTXIndicatorView.swift */; }; @@ -73,7 +79,8 @@ 23C2BE272EE9F4BD00F6A997 /* DeviceHardwareEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23C2BE262EE9F4BD00F6A997 /* DeviceHardwareEntity.swift */; }; 23C2BE2A2EEAF96A00F6A997 /* FirmwareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23C2BE292EEAF96A00F6A997 /* FirmwareViewModel.swift */; }; 23C2BE312EEB823900F6A997 /* NRFDFUSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23C2BE302EEB823900F6A997 /* NRFDFUSheet.swift */; }; - 23C2BE342EEC3F9600F6A997 /* ESP32DFUSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23C2BE332EEC3F9600F6A997 /* ESP32DFUSheet.swift */; }; + 23C2BE342EEC3F9600F6A997 /* ESP32OTAIntroSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23C2BE332EEC3F9600F6A997 /* ESP32OTAIntroSheet.swift */; }; + 23C873E32EF9B58C00BFEA7B /* OTAEnums.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23C873E22EF9B58C00BFEA7B /* OTAEnums.swift */; }; 23D316932E5618D2002FA4FB /* AsyncGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23D316922E5618D2002FA4FB /* AsyncGate.swift */; }; 23D9D9392E50DA97005D1C18 /* ResettableTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23D9D9382E50DA97005D1C18 /* ResettableTimer.swift */; }; 23DC50BB2EE76D9C0023838A /* URL+fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23DC50BA2EE76D9C0023838A /* URL+fetch.swift */; }; @@ -358,13 +365,15 @@ /* 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 = ""; }; + 230A983F2EF86A44004D87F1 /* AsyncCentral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncCentral.swift; sourceTree = ""; }; + 230A98412EF86AA9004D87F1 /* ESP32BLEOTAViewModel2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32BLEOTAViewModel2.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 = ""; }; 23148E2F2EE1CCE500F0DB2C /* MeshtasticAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAPI.swift; sourceTree = ""; }; 2315D19C2EECB3D400E0FAE7 /* UTI+UF2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UTI+UF2.swift"; sourceTree = ""; }; 2315D19F2EECB44800E0FAE7 /* UF2MassStorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UF2MassStorageView.swift; sourceTree = ""; }; 2315D1A42EED94E800E0FAE7 /* FirmwareFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirmwareFile.swift; sourceTree = ""; }; - 23196A6D2EF1BA9100B1504B /* Esp32WifiOTAViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Esp32WifiOTAViewModel.swift; sourceTree = ""; }; + 23196A6D2EF1BA9100B1504B /* ESP32WifiOTAViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32WifiOTAViewModel.swift; sourceTree = ""; }; 23196C6F2EF42D3D00B1504B /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; 231A53772E69ADB900216B99 /* NodeFilterParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeFilterParameters.swift; sourceTree = ""; }; 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnList.swift; sourceTree = ""; }; @@ -402,6 +411,10 @@ 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 = ""; }; + 23825FF42EF6CF0B00C25543 /* NWHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWHost.swift; sourceTree = ""; }; + 23825FF82EF6D9AA00C25543 /* ESP32WifiOTASheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32WifiOTASheet.swift; sourceTree = ""; }; + 23825FFC2EF6E70B00C25543 /* ESP32BLEOTAViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32BLEOTAViewModel.swift; sourceTree = ""; }; + 23825FFE2EF6E79C00C25543 /* ESP32BLEOTASheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32BLEOTASheet.swift; sourceTree = ""; }; 2388EC392EDF8A1400F6F982 /* DFUModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DFUModel.swift; sourceTree = ""; }; 23A1AFB62E42BD2500E46C96 /* RXTXIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RXTXIndicatorView.swift; sourceTree = ""; }; 23AB0E652EE35E0200AFA09D /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; @@ -414,7 +427,8 @@ 23C2BE262EE9F4BD00F6A997 /* DeviceHardwareEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHardwareEntity.swift; sourceTree = ""; }; 23C2BE292EEAF96A00F6A997 /* FirmwareViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirmwareViewModel.swift; sourceTree = ""; }; 23C2BE302EEB823900F6A997 /* NRFDFUSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NRFDFUSheet.swift; sourceTree = ""; }; - 23C2BE332EEC3F9600F6A997 /* ESP32DFUSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32DFUSheet.swift; sourceTree = ""; }; + 23C2BE332EEC3F9600F6A997 /* ESP32OTAIntroSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32OTAIntroSheet.swift; sourceTree = ""; }; + 23C873E22EF9B58C00BFEA7B /* OTAEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTAEnums.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 = ""; }; 23DC50BA2EE76D9C0023838A /* URL+fetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+fetch.swift"; sourceTree = ""; }; @@ -914,6 +928,26 @@ path = Accessory; sourceTree = ""; }; + 23825FFA2EF6E6EC00C25543 /* WiFi */ = { + isa = PBXGroup; + children = ( + 23196A6D2EF1BA9100B1504B /* ESP32WifiOTAViewModel.swift */, + 23825FF82EF6D9AA00C25543 /* ESP32WifiOTASheet.swift */, + ); + path = WiFi; + sourceTree = ""; + }; + 23825FFB2EF6E6FB00C25543 /* BLE */ = { + isa = PBXGroup; + children = ( + 23825FFC2EF6E70B00C25543 /* ESP32BLEOTAViewModel.swift */, + 23825FFE2EF6E79C00C25543 /* ESP32BLEOTASheet.swift */, + 230A983F2EF86A44004D87F1 /* AsyncCentral.swift */, + 230A98412EF86AA9004D87F1 /* ESP32BLEOTAViewModel2.swift */, + ); + path = BLE; + sourceTree = ""; + }; 23C2BD272EE87CFD00F6A997 /* Debugging */ = { isa = PBXGroup; children = ( @@ -927,7 +961,7 @@ children = ( DDD6EEAE29BC024700383354 /* Firmware.swift */, 2315D19E2EECB42D00E0FAE7 /* U2F Mass Storage */, - 23C2BE322EEC3F7800F6A997 /* ESP32 DFU */, + 23C2BE322EEC3F7800F6A997 /* ESP32 OTA */, 23C2BE2F2EEB821400F6A997 /* NRF DFU */, 23196C712EF42D4300B1504B /* Helpers */, ); @@ -943,13 +977,23 @@ path = "NRF DFU"; sourceTree = ""; }; - 23C2BE322EEC3F7800F6A997 /* ESP32 DFU */ = { + 23C2BE322EEC3F7800F6A997 /* ESP32 OTA */ = { isa = PBXGroup; children = ( - 23196A6D2EF1BA9100B1504B /* Esp32WifiOTAViewModel.swift */, - 23C2BE332EEC3F9600F6A997 /* ESP32DFUSheet.swift */, + 23C873E12EF9B57D00BFEA7B /* Helpers */, + 23825FFB2EF6E6FB00C25543 /* BLE */, + 23825FFA2EF6E6EC00C25543 /* WiFi */, + 23C2BE332EEC3F9600F6A997 /* ESP32OTAIntroSheet.swift */, ); - path = "ESP32 DFU"; + path = "ESP32 OTA"; + sourceTree = ""; + }; + 23C873E12EF9B57D00BFEA7B /* Helpers */ = { + isa = PBXGroup; + children = ( + 23C873E22EF9B58C00BFEA7B /* OTAEnums.swift */, + ); + path = Helpers; sourceTree = ""; }; 23D9D9312E50DA0E005D1C18 /* Protocols */ = { @@ -1580,6 +1624,7 @@ 23F061B22E7B056600A1E2EA /* Logger+DataDog.swift */, DD6F65732C6CB80A0053C113 /* View.swift */, 23AB0E652EE35E0200AFA09D /* Image.swift */, + 23825FF42EF6CF0B00C25543 /* NWHost.swift */, ); path = Extensions; sourceTree = ""; @@ -1840,11 +1885,13 @@ files = ( 230BC3972E31071E0046BF2A /* AccessoryManager+Discovery.swift in Sources */, 25F26B1F2C2F611300C9CD9D /* AppData.swift in Sources */, + 23825FFF2EF6E79C00C25543 /* ESP32BLEOTASheet.swift in Sources */, 23C2BE252EE9A8E100F6A997 /* SupportedHardwareBadge.swift in Sources */, 25F26B1E2C2F610D00C9CD9D /* Logger.swift in Sources */, 259792252C2F114500AD1659 /* ChannelEntityExtension.swift in Sources */, BCE2D3C52C7AE369008E6199 /* RestartNodeIntent.swift in Sources */, DD1BEF522E08E9B80090CE24 /* ChannelLock.swift in Sources */, + 23825FFD2EF6E70B00C25543 /* ESP32BLEOTAViewModel.swift in Sources */, 259792262C2F114500AD1659 /* PositionEntityExtension.swift in Sources */, 259792272C2F114500AD1659 /* TraceRouteEntityExtension.swift in Sources */, DDDB444829F8A9C900EE2349 /* String.swift in Sources */, @@ -1860,6 +1907,7 @@ DDD5BB182C2F9C36007E03CA /* OSLogEntryLog.swift in Sources */, DD3501892852FC3B000FC853 /* Settings.swift in Sources */, DDDC22382BA92344002C44F1 /* MeshMapContent.swift in Sources */, + 230A98422EF86AA9004D87F1 /* ESP32BLEOTAViewModel2.swift in Sources */, 23C2BE2A2EEAF96A00F6A997 /* FirmwareViewModel.swift in Sources */, DD5D0A9C2931B9F200F7EA61 /* EthernetModes.swift in Sources */, 6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */, @@ -1900,12 +1948,14 @@ 23C2BE272EE9F4BD00F6A997 /* DeviceHardwareEntity.swift in Sources */, D9C9839D2B79CFD700BDBE6A /* TextMessageSize.swift in Sources */, DDC94FCE29CF55310082EA6E /* RtttlConfig.swift in Sources */, - 23196A6E2EF1BA9100B1504B /* Esp32WifiOTAViewModel.swift in Sources */, + 23196A6E2EF1BA9100B1504B /* ESP32WifiOTAViewModel.swift in Sources */, DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */, DD1BEF4E2E03916A0090CE24 /* ChannelsHelp.swift in Sources */, DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */, BCD93CBA2D9E11A2006C9214 /* DisconnectNodeIntent.swift in Sources */, + 23C873E32EF9B58C00BFEA7B /* OTAEnums.swift in Sources */, DDC94FC129CE063B0082EA6E /* BatteryLevel.swift in Sources */, + 230A98402EF86A44004D87F1 /* AsyncCentral.swift in Sources */, 231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */, 23AD546D2E2AE9630046E9AB /* AccessoryManager+MQTT.swift in Sources */, 25F5D5BE2C3F6D87008036E3 /* NavigationState.swift in Sources */, @@ -1969,6 +2019,7 @@ DDB6ABE628B1406100384BA1 /* LoraConfigEnums.swift in Sources */, 231A53782E69ADB900216B99 /* NodeFilterParameters.swift in Sources */, 232ED4C52E2C5EDD009DA392 /* TCPConnection.swift in Sources */, + 23825FF92EF6D9AA00C25543 /* ESP32WifiOTASheet.swift in Sources */, DDB8F4142A9EE5F000230ECE /* ChannelList.swift in Sources */, BCB35B4F2E5FC42500B04F60 /* MessageNodeIntent.swift in Sources */, DDD43FE32A78C8900083A3E9 /* MqttClientProxyManager.swift in Sources */, @@ -2102,11 +2153,12 @@ DD1BEF4A2E0292320090CE24 /* KeychainHelper.swift in Sources */, DD41582A28585C32009B0E59 /* RangeTestConfig.swift in Sources */, DD1925B728CDA5A400720036 /* CannedMessagesConfigEnums.swift in Sources */, - 23C2BE342EEC3F9600F6A997 /* ESP32DFUSheet.swift in Sources */, + 23C2BE342EEC3F9600F6A997 /* ESP32OTAIntroSheet.swift in Sources */, DDDB444429F8A8DD00EE2349 /* Float.swift in Sources */, DDAB580F2B0DAFBC00147258 /* LocationEntityExtension.swift in Sources */, 233E99BC2D849C8C00CC3A77 /* WindCompactWidget.swift in Sources */, B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */, + 23825FF52EF6CF0B00C25543 /* NWHost.swift in Sources */, 232ED4C32E2C5E89009DA392 /* TCPTransport.swift in Sources */, BC6B45FF2CB2F98900723CEB /* SaveChannelSettingsIntent.swift in Sources */, D93068D72B8146690066FBC8 /* MessageText.swift in Sources */, diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift index f73b8d1d..3b759450 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift @@ -66,6 +66,7 @@ extension AccessoryManager { Logger.transport.info("[Accessory] Event stream closed") } self.activeConnection = (device: device, connection: connection) + self.activeDeviceNum = device.num } catch let error as CBError where error.code == .peerRemovedPairingInformation { await self.connectionStepper?.cancelCurrentlyExecutingStep(withError: AccessoryError.coreBluetoothError(error), cancelFullProcess: true) } @@ -213,6 +214,9 @@ extension AccessoryManager { do { try await connectionStepper?.run() Logger.transport.debug("🔗 [Connect] ConnectionStepper completed.") + } catch AccessoryError.tooManyRetries { + try await self.closeConnection() + updateState(.discovering) } catch { Logger.transport.error("🔗 [Connect] Error returned by connectionStepper: \(error)") try await self.closeConnection() @@ -352,7 +356,7 @@ actor SequentialSteps { return } isRunning = false - return + //return throw AccessoryError.tooManyRetries } diff --git a/Meshtastic/Extensions/NWHost.swift b/Meshtastic/Extensions/NWHost.swift new file mode 100644 index 00000000..2ea8fcb7 --- /dev/null +++ b/Meshtastic/Extensions/NWHost.swift @@ -0,0 +1,26 @@ +// +// NWHost.swift +// Meshtastic +// +// Created by jake on 12/20/25. +// + +import Foundation +import Network + +extension NWEndpoint.Host { + /// Returns the underlying string value (domain name or IP address) + /// without extra debug formatting. + var stringValue: String { + switch self { + case .name(let name, _): + return name + case .ipv4(let ip): + return String(describing: ip) + case .ipv6(let ip): + return String(describing: ip) + @unknown default: + return String(describing: self) + } + } +} diff --git a/Meshtastic/Resources/images/image_manifest.json b/Meshtastic/Resources/images/image_manifest.json index 770e00e4..9506f22a 100644 --- a/Meshtastic/Resources/images/image_manifest.json +++ b/Meshtastic/Resources/images/image_manifest.json @@ -1,190 +1,190 @@ { "files": { - "seeed_xiao_nrf52_kit.svg": { - "etag": "\"660b2c3bee85adeccdd5de7ea8d06648\"" - }, - "heltec-v3-case.svg": { - "etag": "\"e935a15ddd7cd116b9c4203f434ff627\"" - }, - "rak4631_case.svg": { - "etag": "\"d141ca68501d83f3ca19ed74cb7ce12e\"" - }, - "diy.svg": { - "etag": "\"7b670e81e7aace4814887ba681fc9f5b\"" - }, - "station-g2.svg": { - "etag": "\"f0a75bb77ddfcd8fa4c080caa018e539\"" - }, - "meteor_pro.svg": { - "etag": "\"47ba8e4bc6e224fbd3b09401573549dd\"" - }, - "thinknode_m2.svg": { - "etag": "\"97441ac3a41d23e5e0f4702f5788643d\"" - }, - "rak-wismeshtap.svg": { - "etag": "\"8c707dda5c384a10822d3ed785aeb411\"" - }, - "tdeck_pro.svg": { - "etag": "\"6fca0ce5392b390bb7aa690c57ab0fee\"" - }, - "wio_tracker_l1_eink.svg": { - "etag": "\"9074596ea8f08acacfa0ce2c9a48152f\"" - }, - "thinknode_m3.svg": { - "etag": "\"9fbe23b50c26a8c0d5e80a1b9e5bef61\"" + "crowpanel_2_4.svg": { + "etag": "\"3aa8b71d6e9d16f82fddde4ba8b472bd\"" }, "heltec-vision-master-e290.svg": { "etag": "\"71b598c2c125b115663ab2d40abcd154\"" }, - "t-echo.svg": { - "etag": "\"bd2db1e3f0764478a9841ff568abc807\"" - }, - "muzi_r1_neo.svg": { - "etag": "\"d73a20b71a27e530dc6fbe514f3e9d88\"" - }, - "nano-g2-ultra.svg": { - "etag": "\"82575f89ab2f60ffe6c1e009b19b596e\"" - }, - "heltec-mesh-node-t114.svg": { - "etag": "\"ca927ce170fba26438c557af0de47a1e\"" - }, - "tbeam.svg": { - "etag": "\"ad1781f30226fbe36bae1cbad7e85bac\"" - }, - "crowpanel_2_8.svg": { - "etag": "\"caad57326211a595f18b5f494ae24b59\"" - }, - "crowpanel_5_0.svg": { - "etag": "\"a2920df06d5335284db85a2016c0c6c6\"" - }, - "rpipicow.svg": { - "etag": "\"04fd9771add804a62fbfe45b3d360f22\"" - }, - "thinknode_m4.svg": { - "etag": "\"bf1503cde2927c24cafaaeeb1cada43f\"" - }, - "heltec-mesh-node-t114-case.svg": { - "etag": "\"ac7c2abd66e7980db365006332d2b6e7\"" - }, - "techo_lite.svg": { - "etag": "\"42fdf86393b02396e828149f29295239\"" - }, - "tlora-t3s3-epaper.svg": { - "etag": "\"dfe63532b984fd3f34ce26b38e1f0807\"" - }, - "heltec_v4.svg": { - "etag": "\"54e84516a04e1276ca385b41c7aa8b8d\"" - }, - "rak4631.svg": { - "etag": "\"3f19ff501b98598546fb6d6e5db1151c\"" - }, - "lilygo-tlora-pager.svg": { - "etag": "\"deb184deacb8006da18ae4751d2e0591\"" - }, - "heltec-vision-master-t190.svg": { - "etag": "\"7f58cc25f93b203c778a3d6d1c6dc53f\"" - }, - "muzi_base.svg": { - "etag": "\"d82c0733add18e61809c9a2434bf6148\"" - }, - "tlora-v2-1-1_8.svg": { - "etag": "\"7a9de7eff40aab166d5ab9f251dedaaa\"" - }, - "seeed-sensecap-indicator.svg": { - "etag": "\"7a0fc63602d8c978b75799032dfda252\"" - }, - "t-deck.svg": { - "etag": "\"2187caebf4304bb2308c8ee3ca74dd60\"" - }, - "promicro.svg": { - "etag": "\"d100b5d3aacf51191d7c4a7eb28db231\"" - }, - "heltec-ht62-esp32c3-sx1262.svg": { - "etag": "\"1f4d07a164cbc2cb99f26e4a05a763ea\"" - }, "rak_3312.svg": { "etag": "\"a2b5c4fdf127868323c8129f84f8691e\"" }, - "rak11200.svg": { - "etag": "\"1a0bfda4331a9bfd29722382a787c700\"" - }, - "heltec-wireless-tracker.svg": { - "etag": "\"bb7143e1b25d1d18d5727baf69a1caed\"" - }, - "wio_tracker_l1_case.svg": { - "etag": "\"21eccba8adbb33b1df19fe0de79a8734\"" - }, - "crowpanel_3_5.svg": { - "etag": "\"2d4ee10776f01156dd9570da888be34f\"" - }, - "seeed_solar.svg": { - "etag": "\"3cc4099ae22ed261b88f1a9f7d235275\"" - }, - "tracker-t1000-e.svg": { - "etag": "\"b4194c4bb550f8ccbbf205489f37134c\"" - }, - "seeed-xiao-s3.svg": { - "etag": "\"9d583ddf39288934736d7ac248987524\"" - }, - "m5_c6l.svg": { - "etag": "\"f17cb7e59a20ccf41243c666cbe54546\"" - }, - "tlora-v2-1-1_6.svg": { - "etag": "\"7a9de7eff40aab166d5ab9f251dedaaa\"" - }, - "rak-wismesh-tap-v2.svg": { - "etag": "\"4acc893e184de92446357fcb5bba7812\"" - }, - "rak2560.svg": { - "etag": "\"da3e309e4f746f0539e13b1f089411e3\"" - }, - "pico.svg": { - "etag": "\"9f6b3557953065cce6d56ba6e6d48241\"" - }, - "wio-tracker-wm1110.svg": { - "etag": "\"2dfb221a6a481f957a59b81dfb0dbaf7\"" - }, - "thinknode_m1.svg": { - "etag": "\"e525d5710fddf72e1626cf35346a6b25\"" - }, - "t-watch-s3.svg": { - "etag": "\"2e474b5742ec392304c939b4ec63d466\"" - }, - "tbeam-s3-core.svg": { - "etag": "\"04c0dab7e74a5c1e647567e150136e5b\"" - }, - "crowpanel_2_4.svg": { - "etag": "\"3aa8b71d6e9d16f82fddde4ba8b472bd\"" - }, - "heltec-wireless-paper.svg": { - "etag": "\"7a8d8c8e9e712f32ccdb32edcdaebf5e\"" + "rak4631_case.svg": { + "etag": "\"d141ca68501d83f3ca19ed74cb7ce12e\"" }, "heltec-wsl-v3.svg": { "etag": "\"3ecfe8273cdf0d7dfb04dad6c3fa449a\"" }, - "tlora-t3s3-v1.svg": { - "etag": "\"89510451d52482a475e9cc13503f11a6\"" + "seeed_xiao_nrf52_kit.svg": { + "etag": "\"660b2c3bee85adeccdd5de7ea8d06648\"" }, - "crowpanel_7_0.svg": { - "etag": "\"c593914e105b75ee978f5ce2e2a27f1c\"" - }, - "rak11310.svg": { - "etag": "\"0761c4ec6607993e6133aca9634cd42e\"" - }, - "heltec-v3.svg": { - "etag": "\"0e22f17d2a0cd67159a222eb0a01bed1\"" + "rak2560.svg": { + "etag": "\"da3e309e4f746f0539e13b1f089411e3\"" }, "heltec-mesh-solar.svg": { "etag": "\"6d3a4f6266a80493f42c0013e30bb31c\"" }, + "tracker-t1000-e.svg": { + "etag": "\"b4194c4bb550f8ccbbf205489f37134c\"" + }, + "t-watch-s3.svg": { + "etag": "\"2e474b5742ec392304c939b4ec63d466\"" + }, + "thinknode_m1.svg": { + "etag": "\"e525d5710fddf72e1626cf35346a6b25\"" + }, + "rak11310.svg": { + "etag": "\"0761c4ec6607993e6133aca9634cd42e\"" + }, + "heltec-mesh-node-t114.svg": { + "etag": "\"ca927ce170fba26438c557af0de47a1e\"" + }, + "crowpanel_3_5.svg": { + "etag": "\"2d4ee10776f01156dd9570da888be34f\"" + }, + "rak-wismeshtap.svg": { + "etag": "\"8c707dda5c384a10822d3ed785aeb411\"" + }, + "wio_tracker_l1_case.svg": { + "etag": "\"21eccba8adbb33b1df19fe0de79a8734\"" + }, + "heltec-wireless-tracker.svg": { + "etag": "\"bb7143e1b25d1d18d5727baf69a1caed\"" + }, + "rak11200.svg": { + "etag": "\"1a0bfda4331a9bfd29722382a787c700\"" + }, + "lilygo-tlora-pager.svg": { + "etag": "\"deb184deacb8006da18ae4751d2e0591\"" + }, + "station-g2.svg": { + "etag": "\"f0a75bb77ddfcd8fa4c080caa018e539\"" + }, + "thinknode_m3.svg": { + "etag": "\"9fbe23b50c26a8c0d5e80a1b9e5bef61\"" + }, + "m5_c6l.svg": { + "etag": "\"f17cb7e59a20ccf41243c666cbe54546\"" + }, + "crowpanel_5_0.svg": { + "etag": "\"a2920df06d5335284db85a2016c0c6c6\"" + }, + "heltec_v4.svg": { + "etag": "\"54e84516a04e1276ca385b41c7aa8b8d\"" + }, + "wio-tracker-wm1110.svg": { + "etag": "\"2dfb221a6a481f957a59b81dfb0dbaf7\"" + }, + "rak4631.svg": { + "etag": "\"3f19ff501b98598546fb6d6e5db1151c\"" + }, + "diy.svg": { + "etag": "\"7b670e81e7aace4814887ba681fc9f5b\"" + }, + "rpipicow.svg": { + "etag": "\"04fd9771add804a62fbfe45b3d360f22\"" + }, + "muzi_base.svg": { + "etag": "\"d82c0733add18e61809c9a2434bf6148\"" + }, + "seeed-xiao-s3.svg": { + "etag": "\"9d583ddf39288934736d7ac248987524\"" + }, + "heltec-v3-case.svg": { + "etag": "\"e935a15ddd7cd116b9c4203f434ff627\"" + }, + "nano-g2-ultra.svg": { + "etag": "\"82575f89ab2f60ffe6c1e009b19b596e\"" + }, + "tbeam.svg": { + "etag": "\"ad1781f30226fbe36bae1cbad7e85bac\"" + }, + "heltec-wireless-paper.svg": { + "etag": "\"7a8d8c8e9e712f32ccdb32edcdaebf5e\"" + }, + "promicro.svg": { + "etag": "\"d100b5d3aacf51191d7c4a7eb28db231\"" + }, + "tlora-v2-1-1_8.svg": { + "etag": "\"7a9de7eff40aab166d5ab9f251dedaaa\"" + }, + "seeed_solar.svg": { + "etag": "\"3cc4099ae22ed261b88f1a9f7d235275\"" + }, + "crowpanel_7_0.svg": { + "etag": "\"c593914e105b75ee978f5ce2e2a27f1c\"" + }, + "wio_tracker_l1_eink.svg": { + "etag": "\"9074596ea8f08acacfa0ce2c9a48152f\"" + }, "heltec_mesh_pocket.svg": { "etag": "\"933aafb0ce3a7b0e1faa67e951bc98ea\"" }, - "heltec-vision-master-e213.svg": { - "etag": "\"a56c7707865246300bd9e89b1f7155c5\"" + "crowpanel_2_8.svg": { + "etag": "\"caad57326211a595f18b5f494ae24b59\"" + }, + "heltec-vision-master-t190.svg": { + "etag": "\"7f58cc25f93b203c778a3d6d1c6dc53f\"" + }, + "heltec-mesh-node-t114-case.svg": { + "etag": "\"ac7c2abd66e7980db365006332d2b6e7\"" + }, + "heltec-v3.svg": { + "etag": "\"0e22f17d2a0cd67159a222eb0a01bed1\"" + }, + "thinknode_m4.svg": { + "etag": "\"bf1503cde2927c24cafaaeeb1cada43f\"" + }, + "thinknode_m2.svg": { + "etag": "\"97441ac3a41d23e5e0f4702f5788643d\"" + }, + "tlora-t3s3-v1.svg": { + "etag": "\"89510451d52482a475e9cc13503f11a6\"" + }, + "tlora-t3s3-epaper.svg": { + "etag": "\"dfe63532b984fd3f34ce26b38e1f0807\"" + }, + "t-deck.svg": { + "etag": "\"2187caebf4304bb2308c8ee3ca74dd60\"" + }, + "tbeam-s3-core.svg": { + "etag": "\"04c0dab7e74a5c1e647567e150136e5b\"" + }, + "pico.svg": { + "etag": "\"9f6b3557953065cce6d56ba6e6d48241\"" + }, + "rak-wismesh-tap-v2.svg": { + "etag": "\"4acc893e184de92446357fcb5bba7812\"" }, "rak_wismesh_tag.svg": { "etag": "\"257d649982a6689ec7e7c326c0b4dd2f\"" + }, + "t-echo.svg": { + "etag": "\"bd2db1e3f0764478a9841ff568abc807\"" + }, + "tdeck_pro.svg": { + "etag": "\"6fca0ce5392b390bb7aa690c57ab0fee\"" + }, + "heltec-ht62-esp32c3-sx1262.svg": { + "etag": "\"1f4d07a164cbc2cb99f26e4a05a763ea\"" + }, + "seeed-sensecap-indicator.svg": { + "etag": "\"7a0fc63602d8c978b75799032dfda252\"" + }, + "muzi_r1_neo.svg": { + "etag": "\"d73a20b71a27e530dc6fbe514f3e9d88\"" + }, + "techo_lite.svg": { + "etag": "\"42fdf86393b02396e828149f29295239\"" + }, + "tlora-v2-1-1_6.svg": { + "etag": "\"7a9de7eff40aab166d5ab9f251dedaaa\"" + }, + "meteor_pro.svg": { + "etag": "\"47ba8e4bc6e224fbd3b09401573549dd\"" + }, + "heltec-vision-master-e213.svg": { + "etag": "\"a56c7707865246300bd9e89b1f7155c5\"" } }, "api_hash": "5fcbe7d3ead1dc1156bccfa4747231615d2a8825d2eac8b34f220a3f04a48155" diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 DFU/ESP32DFUSheet.swift b/Meshtastic/Views/Settings/Firmware/ESP32 DFU/ESP32DFUSheet.swift deleted file mode 100644 index 0f7d2870..00000000 --- a/Meshtastic/Views/Settings/Firmware/ESP32 DFU/ESP32DFUSheet.swift +++ /dev/null @@ -1,171 +0,0 @@ -// -// ESP32DFUSheet.swift -// Meshtastic -// -// Created by Jake Bordens on 12/12/25. -// - -import SwiftUI -import OSLog -import Network - -struct ESP32DFUSheet: View { - private enum Step { - case intro - case updater - } - - @EnvironmentObject var accessoryManager: AccessoryManager - @Environment(\.dismiss) var dismiss - @Environment(\.managedObjectContext) var context - - @StateObject var ota = Esp32WifiOTAViewModel() - let binFileURL: URL - @State var host: NWEndpoint.Host? - @State private var step: Step = .intro - - init(binFileURL: URL) { - self.binFileURL = binFileURL - } - - var body: some View { - NavigationStack { - ScrollView { - switch step { - case .intro: - VStack(spacing: 24) { - - // MARK: - Info Card - VStack(alignment: .leading, spacing: 12) { - Label("Desktop Recommended", systemImage: "desktopcomputer") - .font(.headline) - - Text("The recommended way to update ESP32 devices is using the **Web Flasher** on a desktop computer (Chrome-based browser).") - .fixedSize(horizontal: false, vertical: true) - - Text("The **Web Flasher** does not support updating on this device or over USB or BLE.") - .font(.caption) - .foregroundStyle(.secondary) - - Link(destination: URL(string: "https://flash.meshtastic.org")!) { - HStack { - Text("Open Web Flasher") - Image(systemName: "arrow.up.right") - } - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .controlSize(.regular) - } - .padding() - .background(Color(UIColor.secondarySystemBackground)) - .cornerRadius(12) - - Divider() - - VStack(alignment: .leading, spacing: 12) { - Label("WiFi OTA Updating", systemImage: "wifi") - .font(.headline) - - HStack(alignment: .top, spacing: 12) { - Image(systemName: "lock.shield") - .font(.title2) - .foregroundStyle(.blue) - - Text("Advanced Users Only.") - .font(.callout) - } - - Text("If you device has the WiFi updater loaded into the OTA_1 partition, you can attempt to use the WiFi update process.") - .font(.caption) - .foregroundStyle(.secondary) - - Button(role: .destructive) { - self.step = .updater - } label: { - Text("I Know What I'm Doing") - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .frame(maxWidth: .infinity) - .cornerRadius(10).disabled(accessoryManager.activeDeviceNum == nil) - } - .padding() - .background(Color(UIColor.secondarySystemBackground)) - .cornerRadius(12) - }.padding(.top) - .padding() - - case .updater: - Text("WiFi Firmware Update") - .font(.headline) - - Text("Please do not leave this screen until this process is complete.") - .multilineTextAlignment(.center) - .padding() - - CircularProgressView(progress: ota.progress, isIndeterminate: (ota.otaState == .handshaking), size: 255.0, subtitleText: ota.otaState.rawValue) - - VStack { - switch ota.otaState { - case .idle: - beginUpdateProcessButton() - - case .error: - Text("Error: \(ota.errorMessage, default: "Unknown")") - - default: - Text("\(ota.statusMessage, default: "")") - } - }.frame(minHeight: 250.0) - .padding() - } - }.navigationTitle("ESP32 Update") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { // Standard placement for "Done" or "Close" - Button("Done") { - dismiss() - }.disabled(![.idle, .success, .error].contains(ota.otaState)) - } - } - - }// Standard Navigation Bar Setup - - .onFirstAppear { - if let connection = accessoryManager.activeConnection?.connection as? TCPConnection { - self.host = connection.host - } - } - } - - @ViewBuilder - func beginUpdateProcessButton() -> some View { - Button { - let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context) - if let connectedNode, let user = connectedNode.user { - Task { - do { - if let host { - let device = accessoryManager.activeConnection?.device - try await accessoryManager.sendRebootOta(fromUser: user, toUser: user, rebootOtaSeconds: 1) - try await accessoryManager.disconnect() - await ota.startUpdate(host: host, firmwareUrl: self.binFileURL) - if let device { - try await Task.sleep(for: .seconds(3)) - try await accessoryManager.connect(to: device, retries: 5) - } - } - } catch { - Logger.mesh.error("Reboot Failed") - } - } - } - } label: { - Label("Reboot into OTA Update Mode", systemImage: "square.and.arrow.down") - }.buttonStyle(.borderedProminent) - .controlSize(.large) - .frame(maxWidth: .infinity) - .cornerRadius(10).disabled(accessoryManager.activeDeviceNum == nil) - } -} diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/AsyncCentral.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/AsyncCentral.swift new file mode 100644 index 00000000..94253dde --- /dev/null +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/AsyncCentral.swift @@ -0,0 +1,166 @@ +// +// AsyncCentral.swift +// Meshtastic +// +// Created by jake on 12/21/25. +// + +import CoreBluetooth +import OSLog + +private let meshtasticOTAServiceId = CBUUID(string: "4FAFC201-1FB5-459E-8FCC-C5C9C331914B") +private let statusCharacteristicId = CBUUID(string: "62EC0272-3EC5-11EB-B378-0242AC130003") +private let otaCharacteristicId = CBUUID(string: "62EC0272-3EC5-11EB-B378-0242AC130005") + +enum BLEError: Error { + case poweredOff, scanTimeout, connectFailed, serviceMissing, characteristicMissing +} + +final class AsyncCentral: NSObject { + private var central: CBCentralManager! + private var scanContinuation: CheckedContinuation? + private var connectContinuation: CheckedContinuation? + private var serviceContinuation: CheckedContinuation<[CBService], Error>? + private var characteristicContinuation: CheckedContinuation<[CBCharacteristic], Error>? + private var notifyContinuation: CheckedContinuation? + private var writeContinuation: CheckedContinuation? + private var notificationStreams: [CBUUID: AsyncStream.Continuation] = [:] + + override init() { + super.init() + central = CBCentralManager(delegate: self, queue: nil) + } + + func waitUntilPoweredOn() async throws { + if central.state == .poweredOn { return } + try await withCheckedThrowingContinuation { cont in + self.powerContinuation = cont + } + } + + private var powerContinuation: CheckedContinuation? +} + +extension AsyncCentral: CBCentralManagerDelegate, CBPeripheralDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + if central.state == .poweredOn { + powerContinuation?.resume() + powerContinuation = nil + } + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, + advertisementData: [String : Any], rssi RSSI: NSNumber) { + scanContinuation?.resume(returning: peripheral) + scanContinuation = nil + central.stopScan() + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + connectContinuation?.resume() + connectContinuation = nil + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + connectContinuation?.resume(throwing: error ?? BLEError.connectFailed) + connectContinuation = nil + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error = error { serviceContinuation?.resume(throwing: error) } + else { serviceContinuation?.resume(returning: peripheral.services ?? []) } + serviceContinuation = nil + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + if let error = error { characteristicContinuation?.resume(throwing: error) } + else { characteristicContinuation?.resume(returning: service.characteristics ?? []) } + characteristicContinuation = nil + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { notifyContinuation?.resume(throwing: error) } + else { notifyContinuation?.resume() } + notifyContinuation = nil + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard error == nil, let data = characteristic.value else { return } + notificationStreams[characteristic.uuid]?.yield(data) + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + writeContinuation?.resume(throwing: error) + } else { + writeContinuation?.resume() + } + writeContinuation = nil + } +} + +extension AsyncCentral { + func scan(for service: CBUUID, timeout: TimeInterval = 10) async throws -> CBPeripheral { + try await withThrowingTaskGroup(of: CBPeripheral.self) { group in + group.addTask { + try await withCheckedThrowingContinuation { cont in + self.scanContinuation = cont + self.central.scanForPeripherals(withServices: [service], options: nil) + } + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + throw BLEError.scanTimeout + } + let result = try await group.next()! // first to finish + group.cancelAll() + return result + } + } + + func connect(_ peripheral: CBPeripheral) async throws { + try await withCheckedThrowingContinuation { cont in + self.connectContinuation = cont + central.connect(peripheral, options: nil) + } + } + + func discoverServices(_ uuids: [CBUUID], on peripheral: CBPeripheral) async throws -> [CBService] { + peripheral.delegate = self + peripheral.discoverServices(uuids) + return try await withCheckedThrowingContinuation { cont in + self.serviceContinuation = cont + } + } + + func discoverCharacteristics(_ uuids: [CBUUID], in service: CBService, on peripheral: CBPeripheral) async throws -> [CBCharacteristic] { + peripheral.discoverCharacteristics(uuids, for: service) + return try await withCheckedThrowingContinuation { cont in + self.characteristicContinuation = cont + } + } + + func setNotify(_ enabled: Bool, for characteristic: CBCharacteristic, on peripheral: CBPeripheral) async throws { + peripheral.setNotifyValue(enabled, for: characteristic) + try await withCheckedThrowingContinuation { cont in + self.notifyContinuation = cont + } + } + + func notifications(for characteristic: CBCharacteristic) -> AsyncStream { + AsyncStream { cont in + notificationStreams[characteristic.uuid] = cont + } + } + + func writeValue(_ data: Data, for characteristic: CBCharacteristic, type: CBCharacteristicWriteType, on peripheral: CBPeripheral) async throws { + if type == .withResponse { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + self.writeContinuation = cont + peripheral.writeValue(data, for: characteristic, type: type) + } + } else { + peripheral.writeValue(data, for: characteristic, type: type) + } + } +} diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTASheet.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTASheet.swift new file mode 100644 index 00000000..1fceb8d8 --- /dev/null +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTASheet.swift @@ -0,0 +1,109 @@ +// +// ESP3BLEOTASheet.swift +// Meshtastic +// +// Created by jake on 12/20/25. +// + +import Foundation +import SwiftUI +import OSLog +import CoreBluetooth + + +struct ESP32BLEOTASheet: View { + @EnvironmentObject var accessoryManager: AccessoryManager + @Environment(\.dismiss) var dismiss + @Environment(\.managedObjectContext) var context + @StateObject var ota = ESP32BLEOTAViewModel2() + + // The stuff were updating, and the place we're updating it to + let binFileURL: URL + @State var peripheral: CBPeripheral? + + var body: some View { + NavigationStack { + List { + Section { + VStack { + Text("Please do not leave this screen until this process is complete.") + .multilineTextAlignment(.center) + }.listRowBackground(Color.clear) + } + + Section { + VStack(alignment: .leading) { + Text("Firmware File").font(.caption).foregroundColor(.secondary) + Text("\(self.binFileURL.lastPathComponent)").font(.caption) + } + VStack(alignment: .leading) { + Text("BLE Device").font(.caption).foregroundColor(.secondary) + Text("\(peripheral?.name, default: "Unknown")").font(.caption) + Text("\(peripheral?.identifier, default: "Unknown")").font(.caption) + } + } footer: { + Text("Please be sure this is correct before proceeding.") + } + + Section { + HStack(alignment: .center) { + Spacer() + CircularProgressView(progress: ota.transferProgress / 100.0, isIndeterminate: (ota.otaStatus == .preparing), size: 225.0, subtitleText: ota.otaStatus.rawValue) + .frame(minHeight: 250.0) + Spacer() + }.listRowBackground(Color.clear) + VStack { + if ota.otaStatus == .idle { + beginBLEProcessButton() + } else { + Text("\(ota.statusMessage)") + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + }.listRowBackground(Color.clear) + }.listRowSeparator(.hidden) + }.navigationTitle("ESP32 BLE Updater") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { // Standard placement for "Done" or "Close" + Button("Done") { + dismiss() + }.disabled(![.idle, .completed, .error].contains(ota.otaStatus)) + } + } + }.task { + if let connection = accessoryManager.activeConnection?.connection as? BLEConnection { + self.peripheral = await connection.peripheral + } + }.interactiveDismissDisabled(true) + + } + + @ViewBuilder + func beginBLEProcessButton() -> some View { + Button { + let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context) + if let connectedNode, let user = connectedNode.user { + Task { + do { + if let peripheral { + try await accessoryManager.sendRebootOta(fromUser: user, toUser: user, rebootOtaSeconds: 2) + try await accessoryManager.disconnect() + + ota.startOTA(binURL: binFileURL) + //ota.startOTA(peripheral: peripheral, binFileURL: binFileURL) + } + } catch { + Logger.mesh.error("Reboot Failed") + } + } + } + } label: { + Label("Reboot into Wifi OTA Update Mode", systemImage: "square.and.arrow.down") + .frame(maxWidth: .infinity) + + }.buttonStyle(.bordered) + .controlSize(.large) + .disabled(accessoryManager.activeDeviceNum == nil) + } +} diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel.swift new file mode 100644 index 00000000..977dd777 --- /dev/null +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel.swift @@ -0,0 +1,530 @@ +// +// ESP32BLEOTAViewModel.swift (previously BLEConnection.swift in the DFU app) +// +// Created by Garth Vander Houwen on 12/4/22 +// + +import CoreBluetooth +import OSLog + +private let meshtasticOTAServiceId = CBUUID(string: "4FAFC201-1FB5-459E-8FCC-C5C9C331914B") +private let statusCharacteristicId = CBUUID(string: "62EC0272-3EC5-11EB-B378-0242AC130003") // ESP32 pTxCharacteristic ESP send (notifying) +private let otaCharacteristicId = CBUUID(string: "62EC0272-3EC5-11EB-B378-0242AC130005") // ESP32 pOtaCharacteristic ESP write + +private let outOfRangeHeuristics: Set = [.unknown, .connectionTimeout, .peripheralDisconnected, .connectionFailed] + +class ESP32BLEOTAViewModel: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeripheralDelegate { + + var manager: CBCentralManager! + var statusCharacteristic: CBCharacteristic? + var otaCharacteristic: CBCharacteristic? + + var otaCharacteristicIsNotifying = false + var statusCharacteristicIsNotifying = false + + var state = StateBLE.poweredOff + + enum StateBLE { + case poweredOff + case restoringConnectingPeripheral(CBPeripheral) + case restoringConnectedPeripheral(CBPeripheral) + case disconnected + case scanning + case connecting(CBPeripheral, Countdown) + case discoveringServices(CBPeripheral, Countdown) + case discoveringCharacteristics(CBPeripheral, Countdown) + case connected(CBPeripheral) + case outOfRange(CBPeripheral) + + var peripheral: CBPeripheral? { + switch self { + case .poweredOff: return nil + case .restoringConnectingPeripheral(let p): return p + case .restoringConnectedPeripheral(let p): return p + case .disconnected: return nil + case .scanning: return nil + case .connecting(let p, _): return p + case .discoveringServices(let p, _): return p + case .discoveringCharacteristics(let p, _): return p + case .connected(let p): return p + case .outOfRange(let p): return p + } + } + } + + // Used by contentView.swift + @Published var name = "" + @Published var connected = false + @Published var transferProgress : Double = 0.0 + @Published var chunkCount = 1 // number of chunks to be sent before peripheral needs to accknowledge. + @Published var elapsedTime = 0.0 + @Published var kBPerSecond = 0.0 + + // OTA file URL + var fileUrl: URL? + var desiredPeripheral: CBPeripheral? + + // transfer varibles + var dataToSend = Data() + var dataBuffer = Data() + var chunkSize = 0 + var dataLength = 0 + var transferOngoing = true + var sentBytes = 0 + var packageCounter = 0 + var startTime = 0.0 + var stopTime = 0.0 + var firstAcknowledgeFromESP32 = false + + // Initiate CentralManager + override init() { + super.init() + manager = CBCentralManager(delegate: self, queue: .none) + manager.delegate = self + } + + func startOTA(peripheral: CBPeripheral, binFileURL: URL) { + self.desiredPeripheral = peripheral + self.fileUrl = binFileURL + + self.startScanning() + } + + // CentralManager State updates + func centralManagerDidUpdateState(_ central: CBCentralManager) { + Logger.services.info("Bluetooth State Updated") + switch manager.state { + case .unknown: + Logger.services.info("Unknown") + case .resetting: + Logger.services.info("Resetting") + case .unsupported: + Logger.services.info("Unsupported") + case .unauthorized: + Logger.services.info("Bluetooth is disabled") + case .poweredOff: + Logger.services.info("Bluetooth is powered off") + case .poweredOn: + Logger.services.info("Bluetooth is working properly") + @unknown default: + Logger.services.info("fatal error") + } + } + + // Discovery (scanning) and handling of BLE devices in range + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { + guard case .scanning = state else { return } + + self.name = peripheral.name ?? "Unknown" + Logger.services.info("Discovered \(self.name)") + + // Check if this is the desired peripheral + if let desiredPeripheral, desiredPeripheral.identifier != peripheral.identifier { + Logger.services.info("This peripheral is not the one we're looking for") + } + + manager.stopScan() + connect(peripheral: peripheral) + } + + // Connection established handler + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + Logger.services.info("Connection suceeded") + + transferOngoing = false + + // Clear the data that we may already have + dataToSend.removeAll(keepingCapacity: false) + + // Make sure we get the discovery callbacks + peripheral.delegate = self + + if peripheral.statusCharacteristic == nil { + discoverServices(peripheral: peripheral) + } else { + setConnected(peripheral: peripheral) + } + } + + // Connection failed + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + Logger.services.info("\(Date()) CM DidFailToConnect") + state = .disconnected + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + transferOngoing = false + Logger.services.info("\(peripheral.name ?? "unknown") disconnected") + // Did our currently-connected peripheral just disconnect? + if state.peripheral?.identifier == peripheral.identifier { + name = "" + connected = false + // IME the error codes encountered are: + // 0 = rebooting the peripheral. + // 6 = out of range. + if let error = error, (error as NSError).domain == CBErrorDomain, + let code = CBError.Code(rawValue: (error as NSError).code), + outOfRangeHeuristics.contains(code) { + // Try reconnect without setting a timeout in the state machine. + // With CB, it's like saying 'please reconnect me at any point + // in the future if this peripheral comes back into range'. + Logger.services.info("Connection failure, try and reconnect when the device is back in range") + manager.connect(peripheral, options: nil) + state = .outOfRange(peripheral) + } else { + // Likely a deliberate unpairing. + state = .disconnected + } + } + } + + // ----------------------------------------- + // Peripheral callbacks + // ----------------------------------------- + + // Discover BLE device service(s) + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + Logger.services.info("Discovered Bluetooth Services") + // Ignore services discovered late. + guard case .discoveringServices = state else { + return + } + if let error = error { + Logger.services.error("\(error.localizedDescription)") + disconnect() + return + } + guard peripheral.meshtasticOTAService != nil else { + Logger.services.info("Meshtastic OTA service missing") + disconnect() + return + } + // All fine so far, go to next step + guard let services = peripheral.services else { return } + for service in services { + peripheral.discoverCharacteristics(nil, for: service) + } + + } + // Discover BLE device Service charachteristics + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + Logger.services.info("Discovering Characteristics For Meshtastic OTA Service") + + if let error = error { + Logger.services.error("\(error.localizedDescription)") + disconnect() + return + } + + guard peripheral.statusCharacteristic != nil else { + Logger.services.info("\(Date()) Desired characteristic missing") + disconnect() + return + } + + guard let characteristics = service.characteristics else { + return + } + + for characteristic in characteristics { + switch characteristic.uuid { + + case statusCharacteristicId: + statusCharacteristic = characteristic + Logger.services.info("Discovered Status Characteristic: \(self.statusCharacteristic!.uuid.uuidString)") + peripheral.setNotifyValue(true, for: characteristic) + + case otaCharacteristicId: + otaCharacteristic = characteristic + Logger.services.info("Discovered OTA Characteristic: \(self.otaCharacteristic!.uuid.uuidString)") + peripheral.setNotifyValue(false, for: characteristic) + + default: + Logger.services.info("\(Date()) unknown") + } + } + setConnected(peripheral: peripheral) + } + // The BLE peripheral device sent some notify data. Deal with it! + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + Logger.services.info("\(Date()) PH didUpdateValueFor") + if let error = error { + Logger.services.error("\(error.localizedDescription)") + return + } + if let data = characteristic.value { + // deal with incoming data + // First check if the incoming data is one byte length? + // if so it's the peripheral acknowledging and telling + // us to send another batch of data + if data.count == 1 { + if !firstAcknowledgeFromESP32 { + firstAcknowledgeFromESP32 = true + startTime = CFAbsoluteTimeGetCurrent() + } + // Logger.services.info("\(Date()) -X-") + if transferOngoing { + packageCounter = 0 + writeDataToPeripheral(characteristic: otaCharacteristic!) + } + } + } + } + + // Called when .withResponse is used. + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor descriptor: CBDescriptor, error: Error?) { + Logger.services.info("\(Date()) PH didWriteValueFor") + if let error = error { + Logger.services.error("\(Date()) Error writing to characteristic: \(error.localizedDescription)") + return + } + } + + // Callback indicating peripheral notifying state + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + Logger.services.info("\(Date()) PH didUpdateNotificationStateFor") + Logger.services.info("\(Date()) PH characteristic: \(characteristic.uuid.uuidString)") + if error == nil { + Logger.services.info("\(Date()) Notification Set OK, isNotifying: \(characteristic.isNotifying)") + if !characteristic.isNotifying { + Logger.services.info("\(Date()) isNotifying is false, set to true again!") + peripheral.setNotifyValue(true, for: characteristic) + } else { + if characteristic.uuid == statusCharacteristic?.uuid { + statusCharacteristicIsNotifying = true + } + } + } + + checkStartTransfer() + } + + func checkStartTransfer() { + guard connected else { + Logger.services.info("Not connected, cannot start transfer") + return + } + guard statusCharacteristicIsNotifying else { + Logger.services.info("Status Characteristic not notifying yet, cannot start transfer") + return + } + guard transferOngoing == false else { + Logger.services.info("Transfer already ongoing") + return + } + + // All set, start the transfer + self.sendFile() + } + + /*------------------------------------------------------------------------- + Functions + -------------------------------------------------------------------------*/ + // Scan for a device with the OTA Service UUID (myDesiredServiceId) + func startScanning() { + Logger.services.info("Scanning for Meshtastic Devices in OTA Mode") + guard manager.state == .poweredOn else { + Logger.services.info("Cannot scan, Bluetooth is not powered on") + return + } + manager.scanForPeripherals(withServices: [meshtasticOTAServiceId], options: nil) +// state = .scanning(Countdown(seconds: 10, closure: { +// self.manager.stopScan() +// self.state = .disconnected +// Logger.services.info("Scan timed out") +// })) + state = .scanning + } + + func disconnect() { + Logger.services.info("Disconnect") + if let peripheral = state.peripheral { + manager.cancelPeripheralConnection(peripheral) + } + state = .disconnected + connected = false + transferOngoing = false + } + + // Connect to the device from the scanning + func connect(peripheral: CBPeripheral) { + Logger.services.info("Connect Button Pushed") + if connected { + manager.cancelPeripheralConnection(peripheral) + } else { + // Connect! + manager.connect(peripheral, options: nil) + name = String(peripheral.name ?? "unknown") + Logger.services.info("Attempting connection to \(self.name)") + state = .connecting(peripheral, Countdown(seconds: 10, closure: { + self.manager.cancelPeripheralConnection(self.state.peripheral!) + self.state = .disconnected + self.connected = false + Logger.services.info("Attempted connection to \(self.name) timed out") + })) + } + } + + // Discover Services of a device + func discoverServices(peripheral: CBPeripheral) { + Logger.services.info("Discovering Meshtastic OTA service") + peripheral.delegate = self + peripheral.discoverServices([meshtasticOTAServiceId]) + state = .discoveringServices(peripheral, Countdown(seconds: 10, closure: { + self.disconnect() + Logger.services.info("\(Date()) Could not discover services") + })) + } + + // Discover Characteristics of a Services + func discoverCharacteristics(peripheral: CBPeripheral) { + Logger.services.info("Discovering characteristics for Meshtastic OTA service") + guard let meshtasticOTAService = peripheral.meshtasticOTAService else { + self.disconnect() + return + } + peripheral.discoverCharacteristics([statusCharacteristicId], for: meshtasticOTAService) + state = .discoveringCharacteristics(peripheral, + Countdown(seconds: 10, + closure: { + self.disconnect() + Logger.services.info("\(Date()) Could not discover characteristics") + })) + } + + func setConnected(peripheral: CBPeripheral) { + Logger.services.info("Max write value with response: \(peripheral.maximumWriteValueLength(for: .withResponse))") + Logger.services.info("Max write value without response: \(peripheral.maximumWriteValueLength(for: .withoutResponse))") + guard let statusCharacteristic = peripheral.statusCharacteristic + else { + Logger.services.info("Missing status characteristic") + disconnect() + return + } + + peripheral.setNotifyValue(true, for: statusCharacteristic) + state = .connected(peripheral) + connected = true + name = String(peripheral.name ?? "unknown") + + checkStartTransfer() + } + + // Peripheral callback when its ready to receive more data without response + func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + if transferOngoing && packageCounter < chunkCount { + writeDataToPeripheral(characteristic: otaCharacteristic!) + } + } + + func sendFile() { + Logger.services.info("Start sending .bin file to device") + + // 1. Get the data from the file(name) and copy data to dataBUffer + guard let fileUrl, let data: Data = try? Data(contentsOf: fileUrl) else { + Logger.services.info("Failed to open .bin file") + return + } + dataBuffer = data + dataLength = dataBuffer.count + + // 1. Get the peripheral and its transfer characteristic + guard let discoveredPeripheral = state.peripheral else { return } + // Send dataLength to the device + let sizeMessage = "OTA_SIZE:\(dataLength)" + if let sizeData = sizeMessage.data(using: .utf8) { + // Send sizeData to the peripheral + // Assuming writeCharacteristic is the characteristic for sending messages + discoveredPeripheral.writeValue(sizeData, for: otaCharacteristic!, type: .withoutResponse) + Logger.services.info("Sent OTA size message: \(sizeMessage)") + } else { + Logger.services.info("Failed to encode OTA size message") + return + } + + // Logger.services.info the total size of the data in hexadecimal format + Logger.services.info("Total data size (hexadecimal): \(String(format: "%02X", self.dataBuffer.count))") + transferOngoing = true + + packageCounter = 0 + // Send the first chunk + elapsedTime = 0.0 + sentBytes = 0 + firstAcknowledgeFromESP32 = false + startTime = CFAbsoluteTimeGetCurrent() + writeDataToPeripheral(characteristic: otaCharacteristic!) + } + + func writeDataToPeripheral(characteristic: CBCharacteristic) { + // 1. Get the peripheral and its transfer characteristic + guard let discoveredPeripheral = state.peripheral else { return } + // ATT MTU - 3 bytes + let maxWriteValueLength = discoveredPeripheral.maximumWriteValueLength(for: .withoutResponse) + chunkSize = maxWriteValueLength - 3 + Logger.services.info("Chunk size: \(self.chunkSize), 0x\(String(format: "%02X", self.chunkSize))") + // Get the data range + var range: Range + + // 2. Loop through and send each chunk to the BLE device + // check to see if the number of iterations completed and the peripheral can accept more data + // package counter allows only "chunkCount" of data to be sent per time. + while transferOngoing && packageCounter < chunkCount { + // 3. Create a range based on the length of data to return + range = (0.. Void) { + timer = Timer.scheduledTimer(withTimeInterval: seconds, repeats: false, block: { _ in closure() }) + } + deinit { + timer.invalidate() + } +} diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel2.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel2.swift new file mode 100644 index 00000000..f0d359fd --- /dev/null +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel2.swift @@ -0,0 +1,105 @@ +// +// ESP32BLEOTAViewModel2.swift +// Meshtastic +// +// Created by jake on 12/21/25. +// + +import Foundation +import CoreBluetooth +import OSLog +import UIKit + +private let meshtasticOTAServiceId = CBUUID(string: "4FAFC201-1FB5-459E-8FCC-C5C9C331914B") +private let statusCharacteristicId = CBUUID(string: "62EC0272-3EC5-11EB-B378-0242AC130003") // ESP32 pTxCharacteristic ESP send (notifying) +private let otaCharacteristicId = CBUUID(string: "62EC0272-3EC5-11EB-B378-0242AC130005") // ESP32 pOtaCharacteristic ESP write + + +@MainActor +final class ESP32BLEOTAViewModel2: ObservableObject { + @Published var name = "" + @Published var transferProgress: Double = 0 + @Published var otaStatus: LocalOTAStatusCode = .idle + @Published var statusMessage: String = "" + + private let ble = AsyncCentral() + + func startOTA(binURL: URL) { + Task { + do { + try await ble.waitUntilPoweredOn() + let peripheral = try await ble.scan(for: meshtasticOTAServiceId) + name = peripheral.name ?? "unknown" + try await ble.connect(peripheral) + otaStatus = .connected + + let services = try await ble.discoverServices([meshtasticOTAServiceId], on: peripheral) + guard let service = services.first(where: { $0.uuid == meshtasticOTAServiceId }) else { throw BLEError.serviceMissing } + + let chars = try await ble.discoverCharacteristics([statusCharacteristicId, otaCharacteristicId], + in: service, + on: peripheral) + guard + let statusChar = chars.first(where: { $0.uuid == statusCharacteristicId }), + let otaChar = chars.first(where: { $0.uuid == otaCharacteristicId }) + else { throw BLEError.characteristicMissing } + + try await ble.setNotify(true, for: statusChar, on: peripheral) + + // Setup the ackStream before we send the invitation + let ackStream = ble.notifications(for: statusChar) + + // Disable the idle timer till we're done + UIApplication.shared.isIdleTimerDisabled = true + + // Start transfer + let data = try Data(contentsOf: binURL) + let sizeMsg = "OTA_SIZE:\(data.count)" + try await ble.writeValue(Data(sizeMsg.utf8), for: otaChar, type: .withoutResponse, on: peripheral) + + var buffer = data + + for await notifyData in ackStream { + if let value = notifyData.first, let code = DeviceBLEOTAStatusCode(rawValue: value) { + switch code { + case .WAITING_FOR_SIZE: + // This probably doesn't happen because we already sent an OTA_SIZE meesage above + Logger.services.info("[ESP BLE OTA] Device is waiting for size") + self.statusMessage = "About to start..." + + case .ERASING_FLASH: + self.otaStatus = .preparing + Logger.services.info("[ESP BLE OTA] Device is erasing the flash") + self.statusMessage = "Preparing flash partition..." + + case .READY_FOR_CHUNK, .CHUNK_ACK: + self.otaStatus = .transferring + self.statusMessage = "Transfer in progress..." + + let chunk = buffer.prefix(peripheral.maximumWriteValueLength(for: .withoutResponse) - 3) + guard !chunk.isEmpty else { break } + try await ble.writeValue(chunk, for: otaChar, type: .withoutResponse, on: peripheral) + buffer.removeFirst(chunk.count) + transferProgress = 100 * (1 - Double(buffer.count) / Double(data.count)) + + case .OTA_COMPLETE: + self.otaStatus = .completed + self.statusMessage = "OTA Complete!" + UIApplication.shared.isIdleTimerDisabled = false + + case .ERROR: + self.otaStatus = .error + self.statusMessage = "Device Reported an Error!" + UIApplication.shared.isIdleTimerDisabled = false + } + } + } + } catch { + // handle error, update UI + self.otaStatus = .error + UIApplication.shared.isIdleTimerDisabled = false + Logger.services.error("OTA failed: \(error.localizedDescription)") + } + } + } +} diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift new file mode 100644 index 00000000..37c3d2eb --- /dev/null +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift @@ -0,0 +1,188 @@ +// +// ESP32DFUSheet.swift +// Meshtastic +// +// Created by Jake Bordens on 12/12/25. +// + +import SwiftUI +import OSLog +import Network + +struct ESP32OTAIntroSheet: View { + private enum Step { + case intro + case updater + } + + @EnvironmentObject var accessoryManager: AccessoryManager + @Environment(\.dismiss) var dismiss + @Environment(\.managedObjectContext) var context + + let binFileURL: URL + + + @State var showWifiUpdater = false + @State var showBLEUpdater = false + + var body: some View { + NavigationStack { + List { + Section { + VStack(alignment: .leading, spacing: 12) { + Label("Desktop Recommended", systemImage: "desktopcomputer") + .font(.headline) + + Text("The recommended way to update ESP32 devices is using the **Web Flasher** on a desktop computer (Chrome-based browser).") + .fixedSize(horizontal: false, vertical: true) + + Text("The **Web Flasher** does not support updating on this device or over USB or BLE.") + .font(.caption) + .foregroundStyle(.secondary) + + Link(destination: URL(string: "https://flash.meshtastic.org")!) { + HStack { + Text("Open Web Flasher") + Image(systemName: "arrow.up.right") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.regular) + }.listRowBackground(Color(UIColor.tertiarySystemBackground)) + + } footer: { + Color.clear.frame(height: 5) + } + + switch OTAMode { + case .wifi: + Section { + VStack(alignment: .leading, spacing: 12) { + Label("WiFi OTA Updating", systemImage: "wifi") + .font(.headline) + + HStack(alignment: .top, spacing: 12) { + Image(systemName: "lock.shield") + .font(.title2) + .foregroundStyle(.blue) + + Text("Advanced Users Only.") + .font(.callout) + } + + Text("If you device has the proper updater loaded into the OTA_1 partition, you can attempt to use the WiFi update process.") + .font(.caption) + .foregroundStyle(.secondary) + + Button(role: .destructive) { + self.showWifiUpdater = true + } label: { + Text("I Know What I'm Doing") + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .frame(maxWidth: .infinity) + .cornerRadius(10).disabled(accessoryManager.activeDeviceNum == nil) + } + .padding() + .listRowBackground(Color(UIColor.tertiarySystemBackground)) + } + case .ble: + VStack(alignment: .leading, spacing: 12) { + Label("BLE OTA Updating", systemImage: "wifi") + .font(.headline) + + HStack(alignment: .top, spacing: 12) { + Image(systemName: "lock.shield") + .font(.title2) + .foregroundStyle(.blue) + + Text("Advanced Users Only.") + .font(.callout) + } + + Text("If you device has the proper updater loaded into the OTA_1 partition, you can attempt to use the BLE update process.") + .font(.caption) + .foregroundStyle(.secondary) + + Button(role: .destructive) { + self.showBLEUpdater = true + } label: { + Text("I Know What I'm Doing") + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .frame(maxWidth: .infinity) + .cornerRadius(10).disabled(accessoryManager.activeDeviceNum == nil) + } + .padding() + .listRowBackground(Color(UIColor.tertiarySystemBackground)) + + default: + EmptyView() + } + + }.sheet(isPresented: $showWifiUpdater) { + ESP32WifiOTASheet(binFileURL: binFileURL) + }.sheet(isPresented: $showBLEUpdater) { + ESP32BLEOTASheet(binFileURL: binFileURL) + } + .navigationTitle("ESP32 Update") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { // Standard placement for "Done" or "Close" + Button("Done") { + dismiss() + } + } + } + } + } + + private enum SupportedOTAMode { + case none + case wifi + case ble + } + + private var OTAMode: SupportedOTAMode { + if let connection = accessoryManager.activeConnection?.connection { + if connection is TCPConnection { + return .wifi + } else if connection is BLEConnection { + return .ble + } + + } + return .none + } + // func beginBLEProcessButton() -> some View { + // Button { + // let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context) + // if let connectedNode, let user = connectedNode.user { + // Task { + // do { + // if let host { + // let device = accessoryManager.activeConnection?.device + // try await accessoryManager.sendRebootOta(fromUser: user, toUser: user, rebootOtaSeconds: 2) + // try await accessoryManager.disconnect() + // await ota.startUpdate(host: host, firmwareUrl: self.binFileURL) + // if let device { + // try await Task.sleep(for: .seconds(3)) + // try await accessoryManager.connect(to: device, retries: 5) + // } + // } + // } catch { + // Logger.mesh.error("Reboot Failed") + // } + // } + // } + // } label: { + // Label("Reboot into BLE OTA Update Mode", systemImage: "square.and.arrow.down") + // .frame(maxWidth: .infinity) + // }.buttonStyle(.bordered) + // .controlSize(.large) + // .disabled(accessoryManager.activeDeviceNum == nil) + // } +} diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/Helpers/OTAEnums.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/Helpers/OTAEnums.swift new file mode 100644 index 00000000..18c2b61c --- /dev/null +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/Helpers/OTAEnums.swift @@ -0,0 +1,26 @@ +// +// OTAEnums.swift +// Meshtastic +// +// Created by jake on 12/22/25. +// + +enum DeviceBLEOTAStatusCode: UInt8 { + case WAITING_FOR_SIZE = 0 + case ERASING_FLASH = 1 + case READY_FOR_CHUNK = 2 + case CHUNK_ACK = 3 + case OTA_COMPLETE = 4 + case ERROR = 5 +} + +enum LocalOTAStatusCode: String, CustomStringConvertible { + var description: String { return self.rawValue } + case idle = "Ready" + case waitingForConnection = "Waiting for Connection" + case connected = "Connected" + case preparing = "Preparing" + case transferring = "Uploading" + case completed = "Completed" + case error = "Error" +} diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTASheet.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTASheet.swift new file mode 100644 index 00000000..ff72c96f --- /dev/null +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTASheet.swift @@ -0,0 +1,112 @@ +// +// ESP32WifiOTASheet.swift +// Meshtastic +// +// Created by jake on 12/20/25. +// + +import Foundation +import SwiftUI +import OSLog + +struct ESP32WifiOTASheet: View { + @EnvironmentObject var accessoryManager: AccessoryManager + @Environment(\.dismiss) var dismiss + @Environment(\.managedObjectContext) var context + @StateObject var ota = ESP32WifiOTAViewModel() + + // The stuff were updating, and the place we're updating it to + let binFileURL: URL + @State var host: String? + + var body: some View { + NavigationStack { + List { + Section { + VStack { + Text("Please do not leave this screen until this process is complete.") + .multilineTextAlignment(.center) + }.listRowBackground(Color.clear) + } + + Section { + VStack(alignment: .leading) { + Text("Firmware File").font(.caption).foregroundColor(.secondary) + Text("\(self.binFileURL.lastPathComponent)").font(.caption) + } + VStack(alignment: .leading) { + Text("Network Location").font(.caption).foregroundColor(.secondary) + Text("\(host ?? "Unknown")").font(.caption) + } + } footer: { + Text("Please be sure this is correct before proceeding.") + } + + Section { + HStack(alignment: .center) { + Spacer() + CircularProgressView(progress: ota.progress, isIndeterminate: (ota.otaState == .preparing), size: 225.0, subtitleText: ota.otaState.rawValue) + .frame(minHeight: 250.0) + Spacer() + }.listRowBackground(Color.clear) + VStack { + switch ota.otaState { + case .idle: + beginWifiProcessButton() + + case .error: + Text("Error: \(ota.errorMessage, default: "Unknown")") + + default: + Text("\(ota.statusMessage, default: "")") + } + }.listRowBackground(Color.clear) + }.listRowSeparator(.hidden) + }.navigationTitle("ESP32 WiFi Updater") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { // Standard placement for "Done" or "Close" + Button("Done") { + dismiss() + }.disabled(![.idle, .completed, .error].contains(ota.otaState)) + } + } + }.task { + if let connection = accessoryManager.activeConnection?.connection as? TCPConnection { + self.host = await connection.host.stringValue + } + }.interactiveDismissDisabled(true) + + } + + @ViewBuilder + func beginWifiProcessButton() -> some View { + Button { + let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context) + if let connectedNode, let user = connectedNode.user { + Task { + do { + if let host { + let device = accessoryManager.activeConnection?.device + try await accessoryManager.sendRebootOta(fromUser: user, toUser: user, rebootOtaSeconds: 1) + try await accessoryManager.disconnect() + await ota.startUpdate(host: host, firmwareUrl: self.binFileURL) + if let device { + try await Task.sleep(for: .seconds(3)) + try await accessoryManager.connect(to: device, retries: 5) + } + } + } catch { + Logger.mesh.error("Reboot Failed") + } + } + } + } label: { + Label("Reboot into Wifi OTA Update Mode", systemImage: "square.and.arrow.down") + .frame(maxWidth: .infinity) + + }.buttonStyle(.bordered) + .controlSize(.large) + .disabled(accessoryManager.activeDeviceNum == nil) + } +} diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 DFU/Esp32WifiOTAViewModel.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTAViewModel.swift similarity index 89% rename from Meshtastic/Views/Settings/Firmware/ESP32 DFU/Esp32WifiOTAViewModel.swift rename to Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTAViewModel.swift index ca6269df..f2cd1120 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 DFU/Esp32WifiOTAViewModel.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTAViewModel.swift @@ -6,25 +6,13 @@ import OSLog import os // Required for OSAllocatedUnfairLock @MainActor -class Esp32WifiOTAViewModel: ObservableObject { - enum OTAState: String, CustomStringConvertible { - var description: String { self.rawValue } - - case idle = "Idle" - case preparing = "Preparing" - case handshaking = "Sending Handshake" - case waitingForConnection = "Waiting for Connection" - case uploading = "Uploading" - case success = "Success" - case error = "Error" - } +class ESP32WifiOTAViewModel: ObservableObject { // MARK: - Published State @Published var statusMessage: String = "Idle" @Published var progress: Double = 0.0 - @Published var isUpdating: Bool = false - @Published var errorMessage: String? = nil - @Published var otaState: OTAState = .idle + @Published var errorMessage: String? + @Published var otaState: LocalOTAStatusCode = .idle // MARK: - Constants private let espPort: NWEndpoint.Port = 3232 @@ -36,20 +24,19 @@ class Esp32WifiOTAViewModel: ObservableObject { // MARK: - Public Interface - func startUpdate(host: NWEndpoint.Host, firmwareUrl: URL, password: String? = nil) async { - guard !isUpdating else { return } + func startUpdate(host: String, firmwareUrl: URL, password: String? = nil) async { + guard self.otaState == .idle else { return } - self.isUpdating = true self.progress = 0.0 self.errorMessage = nil - self.statusMessage = "Preparing..." - self.otaState = .preparing + self.statusMessage = "Connecting..." + self.otaState = .waitingForConnection var listener: NWListener? defer { listener?.cancel() - self.isUpdating = false + self.otaState = .idle } do { @@ -65,7 +52,7 @@ class Esp32WifiOTAViewModel: ObservableObject { Logger.services.info("[ESP OTA] Listening on port \(localPort)") self.statusMessage = "Waiting for device. This can take a while..." - self.otaState = .handshaking + Logger.services.info("[ESP OTA] Starting Handshake loop...") try await performHandshake(host: host, @@ -73,11 +60,11 @@ class Esp32WifiOTAViewModel: ObservableObject { data: firmwareData, password: password) - self.otaState = .waitingForConnection + self.otaState = .connected for try await _ in transferStream { break } self.statusMessage = "Success!" - self.otaState = .success + self.otaState = .completed Logger.services.info("[ESP OTA] Update Complete") } catch { @@ -99,11 +86,12 @@ class Esp32WifiOTAViewModel: ObservableObject { func getPayload() -> Data { return currentPayload } } - private func performHandshake(host: NWEndpoint.Host, localPort: UInt16, data: Data, password: String?) async throws { + private func performHandshake(host: String, localPort: UInt16, data: Data, password: String?) async throws { let initialPayload = try generateInvitationPayload(localPort: localPort, data: data, password: password, authNonce: nil) let state = HandshakeState(initialPayload: initialPayload) - let connection = NWConnection(host: host, port: espPort, using: .udp) + let nwHost = NWEndpoint.Host(host) + let connection = NWConnection(host: nwHost, port: espPort, using: .udp) defer { connection.cancel() } connection.start(queue: .global()) @@ -111,10 +99,11 @@ class Esp32WifiOTAViewModel: ObservableObject { Logger.services.info("[ESP OTA] UDP Connection Ready. Starting broadcast/listen loop.") + var okReceived = false try await withThrowingTaskGroup(of: Void.self) { group in // Task A: Broadcaster group.addTask { - while !Task.isCancelled { + while !Task.isCancelled, !okReceived { let payload = await state.getPayload() connection.send(content: payload, completion: .contentProcessed { _ in }) Logger.services.debug("[ESP OTA] Sent invitation packet") @@ -129,9 +118,11 @@ class Esp32WifiOTAViewModel: ObservableObject { if response == "OK" { Logger.services.info("[ESP OTA] Handshake OK received!") + okReceived = true return } + // THIS IS UNTESTED if response.hasPrefix("AUTH") { Logger.services.info("[ESP OTA] Auth challenge received: \(response)") let components = response.components(separatedBy: " ") @@ -144,6 +135,14 @@ class Esp32WifiOTAViewModel: ObservableObject { await state.updatePayload(newPayload) } } + + if response == "ERASE" { + Logger.services.info("[ESP OTA] Device is erasing the flash partition.") + Task { @MainActor in + self.otaState = .preparing + self.statusMessage = "Preparing flash partition..." + } + } } } @@ -176,6 +175,7 @@ class Esp32WifiOTAViewModel: ObservableObject { nonisolated private func generateInvitationPayload(localPort: UInt16, data: Data, password: String?, authNonce: String?) throws -> Data { let fileMD5 = Insecure.MD5.hash(data: data).map { String(format: "%02hhx", $0) }.joined() + Logger.services.info("Firmware MD5 is \(fileMD5)") let fileSize = data.count var message = "0 \(localPort) \(fileSize) \(fileMD5)" @@ -266,7 +266,7 @@ class Esp32WifiOTAViewModel: ObservableObject { switch state { case .ready: Task { @MainActor in - self.otaState = .uploading + self.otaState = .transferring do { try await self.performChunkedTransfer(connection: connection, data: data) await MainActor.run { diff --git a/Meshtastic/Views/Settings/Firmware/Firmware.swift b/Meshtastic/Views/Settings/Firmware/Firmware.swift index 731209b6..aa4c0b85 100644 --- a/Meshtastic/Views/Settings/Firmware/Firmware.swift +++ b/Meshtastic/Views/Settings/Firmware/Firmware.swift @@ -329,7 +329,7 @@ private struct FirmwareRow: View { case .uf2: UF2MassStorageView(fileURL: firmwareFile.localUrl) case .bin: - ESP32DFUSheet(binFileURL: firmwareFile.localUrl) + ESP32OTAIntroSheet(binFileURL: firmwareFile.localUrl) } } }