ESP32 WiFi Flashing

This commit is contained in:
Jake-B 2025-12-18 16:59:19 -05:00
parent 2c131599cd
commit 0897d9674d
15 changed files with 694 additions and 219 deletions

View file

@ -2914,6 +2914,9 @@
}
}
}
},
"Advanced Users Only." : {
},
"After" : {
"localizations" : {
@ -8688,7 +8691,18 @@
}
}
},
"Connection Attempt %lld of %lld" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Connection Attempt %1$lld of %2$lld"
}
}
}
},
"Connection Attempt %lld of 10" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@ -10200,9 +10214,8 @@
}
}
},
"Desktop Required" : {
"comment" : "A heading explaining that the recommended way to update an ESP32 device is using the Web Flasher on a desktop computer.",
"isCommentAutoGenerated" : true
"Desktop Recommended" : {
},
"Details" : {
"comment" : "The title of the view that lists detailed information about a single database entity.",
@ -15504,10 +15517,6 @@
}
}
},
"For advanced use cases, you can send a reboot command to the node using the following commands:" : {
"comment" : "A description of how to send a reboot command to an ESP32.",
"isCommentAutoGenerated" : true
},
"For all Mqtt functionality other than the map report you must also set uplink and downlink for each channel you want to bridge over Mqtt." : {
"localizations" : {
"it" : {
@ -18224,6 +18233,9 @@
}
}
}
},
"If you device has the WiFi updater loaded into the OTA_1 partition, you can attempt to use the WiFi update process." : {
},
"Ignore MQTT" : {
"localizations" : {
@ -28502,6 +28514,10 @@
}
}
},
"Reboot into OTA Update Mode" : {
"comment" : "A button that initiates a reboot of the connected device into OTA update mode.",
"isCommentAutoGenerated" : true
},
"Reboot node?" : {
"localizations" : {
"de" : {
@ -29795,6 +29811,9 @@
}
}
}
},
"Retrying (attempt %@)" : {
},
"Retrying (attempt %lld)" : {
"localizations" : {
@ -32349,10 +32368,6 @@
}
}
},
"Send Normal Reboot" : {
"comment" : "A button that attempts to force an ESP32 device to reboot into normal operation.",
"isCommentAutoGenerated" : true
},
"Send Notifications" : {
"localizations" : {
"de" : {
@ -40649,10 +40664,6 @@
}
}
},
"Utilities" : {
"comment" : "A section header that indicates advanced utilities for the ESP32 update process.",
"isCommentAutoGenerated" : true
},
"Utilizes the network connection on your phone to connect to MQTT." : {
"localizations" : {
"it" : {
@ -41786,6 +41797,9 @@
}
}
}
},
"WiFi Firmware Update" : {
},
"WiFi Options" : {
"localizations" : {
@ -41820,6 +41834,9 @@
}
}
}
},
"WiFi OTA Updating" : {
},
"Will sleep everything as much as possible, for the tracker and sensor role this will also include the lora radio. Don't use this setting if you want to use your device with the phone apps or are using a device without a user button." : {
"localizations" : {

View file

@ -22,6 +22,8 @@
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 */; };
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 */; };
231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */; };
@ -362,6 +364,8 @@
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>"; };
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>"; };
231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = "<group>"; };
@ -843,6 +847,14 @@
path = Firmware;
sourceTree = "<group>";
};
23196C712EF42D4300B1504B /* Helpers */ = {
isa = PBXGroup;
children = (
23196C6F2EF42D3D00B1504B /* CircularProgressView.swift */,
);
path = Helpers;
sourceTree = "<group>";
};
231B3F1E2D0879BC0069A07D /* Metrics Visualization */ = {
isa = PBXGroup;
children = (
@ -917,6 +929,7 @@
2315D19E2EECB42D00E0FAE7 /* U2F Mass Storage */,
23C2BE322EEC3F7800F6A997 /* ESP32 DFU */,
23C2BE2F2EEB821400F6A997 /* NRF DFU */,
23196C712EF42D4300B1504B /* Helpers */,
);
path = Firmware;
sourceTree = "<group>";
@ -933,6 +946,7 @@
23C2BE322EEC3F7800F6A997 /* ESP32 DFU */ = {
isa = PBXGroup;
children = (
23196A6D2EF1BA9100B1504B /* Esp32WifiOTAViewModel.swift */,
23C2BE332EEC3F9600F6A997 /* ESP32DFUSheet.swift */,
);
path = "ESP32 DFU";
@ -1886,6 +1900,7 @@
23C2BE272EE9F4BD00F6A997 /* DeviceHardwareEntity.swift in Sources */,
D9C9839D2B79CFD700BDBE6A /* TextMessageSize.swift in Sources */,
DDC94FCE29CF55310082EA6E /* RtttlConfig.swift in Sources */,
23196A6E2EF1BA9100B1504B /* Esp32WifiOTAViewModel.swift in Sources */,
DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */,
DD1BEF4E2E03916A0090CE24 /* ChannelsHelp.swift in Sources */,
DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */,
@ -2060,6 +2075,7 @@
25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */,
DD3CC6C028E7A60700FA9159 /* MessagingEnums.swift in Sources */,
DD6F657B2C6EC2900053C113 /* LockLegend.swift in Sources */,
23196C702EF42D3D00B1504B /* CircularProgressView.swift in Sources */,
DD97E96628EFD9820056DDA4 /* MeshtasticLogo.swift in Sources */,
DDAB580D2B0DAA9E00147258 /* Routes.swift in Sources */,
2344A2AB2D66974300170A77 /* ManagedAttributePropertyWrapper.swift in Sources */,
@ -2318,7 +2334,18 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Meshtastic/Preview Content\"";
ENABLE_APP_SANDBOX = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO;
ENABLE_PREVIEWS = YES;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
ENABLE_RESOURCE_ACCESS_USB = NO;
INFOPLIST_FILE = Meshtastic/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Meshtastic;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
@ -2353,7 +2380,18 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Meshtastic/Preview Content\"";
ENABLE_APP_SANDBOX = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO;
ENABLE_PREVIEWS = YES;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
ENABLE_RESOURCE_ACCESS_USB = NO;
INFOPLIST_FILE = Meshtastic/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Meshtastic;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";

View file

@ -14,7 +14,7 @@ private let maxRetries = 1
private let retryDelay: Duration = .seconds(2)
extension AccessoryManager {
func connect(to device: Device, withConnection: Connection? = nil, wantConfig: Bool = true, wantDatabase: Bool = true, versionCheck: Bool = true) async throws {
func connect(to device: Device, withConnection: Connection? = nil, wantConfig: Bool = true, wantDatabase: Bool = true, versionCheck: Bool = true, retries: Int? = nil) async throws {
Logger.transport.info("AccessoryManager.connect(to: \(device.name, privacy: .public), withConnection: \(withConnection != nil), wantConfig: \(wantConfig), wantDatabase: \(wantDatabase), versionCheck: \(versionCheck))")
// Prevent new connection if one is active
if activeConnection != nil {
@ -32,14 +32,14 @@ extension AccessoryManager {
expectedNodeDBSize = nil
// Prepare to connect
self.connectionStepper = SequentialSteps(maxRetries: maxRetries, retryDelay: retryDelay) {
self.connectionStepper = SequentialSteps(maxRetries: retries ?? maxRetries, retryDelay: retryDelay) {
// Step 0
Step { @MainActor retryAttempt in
Logger.transport.info("🔗👟 [Connect] Starting connection to \(device.id, privacy: .public)")
if retryAttempt > 0 {
try await self.closeConnection() // clean-up before retries.
self.updateState(.retrying(attempt: retryAttempt + 1))
self.updateState(.retrying(attempt: retryAttempt + 1, maxAttempts: retries ?? maxRetries))
self.allowDisconnect = true
} else {
self.updateState(.connecting)

View file

@ -1437,9 +1437,9 @@ extension AccessoryManager {
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
}
public func sendRebootOta(fromUser: UserEntity, toUser: UserEntity) async throws {
public func sendRebootOta(fromUser: UserEntity, toUser: UserEntity, rebootOtaSeconds: Int32 = 5) async throws {
var adminPacket = AdminMessage()
adminPacket.rebootOtaSeconds = 5
adminPacket.rebootOtaSeconds = rebootOtaSeconds
if fromUser != toUser {
adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data()
}

View file

@ -77,7 +77,7 @@ enum AccessoryManagerState: Equatable {
case idle
case discovering
case connecting
case retrying(attempt: Int)
case retrying(attempt: Int, maxAttempts: Int)
case retrievingDatabase(nodeCount: Int)
case communicating
case subscribed
@ -312,6 +312,10 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
device[keyPath: key] = value
self.activeConnection = (device: device, connection: activeConnection.connection)
}
// Make sure activeDeviceNum is up to date.
if key == \.num, self.activeDeviceNum != device.num {
self.activeDeviceNum = device.num
}
}

View file

@ -29,6 +29,10 @@ actor TCPConnection: Connection {
self.nwHost = NWEndpoint.Host(host)
self.nwPort = NWEndpoint.Port(integerLiteral: UInt16(port))
}
var host: NWEndpoint.Host {
return nwHost
}
private func waitForMagicBytes() async throws -> Bool {
let startOfFrame: [UInt8] = [0x94, 0xc3]

View file

@ -98,6 +98,7 @@ class TCPTransport: NSObject, Transport, NetServiceBrowserDelegate, NetServiceDe
name: name,
transportType: .tcp,
identifier: "\(host):\(port)")
Logger.transport.debug("TCP found: \(name) \(host):\(port)")
continuation?.yield(.deviceFound(device))
}

View file

@ -9,6 +9,8 @@
</array>
<key>com.apple.developer.carplay-communication</key>
<true/>
<key>com.apple.developer.networking.custom-protocol</key>
<true/>
<key>com.apple.developer.usernotifications.critical-alerts</key>
<true/>
<key>com.apple.developer.weatherkit</key>

View file

@ -57,7 +57,7 @@ extension FirmwareFile {
var description: String { return rawValue }
case uf2 = ".uf2"
case bin = ".bin"
case bin = "-update.bin"
case otaZip = "-ota.zip"
}

View file

@ -221,8 +221,8 @@ struct Connect: View {
Text("Retreiving nodes . .")
.font(.callout)
.foregroundColor(.orange)
case .retrying(let attempt):
Text("Connection Attempt \(attempt) of 10")
case .retrying(let attempt, let maxAttempts):
Text("Connection Attempt \(attempt) of \(maxAttempts)")
.font(.callout)
.foregroundColor(.orange)
default:

View file

@ -7,120 +7,165 @@
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 {
VStack(spacing: 24) {
// MARK: - Info Card
VStack(alignment: .leading, spacing: 12) {
Label("Desktop Required", systemImage: "desktopcomputer")
.font(.headline)
switch step {
case .intro:
VStack(spacing: 24) {
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")
// 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)
}
.frame(maxWidth: .infinity)
.buttonStyle(.bordered)
.controlSize(.regular)
}
.buttonStyle(.bordered)
.controlSize(.regular)
}
.padding()
.background(Color(UIColor.secondarySystemBackground))
.cornerRadius(12)
Divider()
// MARK: - OTA Section
VStack(alignment: .leading, spacing: 16) {
Label("Utilities", systemImage: "exclamationmark.triangle")
.font(.headline)
.foregroundStyle(.orange)
.padding()
.background(Color(UIColor.secondarySystemBackground))
.cornerRadius(12)
Text("For advanced use cases, you can send a reboot command to the node using the following commands:")
.fixedSize(horizontal: false, vertical: true)
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()
resetIntoOTAButton()
normalRebootButton()
}
.padding(.horizontal)
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()
}
.padding(.top)
}.padding()
// Standard Navigation Bar Setup
.navigationTitle("ESP32 Update")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { // Standard placement for "Done" or "Close"
Button("Done") {
dismiss()
}.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 normalRebootButton() -> some View {
func beginUpdateProcessButton() -> some View {
Button {
let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context)
if let connectedNode, let user = connectedNode.user {
Task {
do {
try await accessoryManager.sendRebootOta(fromUser: user, toUser: user)
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("Send Normal Reboot", systemImage: "square.and.arrow.down")
}.buttonStyle(.borderedProminent)
.controlSize(.large)
.frame(maxWidth: .infinity)
.cornerRadius(10).disabled(accessoryManager.activeDeviceNum == nil)
}
@ViewBuilder
func resetIntoOTAButton() -> some View {
Button {
let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context)
if let connectedNode, let user = connectedNode.user {
Task {
do {
try await accessoryManager.sendEnterDfuMode(fromUser: user, toUser: user)
} catch {
Logger.mesh.error("Reboot Failed")
}
}
}
} label: {
Label(" Send Reboot into DFU", systemImage: "square.and.arrow.down")
Label("Reboot into OTA Update Mode", systemImage: "square.and.arrow.down")
}.buttonStyle(.borderedProminent)
.controlSize(.large)
.frame(maxWidth: .infinity)
.cornerRadius(10).disabled(accessoryManager.activeDeviceNum == nil)
}
}
#Preview {
ESP32DFUSheet()
// Mock environment object for preview to work
.environmentObject(AccessoryManager())
}

View file

@ -0,0 +1,355 @@
import Foundation
import Network
import CryptoKit
import Combine
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"
}
// 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
// MARK: - Constants
private let espPort: NWEndpoint.Port = 3232
private let chunkSize = 1460
private let retryDelay: TimeInterval = 2.0
private let handshakeTotalTimeout: TimeInterval = 30.0
private var transferContinuation: AsyncThrowingStream<Void, Error>.Continuation?
// MARK: - Public Interface
func startUpdate(host: NWEndpoint.Host, firmwareUrl: URL, password: String? = nil) async {
guard !isUpdating else { return }
self.isUpdating = true
self.progress = 0.0
self.errorMessage = nil
self.statusMessage = "Preparing..."
self.otaState = .preparing
var listener: NWListener?
defer {
listener?.cancel()
self.isUpdating = false
}
do {
let firmwareData = try Data(contentsOf: firmwareUrl)
let transferStream = AsyncThrowingStream<Void, Error> { continuation in
self.transferContinuation = continuation
}
Logger.services.info("[ESP OTA] Starting local TCP Listener...")
let (setupListener, localPort) = try await setupListener(sending: firmwareData)
listener = setupListener
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,
localPort: localPort,
data: firmwareData,
password: password)
self.otaState = .waitingForConnection
for try await _ in transferStream { break }
self.statusMessage = "Success!"
self.otaState = .success
Logger.services.info("[ESP OTA] Update Complete")
} catch {
Logger.services.error("[ESP OTA] Error: \(error.localizedDescription)")
self.errorMessage = error.localizedDescription
self.statusMessage = "Failed"
self.otaState = .error
self.transferContinuation?.finish(throwing: error)
self.transferContinuation = nil
}
}
// MARK: - Phase 2: Handshake Logic
private actor HandshakeState {
var currentPayload: Data
init(initialPayload: Data) { self.currentPayload = initialPayload }
func updatePayload(_ data: Data) { self.currentPayload = data }
func getPayload() -> Data { return currentPayload }
}
private func performHandshake(host: NWEndpoint.Host, 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)
defer { connection.cancel() }
connection.start(queue: .global())
try await waitForConnectionReady(connection)
Logger.services.info("[ESP OTA] UDP Connection Ready. Starting broadcast/listen loop.")
try await withThrowingTaskGroup(of: Void.self) { group in
// Task A: Broadcaster
group.addTask {
while !Task.isCancelled {
let payload = await state.getPayload()
connection.send(content: payload, completion: .contentProcessed { _ in })
Logger.services.debug("[ESP OTA] Sent invitation packet")
try await Task.sleep(nanoseconds: UInt64(self.retryDelay * 1_000_000_000))
}
}
// Task B: Listener
group.addTask {
while !Task.isCancelled {
let response = try await self.receiveNextMessage(connection: connection)
if response == "OK" {
Logger.services.info("[ESP OTA] Handshake OK received!")
return
}
if response.hasPrefix("AUTH") {
Logger.services.info("[ESP OTA] Auth challenge received: \(response)")
let components = response.components(separatedBy: " ")
if components.count > 1 {
let nonce = components[1]
let newPayload = try self.generateInvitationPayload(localPort: localPort,
data: data,
password: password,
authNonce: nonce)
await state.updatePayload(newPayload)
}
}
}
}
// Task C: Timeout
group.addTask {
try await Task.sleep(nanoseconds: UInt64(self.handshakeTotalTimeout * 1_000_000_000))
throw OTAError.timeout
}
try await group.next()
group.cancelAll()
}
}
// MARK: - UDP Helpers (Nonisolated)
nonisolated private func receiveNextMessage(connection: NWConnection) async throws -> String {
return try await withCheckedThrowingContinuation { continuation in
connection.receiveMessage { content, _, _, error in
if let error = error {
continuation.resume(throwing: error)
} else if let data = content, let str = String(data: data, encoding: .utf8) {
continuation.resume(returning: str.trimmingCharacters(in: .whitespacesAndNewlines))
} else {
continuation.resume(returning: "")
}
}
}
}
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()
let fileSize = data.count
var message = "0 \(localPort) \(fileSize) \(fileMD5)"
if let nonce = authNonce, let pass = password {
let authInput = pass + nonce
if let authData = authInput.data(using: .utf8) {
let authHash = Insecure.MD5.hash(data: authData).map { String(format: "%02hhx", $0) }.joined()
message += " " + authHash
}
}
guard let payload = message.data(using: .utf8) else { throw OTAError.encodingFailed }
return payload
}
/// Uses OSAllocatedUnfairLock to safely ensure resume is called exactly once
nonisolated private func waitForConnectionReady(_ connection: NWConnection) async throws {
return try await withCheckedThrowingContinuation { continuation in
let stateLock = OSAllocatedUnfairLock(initialState: false) // The Idiomatic Swift 6 Lock
connection.stateUpdateHandler = { state in
// We lock, check if we already resumed, set to true, and perform logic
stateLock.withLock { hasResumed in
if hasResumed { return }
switch state {
case .ready:
hasResumed = true
continuation.resume()
case .failed(let err):
hasResumed = true
continuation.resume(throwing: err)
case .cancelled:
hasResumed = true
continuation.resume(throwing: CancellationError())
default:
break
}
}
}
}
}
// MARK: - Phase 1 & 4 (Listener & Transfer)
private func setupListener(sending firmware: Data) async throws -> (NWListener, UInt16) {
let parameters = NWParameters(tls: nil)
parameters.includePeerToPeer = true
parameters.prohibitedInterfaceTypes = [.cellular]
let listener = try NWListener(using: parameters, on: .init(integerLiteral: 0))
return try await withCheckedThrowingContinuation { continuation in
let stateLock = OSAllocatedUnfairLock(initialState: false)
listener.newConnectionHandler = { newConnection in
Logger.services.info("[ESP OTA] Accepted connection from \(String(describing: newConnection.endpoint))")
Task { @MainActor in
self.handleIncomingConnection(connection: newConnection, data: firmware)
newConnection.start(queue: .global())
}
}
listener.stateUpdateHandler = { state in
stateLock.withLock { hasResumed in
if hasResumed { return }
switch state {
case .ready:
if let port = listener.port {
hasResumed = true
continuation.resume(returning: (listener, port.rawValue))
}
case .failed(let error):
hasResumed = true
continuation.resume(throwing: error)
default:
break
}
}
}
listener.start(queue: .global())
}
}
private func handleIncomingConnection(connection: NWConnection, data: Data) {
connection.stateUpdateHandler = { state in
switch state {
case .ready:
Task { @MainActor in
self.otaState = .uploading
do {
try await self.performChunkedTransfer(connection: connection, data: data)
await MainActor.run {
self.transferContinuation?.yield()
self.transferContinuation?.finish()
self.transferContinuation = nil
}
} catch {
await MainActor.run {
self.transferContinuation?.finish(throwing: error)
self.transferContinuation = nil
}
}
connection.cancel()
}
case .failed(let error):
Task { @MainActor in
self.transferContinuation?.finish(throwing: error)
self.transferContinuation = nil
}
default: break
}
}
}
nonisolated private func performChunkedTransfer(connection: NWConnection, data: Data) async throws {
var offset = 0
let totalSize = data.count
while offset < totalSize {
let endIndex = min(offset + chunkSize, totalSize)
let chunk = data[offset..<endIndex]
try await connection.sendAsync(data: chunk)
offset += chunk.count
let percent = Double(offset) / Double(totalSize)
if offset % (chunkSize * 10) == 0 {
await MainActor.run {
self.progress = percent
self.statusMessage = "Please stay on this screen while update completes..."
}
}
}
await MainActor.run {
self.progress = 1.0
self.statusMessage = "Done..."
}
try await Task.sleep(nanoseconds: 3_000_000_000)
}
}
// MARK: - Extensions
enum OTAError: Error, LocalizedError {
case encodingFailed
case connectionFailed
case unexpectedResponse(String)
case authFailed
case timeout
var errorDescription: String? {
switch self {
case .timeout: return "ESP32 failed to respond in time."
case .connectionFailed: return "Failed to establish connection."
case .unexpectedResponse(let r): return "Unexpected response: \(r)"
default: return "OTA Error"
}
}
}
extension NWConnection {
func sendAsync(data: Data) async throws {
return try await withCheckedThrowingContinuation { continuation in
self.send(content: data, completion: .contentProcessed { error in
if let error = error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
})
}
}
}

View file

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

View file

@ -0,0 +1,125 @@
//
// CircularProgressView.swift
// Meshtastic
//
// Created by jake on 12/18/25.
//
import SwiftUI
struct CircularProgressView: View {
let progress: Double
var isIndeterminate: Bool = false
var lineWidth: CGFloat = 20
var size: CGFloat = 150
var strokeColor: Color = .blue
var backgroundColor: Color = .gray.opacity(0.2)
var percentageFontSize: CGFloat = 48.0
var subtitleText: String = "Loading..."
var showSubtitle: Bool = true
@State private var rotation: Double = 0
private var isComplete: Bool {
progress >= 1.0 && !isIndeterminate
}
var body: some View {
ZStack {
// 1. Background circle
Circle()
.stroke(backgroundColor, lineWidth: lineWidth)
// 2. Progress circle
Circle()
.trim(from: 0, to: isIndeterminate ? 0.25 : progress)
.stroke(
isComplete ? .green : strokeColor,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
// Logic: If indeterminate, spin. If not, fixed at -90 (12 o'clock)
.rotationEffect(.degrees(isIndeterminate ? rotation : -90))
// Only animate the progress filling up, not the mode switch
.animation(isIndeterminate ? nil : .spring(response: 0.6), value: progress)
// This tells SwiftUI: "If isIndeterminate changes, this is a NEW view."
// This forces the old spinning view to be destroyed (killing the animation)
// and a new static view to be created.
.id(isIndeterminate)
// 3. Content
if isComplete {
completedView
} else {
inProgressView
}
}
.frame(width: size, height: size)
.onAppear {
updateAnimationStatus()
}
.onChange(of: isIndeterminate) { _, _ in
updateAnimationStatus()
}
}
private func updateAnimationStatus() {
if isIndeterminate {
// Reset rotation to 0 without animation to start clean
rotation = 0
// Start the infinite spin
withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) {
rotation = 360
}
} else {
// Determine mode: The .id() modifier handles the visual stop,
// but we reset the state here for cleanliness.
// We use a transaction to disable animations for this state reset.
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
rotation = 0
}
}
}
// Extracted views remain the same...
private var completedView: some View {
ZStack {
Circle()
.fill(Color.green.opacity(0.15))
.frame(width: size * 0.6, height: size * 0.6)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: percentageFontSize * 1.5, weight: .bold))
.foregroundColor(.green)
}
.transition(.scale.combined(with: .opacity))
}
private var inProgressView: some View {
VStack(spacing: 8) {
if !isIndeterminate {
Text("\(Int(progress * 100))%")
.font(.system(size: percentageFontSize, weight: .bold))
.foregroundColor(.primary)
.contentTransition(.numericText())
.animation(.default, value: progress)
} else {
Image(systemName: "clock")
.font(.system(size: percentageFontSize * 0.8))
.foregroundColor(strokeColor)
}
if showSubtitle {
// Modified to prefer the passed-in text unless it's empty,
// falling back to "Please wait" only if needed.
Text(isIndeterminate && subtitleText == "Loading..." ? "Please wait" : subtitleText)
.font(.callout)
.foregroundColor(.secondary)
}
}
.transition(.opacity)
}
}

View file

@ -87,119 +87,3 @@ struct NRFDFUSheet: View {
}
}
struct CircularProgressView: View {
let progress: Double
var isIndeterminate: Bool = false
var lineWidth: CGFloat = 20
var size: CGFloat = 150
var strokeColor: Color = .blue
var backgroundColor: Color = .gray.opacity(0.2)
var percentageFontSize: CGFloat = 48.0
var subtitleText: String = "Loading..."
var showSubtitle: Bool = true
@State private var rotation: Double = 0
private var isComplete: Bool {
progress >= 1.0 && !isIndeterminate
}
var body: some View {
ZStack {
// 1. Background circle
Circle()
.stroke(backgroundColor, lineWidth: lineWidth)
// 2. Progress circle
Circle()
.trim(from: 0, to: isIndeterminate ? 0.25 : progress)
.stroke(
isComplete ? .green : strokeColor,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
// Logic: If indeterminate, spin. If not, fixed at -90 (12 o'clock)
.rotationEffect(.degrees(isIndeterminate ? rotation : -90))
// Only animate the progress filling up, not the mode switch
.animation(isIndeterminate ? nil : .spring(response: 0.6), value: progress)
// This tells SwiftUI: "If isIndeterminate changes, this is a NEW view."
// This forces the old spinning view to be destroyed (killing the animation)
// and a new static view to be created.
.id(isIndeterminate)
// 3. Content
if isComplete {
completedView
} else {
inProgressView
}
}
.frame(width: size, height: size)
.onAppear {
updateAnimationStatus()
}
.onChange(of: isIndeterminate) { _, _ in
updateAnimationStatus()
}
}
private func updateAnimationStatus() {
if isIndeterminate {
// Reset rotation to 0 without animation to start clean
rotation = 0
// Start the infinite spin
withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) {
rotation = 360
}
} else {
// Determine mode: The .id() modifier handles the visual stop,
// but we reset the state here for cleanliness.
// We use a transaction to disable animations for this state reset.
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
rotation = 0
}
}
}
// Extracted views remain the same...
private var completedView: some View {
ZStack {
Circle()
.fill(Color.green.opacity(0.15))
.frame(width: size * 0.6, height: size * 0.6)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: percentageFontSize * 1.5, weight: .bold))
.foregroundColor(.green)
}
.transition(.scale.combined(with: .opacity))
}
private var inProgressView: some View {
VStack(spacing: 8) {
if !isIndeterminate {
Text("\(Int(progress * 100))%")
.font(.system(size: percentageFontSize, weight: .bold))
.foregroundColor(.primary)
.contentTransition(.numericText())
.animation(.default, value: progress)
} else {
Image(systemName: "clock")
.font(.system(size: percentageFontSize * 0.8))
.foregroundColor(strokeColor)
}
if showSubtitle {
// Modified to prefer the passed-in text unless it's empty,
// falling back to "Please wait" only if needed.
Text(isIndeterminate && subtitleText == "Loading..." ? "Please wait" : subtitleText)
.font(.callout)
.foregroundColor(.secondary)
}
}
.transition(.opacity)
}
}