diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 9519db62..239e32b4 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -29866,6 +29866,10 @@ } } }, + "Retry" : { + "comment" : "A button label that says \"Retry\".", + "isCommentAutoGenerated" : true + }, "Retrying (attempt %@)" : { }, diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift index 1b98a4b6..b9959725 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift @@ -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 diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift index 1156bda5..510ec121 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift @@ -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? var connectionEventTask: Task ? var locationTask: Task? diff --git a/Meshtastic/Resources/images/image_manifest.json b/Meshtastic/Resources/images/image_manifest.json index d3a6c9aa..ccdc3904 100644 --- a/Meshtastic/Resources/images/image_manifest.json +++ b/Meshtastic/Resources/images/image_manifest.json @@ -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" diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTASheet.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTASheet.swift index a9f388d8..2b7b2249 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTASheet.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTASheet.swift @@ -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") diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel.swift index 76acae9a..59bd0162 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel.swift @@ -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(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 + } + } } - diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel2 2.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel2 2.swift deleted file mode 100644 index fc04476e..00000000 --- a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel2 2.swift +++ /dev/null @@ -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 \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..= 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 - } - } -} diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift index c47bff3f..6812d88a 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift @@ -159,7 +159,7 @@ struct ESP32OTAIntroSheet: View { } } } - } + }.textCase(nil) } private enum SupportedOTAMode { diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTASheet.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTASheet.swift index 6188e117..c23c205d 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTASheet.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTASheet.swift @@ -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)) diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTAViewModel.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTAViewModel.swift index fb5337d0..4636f194 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTAViewModel.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTAViewModel.swift @@ -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.. 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_" 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 \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..(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.. 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?>(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)" diff --git a/Meshtastic/Views/Settings/Firmware/Firmware.swift b/Meshtastic/Views/Settings/Firmware/Firmware.swift index 73cc2675..b48996ec 100644 --- a/Meshtastic/Views/Settings/Firmware/Firmware.swift +++ b/Meshtastic/Views/Settings/Firmware/Firmware.swift @@ -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 } } diff --git a/Meshtastic/Views/Settings/Firmware/Helpers/CircularProgressView.swift b/Meshtastic/Views/Settings/Firmware/Helpers/CircularProgressView.swift index 228094bf..8b81a1d6 100644 --- a/Meshtastic/Views/Settings/Firmware/Helpers/CircularProgressView.swift +++ b/Meshtastic/Views/Settings/Firmware/Helpers/CircularProgressView.swift @@ -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)