mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Work in progress
This commit is contained in:
parent
66abd091cc
commit
f44a16697b
12 changed files with 592 additions and 543 deletions
|
|
@ -29866,6 +29866,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Retry" : {
|
||||
"comment" : "A button label that says \"Retry\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Retrying (attempt %@)" : {
|
||||
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>?
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -159,7 +159,7 @@ struct ESP32OTAIntroSheet: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.textCase(nil)
|
||||
}
|
||||
|
||||
private enum SupportedOTAMode {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue