Initial unified Meshtastic-OTA updater support

This commit is contained in:
Jake-B 2025-12-22 12:45:45 -05:00
parent 811bbdfd20
commit 10ffdf9e10
15 changed files with 1552 additions and 381 deletions

View file

@ -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" : {

View file

@ -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 = "<group>"; };
108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = "<group>"; };
230A983F2EF86A44004D87F1 /* AsyncCentral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncCentral.swift; sourceTree = "<group>"; };
230A98412EF86AA9004D87F1 /* ESP32BLEOTAViewModel2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32BLEOTAViewModel2.swift; sourceTree = "<group>"; };
230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Discovery.swift"; sourceTree = "<group>"; };
231251372E3BC96400E6ED07 /* BLEAuthorizationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEAuthorizationHelper.swift; sourceTree = "<group>"; };
23148E2F2EE1CCE500F0DB2C /* MeshtasticAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAPI.swift; sourceTree = "<group>"; };
2315D19C2EECB3D400E0FAE7 /* UTI+UF2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UTI+UF2.swift"; sourceTree = "<group>"; };
2315D19F2EECB44800E0FAE7 /* UF2MassStorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UF2MassStorageView.swift; sourceTree = "<group>"; };
2315D1A42EED94E800E0FAE7 /* FirmwareFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirmwareFile.swift; sourceTree = "<group>"; };
23196A6D2EF1BA9100B1504B /* Esp32WifiOTAViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Esp32WifiOTAViewModel.swift; sourceTree = "<group>"; };
23196A6D2EF1BA9100B1504B /* ESP32WifiOTAViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32WifiOTAViewModel.swift; sourceTree = "<group>"; };
23196C6F2EF42D3D00B1504B /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = "<group>"; };
231A53772E69ADB900216B99 /* NodeFilterParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeFilterParameters.swift; sourceTree = "<group>"; };
231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnList.swift; sourceTree = "<group>"; };
@ -402,6 +411,10 @@
237AEB962E1FE627003B7CE3 /* BLETransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLETransport.swift; sourceTree = "<group>"; };
237AEB982E20098B003B7CE3 /* BLEConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEConnection.swift; sourceTree = "<group>"; };
237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimitedButton.swift; sourceTree = "<group>"; };
23825FF42EF6CF0B00C25543 /* NWHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWHost.swift; sourceTree = "<group>"; };
23825FF82EF6D9AA00C25543 /* ESP32WifiOTASheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32WifiOTASheet.swift; sourceTree = "<group>"; };
23825FFC2EF6E70B00C25543 /* ESP32BLEOTAViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32BLEOTAViewModel.swift; sourceTree = "<group>"; };
23825FFE2EF6E79C00C25543 /* ESP32BLEOTASheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32BLEOTASheet.swift; sourceTree = "<group>"; };
2388EC392EDF8A1400F6F982 /* DFUModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DFUModel.swift; sourceTree = "<group>"; };
23A1AFB62E42BD2500E46C96 /* RXTXIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RXTXIndicatorView.swift; sourceTree = "<group>"; };
23AB0E652EE35E0200AFA09D /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = "<group>"; };
@ -414,7 +427,8 @@
23C2BE262EE9F4BD00F6A997 /* DeviceHardwareEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHardwareEntity.swift; sourceTree = "<group>"; };
23C2BE292EEAF96A00F6A997 /* FirmwareViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirmwareViewModel.swift; sourceTree = "<group>"; };
23C2BE302EEB823900F6A997 /* NRFDFUSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NRFDFUSheet.swift; sourceTree = "<group>"; };
23C2BE332EEC3F9600F6A997 /* ESP32DFUSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32DFUSheet.swift; sourceTree = "<group>"; };
23C2BE332EEC3F9600F6A997 /* ESP32OTAIntroSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32OTAIntroSheet.swift; sourceTree = "<group>"; };
23C873E22EF9B58C00BFEA7B /* OTAEnums.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTAEnums.swift; sourceTree = "<group>"; };
23D316922E5618D2002FA4FB /* AsyncGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncGate.swift; sourceTree = "<group>"; };
23D9D9382E50DA97005D1C18 /* ResettableTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResettableTimer.swift; sourceTree = "<group>"; };
23DC50BA2EE76D9C0023838A /* URL+fetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+fetch.swift"; sourceTree = "<group>"; };
@ -914,6 +928,26 @@
path = Accessory;
sourceTree = "<group>";
};
23825FFA2EF6E6EC00C25543 /* WiFi */ = {
isa = PBXGroup;
children = (
23196A6D2EF1BA9100B1504B /* ESP32WifiOTAViewModel.swift */,
23825FF82EF6D9AA00C25543 /* ESP32WifiOTASheet.swift */,
);
path = WiFi;
sourceTree = "<group>";
};
23825FFB2EF6E6FB00C25543 /* BLE */ = {
isa = PBXGroup;
children = (
23825FFC2EF6E70B00C25543 /* ESP32BLEOTAViewModel.swift */,
23825FFE2EF6E79C00C25543 /* ESP32BLEOTASheet.swift */,
230A983F2EF86A44004D87F1 /* AsyncCentral.swift */,
230A98412EF86AA9004D87F1 /* ESP32BLEOTAViewModel2.swift */,
);
path = BLE;
sourceTree = "<group>";
};
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 = "<group>";
};
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 = "<group>";
};
23C873E12EF9B57D00BFEA7B /* Helpers */ = {
isa = PBXGroup;
children = (
23C873E22EF9B58C00BFEA7B /* OTAEnums.swift */,
);
path = Helpers;
sourceTree = "<group>";
};
23D9D9312E50DA0E005D1C18 /* Protocols */ = {
@ -1580,6 +1624,7 @@
23F061B22E7B056600A1E2EA /* Logger+DataDog.swift */,
DD6F65732C6CB80A0053C113 /* View.swift */,
23AB0E652EE35E0200AFA09D /* Image.swift */,
23825FF42EF6CF0B00C25543 /* NWHost.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -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 */,

View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -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"

View file

@ -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)
}
}

View file

@ -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<CBPeripheral, Error>?
private var connectContinuation: CheckedContinuation<Void, Error>?
private var serviceContinuation: CheckedContinuation<[CBService], Error>?
private var characteristicContinuation: CheckedContinuation<[CBCharacteristic], Error>?
private var notifyContinuation: CheckedContinuation<Void, Error>?
private var writeContinuation: CheckedContinuation<Void, Error>?
private var notificationStreams: [CBUUID: AsyncStream<Data>.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<Void, Error>?
}
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<Data> {
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<Void, Error>) in
self.writeContinuation = cont
peripheral.writeValue(data, for: characteristic, type: type)
}
} else {
peripheral.writeValue(data, for: characteristic, type: type)
}
}
}

View file

@ -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)
}
}

View file

@ -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<CBError.Code> = [.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<Data.Index>
// 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..<min(chunkSize, dataBuffer.count))
// 4. Get a subcopy copy of data
let subData = dataBuffer.subdata(in: range)
// Logger.services.info the first byte of the subData package as hexadecimal
if let firstByte = subData.first {
Logger.services.info("First byte of subData package: \(String(format: "%02X", firstByte))")
}
// 5. Send data chunk to BLE peripheral, send EOF when buffer is empty.
if !dataBuffer.isEmpty {
discoveredPeripheral.writeValue(subData, for: characteristic, type: .withoutResponse)
packageCounter += 1
// Logger.services.info(" Packages: \(packageCounter) bytes: \(subData.count)")
} else {
transferOngoing = false
}
if discoveredPeripheral.canSendWriteWithoutResponse {
Logger.services.info("BLE peripheral ready?: \(discoveredPeripheral.canSendWriteWithoutResponse)")
}
// 6. Remove already sent data from buffer
dataBuffer.removeSubrange(range)
// 7. calculate and Logger.services.info the transfer progress in %
transferProgress = (1 - (Double(dataBuffer.count) / Double(dataLength))) * 100
Logger.services.info("File transfer progress: \(String(format: "%.02f", self.transferProgress))%")
sentBytes += chunkSize
elapsedTime = CFAbsoluteTimeGetCurrent() - startTime
let kbPs = Double(sentBytes) / elapsedTime
kBPerSecond = kbPs / 1000
}
}
}
extension CBPeripheral {
// Helper to find the service we're interested in.
var meshtasticOTAService: CBService? {
guard let services = services else { return nil }
return services.first { $0.uuid == meshtasticOTAServiceId }
}
// Helper to find the characteristic we're interested in.
var statusCharacteristic: CBCharacteristic? {
guard let characteristics = meshtasticOTAService?.characteristics else {
return nil
}
return characteristics.first { $0.uuid == statusCharacteristicId }
}
}
class Countdown {
let timer: Timer
init(seconds: TimeInterval, closure: @escaping () -> Void) {
timer = Timer.scheduledTimer(withTimeInterval: seconds, repeats: false, block: { _ in closure() })
}
deinit {
timer.invalidate()
}
}

View file

@ -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)")
}
}
}
}

View file

@ -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)
// }
}

View file

@ -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"
}

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -329,7 +329,7 @@ private struct FirmwareRow: View {
case .uf2:
UF2MassStorageView(fileURL: firmwareFile.localUrl)
case .bin:
ESP32DFUSheet(binFileURL: firmwareFile.localUrl)
ESP32OTAIntroSheet(binFileURL: firmwareFile.localUrl)
}
}
}