mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
ESP32 WiFi Flashing
This commit is contained in:
parent
2c131599cd
commit
0897d9674d
15 changed files with 694 additions and 219 deletions
|
|
@ -2914,6 +2914,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Advanced Users Only." : {
|
||||
|
||||
},
|
||||
"After" : {
|
||||
"localizations" : {
|
||||
|
|
@ -8688,7 +8691,18 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Connection Attempt %lld of %lld" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Connection Attempt %1$lld of %2$lld"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Connection Attempt %lld of 10" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -10200,9 +10214,8 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Desktop Required" : {
|
||||
"comment" : "A heading explaining that the recommended way to update an ESP32 device is using the Web Flasher on a desktop computer.",
|
||||
"isCommentAutoGenerated" : true
|
||||
"Desktop Recommended" : {
|
||||
|
||||
},
|
||||
"Details" : {
|
||||
"comment" : "The title of the view that lists detailed information about a single database entity.",
|
||||
|
|
@ -15504,10 +15517,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"For advanced use cases, you can send a reboot command to the node using the following commands:" : {
|
||||
"comment" : "A description of how to send a reboot command to an ESP32.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"For all Mqtt functionality other than the map report you must also set uplink and downlink for each channel you want to bridge over Mqtt." : {
|
||||
"localizations" : {
|
||||
"it" : {
|
||||
|
|
@ -18224,6 +18233,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"If you device has the WiFi updater loaded into the OTA_1 partition, you can attempt to use the WiFi update process." : {
|
||||
|
||||
},
|
||||
"Ignore MQTT" : {
|
||||
"localizations" : {
|
||||
|
|
@ -28502,6 +28514,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Reboot into OTA Update Mode" : {
|
||||
"comment" : "A button that initiates a reboot of the connected device into OTA update mode.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Reboot node?" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
|
|
@ -29795,6 +29811,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Retrying (attempt %@)" : {
|
||||
|
||||
},
|
||||
"Retrying (attempt %lld)" : {
|
||||
"localizations" : {
|
||||
|
|
@ -32349,10 +32368,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Send Normal Reboot" : {
|
||||
"comment" : "A button that attempts to force an ESP32 device to reboot into normal operation.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Send Notifications" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
|
|
@ -40649,10 +40664,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Utilities" : {
|
||||
"comment" : "A section header that indicates advanced utilities for the ESP32 update process.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Utilizes the network connection on your phone to connect to MQTT." : {
|
||||
"localizations" : {
|
||||
"it" : {
|
||||
|
|
@ -41786,6 +41797,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"WiFi Firmware Update" : {
|
||||
|
||||
},
|
||||
"WiFi Options" : {
|
||||
"localizations" : {
|
||||
|
|
@ -41820,6 +41834,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"WiFi OTA Updating" : {
|
||||
|
||||
},
|
||||
"Will sleep everything as much as possible, for the tracker and sensor role this will also include the lora radio. Don't use this setting if you want to use your device with the phone apps or are using a device without a user button." : {
|
||||
"localizations" : {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@
|
|||
2315D1A02EECB44800E0FAE7 /* UF2MassStorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2315D19F2EECB44800E0FAE7 /* UF2MassStorageView.swift */; };
|
||||
2315D1A52EED94E800E0FAE7 /* FirmwareFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2315D1A42EED94E800E0FAE7 /* FirmwareFile.swift */; };
|
||||
2315D1A82EEF2ED400E0FAE7 /* SwiftDraw in Frameworks */ = {isa = PBXBuildFile; productRef = 2315D1A72EEF2ED400E0FAE7 /* SwiftDraw */; };
|
||||
23196A6E2EF1BA9100B1504B /* Esp32WifiOTAViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23196A6D2EF1BA9100B1504B /* Esp32WifiOTAViewModel.swift */; };
|
||||
23196C702EF42D3D00B1504B /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23196C6F2EF42D3D00B1504B /* CircularProgressView.swift */; };
|
||||
231A53782E69ADB900216B99 /* NodeFilterParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231A53772E69ADB900216B99 /* NodeFilterParameters.swift */; };
|
||||
231B3F212D087A4C0069A07D /* MetricTableColumn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */; };
|
||||
231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */; };
|
||||
|
|
@ -362,6 +364,8 @@
|
|||
2315D19C2EECB3D400E0FAE7 /* UTI+UF2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UTI+UF2.swift"; sourceTree = "<group>"; };
|
||||
2315D19F2EECB44800E0FAE7 /* UF2MassStorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UF2MassStorageView.swift; sourceTree = "<group>"; };
|
||||
2315D1A42EED94E800E0FAE7 /* FirmwareFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirmwareFile.swift; sourceTree = "<group>"; };
|
||||
23196A6D2EF1BA9100B1504B /* Esp32WifiOTAViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Esp32WifiOTAViewModel.swift; sourceTree = "<group>"; };
|
||||
23196C6F2EF42D3D00B1504B /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = "<group>"; };
|
||||
231A53772E69ADB900216B99 /* NodeFilterParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeFilterParameters.swift; sourceTree = "<group>"; };
|
||||
231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnList.swift; sourceTree = "<group>"; };
|
||||
231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -843,6 +847,14 @@
|
|||
path = Firmware;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
23196C712EF42D4300B1504B /* Helpers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
23196C6F2EF42D3D00B1504B /* CircularProgressView.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
231B3F1E2D0879BC0069A07D /* Metrics Visualization */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -917,6 +929,7 @@
|
|||
2315D19E2EECB42D00E0FAE7 /* U2F Mass Storage */,
|
||||
23C2BE322EEC3F7800F6A997 /* ESP32 DFU */,
|
||||
23C2BE2F2EEB821400F6A997 /* NRF DFU */,
|
||||
23196C712EF42D4300B1504B /* Helpers */,
|
||||
);
|
||||
path = Firmware;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -933,6 +946,7 @@
|
|||
23C2BE322EEC3F7800F6A997 /* ESP32 DFU */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
23196A6D2EF1BA9100B1504B /* Esp32WifiOTAViewModel.swift */,
|
||||
23C2BE332EEC3F9600F6A997 /* ESP32DFUSheet.swift */,
|
||||
);
|
||||
path = "ESP32 DFU";
|
||||
|
|
@ -1886,6 +1900,7 @@
|
|||
23C2BE272EE9F4BD00F6A997 /* DeviceHardwareEntity.swift in Sources */,
|
||||
D9C9839D2B79CFD700BDBE6A /* TextMessageSize.swift in Sources */,
|
||||
DDC94FCE29CF55310082EA6E /* RtttlConfig.swift in Sources */,
|
||||
23196A6E2EF1BA9100B1504B /* Esp32WifiOTAViewModel.swift in Sources */,
|
||||
DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */,
|
||||
DD1BEF4E2E03916A0090CE24 /* ChannelsHelp.swift in Sources */,
|
||||
DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */,
|
||||
|
|
@ -2060,6 +2075,7 @@
|
|||
25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */,
|
||||
DD3CC6C028E7A60700FA9159 /* MessagingEnums.swift in Sources */,
|
||||
DD6F657B2C6EC2900053C113 /* LockLegend.swift in Sources */,
|
||||
23196C702EF42D3D00B1504B /* CircularProgressView.swift in Sources */,
|
||||
DD97E96628EFD9820056DDA4 /* MeshtasticLogo.swift in Sources */,
|
||||
DDAB580D2B0DAA9E00147258 /* Routes.swift in Sources */,
|
||||
2344A2AB2D66974300170A77 /* ManagedAttributePropertyWrapper.swift in Sources */,
|
||||
|
|
@ -2318,7 +2334,18 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Meshtastic/Preview Content\"";
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
||||
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
|
||||
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
|
||||
ENABLE_RESOURCE_ACCESS_USB = NO;
|
||||
INFOPLIST_FILE = Meshtastic/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Meshtastic;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
|
|
@ -2353,7 +2380,18 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Meshtastic/Preview Content\"";
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
||||
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
|
||||
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
|
||||
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
|
||||
ENABLE_RESOURCE_ACCESS_USB = NO;
|
||||
INFOPLIST_FILE = Meshtastic/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Meshtastic;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ private let maxRetries = 1
|
|||
private let retryDelay: Duration = .seconds(2)
|
||||
|
||||
extension AccessoryManager {
|
||||
func connect(to device: Device, withConnection: Connection? = nil, wantConfig: Bool = true, wantDatabase: Bool = true, versionCheck: Bool = true) async throws {
|
||||
func connect(to device: Device, withConnection: Connection? = nil, wantConfig: Bool = true, wantDatabase: Bool = true, versionCheck: Bool = true, retries: Int? = nil) async throws {
|
||||
Logger.transport.info("AccessoryManager.connect(to: \(device.name, privacy: .public), withConnection: \(withConnection != nil), wantConfig: \(wantConfig), wantDatabase: \(wantDatabase), versionCheck: \(versionCheck))")
|
||||
// Prevent new connection if one is active
|
||||
if activeConnection != nil {
|
||||
|
|
@ -32,14 +32,14 @@ extension AccessoryManager {
|
|||
expectedNodeDBSize = nil
|
||||
|
||||
// Prepare to connect
|
||||
self.connectionStepper = SequentialSteps(maxRetries: maxRetries, retryDelay: retryDelay) {
|
||||
self.connectionStepper = SequentialSteps(maxRetries: retries ?? maxRetries, retryDelay: retryDelay) {
|
||||
|
||||
// Step 0
|
||||
Step { @MainActor retryAttempt in
|
||||
Logger.transport.info("🔗👟 [Connect] Starting connection to \(device.id, privacy: .public)")
|
||||
if retryAttempt > 0 {
|
||||
try await self.closeConnection() // clean-up before retries.
|
||||
self.updateState(.retrying(attempt: retryAttempt + 1))
|
||||
self.updateState(.retrying(attempt: retryAttempt + 1, maxAttempts: retries ?? maxRetries))
|
||||
self.allowDisconnect = true
|
||||
} else {
|
||||
self.updateState(.connecting)
|
||||
|
|
|
|||
|
|
@ -1437,9 +1437,9 @@ extension AccessoryManager {
|
|||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
}
|
||||
|
||||
public func sendRebootOta(fromUser: UserEntity, toUser: UserEntity) async throws {
|
||||
public func sendRebootOta(fromUser: UserEntity, toUser: UserEntity, rebootOtaSeconds: Int32 = 5) async throws {
|
||||
var adminPacket = AdminMessage()
|
||||
adminPacket.rebootOtaSeconds = 5
|
||||
adminPacket.rebootOtaSeconds = rebootOtaSeconds
|
||||
if fromUser != toUser {
|
||||
adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ enum AccessoryManagerState: Equatable {
|
|||
case idle
|
||||
case discovering
|
||||
case connecting
|
||||
case retrying(attempt: Int)
|
||||
case retrying(attempt: Int, maxAttempts: Int)
|
||||
case retrievingDatabase(nodeCount: Int)
|
||||
case communicating
|
||||
case subscribed
|
||||
|
|
@ -312,6 +312,10 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
|
||||
device[keyPath: key] = value
|
||||
self.activeConnection = (device: device, connection: activeConnection.connection)
|
||||
|
||||
}
|
||||
// Make sure activeDeviceNum is up to date.
|
||||
if key == \.num, self.activeDeviceNum != device.num {
|
||||
self.activeDeviceNum = device.num
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ actor TCPConnection: Connection {
|
|||
self.nwHost = NWEndpoint.Host(host)
|
||||
self.nwPort = NWEndpoint.Port(integerLiteral: UInt16(port))
|
||||
}
|
||||
|
||||
var host: NWEndpoint.Host {
|
||||
return nwHost
|
||||
}
|
||||
|
||||
private func waitForMagicBytes() async throws -> Bool {
|
||||
let startOfFrame: [UInt8] = [0x94, 0xc3]
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ class TCPTransport: NSObject, Transport, NetServiceBrowserDelegate, NetServiceDe
|
|||
name: name,
|
||||
transportType: .tcp,
|
||||
identifier: "\(host):\(port)")
|
||||
Logger.transport.debug("TCP found: \(name) \(host):\(port)")
|
||||
continuation?.yield(.deviceFound(device))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
</array>
|
||||
<key>com.apple.developer.carplay-communication</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.networking.custom-protocol</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.usernotifications.critical-alerts</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.weatherkit</key>
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ extension FirmwareFile {
|
|||
var description: String { return rawValue }
|
||||
|
||||
case uf2 = ".uf2"
|
||||
case bin = ".bin"
|
||||
case bin = "-update.bin"
|
||||
case otaZip = "-ota.zip"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -221,8 +221,8 @@ struct Connect: View {
|
|||
Text("Retreiving nodes . .")
|
||||
.font(.callout)
|
||||
.foregroundColor(.orange)
|
||||
case .retrying(let attempt):
|
||||
Text("Connection Attempt \(attempt) of 10")
|
||||
case .retrying(let attempt, let maxAttempts):
|
||||
Text("Connection Attempt \(attempt) of \(maxAttempts)")
|
||||
.font(.callout)
|
||||
.foregroundColor(.orange)
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -7,120 +7,165 @@
|
|||
|
||||
import SwiftUI
|
||||
import OSLog
|
||||
import Network
|
||||
|
||||
struct ESP32DFUSheet: View {
|
||||
private enum Step {
|
||||
case intro
|
||||
case updater
|
||||
}
|
||||
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.managedObjectContext) var context
|
||||
|
||||
@StateObject var ota = Esp32WifiOTAViewModel()
|
||||
let binFileURL: URL
|
||||
@State var host: NWEndpoint.Host?
|
||||
@State private var step: Step = .intro
|
||||
|
||||
init(binFileURL: URL) {
|
||||
self.binFileURL = binFileURL
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
|
||||
// MARK: - Info Card
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label("Desktop Required", systemImage: "desktopcomputer")
|
||||
.font(.headline)
|
||||
switch step {
|
||||
case .intro:
|
||||
VStack(spacing: 24) {
|
||||
|
||||
Text("The recommended way to update ESP32 devices is using the **Web Flasher** on a desktop computer (Chrome-based browser).")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text("The **Web Flasher** does not support updating on this device or over USB or BLE.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Link(destination: URL(string: "https://flash.meshtastic.org")!) {
|
||||
HStack {
|
||||
Text("Open Web Flasher")
|
||||
Image(systemName: "arrow.up.right")
|
||||
// MARK: - Info Card
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label("Desktop Recommended", systemImage: "desktopcomputer")
|
||||
.font(.headline)
|
||||
|
||||
Text("The recommended way to update ESP32 devices is using the **Web Flasher** on a desktop computer (Chrome-based browser).")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text("The **Web Flasher** does not support updating on this device or over USB or BLE.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Link(destination: URL(string: "https://flash.meshtastic.org")!) {
|
||||
HStack {
|
||||
Text("Open Web Flasher")
|
||||
Image(systemName: "arrow.up.right")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.regular)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.regular)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(UIColor.secondarySystemBackground))
|
||||
.cornerRadius(12)
|
||||
|
||||
Divider()
|
||||
|
||||
// MARK: - OTA Section
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Label("Utilities", systemImage: "exclamationmark.triangle")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.orange)
|
||||
.padding()
|
||||
.background(Color(UIColor.secondarySystemBackground))
|
||||
.cornerRadius(12)
|
||||
|
||||
Text("For advanced use cases, you can send a reboot command to the node using the following commands:")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label("WiFi OTA Updating", systemImage: "wifi")
|
||||
.font(.headline)
|
||||
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "lock.shield")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Text("Advanced Users Only.")
|
||||
.font(.callout)
|
||||
}
|
||||
|
||||
Text("If you device has the WiFi updater loaded into the OTA_1 partition, you can attempt to use the WiFi update process.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button(role: .destructive) {
|
||||
self.step = .updater
|
||||
} label: {
|
||||
Text("I Know What I'm Doing")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.frame(maxWidth: .infinity)
|
||||
.cornerRadius(10).disabled(accessoryManager.activeDeviceNum == nil)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(UIColor.secondarySystemBackground))
|
||||
.cornerRadius(12)
|
||||
}.padding(.top)
|
||||
.padding()
|
||||
|
||||
resetIntoOTAButton()
|
||||
normalRebootButton()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
case .updater:
|
||||
Text("WiFi Firmware Update")
|
||||
.font(.headline)
|
||||
|
||||
Text("Please do not leave this screen until this process is complete.")
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
|
||||
CircularProgressView(progress: ota.progress, isIndeterminate: (ota.otaState == .handshaking), size: 255.0, subtitleText: ota.otaState.rawValue)
|
||||
|
||||
VStack {
|
||||
switch ota.otaState {
|
||||
case .idle:
|
||||
beginUpdateProcessButton()
|
||||
|
||||
case .error:
|
||||
Text("Error: \(ota.errorMessage, default: "Unknown")")
|
||||
|
||||
default:
|
||||
Text("\(ota.statusMessage, default: "")")
|
||||
}
|
||||
}.frame(minHeight: 250.0)
|
||||
.padding()
|
||||
}
|
||||
.padding(.top)
|
||||
}.padding()
|
||||
// Standard Navigation Bar Setup
|
||||
.navigationTitle("ESP32 Update")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { // Standard placement for "Done" or "Close"
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}.navigationTitle("ESP32 Update")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { // Standard placement for "Done" or "Close"
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}.disabled(![.idle, .success, .error].contains(ota.otaState))
|
||||
}
|
||||
}
|
||||
|
||||
}// Standard Navigation Bar Setup
|
||||
|
||||
.onFirstAppear {
|
||||
if let connection = accessoryManager.activeConnection?.connection as? TCPConnection {
|
||||
self.host = connection.host
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
func normalRebootButton() -> some View {
|
||||
func beginUpdateProcessButton() -> some View {
|
||||
Button {
|
||||
let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context)
|
||||
if let connectedNode, let user = connectedNode.user {
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendRebootOta(fromUser: user, toUser: user)
|
||||
if let host {
|
||||
let device = accessoryManager.activeConnection?.device
|
||||
try await accessoryManager.sendRebootOta(fromUser: user, toUser: user, rebootOtaSeconds: 1)
|
||||
try await accessoryManager.disconnect()
|
||||
await ota.startUpdate(host: host, firmwareUrl: self.binFileURL)
|
||||
if let device {
|
||||
try await Task.sleep(for: .seconds(3))
|
||||
try await accessoryManager.connect(to: device, retries: 5)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.mesh.error("Reboot Failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Send Normal Reboot", systemImage: "square.and.arrow.down")
|
||||
}.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.frame(maxWidth: .infinity)
|
||||
.cornerRadius(10).disabled(accessoryManager.activeDeviceNum == nil)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func resetIntoOTAButton() -> some View {
|
||||
Button {
|
||||
let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context)
|
||||
if let connectedNode, let user = connectedNode.user {
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendEnterDfuMode(fromUser: user, toUser: user)
|
||||
} catch {
|
||||
Logger.mesh.error("Reboot Failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(" Send Reboot into DFU", systemImage: "square.and.arrow.down")
|
||||
Label("Reboot into OTA Update Mode", systemImage: "square.and.arrow.down")
|
||||
}.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.frame(maxWidth: .infinity)
|
||||
.cornerRadius(10).disabled(accessoryManager.activeDeviceNum == nil)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ESP32DFUSheet()
|
||||
// Mock environment object for preview to work
|
||||
.environmentObject(AccessoryManager())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,355 @@
|
|||
import Foundation
|
||||
import Network
|
||||
import CryptoKit
|
||||
import Combine
|
||||
import OSLog
|
||||
import os // Required for OSAllocatedUnfairLock
|
||||
|
||||
@MainActor
|
||||
class Esp32WifiOTAViewModel: ObservableObject {
|
||||
enum OTAState: String, CustomStringConvertible {
|
||||
var description: String { self.rawValue }
|
||||
|
||||
case idle = "Idle"
|
||||
case preparing = "Preparing"
|
||||
case handshaking = "Sending Handshake"
|
||||
case waitingForConnection = "Waiting for Connection"
|
||||
case uploading = "Uploading"
|
||||
case success = "Success"
|
||||
case error = "Error"
|
||||
}
|
||||
|
||||
// MARK: - Published State
|
||||
@Published var statusMessage: String = "Idle"
|
||||
@Published var progress: Double = 0.0
|
||||
@Published var isUpdating: Bool = false
|
||||
@Published var errorMessage: String? = nil
|
||||
@Published var otaState: OTAState = .idle
|
||||
|
||||
// MARK: - Constants
|
||||
private let espPort: NWEndpoint.Port = 3232
|
||||
private let chunkSize = 1460
|
||||
private let retryDelay: TimeInterval = 2.0
|
||||
private let handshakeTotalTimeout: TimeInterval = 30.0
|
||||
|
||||
private var transferContinuation: AsyncThrowingStream<Void, Error>.Continuation?
|
||||
|
||||
// MARK: - Public Interface
|
||||
|
||||
func startUpdate(host: NWEndpoint.Host, firmwareUrl: URL, password: String? = nil) async {
|
||||
guard !isUpdating else { return }
|
||||
|
||||
self.isUpdating = true
|
||||
self.progress = 0.0
|
||||
self.errorMessage = nil
|
||||
self.statusMessage = "Preparing..."
|
||||
self.otaState = .preparing
|
||||
|
||||
var listener: NWListener?
|
||||
|
||||
defer {
|
||||
listener?.cancel()
|
||||
self.isUpdating = false
|
||||
}
|
||||
|
||||
do {
|
||||
let firmwareData = try Data(contentsOf: firmwareUrl)
|
||||
|
||||
let transferStream = AsyncThrowingStream<Void, Error> { continuation in
|
||||
self.transferContinuation = continuation
|
||||
}
|
||||
|
||||
Logger.services.info("[ESP OTA] Starting local TCP Listener...")
|
||||
let (setupListener, localPort) = try await setupListener(sending: firmwareData)
|
||||
listener = setupListener
|
||||
Logger.services.info("[ESP OTA] Listening on port \(localPort)")
|
||||
|
||||
self.statusMessage = "Waiting for device. This can take a while..."
|
||||
self.otaState = .handshaking
|
||||
Logger.services.info("[ESP OTA] Starting Handshake loop...")
|
||||
|
||||
try await performHandshake(host: host,
|
||||
localPort: localPort,
|
||||
data: firmwareData,
|
||||
password: password)
|
||||
|
||||
self.otaState = .waitingForConnection
|
||||
for try await _ in transferStream { break }
|
||||
|
||||
self.statusMessage = "Success!"
|
||||
self.otaState = .success
|
||||
Logger.services.info("[ESP OTA] Update Complete")
|
||||
|
||||
} catch {
|
||||
Logger.services.error("[ESP OTA] Error: \(error.localizedDescription)")
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.statusMessage = "Failed"
|
||||
self.otaState = .error
|
||||
self.transferContinuation?.finish(throwing: error)
|
||||
self.transferContinuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 2: Handshake Logic
|
||||
|
||||
private actor HandshakeState {
|
||||
var currentPayload: Data
|
||||
init(initialPayload: Data) { self.currentPayload = initialPayload }
|
||||
func updatePayload(_ data: Data) { self.currentPayload = data }
|
||||
func getPayload() -> Data { return currentPayload }
|
||||
}
|
||||
|
||||
private func performHandshake(host: NWEndpoint.Host, localPort: UInt16, data: Data, password: String?) async throws {
|
||||
let initialPayload = try generateInvitationPayload(localPort: localPort, data: data, password: password, authNonce: nil)
|
||||
let state = HandshakeState(initialPayload: initialPayload)
|
||||
|
||||
let connection = NWConnection(host: host, port: espPort, using: .udp)
|
||||
defer { connection.cancel() }
|
||||
|
||||
connection.start(queue: .global())
|
||||
try await waitForConnectionReady(connection)
|
||||
|
||||
Logger.services.info("[ESP OTA] UDP Connection Ready. Starting broadcast/listen loop.")
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
// Task A: Broadcaster
|
||||
group.addTask {
|
||||
while !Task.isCancelled {
|
||||
let payload = await state.getPayload()
|
||||
connection.send(content: payload, completion: .contentProcessed { _ in })
|
||||
Logger.services.debug("[ESP OTA] Sent invitation packet")
|
||||
try await Task.sleep(nanoseconds: UInt64(self.retryDelay * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
|
||||
// Task B: Listener
|
||||
group.addTask {
|
||||
while !Task.isCancelled {
|
||||
let response = try await self.receiveNextMessage(connection: connection)
|
||||
|
||||
if response == "OK" {
|
||||
Logger.services.info("[ESP OTA] Handshake OK received!")
|
||||
return
|
||||
}
|
||||
|
||||
if response.hasPrefix("AUTH") {
|
||||
Logger.services.info("[ESP OTA] Auth challenge received: \(response)")
|
||||
let components = response.components(separatedBy: " ")
|
||||
if components.count > 1 {
|
||||
let nonce = components[1]
|
||||
let newPayload = try self.generateInvitationPayload(localPort: localPort,
|
||||
data: data,
|
||||
password: password,
|
||||
authNonce: nonce)
|
||||
await state.updatePayload(newPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Task C: Timeout
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(self.handshakeTotalTimeout * 1_000_000_000))
|
||||
throw OTAError.timeout
|
||||
}
|
||||
|
||||
try await group.next()
|
||||
group.cancelAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UDP Helpers (Nonisolated)
|
||||
|
||||
nonisolated private func receiveNextMessage(connection: NWConnection) async throws -> String {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
connection.receiveMessage { content, _, _, error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: error)
|
||||
} else if let data = content, let str = String(data: data, encoding: .utf8) {
|
||||
continuation.resume(returning: str.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
} else {
|
||||
continuation.resume(returning: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private func generateInvitationPayload(localPort: UInt16, data: Data, password: String?, authNonce: String?) throws -> Data {
|
||||
let fileMD5 = Insecure.MD5.hash(data: data).map { String(format: "%02hhx", $0) }.joined()
|
||||
let fileSize = data.count
|
||||
var message = "0 \(localPort) \(fileSize) \(fileMD5)"
|
||||
|
||||
if let nonce = authNonce, let pass = password {
|
||||
let authInput = pass + nonce
|
||||
if let authData = authInput.data(using: .utf8) {
|
||||
let authHash = Insecure.MD5.hash(data: authData).map { String(format: "%02hhx", $0) }.joined()
|
||||
message += " " + authHash
|
||||
}
|
||||
}
|
||||
|
||||
guard let payload = message.data(using: .utf8) else { throw OTAError.encodingFailed }
|
||||
return payload
|
||||
}
|
||||
|
||||
/// Uses OSAllocatedUnfairLock to safely ensure resume is called exactly once
|
||||
nonisolated private func waitForConnectionReady(_ connection: NWConnection) async throws {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let stateLock = OSAllocatedUnfairLock(initialState: false) // The Idiomatic Swift 6 Lock
|
||||
|
||||
connection.stateUpdateHandler = { state in
|
||||
// We lock, check if we already resumed, set to true, and perform logic
|
||||
stateLock.withLock { hasResumed in
|
||||
if hasResumed { return }
|
||||
|
||||
switch state {
|
||||
case .ready:
|
||||
hasResumed = true
|
||||
continuation.resume()
|
||||
case .failed(let err):
|
||||
hasResumed = true
|
||||
continuation.resume(throwing: err)
|
||||
case .cancelled:
|
||||
hasResumed = true
|
||||
continuation.resume(throwing: CancellationError())
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 1 & 4 (Listener & Transfer)
|
||||
|
||||
private func setupListener(sending firmware: Data) async throws -> (NWListener, UInt16) {
|
||||
let parameters = NWParameters(tls: nil)
|
||||
parameters.includePeerToPeer = true
|
||||
parameters.prohibitedInterfaceTypes = [.cellular]
|
||||
|
||||
let listener = try NWListener(using: parameters, on: .init(integerLiteral: 0))
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let stateLock = OSAllocatedUnfairLock(initialState: false)
|
||||
|
||||
listener.newConnectionHandler = { newConnection in
|
||||
Logger.services.info("[ESP OTA] Accepted connection from \(String(describing: newConnection.endpoint))")
|
||||
Task { @MainActor in
|
||||
self.handleIncomingConnection(connection: newConnection, data: firmware)
|
||||
newConnection.start(queue: .global())
|
||||
}
|
||||
}
|
||||
|
||||
listener.stateUpdateHandler = { state in
|
||||
stateLock.withLock { hasResumed in
|
||||
if hasResumed { return }
|
||||
|
||||
switch state {
|
||||
case .ready:
|
||||
if let port = listener.port {
|
||||
hasResumed = true
|
||||
continuation.resume(returning: (listener, port.rawValue))
|
||||
}
|
||||
case .failed(let error):
|
||||
hasResumed = true
|
||||
continuation.resume(throwing: error)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
listener.start(queue: .global())
|
||||
}
|
||||
}
|
||||
|
||||
private func handleIncomingConnection(connection: NWConnection, data: Data) {
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
Task { @MainActor in
|
||||
self.otaState = .uploading
|
||||
do {
|
||||
try await self.performChunkedTransfer(connection: connection, data: data)
|
||||
await MainActor.run {
|
||||
self.transferContinuation?.yield()
|
||||
self.transferContinuation?.finish()
|
||||
self.transferContinuation = nil
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.transferContinuation?.finish(throwing: error)
|
||||
self.transferContinuation = nil
|
||||
}
|
||||
}
|
||||
connection.cancel()
|
||||
}
|
||||
case .failed(let error):
|
||||
Task { @MainActor in
|
||||
self.transferContinuation?.finish(throwing: error)
|
||||
self.transferContinuation = nil
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private func performChunkedTransfer(connection: NWConnection, data: Data) async throws {
|
||||
var offset = 0
|
||||
let totalSize = data.count
|
||||
|
||||
while offset < totalSize {
|
||||
let endIndex = min(offset + chunkSize, totalSize)
|
||||
let chunk = data[offset..<endIndex]
|
||||
try await connection.sendAsync(data: chunk)
|
||||
|
||||
offset += chunk.count
|
||||
let percent = Double(offset) / Double(totalSize)
|
||||
|
||||
if offset % (chunkSize * 10) == 0 {
|
||||
await MainActor.run {
|
||||
self.progress = percent
|
||||
self.statusMessage = "Please stay on this screen while update completes..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.progress = 1.0
|
||||
self.statusMessage = "Done..."
|
||||
}
|
||||
|
||||
try await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
enum OTAError: Error, LocalizedError {
|
||||
case encodingFailed
|
||||
case connectionFailed
|
||||
case unexpectedResponse(String)
|
||||
case authFailed
|
||||
case timeout
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .timeout: return "ESP32 failed to respond in time."
|
||||
case .connectionFailed: return "Failed to establish connection."
|
||||
case .unexpectedResponse(let r): return "Unexpected response: \(r)"
|
||||
default: return "OTA Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NWConnection {
|
||||
func sendAsync(data: Data) async throws {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
self.send(content: data, completion: .contentProcessed { error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -329,7 +329,7 @@ private struct FirmwareRow: View {
|
|||
case .uf2:
|
||||
UF2MassStorageView(fileURL: firmwareFile.localUrl)
|
||||
case .bin:
|
||||
ESP32DFUSheet()
|
||||
ESP32DFUSheet(binFileURL: firmwareFile.localUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
//
|
||||
// CircularProgressView.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by jake on 12/18/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CircularProgressView: View {
|
||||
let progress: Double
|
||||
var isIndeterminate: Bool = false
|
||||
|
||||
var lineWidth: CGFloat = 20
|
||||
var size: CGFloat = 150
|
||||
var strokeColor: Color = .blue
|
||||
var backgroundColor: Color = .gray.opacity(0.2)
|
||||
var percentageFontSize: CGFloat = 48.0
|
||||
var subtitleText: String = "Loading..."
|
||||
var showSubtitle: Bool = true
|
||||
|
||||
@State private var rotation: Double = 0
|
||||
|
||||
private var isComplete: Bool {
|
||||
progress >= 1.0 && !isIndeterminate
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 1. Background circle
|
||||
Circle()
|
||||
.stroke(backgroundColor, lineWidth: lineWidth)
|
||||
|
||||
// 2. Progress circle
|
||||
Circle()
|
||||
.trim(from: 0, to: isIndeterminate ? 0.25 : progress)
|
||||
.stroke(
|
||||
isComplete ? .green : strokeColor,
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
// Logic: If indeterminate, spin. If not, fixed at -90 (12 o'clock)
|
||||
.rotationEffect(.degrees(isIndeterminate ? rotation : -90))
|
||||
// Only animate the progress filling up, not the mode switch
|
||||
.animation(isIndeterminate ? nil : .spring(response: 0.6), value: progress)
|
||||
|
||||
// This tells SwiftUI: "If isIndeterminate changes, this is a NEW view."
|
||||
// This forces the old spinning view to be destroyed (killing the animation)
|
||||
// and a new static view to be created.
|
||||
.id(isIndeterminate)
|
||||
|
||||
// 3. Content
|
||||
if isComplete {
|
||||
completedView
|
||||
} else {
|
||||
inProgressView
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.onAppear {
|
||||
updateAnimationStatus()
|
||||
}
|
||||
.onChange(of: isIndeterminate) { _, _ in
|
||||
updateAnimationStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAnimationStatus() {
|
||||
if isIndeterminate {
|
||||
// Reset rotation to 0 without animation to start clean
|
||||
rotation = 0
|
||||
// Start the infinite spin
|
||||
withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) {
|
||||
rotation = 360
|
||||
}
|
||||
} else {
|
||||
// Determine mode: The .id() modifier handles the visual stop,
|
||||
// but we reset the state here for cleanliness.
|
||||
// We use a transaction to disable animations for this state reset.
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
rotation = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extracted views remain the same...
|
||||
private var completedView: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.green.opacity(0.15))
|
||||
.frame(width: size * 0.6, height: size * 0.6)
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: percentageFontSize * 1.5, weight: .bold))
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
|
||||
private var inProgressView: some View {
|
||||
VStack(spacing: 8) {
|
||||
if !isIndeterminate {
|
||||
Text("\(Int(progress * 100))%")
|
||||
.font(.system(size: percentageFontSize, weight: .bold))
|
||||
.foregroundColor(.primary)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.default, value: progress)
|
||||
} else {
|
||||
Image(systemName: "clock")
|
||||
.font(.system(size: percentageFontSize * 0.8))
|
||||
.foregroundColor(strokeColor)
|
||||
}
|
||||
|
||||
if showSubtitle {
|
||||
// Modified to prefer the passed-in text unless it's empty,
|
||||
// falling back to "Please wait" only if needed.
|
||||
Text(isIndeterminate && subtitleText == "Loading..." ? "Please wait" : subtitleText)
|
||||
.font(.callout)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
|
@ -87,119 +87,3 @@ struct NRFDFUSheet: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct CircularProgressView: View {
|
||||
let progress: Double
|
||||
var isIndeterminate: Bool = false
|
||||
|
||||
var lineWidth: CGFloat = 20
|
||||
var size: CGFloat = 150
|
||||
var strokeColor: Color = .blue
|
||||
var backgroundColor: Color = .gray.opacity(0.2)
|
||||
var percentageFontSize: CGFloat = 48.0
|
||||
var subtitleText: String = "Loading..."
|
||||
var showSubtitle: Bool = true
|
||||
|
||||
@State private var rotation: Double = 0
|
||||
|
||||
private var isComplete: Bool {
|
||||
progress >= 1.0 && !isIndeterminate
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 1. Background circle
|
||||
Circle()
|
||||
.stroke(backgroundColor, lineWidth: lineWidth)
|
||||
|
||||
// 2. Progress circle
|
||||
Circle()
|
||||
.trim(from: 0, to: isIndeterminate ? 0.25 : progress)
|
||||
.stroke(
|
||||
isComplete ? .green : strokeColor,
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
// Logic: If indeterminate, spin. If not, fixed at -90 (12 o'clock)
|
||||
.rotationEffect(.degrees(isIndeterminate ? rotation : -90))
|
||||
// Only animate the progress filling up, not the mode switch
|
||||
.animation(isIndeterminate ? nil : .spring(response: 0.6), value: progress)
|
||||
|
||||
// This tells SwiftUI: "If isIndeterminate changes, this is a NEW view."
|
||||
// This forces the old spinning view to be destroyed (killing the animation)
|
||||
// and a new static view to be created.
|
||||
.id(isIndeterminate)
|
||||
|
||||
// 3. Content
|
||||
if isComplete {
|
||||
completedView
|
||||
} else {
|
||||
inProgressView
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.onAppear {
|
||||
updateAnimationStatus()
|
||||
}
|
||||
.onChange(of: isIndeterminate) { _, _ in
|
||||
updateAnimationStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAnimationStatus() {
|
||||
if isIndeterminate {
|
||||
// Reset rotation to 0 without animation to start clean
|
||||
rotation = 0
|
||||
// Start the infinite spin
|
||||
withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) {
|
||||
rotation = 360
|
||||
}
|
||||
} else {
|
||||
// Determine mode: The .id() modifier handles the visual stop,
|
||||
// but we reset the state here for cleanliness.
|
||||
// We use a transaction to disable animations for this state reset.
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
rotation = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extracted views remain the same...
|
||||
private var completedView: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.green.opacity(0.15))
|
||||
.frame(width: size * 0.6, height: size * 0.6)
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: percentageFontSize * 1.5, weight: .bold))
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
|
||||
private var inProgressView: some View {
|
||||
VStack(spacing: 8) {
|
||||
if !isIndeterminate {
|
||||
Text("\(Int(progress * 100))%")
|
||||
.font(.system(size: percentageFontSize, weight: .bold))
|
||||
.foregroundColor(.primary)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.default, value: progress)
|
||||
} else {
|
||||
Image(systemName: "clock")
|
||||
.font(.system(size: percentageFontSize * 0.8))
|
||||
.foregroundColor(strokeColor)
|
||||
}
|
||||
|
||||
if showSubtitle {
|
||||
// Modified to prefer the passed-in text unless it's empty,
|
||||
// falling back to "Please wait" only if needed.
|
||||
Text(isIndeterminate && subtitleText == "Loading..." ? "Please wait" : subtitleText)
|
||||
.font(.callout)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue