diff --git a/Localizable.xcstrings b/Localizable.xcstrings index cf531156..f665113c 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -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" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 8a02ef70..370c5f3b 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -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 = ""; }; 2315D19F2EECB44800E0FAE7 /* UF2MassStorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UF2MassStorageView.swift; sourceTree = ""; }; 2315D1A42EED94E800E0FAE7 /* FirmwareFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirmwareFile.swift; sourceTree = ""; }; + 23196A6D2EF1BA9100B1504B /* Esp32WifiOTAViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Esp32WifiOTAViewModel.swift; sourceTree = ""; }; + 23196C6F2EF42D3D00B1504B /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; 231A53772E69ADB900216B99 /* NodeFilterParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeFilterParameters.swift; sourceTree = ""; }; 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnList.swift; sourceTree = ""; }; 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = ""; }; @@ -843,6 +847,14 @@ path = Firmware; sourceTree = ""; }; + 23196C712EF42D4300B1504B /* Helpers */ = { + isa = PBXGroup; + children = ( + 23196C6F2EF42D3D00B1504B /* CircularProgressView.swift */, + ); + path = Helpers; + sourceTree = ""; + }; 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 = ""; @@ -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"; diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift index d5ca0929..f73b8d1d 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift @@ -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) diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift index d0fe1003..b5c06adc 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift @@ -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() } diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift index 07513866..89d1b271 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift @@ -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 } } diff --git a/Meshtastic/Accessory/Transports/TCP/TCPConnection.swift b/Meshtastic/Accessory/Transports/TCP/TCPConnection.swift index 4832beb6..3bacb2c5 100644 --- a/Meshtastic/Accessory/Transports/TCP/TCPConnection.swift +++ b/Meshtastic/Accessory/Transports/TCP/TCPConnection.swift @@ -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] diff --git a/Meshtastic/Accessory/Transports/TCP/TCPTransport.swift b/Meshtastic/Accessory/Transports/TCP/TCPTransport.swift index 1acc988c..84bd78bf 100644 --- a/Meshtastic/Accessory/Transports/TCP/TCPTransport.swift +++ b/Meshtastic/Accessory/Transports/TCP/TCPTransport.swift @@ -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)) } diff --git a/Meshtastic/Meshtastic.entitlements b/Meshtastic/Meshtastic.entitlements index 4dbdb836..e88fecad 100644 --- a/Meshtastic/Meshtastic.entitlements +++ b/Meshtastic/Meshtastic.entitlements @@ -9,6 +9,8 @@ com.apple.developer.carplay-communication + com.apple.developer.networking.custom-protocol + com.apple.developer.usernotifications.critical-alerts com.apple.developer.weatherkit diff --git a/Meshtastic/Model/Firmware/FirmwareFile.swift b/Meshtastic/Model/Firmware/FirmwareFile.swift index d23e0a7c..853ec8a5 100644 --- a/Meshtastic/Model/Firmware/FirmwareFile.swift +++ b/Meshtastic/Model/Firmware/FirmwareFile.swift @@ -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" } diff --git a/Meshtastic/Views/Connect/Connect.swift b/Meshtastic/Views/Connect/Connect.swift index bb43ae04..8a9d473d 100644 --- a/Meshtastic/Views/Connect/Connect.swift +++ b/Meshtastic/Views/Connect/Connect.swift @@ -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: diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 DFU/ESP32DFUSheet.swift b/Meshtastic/Views/Settings/Firmware/ESP32 DFU/ESP32DFUSheet.swift index 6ea90071..0f7d2870 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 DFU/ESP32DFUSheet.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 DFU/ESP32DFUSheet.swift @@ -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()) -} diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 DFU/Esp32WifiOTAViewModel.swift b/Meshtastic/Views/Settings/Firmware/ESP32 DFU/Esp32WifiOTAViewModel.swift new file mode 100644 index 00000000..ca6269df --- /dev/null +++ b/Meshtastic/Views/Settings/Firmware/ESP32 DFU/Esp32WifiOTAViewModel.swift @@ -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.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 { 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..= 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) + } +} diff --git a/Meshtastic/Views/Settings/Firmware/NRF DFU/NRFDFUSheet.swift b/Meshtastic/Views/Settings/Firmware/NRF DFU/NRFDFUSheet.swift index 17b31db1..66a70c37 100644 --- a/Meshtastic/Views/Settings/Firmware/NRF DFU/NRFDFUSheet.swift +++ b/Meshtastic/Views/Settings/Firmware/NRF DFU/NRFDFUSheet.swift @@ -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) - } -}