Work in progress

This commit is contained in:
Jake-B 2026-01-05 17:07:03 -05:00
parent 66abd091cc
commit f44a16697b
12 changed files with 592 additions and 543 deletions

View file

@ -29866,6 +29866,10 @@
}
}
},
"Retry" : {
"comment" : "A button label that says \"Retry\".",
"isCommentAutoGenerated" : true
},
"Retrying (attempt %@)" : {
},

View file

@ -33,7 +33,7 @@ extension AccessoryManager {
Logger.transport.debug("🔎 [Discovery] Existing discovery task is active.")
return
}
if suspendDiscovery { return }
if otaInProgress { return }
updateState(.discovering)
discoveryTask = Task { @MainActor in

View file

@ -149,7 +149,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
var connectionSteps: SequentialSteps?
// Public due to file separation
var suspendDiscovery: Bool = false
var otaInProgress: Bool = false
var discoveryTask: Task<Void, Never>?
var connectionEventTask: Task <Void, Error>?
var locationTask: Task<Void, Error>?

View file

@ -1,196 +1,196 @@
{
"files": {
"heltec-v3-case.svg": {
"etag": "\"e935a15ddd7cd116b9c4203f434ff627\""
},
"thinknode_m1.svg": {
"etag": "\"e525d5710fddf72e1626cf35346a6b25\""
},
"seeed-xiao-s3.svg": {
"etag": "\"9d583ddf39288934736d7ac248987524\""
},
"rak-wismeshtap.svg": {
"etag": "\"8c707dda5c384a10822d3ed785aeb411\""
},
"heltec-vision-master-e213.svg": {
"etag": "\"a56c7707865246300bd9e89b1f7155c5\""
},
"crowpanel_2_8.svg": {
"etag": "\"caad57326211a595f18b5f494ae24b59\""
},
"tbeam.svg": {
"etag": "\"ad1781f30226fbe36bae1cbad7e85bac\""
},
"rak2560.svg": {
"etag": "\"da3e309e4f746f0539e13b1f089411e3\""
},
"crowpanel_7_0.svg": {
"etag": "\"c593914e105b75ee978f5ce2e2a27f1c\""
"lilygo-tlora-pager.svg": {
"etag": "\"deb184deacb8006da18ae4751d2e0591\""
},
"wio-tracker-wm1110.svg": {
"etag": "\"2dfb221a6a481f957a59b81dfb0dbaf7\""
},
"heltec-mesh-node-t114-case.svg": {
"etag": "\"ac7c2abd66e7980db365006332d2b6e7\""
"heltec-v3-case.svg": {
"etag": "\"e935a15ddd7cd116b9c4203f434ff627\""
},
"heltec-ht62-esp32c3-sx1262.svg": {
"etag": "\"1f4d07a164cbc2cb99f26e4a05a763ea\""
},
"heltec-v3.svg": {
"etag": "\"0e22f17d2a0cd67159a222eb0a01bed1\""
},
"diy.svg": {
"etag": "\"7b670e81e7aace4814887ba681fc9f5b\""
},
"heltec-vision-master-e290.svg": {
"etag": "\"71b598c2c125b115663ab2d40abcd154\""
},
"heltec-mesh-solar.svg": {
"etag": "\"6d3a4f6266a80493f42c0013e30bb31c\""
},
"seeed_xiao_nrf52_kit.svg": {
"etag": "\"660b2c3bee85adeccdd5de7ea8d06648\""
},
"rak_wismesh_tag.svg": {
"etag": "\"257d649982a6689ec7e7c326c0b4dd2f\""
},
"nano-g2-ultra.svg": {
"etag": "\"82575f89ab2f60ffe6c1e009b19b596e\""
},
"wio_tracker_l1_case.svg": {
"etag": "\"21eccba8adbb33b1df19fe0de79a8734\""
},
"lilygo-tlora-pager.svg": {
"etag": "\"deb184deacb8006da18ae4751d2e0591\""
},
"heltec_mesh_pocket.svg": {
"etag": "\"933aafb0ce3a7b0e1faa67e951bc98ea\""
},
"rak11200.svg": {
"etag": "\"1a0bfda4331a9bfd29722382a787c700\""
},
"rak11310.svg": {
"etag": "\"0761c4ec6607993e6133aca9634cd42e\""
},
"tlora-t3s3-v1.svg": {
"etag": "\"89510451d52482a475e9cc13503f11a6\""
},
"muzi_r1_neo.svg": {
"etag": "\"d73a20b71a27e530dc6fbe514f3e9d88\""
},
"thinknode_m3.svg": {
"etag": "\"9fbe23b50c26a8c0d5e80a1b9e5bef61\""
},
"tlora-v2-1-1_6.svg": {
"etag": "\"7a9de7eff40aab166d5ab9f251dedaaa\""
},
"rak-wismesh-tap-v2.svg": {
"etag": "\"4acc893e184de92446357fcb5bba7812\""
},
"t-echo.svg": {
"etag": "\"bd2db1e3f0764478a9841ff568abc807\""
},
"seeed-sensecap-indicator.svg": {
"etag": "\"7a0fc63602d8c978b75799032dfda252\""
},
"thinknode_m2.svg": {
"etag": "\"97441ac3a41d23e5e0f4702f5788643d\""
},
"crowpanel_3_5.svg": {
"etag": "\"2d4ee10776f01156dd9570da888be34f\""
},
"tbeam-s3-core.svg": {
"etag": "\"04c0dab7e74a5c1e647567e150136e5b\""
},
"tlora-t3s3-epaper.svg": {
"etag": "\"dfe63532b984fd3f34ce26b38e1f0807\""
},
"heltec_v4.svg": {
"etag": "\"54e84516a04e1276ca385b41c7aa8b8d\""
},
"heltec-wireless-tracker.svg": {
"etag": "\"bb7143e1b25d1d18d5727baf69a1caed\""
},
"station-g2.svg": {
"etag": "\"f0a75bb77ddfcd8fa4c080caa018e539\""
},
"heltec-vision-master-t190.svg": {
"etag": "\"7f58cc25f93b203c778a3d6d1c6dc53f\""
},
"muzi_base.svg": {
"etag": "\"d82c0733add18e61809c9a2434bf6148\""
},
"meteor_pro.svg": {
"etag": "\"47ba8e4bc6e224fbd3b09401573549dd\""
},
"tlora-v2-1-1_8.svg": {
"etag": "\"7a9de7eff40aab166d5ab9f251dedaaa\""
},
"rak4631.svg": {
"etag": "\"3f19ff501b98598546fb6d6e5db1151c\""
},
"heltec-wireless-paper.svg": {
"etag": "\"7a8d8c8e9e712f32ccdb32edcdaebf5e\""
"tbeam.svg": {
"etag": "\"ad1781f30226fbe36bae1cbad7e85bac\""
},
"heltec-mesh-node-t114.svg": {
"etag": "\"ca927ce170fba26438c557af0de47a1e\""
},
"seeed_solar.svg": {
"etag": "\"3cc4099ae22ed261b88f1a9f7d235275\""
"tlora-t3s3-v1.svg": {
"etag": "\"89510451d52482a475e9cc13503f11a6\""
},
"rak4631_case.svg": {
"etag": "\"d141ca68501d83f3ca19ed74cb7ce12e\""
},
"tdeck_pro.svg": {
"etag": "\"6fca0ce5392b390bb7aa690c57ab0fee\""
"techo_lite.svg": {
"etag": "\"42fdf86393b02396e828149f29295239\""
},
"rpipicow.svg": {
"etag": "\"04fd9771add804a62fbfe45b3d360f22\""
},
"wio_tracker_l1_eink.svg": {
"etag": "\"9074596ea8f08acacfa0ce2c9a48152f\""
},
"thinknode_m4.svg": {
"etag": "\"bf1503cde2927c24cafaaeeb1cada43f\""
},
"crowpanel_2_4.svg": {
"etag": "\"3aa8b71d6e9d16f82fddde4ba8b472bd\""
},
"promicro.svg": {
"etag": "\"d100b5d3aacf51191d7c4a7eb28db231\""
},
"t-watch-s3.svg": {
"etag": "\"2e474b5742ec392304c939b4ec63d466\""
},
"tracker-t1000-e.svg": {
"etag": "\"b4194c4bb550f8ccbbf205489f37134c\""
},
"pico.svg": {
"etag": "\"9f6b3557953065cce6d56ba6e6d48241\""
"seeed-sensecap-indicator.svg": {
"etag": "\"7a0fc63602d8c978b75799032dfda252\""
},
"rak3401.svg": {
"etag": "\"57ad9217c0455c1786cfb3c2f1471651\""
},
"m5_c6l.svg": {
"etag": "\"f17cb7e59a20ccf41243c666cbe54546\""
"seeed-xiao-s3.svg": {
"etag": "\"9d583ddf39288934736d7ac248987524\""
},
"crowpanel_5_0.svg": {
"etag": "\"a2920df06d5335284db85a2016c0c6c6\""
"t-echo.svg": {
"etag": "\"bd2db1e3f0764478a9841ff568abc807\""
},
"heltec-mesh-node-t114-case.svg": {
"etag": "\"ac7c2abd66e7980db365006332d2b6e7\""
},
"thinknode_m3.svg": {
"etag": "\"9fbe23b50c26a8c0d5e80a1b9e5bef61\""
},
"promicro.svg": {
"etag": "\"d100b5d3aacf51191d7c4a7eb28db231\""
},
"seeed_solar.svg": {
"etag": "\"3cc4099ae22ed261b88f1a9f7d235275\""
},
"meteor_pro.svg": {
"etag": "\"47ba8e4bc6e224fbd3b09401573549dd\""
},
"rak11310.svg": {
"etag": "\"0761c4ec6607993e6133aca9634cd42e\""
},
"heltec_v4.svg": {
"etag": "\"54e84516a04e1276ca385b41c7aa8b8d\""
},
"rak-wismeshtap.svg": {
"etag": "\"8c707dda5c384a10822d3ed785aeb411\""
},
"heltec-mesh-solar.svg": {
"etag": "\"6d3a4f6266a80493f42c0013e30bb31c\""
},
"tlora-v2-1-1_6.svg": {
"etag": "\"7a9de7eff40aab166d5ab9f251dedaaa\""
},
"diy.svg": {
"etag": "\"7b670e81e7aace4814887ba681fc9f5b\""
},
"seeed_xiao_nrf52_kit.svg": {
"etag": "\"660b2c3bee85adeccdd5de7ea8d06648\""
},
"crowpanel_3_5.svg": {
"etag": "\"2d4ee10776f01156dd9570da888be34f\""
},
"heltec-wireless-paper.svg": {
"etag": "\"7a8d8c8e9e712f32ccdb32edcdaebf5e\""
},
"crowpanel_2_4.svg": {
"etag": "\"3aa8b71d6e9d16f82fddde4ba8b472bd\""
},
"crowpanel_2_8.svg": {
"etag": "\"caad57326211a595f18b5f494ae24b59\""
},
"tlora-v2-1-1_8.svg": {
"etag": "\"7a9de7eff40aab166d5ab9f251dedaaa\""
},
"heltec_mesh_pocket.svg": {
"etag": "\"933aafb0ce3a7b0e1faa67e951bc98ea\""
},
"tbeam-s3-core.svg": {
"etag": "\"04c0dab7e74a5c1e647567e150136e5b\""
},
"t-watch-s3.svg": {
"etag": "\"2e474b5742ec392304c939b4ec63d466\""
},
"rak2560.svg": {
"etag": "\"da3e309e4f746f0539e13b1f089411e3\""
},
"pico.svg": {
"etag": "\"9f6b3557953065cce6d56ba6e6d48241\""
},
"heltec-wireless-tracker.svg": {
"etag": "\"bb7143e1b25d1d18d5727baf69a1caed\""
},
"rak11200.svg": {
"etag": "\"1a0bfda4331a9bfd29722382a787c700\""
},
"heltec-ht62-esp32c3-sx1262.svg": {
"etag": "\"1f4d07a164cbc2cb99f26e4a05a763ea\""
},
"heltec-vision-master-e213.svg": {
"etag": "\"a56c7707865246300bd9e89b1f7155c5\""
},
"crowpanel_7_0.svg": {
"etag": "\"c593914e105b75ee978f5ce2e2a27f1c\""
},
"rak4631.svg": {
"etag": "\"3f19ff501b98598546fb6d6e5db1151c\""
},
"wio_tracker_l1_eink.svg": {
"etag": "\"9074596ea8f08acacfa0ce2c9a48152f\""
},
"rak_wismesh_tag.svg": {
"etag": "\"257d649982a6689ec7e7c326c0b4dd2f\""
},
"t-echo_plus.svg": {
"etag": "\"33167aba85fe48d9d7d724f024a9ddf2\""
},
"muzi_base.svg": {
"etag": "\"d82c0733add18e61809c9a2434bf6148\""
},
"wio_tracker_l1_case.svg": {
"etag": "\"21eccba8adbb33b1df19fe0de79a8734\""
},
"thinknode_m4.svg": {
"etag": "\"bf1503cde2927c24cafaaeeb1cada43f\""
},
"rak-wismesh-tap-v2.svg": {
"etag": "\"4acc893e184de92446357fcb5bba7812\""
},
"station-g2.svg": {
"etag": "\"f0a75bb77ddfcd8fa4c080caa018e539\""
},
"t-deck.svg": {
"etag": "\"2187caebf4304bb2308c8ee3ca74dd60\""
},
"techo_lite.svg": {
"etag": "\"42fdf86393b02396e828149f29295239\""
"crowpanel_5_0.svg": {
"etag": "\"a2920df06d5335284db85a2016c0c6c6\""
},
"heltec-wsl-v3.svg": {
"etag": "\"3ecfe8273cdf0d7dfb04dad6c3fa449a\""
},
"tracker-t1000-e.svg": {
"etag": "\"b4194c4bb550f8ccbbf205489f37134c\""
},
"thinknode_m1.svg": {
"etag": "\"e525d5710fddf72e1626cf35346a6b25\""
},
"heltec-vision-master-t190.svg": {
"etag": "\"7f58cc25f93b203c778a3d6d1c6dc53f\""
},
"heltec-vision-master-e290.svg": {
"etag": "\"71b598c2c125b115663ab2d40abcd154\""
},
"rak_3312.svg": {
"etag": "\"a2b5c4fdf127868323c8129f84f8691e\""
},
"heltec-wsl-v3.svg": {
"etag": "\"3ecfe8273cdf0d7dfb04dad6c3fa449a\""
"nano-g2-ultra.svg": {
"etag": "\"82575f89ab2f60ffe6c1e009b19b596e\""
},
"thinknode_m2.svg": {
"etag": "\"97441ac3a41d23e5e0f4702f5788643d\""
},
"heltec-v3.svg": {
"etag": "\"0e22f17d2a0cd67159a222eb0a01bed1\""
},
"m5_c6l.svg": {
"etag": "\"f17cb7e59a20ccf41243c666cbe54546\""
},
"rpipicow.svg": {
"etag": "\"04fd9771add804a62fbfe45b3d360f22\""
},
"tdeck_pro.svg": {
"etag": "\"6fca0ce5392b390bb7aa690c57ab0fee\""
},
"muzi_r1_neo.svg": {
"etag": "\"d73a20b71a27e530dc6fbe514f3e9d88\""
},
"tlora-t3s3-epaper.svg": {
"etag": "\"dfe63532b984fd3f34ce26b38e1f0807\""
}
},
"api_hash": "afd2db6d7119fe62bf6575760b3c7f635711f437eeab4ef306065d92b136fd9d"

View file

@ -16,7 +16,7 @@ struct ESP32BLEOTASheet: View {
@Environment(\.dismiss) var dismiss
@Environment(\.managedObjectContext) var context
@StateObject var ota = ESP32BLEOTAViewModel()
@State var rebootNeeded = true
// The stuff were updating, and the place we're updating it to
let binFileURL: URL
@State var peripheral: CBPeripheral?
@ -60,6 +60,8 @@ struct ESP32BLEOTASheet: View {
VStack {
if ota.otaStatus == .idle {
beginBLEProcessButton()
} else if ota.otaStatus == .error {
retryButton()
} else {
Text("\(ota.statusMessage)")
.frame(maxWidth: .infinity)
@ -83,9 +85,36 @@ struct ESP32BLEOTASheet: View {
self.peripheral = await connection.peripheral
}
}.interactiveDismissDisabled(true)
.textCase(nil)
}
@ViewBuilder
func retryButton() -> some View {
VStack(spacing: 12) {
Text("Error: \(ota.statusMessage)")
.multilineTextAlignment(.center)
.foregroundStyle(.red)
.font(.headline)
Button {
var transaction = Transaction(animation: .none)
transaction.disablesAnimations = true
withTransaction(transaction) {
rebootNeeded = false
ota.retry()
}
} label: {
Label("Retry", systemImage: "arrow.clockwise")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.large)
}
}
@ViewBuilder
func beginBLEProcessButton() -> some View {
Button {
@ -93,21 +122,23 @@ struct ESP32BLEOTASheet: View {
if let connectedNode, let user = connectedNode.user {
Task {
do {
let data = try Data(contentsOf: binFileURL)
let sha256Digest = Data(SHA256.hash(data: data))
// Send the reboot command to the node via existing mesh protocol
try await accessoryManager.sendRebootOta(fromUser: user, toUser: user, mode: .otaBle, otaHash: sha256Digest)
// Disconnect app so the ViewModel can grab the new OTA-Mode advertisement
try await accessoryManager.disconnect()
// disable discovery
accessoryManager.suspendDiscovery = true
accessoryManager.stopDiscovery()
// Wait briefly for device to reboot
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
if rebootNeeded {
let data = try Data(contentsOf: binFileURL)
let sha256Digest = Data(SHA256.hash(data: data))
// Send the reboot command to the node via existing mesh protocol
try await accessoryManager.sendRebootOta(fromUser: user, toUser: user, mode: .otaBle, otaHash: sha256Digest)
// Disconnect app so the ViewModel can grab the new OTA-Mode advertisement
try await accessoryManager.disconnect()
// disable discovery
accessoryManager.otaInProgress = true
accessoryManager.stopDiscovery()
// Wait briefly for device to reboot
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
}
// Set auto-reconnect
accessoryManager.shouldAutomaticallyConnectToPreferredPeripheral = true
@ -116,7 +147,7 @@ struct ESP32BLEOTASheet: View {
await ota.startOTA(binURL: binFileURL, desiredPeripheral: peripheral?.identifier)
// restart discovery
accessoryManager.suspendDiscovery = false
accessoryManager.otaInProgress = false
accessoryManager.startDiscovery()
} catch {
Logger.mesh.error("Reboot Failed")

View file

@ -15,6 +15,20 @@ private let meshtasticOTAServiceId = CBUUID(string: "4FAFC201-1FB5-459E-8FCC-C5C
private let statusCharacteristicId = CBUUID(string: "62EC0272-3EC5-11EB-B378-0242AC130003") // ESP32 Send (Notify) -> "OK", "ACK", "ERR..."
private let otaCharacteristicId = CBUUID(string: "62EC0272-3EC5-11EB-B378-0242AC130005") // ESP32 Receive (Write)
enum BLEOTAFailure: Error, LocalizedError {
case timeout
case unexpectedResponse(String)
case disconnected
var errorDescription: String? {
switch self {
case .timeout: return "The operation timed out."
case .unexpectedResponse(let s): return "Device sent unexpected response: \(s)"
case .disconnected: return "Device disconnected unexpectedly."
}
}
}
@MainActor
final class ESP32BLEOTAViewModel: ObservableObject {
@Published var name = ""
@ -24,6 +38,14 @@ final class ESP32BLEOTAViewModel: ObservableObject {
private let ble = AsyncCentral()
// MARK: - User Actions
func retry() {
self.transferProgress = 0
self.statusMessage = ""
self.otaStatus = .idle
}
func startOTA(binURL: URL, desiredPeripheral: UUID?) async {
// Prevent screen sleep during update
UIApplication.shared.isIdleTimerDisabled = true
@ -33,27 +55,44 @@ final class ESP32BLEOTAViewModel: ObservableObject {
self.statusMessage = "Connecting..."
self.otaStatus = .waitingForConnection
// Scan has its own internal timeout logic in AsyncCentral
try await ble.waitUntilPoweredOn()
let peripheral = try await ble.scan(for: meshtasticOTAServiceId)
let peripheral = try await ble.scan(for: meshtasticOTAServiceId, timeout: 15.0)
name = peripheral.name ?? "unknown"
try await ble.connect(peripheral)
// Connect with timeout (10s)
try await withTimeout(seconds: 10) {
try await self.ble.connect(peripheral)
}
otaStatus = .connected
self.statusMessage = "Discovering Services..."
let services = try await ble.discoverServices([meshtasticOTAServiceId], on: peripheral)
// Discover Services with timeout (10s)
let services = try await withTimeout(seconds: 10) {
try await self.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)
// Discover Characteristics with timeout (10s)
let chars = try await withTimeout(seconds: 10) {
try await self.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 }
// --- 2. Setup Notification Stream ---
try await ble.setNotify(true, for: statusChar, on: peripheral)
// Timeout for setting notify (usually fast, but good to be safe)
try await withTimeout(seconds: 5) {
try await self.ble.setNotify(true, for: statusChar, on: peripheral)
}
let stream = ble.notifications(for: statusChar)
var iterator = stream.makeAsyncIterator()
@ -77,10 +116,18 @@ final class ESP32BLEOTAViewModel: ObservableObject {
// Wait for "OK" response from ESP32, handling "ERASING" intermediate state
var handshakeComplete = false
// Handshake loop
while !handshakeComplete {
guard let handshakeData = await iterator.next(),
let handshakeStr = String(data: handshakeData, encoding: .utf8) else {
throw OTAError.unexpectedResponse("Connection lost during handshake")
// We allow a generous timeout (30s) here because "ERASING" flash can take time on the ESP32
// before it sends the next message.
guard let handshakeData = try await withTimeout(seconds: 30, operation: {
await iterator.next()
}) else {
throw BLEOTAFailure.disconnected
}
guard let handshakeStr = String(data: handshakeData, encoding: .utf8) else {
throw BLEOTAFailure.unexpectedResponse("Encoding Error")
}
let trimmed = handshakeStr.trimmingCharacters(in: .whitespacesAndNewlines)
@ -91,10 +138,10 @@ final class ESP32BLEOTAViewModel: ObservableObject {
// Update UI to let user know the device is busy erasing partition
self.statusMessage = "Erasing partition..."
Logger.services.info("Device is erasing flash...")
// Continue loop to wait for OK
// We loop again, resetting the 30s timeout for the next message
} else {
// Any other response is an error
throw OTAError.unexpectedResponse(trimmed)
throw BLEOTAFailure.unexpectedResponse(trimmed)
}
}
@ -119,11 +166,16 @@ final class ESP32BLEOTAViewModel: ObservableObject {
let nextOffset = offset + chunk.count
// [FLOW CONTROL]
// Wait for ACK (or OK if last packet) before proceeding.
// This ensures the ESP32 has written the chunk to flash.
guard let respData = await iterator.next(),
let respStr = String(data: respData, encoding: .utf8) else {
throw OTAError.unexpectedResponse("Connection lost waiting for ACK")
// Wait for ACK (or OK if last packet).
// We use a 5 second timeout per chunk. If the device stalls, we fail.
guard let respData = try await withTimeout(seconds: 5.0, operation: {
await iterator.next()
}) else {
throw BLEOTAFailure.disconnected
}
guard let respStr = String(data: respData, encoding: .utf8) else {
throw BLEOTAFailure.unexpectedResponse("Encoding Error")
}
let trimmed = respStr.trimmingCharacters(in: .whitespacesAndNewlines)
@ -149,27 +201,57 @@ final class ESP32BLEOTAViewModel: ObservableObject {
break // Exit loop
} else {
// OK received before we finished sending? Error.
throw OTAError.unexpectedResponse("Premature OK received at offset \(nextOffset)")
throw BLEOTAFailure.unexpectedResponse("Premature OK received at offset \(nextOffset)")
}
} else {
// Likely ERR or garbage
throw OTAError.unexpectedResponse(trimmed)
throw BLEOTAFailure.unexpectedResponse(trimmed)
}
}
// Double check completion state
if self.otaStatus != .completed {
throw OTAError.unexpectedResponse("Stream ended without OK")
throw BLEOTAFailure.unexpectedResponse("Stream ended without OK")
}
ble.disconnect(peripheral)
} catch {
self.otaStatus = .error
self.statusMessage = "Error: \(error.localizedDescription)"
self.statusMessage = error.localizedDescription
Logger.services.error("OTA Failed: \(error.localizedDescription)")
}
UIApplication.shared.isIdleTimerDisabled = false
}
// MARK: - Helpers
/// Executes an async operation with a strict timeout.
/// - Parameters:
/// - seconds: The timeout duration.
/// - operation: The async closure to execute.
/// - Returns: The result of the operation.
/// - Throws: `BLEOTAFailure.timeout` if time expires, or rethrows errors from the operation.
private func withTimeout<T>(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T {
return try await withThrowingTaskGroup(of: T.self) { group in
// Task 1: The actual operation
group.addTask {
return try await operation()
}
// Task 2: The timer
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw BLEOTAFailure.timeout
}
// Wait for the first one to complete
let result = try await group.next()!
// Cancel the other task (e.g. if operation finishes, cancel timer)
group.cancelAll()
return result
}
}
}

View file

@ -1,178 +0,0 @@
//
// ESP32BLEOTAViewModel2.swift
// Meshtastic
//
// Created by jake on 12/21/25.
//
import Foundation
import CoreBluetooth
import OSLog
import UIKit
import CryptoKit
private let meshtasticOTAServiceId = CBUUID(string: "4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
private let statusCharacteristicId = CBUUID(string: "62EC0272-3EC5-11EB-B378-0242AC130003") // ESP32 Send (Notify) -> "OK", "ACK", "ERR..."
private let otaCharacteristicId = CBUUID(string: "62EC0272-3EC5-11EB-B378-0242AC130005") // ESP32 Receive (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 {
// Prevent screen sleep during update
UIApplication.shared.isIdleTimerDisabled = true
do {
// --- 1. Connection Phase ---
self.statusMessage = "Connecting..."
self.otaStatus = .waitingForConnection
try await ble.waitUntilPoweredOn()
let peripheral = try await ble.scan(for: meshtasticOTAServiceId)
name = peripheral.name ?? "unknown"
try await ble.connect(peripheral)
otaStatus = .connected
self.statusMessage = "Discovering Services..."
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 }
// --- 2. Setup Notification Stream ---
try await ble.setNotify(true, for: statusChar, on: peripheral)
let stream = ble.notifications(for: statusChar)
var iterator = stream.makeAsyncIterator()
// --- 3. Prepare Firmware & Command ---
let data = try Data(contentsOf: binURL)
let sha256Digest = SHA256.hash(data: data)
let fileHash = sha256Digest.map { String(format: "%02hhx", $0) }.joined()
let fileSize = data.count
Logger.services.info("Firmware Size: \(fileSize), Hash: \(fileHash)")
// Unified Protocol Command: "OTA <size> <hash>\n"
let command = "OTA \(fileSize) \(fileHash)\n"
// --- 4. Handshake ---
self.statusMessage = "Negotiating..."
// Send command
try await ble.writeValue(Data(command.utf8), for: otaChar, type: .withResponse, on: peripheral)
// Wait for "OK" response from ESP32, handling "ERASING" intermediate state
var handshakeComplete = false
while !handshakeComplete {
guard let handshakeData = await iterator.next(),
let handshakeStr = String(data: handshakeData, encoding: .utf8) else {
throw OTAError.unexpectedResponse("Connection lost during handshake")
}
let trimmed = handshakeStr.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "OK" {
handshakeComplete = true
} else if trimmed == "ERASING" {
// Update UI to let user know the device is busy erasing partition
self.statusMessage = "Erasing partition..."
Logger.services.info("Device is erasing flash...")
// Continue loop to wait for OK
} else {
// Any other response is an error
throw OTAError.unexpectedResponse(trimmed)
}
}
Logger.services.info("Handshake OK. Starting Stream.")
Logger.services.info("Handshake OK. Starting Stream.")
// --- 5. Upload Stream ---
self.otaStatus = .transferring
self.statusMessage = "Uploading..."
var offset = 0
// Use MTU - 3 bytes overhead for chunk size.
let chunkSize = peripheral.maximumWriteValueLength(for: .withoutResponse)
while offset < fileSize {
let endIndex = min(offset + chunkSize, fileSize)
let chunk = data.subdata(in: offset..<endIndex)
// Send chunk
try await ble.writeValue(chunk, for: otaChar, type: .withoutResponse, on: peripheral)
// Optimistically calculate new offset to determine if this was the last chunk
let nextOffset = offset + chunk.count
// [FLOW CONTROL]
// Wait for ACK (or OK if last packet) before proceeding.
// This ensures the ESP32 has written the chunk to flash.
guard let respData = await iterator.next(),
let respStr = String(data: respData, encoding: .utf8) else {
throw OTAError.unexpectedResponse("Connection lost waiting for ACK")
}
let trimmed = respStr.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "ACK" {
// Normal chunk processed successfully
offset = nextOffset
// Update UI occasionally
if offset % (chunkSize * 20) == 0 {
self.transferProgress = Double(offset) / Double(fileSize)
}
} else if trimmed == "OK" {
// "OK" indicates completion (hash verified, partition set).
// This should only happen on the very last chunk.
if nextOffset >= fileSize {
offset = nextOffset
self.transferProgress = 1.0
self.otaStatus = .completed
self.statusMessage = "Success! Rebooting..."
Logger.services.info("OTA Success (OK received on last chunk)")
break // Exit loop
} else {
// OK received before we finished sending? Error.
throw OTAError.unexpectedResponse("Premature OK received at offset \(nextOffset)")
}
} else {
// Likely ERR or garbage
throw OTAError.unexpectedResponse(trimmed)
}
}
// Double check completion state
if self.otaStatus != .completed {
throw OTAError.unexpectedResponse("Stream ended without OK")
}
} catch {
self.otaStatus = .error
self.statusMessage = "Error: \(error.localizedDescription)"
Logger.services.error("OTA Failed: \(error.localizedDescription)")
}
UIApplication.shared.isIdleTimerDisabled = false
}
}
}

View file

@ -159,7 +159,7 @@ struct ESP32OTAIntroSheet: View {
}
}
}
}
}.textCase(nil)
}
private enum SupportedOTAMode {

View file

@ -19,6 +19,7 @@ struct ESP32WifiOTASheet: View {
// The stuff were updating, and the place we're updating it to
let binFileURL: URL
@State var host: String?
@State var alreadyRebooted: Bool = false
init(binFileURL: URL, host: String? = nil) {
self.binFileURL = binFileURL
@ -66,6 +67,10 @@ struct ESP32WifiOTASheet: View {
.multilineTextAlignment(.center)
.font(.headline)
Button("Retry") {
ota.retry()
}
default:
Text("\(ota.statusMessage, default: "")")
.frame(maxWidth: .infinity)
@ -92,6 +97,31 @@ struct ESP32WifiOTASheet: View {
}
@ViewBuilder
func retryButton() -> some View {
VStack(spacing: 12) {
Text("Error: \(ota.statusMessage)")
.multilineTextAlignment(.center)
.foregroundStyle(.red)
.font(.headline)
Button {
var transaction = Transaction(animation: .none)
transaction.disablesAnimations = true
withTransaction(transaction) {
ota.retry()
}
} label: {
Label("Retry", systemImage: "arrow.clockwise")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.large)
}
}
@ViewBuilder
func beginWifiProcessButton() -> some View {
Button {
@ -100,14 +130,18 @@ struct ESP32WifiOTASheet: View {
Task {
do {
if let host {
let data = try Data(contentsOf: binFileURL)
let digest = SHA256.hash(data: data)
let sha256Digest = Data(digest)
Logger.services.debug("Requesting reboot for OTA with hash: \(digest)")
let device = accessoryManager.activeConnection?.device
try await accessoryManager.sendRebootOta(fromUser: user, toUser: user, mode: .otaWifi, otaHash: sha256Digest)
try await Task.sleep(for: .seconds(0.5))
try await accessoryManager.disconnect()
if !alreadyRebooted {
let data = try Data(contentsOf: binFileURL)
let digest = SHA256.hash(data: data)
let sha256Digest = Data(digest)
Logger.services.debug("Requesting reboot for OTA with hash: \(digest)")
try await accessoryManager.sendRebootOta(fromUser: user, toUser: user, mode: .otaWifi, otaHash: sha256Digest)
try await Task.sleep(for: .seconds(0.5))
try await accessoryManager.disconnect()
alreadyRebooted = true
}
await ota.startUpdate(host: host, firmwareUrl: self.binFileURL)
if let device {
try await Task.sleep(for: .seconds(3))

View file

@ -16,16 +16,23 @@ class ESP32WifiOTAViewModel: ObservableObject {
// MARK: - Constants
private let port: NWEndpoint.Port = 3232
private let chunkSize = 4096 // 4KB chunks for efficient TCP transfer
private let chunkSize = 1024
// How long to wait for the device to reboot and accept TCP connections (Manual Host)
// or broadcast UDP packets (Auto Discovery)
private let connectionTimeout: TimeInterval = 60.0
private let packetTimeout: TimeInterval = 10.0
private let finalVerifyTimeout: TimeInterval = 30.0
// MARK: - Public Interface
func retry() {
self.progress = 0
self.statusMessage = "Idle"
self.errorMessage = nil
self.otaState = .idle
}
func startUpdate(host: String? = nil, firmwareUrl: URL, password: String? = nil) async {
guard otaState == .idle else { return }
guard otaState == .idle || otaState == .error else { return }
progress = 0.0
errorMessage = nil
@ -41,7 +48,6 @@ class ESP32WifiOTAViewModel: ObservableObject {
Logger.services.info("[ESP OTA] Using manual host: \(manualHost)")
targetEndpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
// Wait for the device to reboot and become responsive on TCP
statusMessage = "Waiting for device..."
try await waitForManualHostReboot(endpoint: targetEndpoint)
} else {
@ -56,6 +62,7 @@ class ESP32WifiOTAViewModel: ObservableObject {
try await connectAndUpload(endpoint: targetEndpoint, data: firmwareData)
// 3. Success
statusMessage = "Success!"
otaState = .completed
Logger.services.info("[ESP OTA] Update Complete")
@ -68,77 +75,179 @@ class ESP32WifiOTAViewModel: ObservableObject {
}
}
// MARK: - Manual Host Logic (TCP Polling)
// MARK: - TCP Protocol Logic
/// Actively probes the target port until a connection is accepted or timeout occurs.
private func waitForManualHostReboot(endpoint: NWEndpoint) async throws {
let deadline = Date().addingTimeInterval(connectionTimeout)
private func connectAndUpload(endpoint: NWEndpoint, data: Data) async throws {
let connection = NWConnection(to: endpoint, using: .tcp)
let reader = AsyncLineReader(connection: connection)
Logger.services.info("[ESP OTA] Probing TCP \(String(describing: endpoint)) for availability...")
// 1. Establish TCP Connection
connection.start(queue: .global())
try await waitForConnectionReady(connection)
while Date() < deadline {
if await probeTcpConnection(endpoint: endpoint) {
Logger.services.info("[ESP OTA] TCP Probe successful. Device is up.")
return
// 2. Prepare Command
let sha256Digest = SHA256.hash(data: data)
let fileHash = sha256Digest.map { String(format: "%02hhx", $0) }.joined()
let command = "OTA \(data.count) \(fileHash)\n"
Logger.services.info("[ESP OTA] Sending Command: \(command)")
// 3. Send Command
try await connection.sendAsync(data: command.data(using: .utf8)!)
// 4. Handshake (Wait for "OK" or "ERASING")
var handshakeComplete = false
while !handshakeComplete {
let response: String
do {
// Timeout logic relies on the Reader unblocking immediately on cancel
response = try await withTimeout(seconds: 60.0) {
try await reader.readLine()
}
} catch {
Logger.services.error("[ESP OTA] Handshake Timeout. Cancelling connection.")
connection.cancel()
throw error
}
let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "OK" {
handshakeComplete = true
} else if trimmed == "ERASING" {
await updateUI { self.statusMessage = "Erasing partition..." }
} else if trimmed.isEmpty {
continue
} else {
connection.cancel()
throw OTAError.unexpectedResponse(trimmed)
}
// Wait a bit before retrying
try await Task.sleep(for: .seconds(1))
}
// 5. Parallel Upload & Verification
await updateUI {
self.otaState = .transferring
self.statusMessage = "Uploading firmware..."
}
let isUploading = OSAllocatedUnfairLock(initialState: true)
try await withThrowingTaskGroup(of: Void.self) { group in
// TASK A: Writer (Sends Data)
group.addTask {
var offset = 0
let totalSize = data.count
while offset < totalSize {
try Task.checkCancellation()
let endIndex = min(offset + self.chunkSize, totalSize)
let chunk = data[offset..<endIndex]
try await connection.sendAsync(data: chunk)
offset += chunk.count
if offset % (self.chunkSize * 10) == 0 {
let percent = Double(offset) / Double(totalSize)
await self.updateUI { self.progress = percent }
}
}
isUploading.withLock { $0 = false }
Logger.services.info("[ESP OTA] Writer Task: All data sent.")
}
// TASK B: Reader (Processes ACKs and OK)
group.addTask {
var finished = false
while !finished {
try Task.checkCancellation()
let currentPhaseIsUploading = isUploading.withLock { $0 }
let timeoutDuration = currentPhaseIsUploading ? self.packetTimeout : self.finalVerifyTimeout
if !currentPhaseIsUploading {
await self.updateUI { self.statusMessage = "Verifying..." }
}
let line: String
do {
line = try await self.withTimeout(seconds: timeoutDuration) {
try await reader.readLine()
}
} catch {
Logger.services.error("[ESP OTA] Read Timeout. Cancelling connection.")
connection.cancel()
throw OTAError.timeout
}
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "OK" {
finished = true
} else if trimmed == "ACK" {
continue
} else if trimmed.isEmpty {
continue
} else {
connection.cancel()
throw OTAError.unexpectedResponse(trimmed)
}
}
}
try await group.waitForAll()
}
connection.cancel()
}
// MARK: - Helpers
private func waitForManualHostReboot(endpoint: NWEndpoint) async throws {
let deadline = Date().addingTimeInterval(connectionTimeout)
while Date() < deadline {
if await probeTcpConnection(endpoint: endpoint) { return }
try await Task.sleep(for: .seconds(1))
}
throw OTAError.timeout
}
/// Helper to attempt a single connection. Returns true if successful, false if refused/unreachable.
private func probeTcpConnection(endpoint: NWEndpoint) async -> Bool {
let connection = NWConnection(to: endpoint, using: .tcp)
return await withCheckedContinuation { continuation in
var hasResumed = false
connection.stateUpdateHandler = { state in
if hasResumed { return }
switch state {
case .ready:
hasResumed = true
connection.cancel() // Close the probe connection immediately
connection.cancel()
continuation.resume(returning: true)
case .failed(_):
case .failed(_), .waiting(_):
hasResumed = true
connection.cancel()
continuation.resume(returning: false)
case .waiting(_):
// .waiting usually implies the network interface is up but the host isn't responding (yet)
// We treat this as a fail for this specific probe attempt to trigger a retry loop
hasResumed = true
connection.cancel()
continuation.resume(returning: false)
default:
break
default: break
}
}
connection.start(queue: .global())
}
}
// MARK: - Auto Discovery Logic (UDP Listener)
/// Listens for UDP broadcasts on port 3232 to find the ESP32.
private func discoverDevice() async throws -> NWEndpoint {
let parameters = NWParameters.udp
parameters.allowLocalEndpointReuse = true
let listener = try NWListener(using: parameters, on: port)
return try await withCheckedThrowingContinuation { continuation in
let hasResumed = OSAllocatedUnfairLock(initialState: false)
// Handle incoming UDP packets
listener.newConnectionHandler = { newConnection in
newConnection.start(queue: .global())
newConnection.receiveMessage { data, context, isComplete, error in
if let data = data, let message = String(data: data, encoding: .utf8) {
// C++ sends: "MeshtasticOTA_<MAC>"
if message.hasPrefix("MeshtasticOTA") {
hasResumed.withLock { resumed in
if !resumed {
@ -157,10 +266,7 @@ class ESP32WifiOTAViewModel: ObservableObject {
newConnection.cancel()
}
}
listener.start(queue: .global())
// Timeout logic
Task {
try await Task.sleep(for: .seconds(connectionTimeout))
hasResumed.withLock { resumed in
@ -174,85 +280,8 @@ class ESP32WifiOTAViewModel: ObservableObject {
}
}
// MARK: - TCP Protocol Logic
private func connectAndUpload(endpoint: NWEndpoint, data: Data) async throws {
let connection = NWConnection(to: endpoint, using: .tcp)
// 1. Establish TCP Connection
connection.start(queue: .global())
try await waitForConnectionReady(connection)
// 2. Prepare Command: OTA <size> <hash>\n
// \n is critical for the C++ OtaProcessor to trigger parsing
let sha256Digest = SHA256.hash(data: data)
let fileHash = sha256Digest.map { String(format: "%02hhx", $0) }.joined()
let command = "OTA \(data.count) \(fileHash)\n"
Logger.services.info("[ESP OTA] Sending Command: \(command)")
// 3. Send Command
try await connection.sendAsync(data: command.data(using: .utf8)!)
// 4. Wait for initial "OK\n", handling "ERASING"
var handshakeComplete = false
while !handshakeComplete {
let response = try await readLine(from: connection)
let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "OK" {
handshakeComplete = true
} else if trimmed == "ERASING" {
await updateUI {
self.statusMessage = "Erasing partition..."
}
Logger.services.info("[ESP OTA] Device is erasing flash...")
} else {
throw OTAError.unexpectedResponse(response)
}
}
// 5. Stream Firmware Data
await updateUI {
self.otaState = .transferring
self.statusMessage = "Uploading firmware..."
}
try await performChunkedTransfer(connection: connection, data: data)
// 6. Wait for final "OK\n" (Validation/Flash Complete)
Logger.services.info("[ESP OTA] Upload done. Waiting for final verification...")
await updateUI {
self.statusMessage = "Verifying..."
self.progress = 1.0
}
// We set a longer receive timeout here because Flash operations (hash check) can take a few seconds
let finalResponse = try await readLine(from: connection)
if finalResponse.trimmingCharacters(in: .whitespacesAndNewlines) != "OK" {
throw OTAError.unexpectedResponse(finalResponse)
}
connection.cancel()
}
// MARK: - Network Helpers
/// Reads from connection until a newline character is found.
nonisolated private func readLine(from connection: NWConnection) async throws -> String {
return try await withCheckedThrowingContinuation { continuation in
connection.receive(minimumIncompleteLength: 1, maximumLength: 1024) { data, _, _, error in
if let error = error {
continuation.resume(throwing: error)
return
}
if let data = data, let string = String(data: data, encoding: .utf8) {
continuation.resume(returning: string)
} else {
continuation.resume(throwing: OTAError.encodingFailed)
}
}
}
private func updateUI(_ block: @MainActor @Sendable () -> Void) async {
await MainActor.run { block() }
}
nonisolated private func waitForConnectionReady(_ connection: NWConnection) async throws {
@ -276,38 +305,80 @@ class ESP32WifiOTAViewModel: ObservableObject {
}
}
nonisolated private func performChunkedTransfer(connection: NWConnection, data: Data) async throws {
var offset = 0
let totalSize = data.count
while offset < totalSize && !Task.isCancelled {
let endIndex = min(offset + chunkSize, totalSize)
let chunk = data[offset..<endIndex]
// We do NOT wait for ACK here.
// TCP handles flow control. C++ net_ota logic does not send ACKs for chunks.
try await connection.sendAsync(data: chunk)
offset += chunk.count
let percent = Double(offset) / Double(totalSize)
// Update UI periodically
if offset % (chunkSize * 10) == 0 {
await updateUI {
self.progress = percent
}
nonisolated private func withTimeout<T>(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T {
return try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { return try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw OTAError.timeout
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
private func updateUI(_ block: @MainActor @Sendable () -> Void) async {
await MainActor.run { block() }
}
}
// MARK: - Async Buffered Reader (Corrected for Deadlocks)
actor AsyncLineReader {
private let connection: NWConnection
private var buffer = Data()
init(connection: NWConnection) {
self.connection = connection
}
func readLine() async throws -> String {
while true {
try Task.checkCancellation()
if let range = buffer.range(of: Data([0x0A])) { // \n
let lineData = buffer.subdata(in: 0..<range.lowerBound)
buffer.removeSubrange(0..<range.upperBound)
return String(data: lineData, encoding: .utf8) ?? ""
}
let incoming = try await receiveNextChunk()
buffer.append(incoming)
}
}
private func receiveNextChunk() async throws -> Data {
// We use a Lock to hold the continuation so the cancellation handler
// can force-resume it if the network stack hangs.
let lock = OSAllocatedUnfairLock<CheckedContinuation<Data, Error>?>(initialState: nil)
return try await withTaskCancellationHandler {
return try await withCheckedThrowingContinuation { continuation in
lock.withLock { $0 = continuation }
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in
lock.withLock { state in
guard let cont = state else { return } // Already cancelled/resumed
state = nil
if let error = error {
cont.resume(throwing: error)
} else if let data = data {
cont.resume(returning: data)
} else {
cont.resume(throwing: OTAError.connectionFailed)
}
}
}
}
} onCancel: {
connection.cancel()
// Force resume the continuation to unblock 'await'
lock.withLock { state in
guard let cont = state else { return }
state = nil
cont.resume(throwing: CancellationError())
}
}
}
}
// MARK: - Extensions & Errors
enum OTAError: Error, LocalizedError {
case encodingFailed
case connectionFailed
@ -317,7 +388,7 @@ enum OTAError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case .timeout: return "Timeout waiting for device."
case .timeout: return "Device stopped responding."
case .connectionFailed: return "Failed to establish connection."
case .discoveryFailed: return "Could not discover ESP32."
case .unexpectedResponse(let r): return "Error from device: \(r)"

View file

@ -255,7 +255,7 @@ private struct FirmwareContentView: View {
}.buttonStyle(.bordered)
.controlSize(.small)
}
}
}.textCase(nil)
#else
HStack {
Text("Firmware Releases")
@ -269,7 +269,7 @@ private struct FirmwareContentView: View {
}
}
}
}
}.textCase(nil)
#endif
}
}

View file

@ -40,12 +40,17 @@ struct CircularProgressView: View {
)
// 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)
// MARK: - Animation Fix
// If indeterminate OR if progress is 0 (reset), we disable the animation (nil).
// Otherwise, we use the spring animation.
.animation(
(isIndeterminate || progress == 0) ? 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.
// This forces the old spinning view to be destroyed and a new static view to be created.
.id(isIndeterminate)
// 3. Content
@ -84,7 +89,7 @@ struct CircularProgressView: View {
}
}
// Extracted views remain the same...
// Extracted views...
private var completedView: some View {
ZStack {
Circle()
@ -105,7 +110,9 @@ struct CircularProgressView: View {
.font(.system(size: percentageFontSize, weight: .bold))
.foregroundColor(.primary)
.contentTransition(.numericText())
.animation(.default, value: progress)
// MARK: - Text Animation Fix
// Prevent the numbers from "rolling down" when resetting to 0
.animation(progress == 0 ? nil : .default, value: progress)
} else {
Image(systemName: "clock")
.font(.system(size: percentageFontSize * 0.8))
@ -113,8 +120,6 @@ struct CircularProgressView: View {
}
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)