diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index c03baa67..54b9eb0c 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -16,7 +16,7 @@ 10D109F22E2047D600536CE6 /* DatadogSessionReplay in Frameworks */ = {isa = PBXBuildFile; productRef = 10D109F12E2047D600536CE6 /* DatadogSessionReplay */; }; 10D109F42E2047D600536CE6 /* DatadogTrace in Frameworks */ = {isa = PBXBuildFile; productRef = 10D109F32E2047D600536CE6 /* DatadogTrace */; }; 230A98402EF86A44004D87F1 /* AsyncCentral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 230A983F2EF86A44004D87F1 /* AsyncCentral.swift */; }; - 230A98422EF86AA9004D87F1 /* ESP32BLEOTAViewModel2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 230A98412EF86AA9004D87F1 /* ESP32BLEOTAViewModel2.swift */; }; + 230A98422EF86AA9004D87F1 /* ESP32BLEOTAViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 230A98412EF86AA9004D87F1 /* ESP32BLEOTAViewModel.swift */; }; 230BC3972E31071E0046BF2A /* AccessoryManager+Discovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */; }; 231251382E3BC96400E6ED07 /* BLEAuthorizationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231251372E3BC96400E6ED07 /* BLEAuthorizationHelper.swift */; }; 23148E302EE1CCE500F0DB2C /* MeshtasticAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23148E2F2EE1CCE500F0DB2C /* MeshtasticAPI.swift */; }; @@ -64,7 +64,6 @@ 237B46962DC8F1C100B22D99 /* RateLimitedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */; }; 23825FF52EF6CF0B00C25543 /* NWHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23825FF42EF6CF0B00C25543 /* NWHost.swift */; }; 23825FF92EF6D9AA00C25543 /* ESP32WifiOTASheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23825FF82EF6D9AA00C25543 /* ESP32WifiOTASheet.swift */; }; - 23825FFD2EF6E70B00C25543 /* ESP32BLEOTAViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23825FFC2EF6E70B00C25543 /* ESP32BLEOTAViewModel.swift */; }; 23825FFF2EF6E79C00C25543 /* ESP32BLEOTASheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23825FFE2EF6E79C00C25543 /* ESP32BLEOTASheet.swift */; }; 2388EC382EDF88E900F6F982 /* NordicDFU in Frameworks */ = {isa = PBXBuildFile; productRef = 2388EC372EDF88E900F6F982 /* NordicDFU */; }; 2388EC3A2EDF8A1400F6F982 /* DFUModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2388EC392EDF8A1400F6F982 /* DFUModel.swift */; }; @@ -366,7 +365,7 @@ 108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContactQRDialog.swift; sourceTree = ""; }; 108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = ""; }; 230A983F2EF86A44004D87F1 /* AsyncCentral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncCentral.swift; sourceTree = ""; }; - 230A98412EF86AA9004D87F1 /* ESP32BLEOTAViewModel2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32BLEOTAViewModel2.swift; sourceTree = ""; }; + 230A98412EF86AA9004D87F1 /* ESP32BLEOTAViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32BLEOTAViewModel.swift; sourceTree = ""; }; 230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Discovery.swift"; sourceTree = ""; }; 231251372E3BC96400E6ED07 /* BLEAuthorizationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEAuthorizationHelper.swift; sourceTree = ""; }; 23148E2F2EE1CCE500F0DB2C /* MeshtasticAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAPI.swift; sourceTree = ""; }; @@ -413,7 +412,6 @@ 237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimitedButton.swift; sourceTree = ""; }; 23825FF42EF6CF0B00C25543 /* NWHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWHost.swift; sourceTree = ""; }; 23825FF82EF6D9AA00C25543 /* ESP32WifiOTASheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32WifiOTASheet.swift; sourceTree = ""; }; - 23825FFC2EF6E70B00C25543 /* ESP32BLEOTAViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32BLEOTAViewModel.swift; sourceTree = ""; }; 23825FFE2EF6E79C00C25543 /* ESP32BLEOTASheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32BLEOTASheet.swift; sourceTree = ""; }; 2388EC392EDF8A1400F6F982 /* DFUModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DFUModel.swift; sourceTree = ""; }; 23A1AFB62E42BD2500E46C96 /* RXTXIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RXTXIndicatorView.swift; sourceTree = ""; }; @@ -940,10 +938,9 @@ 23825FFB2EF6E6FB00C25543 /* BLE */ = { isa = PBXGroup; children = ( - 23825FFC2EF6E70B00C25543 /* ESP32BLEOTAViewModel.swift */, 23825FFE2EF6E79C00C25543 /* ESP32BLEOTASheet.swift */, 230A983F2EF86A44004D87F1 /* AsyncCentral.swift */, - 230A98412EF86AA9004D87F1 /* ESP32BLEOTAViewModel2.swift */, + 230A98412EF86AA9004D87F1 /* ESP32BLEOTAViewModel.swift */, ); path = BLE; sourceTree = ""; @@ -1891,7 +1888,6 @@ 259792252C2F114500AD1659 /* ChannelEntityExtension.swift in Sources */, BCE2D3C52C7AE369008E6199 /* RestartNodeIntent.swift in Sources */, DD1BEF522E08E9B80090CE24 /* ChannelLock.swift in Sources */, - 23825FFD2EF6E70B00C25543 /* ESP32BLEOTAViewModel.swift in Sources */, 259792262C2F114500AD1659 /* PositionEntityExtension.swift in Sources */, 259792272C2F114500AD1659 /* TraceRouteEntityExtension.swift in Sources */, DDDB444829F8A9C900EE2349 /* String.swift in Sources */, @@ -1907,7 +1903,7 @@ DDD5BB182C2F9C36007E03CA /* OSLogEntryLog.swift in Sources */, DD3501892852FC3B000FC853 /* Settings.swift in Sources */, DDDC22382BA92344002C44F1 /* MeshMapContent.swift in Sources */, - 230A98422EF86AA9004D87F1 /* ESP32BLEOTAViewModel2.swift in Sources */, + 230A98422EF86AA9004D87F1 /* ESP32BLEOTAViewModel.swift in Sources */, 23C2BE2A2EEAF96A00F6A997 /* FirmwareViewModel.swift in Sources */, DD5D0A9C2931B9F200F7EA61 /* EthernetModes.swift in Sources */, 6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */, diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTASheet.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTASheet.swift index ce3f3f73..b08c88a2 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTASheet.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTASheet.swift @@ -15,7 +15,7 @@ struct ESP32BLEOTASheet: View { @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.dismiss) var dismiss @Environment(\.managedObjectContext) var context - @StateObject var ota = ESP32BLEOTAViewModel2() + @StateObject var ota = ESP32BLEOTAViewModel() // The stuff were updating, and the place we're updating it to let binFileURL: URL diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel.swift index 977dd777..216650c0 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel.swift @@ -1,530 +1,176 @@ // -// ESP32BLEOTAViewModel.swift (previously BLEConnection.swift in the DFU app) +// ESP32BLEOTAViewModel2.swift +// Meshtastic // -// Created by Garth Vander Houwen on 12/4/22 +// 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 pTxCharacteristic ESP send (notifying) -private let otaCharacteristicId = CBUUID(string: "62EC0272-3EC5-11EB-B378-0242AC130005") // ESP32 pOtaCharacteristic ESP write +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) -private let outOfRangeHeuristics: Set = [.unknown, .connectionTimeout, .peripheralDisconnected, .connectionFailed] - -class ESP32BLEOTAViewModel: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeripheralDelegate { - - var manager: CBCentralManager! - var statusCharacteristic: CBCharacteristic? - var otaCharacteristic: CBCharacteristic? - - var otaCharacteristicIsNotifying = false - var statusCharacteristicIsNotifying = false - - var state = StateBLE.poweredOff - - enum StateBLE { - case poweredOff - case restoringConnectingPeripheral(CBPeripheral) - case restoringConnectedPeripheral(CBPeripheral) - case disconnected - case scanning - case connecting(CBPeripheral, Countdown) - case discoveringServices(CBPeripheral, Countdown) - case discoveringCharacteristics(CBPeripheral, Countdown) - case connected(CBPeripheral) - case outOfRange(CBPeripheral) - - var peripheral: CBPeripheral? { - switch self { - case .poweredOff: return nil - case .restoringConnectingPeripheral(let p): return p - case .restoringConnectedPeripheral(let p): return p - case .disconnected: return nil - case .scanning: return nil - case .connecting(let p, _): return p - case .discoveringServices(let p, _): return p - case .discoveringCharacteristics(let p, _): return p - case .connected(let p): return p - case .outOfRange(let p): return p - } - } - } - - // Used by contentView.swift +@MainActor +final class ESP32BLEOTAViewModel: ObservableObject { @Published var name = "" - @Published var connected = false - @Published var transferProgress : Double = 0.0 - @Published var chunkCount = 1 // number of chunks to be sent before peripheral needs to accknowledge. - @Published var elapsedTime = 0.0 - @Published var kBPerSecond = 0.0 + @Published var transferProgress: Double = 0 + @Published var otaStatus: LocalOTAStatusCode = .idle + @Published var statusMessage: String = "" + + private let ble = AsyncCentral() - // OTA file URL - var fileUrl: URL? - var desiredPeripheral: CBPeripheral? - - // transfer varibles - var dataToSend = Data() - var dataBuffer = Data() - var chunkSize = 0 - var dataLength = 0 - var transferOngoing = true - var sentBytes = 0 - var packageCounter = 0 - var startTime = 0.0 - var stopTime = 0.0 - var firstAcknowledgeFromESP32 = false - - // Initiate CentralManager - override init() { - super.init() - manager = CBCentralManager(delegate: self, queue: .none) - manager.delegate = self - } - - func startOTA(peripheral: CBPeripheral, binFileURL: URL) { - self.desiredPeripheral = peripheral - self.fileUrl = binFileURL - - self.startScanning() - } - - // CentralManager State updates - func centralManagerDidUpdateState(_ central: CBCentralManager) { - Logger.services.info("Bluetooth State Updated") - switch manager.state { - case .unknown: - Logger.services.info("Unknown") - case .resetting: - Logger.services.info("Resetting") - case .unsupported: - Logger.services.info("Unsupported") - case .unauthorized: - Logger.services.info("Bluetooth is disabled") - case .poweredOff: - Logger.services.info("Bluetooth is powered off") - case .poweredOn: - Logger.services.info("Bluetooth is working properly") - @unknown default: - Logger.services.info("fatal error") - } - } - - // Discovery (scanning) and handling of BLE devices in range - func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { - guard case .scanning = state else { return } - - self.name = peripheral.name ?? "Unknown" - Logger.services.info("Discovered \(self.name)") - - // Check if this is the desired peripheral - if let desiredPeripheral, desiredPeripheral.identifier != peripheral.identifier { - Logger.services.info("This peripheral is not the one we're looking for") - } - - manager.stopScan() - connect(peripheral: peripheral) - } - - // Connection established handler - func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - Logger.services.info("Connection suceeded") - - transferOngoing = false - - // Clear the data that we may already have - dataToSend.removeAll(keepingCapacity: false) - - // Make sure we get the discovery callbacks - peripheral.delegate = self - - if peripheral.statusCharacteristic == nil { - discoverServices(peripheral: peripheral) - } else { - setConnected(peripheral: peripheral) - } - } - - // Connection failed - func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { - Logger.services.info("\(Date()) CM DidFailToConnect") - state = .disconnected - } - - func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { - transferOngoing = false - Logger.services.info("\(peripheral.name ?? "unknown") disconnected") - // Did our currently-connected peripheral just disconnect? - if state.peripheral?.identifier == peripheral.identifier { - name = "" - connected = false - // IME the error codes encountered are: - // 0 = rebooting the peripheral. - // 6 = out of range. - if let error = error, (error as NSError).domain == CBErrorDomain, - let code = CBError.Code(rawValue: (error as NSError).code), - outOfRangeHeuristics.contains(code) { - // Try reconnect without setting a timeout in the state machine. - // With CB, it's like saying 'please reconnect me at any point - // in the future if this peripheral comes back into range'. - Logger.services.info("Connection failure, try and reconnect when the device is back in range") - manager.connect(peripheral, options: nil) - state = .outOfRange(peripheral) - } else { - // Likely a deliberate unpairing. - state = .disconnected - } - } - } - - // ----------------------------------------- - // Peripheral callbacks - // ----------------------------------------- - - // Discover BLE device service(s) - func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { - Logger.services.info("Discovered Bluetooth Services") - // Ignore services discovered late. - guard case .discoveringServices = state else { - return - } - if let error = error { - Logger.services.error("\(error.localizedDescription)") - disconnect() - return - } - guard peripheral.meshtasticOTAService != nil else { - Logger.services.info("Meshtastic OTA service missing") - disconnect() - return - } - // All fine so far, go to next step - guard let services = peripheral.services else { return } - for service in services { - peripheral.discoverCharacteristics(nil, for: service) - } - - } - // Discover BLE device Service charachteristics - func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - Logger.services.info("Discovering Characteristics For Meshtastic OTA Service") - - if let error = error { - Logger.services.error("\(error.localizedDescription)") - disconnect() - return - } - - guard peripheral.statusCharacteristic != nil else { - Logger.services.info("\(Date()) Desired characteristic missing") - disconnect() - return - } - - guard let characteristics = service.characteristics else { - return - } - - for characteristic in characteristics { - switch characteristic.uuid { - - case statusCharacteristicId: - statusCharacteristic = characteristic - Logger.services.info("Discovered Status Characteristic: \(self.statusCharacteristic!.uuid.uuidString)") - peripheral.setNotifyValue(true, for: characteristic) - - case otaCharacteristicId: - otaCharacteristic = characteristic - Logger.services.info("Discovered OTA Characteristic: \(self.otaCharacteristic!.uuid.uuidString)") - peripheral.setNotifyValue(false, for: characteristic) - - default: - Logger.services.info("\(Date()) unknown") - } - } - setConnected(peripheral: peripheral) - } - // The BLE peripheral device sent some notify data. Deal with it! - func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - Logger.services.info("\(Date()) PH didUpdateValueFor") - if let error = error { - Logger.services.error("\(error.localizedDescription)") - return - } - if let data = characteristic.value { - // deal with incoming data - // First check if the incoming data is one byte length? - // if so it's the peripheral acknowledging and telling - // us to send another batch of data - if data.count == 1 { - if !firstAcknowledgeFromESP32 { - firstAcknowledgeFromESP32 = true - startTime = CFAbsoluteTimeGetCurrent() - } - // Logger.services.info("\(Date()) -X-") - if transferOngoing { - packageCounter = 0 - writeDataToPeripheral(characteristic: otaCharacteristic!) - } - } - } - } - - // Called when .withResponse is used. - func peripheral(_ peripheral: CBPeripheral, didWriteValueFor descriptor: CBDescriptor, error: Error?) { - Logger.services.info("\(Date()) PH didWriteValueFor") - if let error = error { - Logger.services.error("\(Date()) Error writing to characteristic: \(error.localizedDescription)") - return - } - } - - // Callback indicating peripheral notifying state - func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { - Logger.services.info("\(Date()) PH didUpdateNotificationStateFor") - Logger.services.info("\(Date()) PH characteristic: \(characteristic.uuid.uuidString)") - if error == nil { - Logger.services.info("\(Date()) Notification Set OK, isNotifying: \(characteristic.isNotifying)") - if !characteristic.isNotifying { - Logger.services.info("\(Date()) isNotifying is false, set to true again!") - peripheral.setNotifyValue(true, for: characteristic) - } else { - if characteristic.uuid == statusCharacteristic?.uuid { - statusCharacteristicIsNotifying = true - } - } - } - - checkStartTransfer() - } - - func checkStartTransfer() { - guard connected else { - Logger.services.info("Not connected, cannot start transfer") - return - } - guard statusCharacteristicIsNotifying else { - Logger.services.info("Status Characteristic not notifying yet, cannot start transfer") - return - } - guard transferOngoing == false else { - Logger.services.info("Transfer already ongoing") - return - } + func startOTA(binURL: URL) { + Task { + // Prevent screen sleep during update + UIApplication.shared.isIdleTimerDisabled = true - // All set, start the transfer - self.sendFile() - } - - /*------------------------------------------------------------------------- - Functions - -------------------------------------------------------------------------*/ - // Scan for a device with the OTA Service UUID (myDesiredServiceId) - func startScanning() { - Logger.services.info("Scanning for Meshtastic Devices in OTA Mode") - guard manager.state == .poweredOn else { - Logger.services.info("Cannot scan, Bluetooth is not powered on") - return - } - manager.scanForPeripherals(withServices: [meshtasticOTAServiceId], options: nil) -// state = .scanning(Countdown(seconds: 10, closure: { -// self.manager.stopScan() -// self.state = .disconnected -// Logger.services.info("Scan timed out") -// })) - state = .scanning - } - - func disconnect() { - Logger.services.info("Disconnect") - if let peripheral = state.peripheral { - manager.cancelPeripheralConnection(peripheral) - } - state = .disconnected - connected = false - transferOngoing = false - } - - // Connect to the device from the scanning - func connect(peripheral: CBPeripheral) { - Logger.services.info("Connect Button Pushed") - if connected { - manager.cancelPeripheralConnection(peripheral) - } else { - // Connect! - manager.connect(peripheral, options: nil) - name = String(peripheral.name ?? "unknown") - Logger.services.info("Attempting connection to \(self.name)") - state = .connecting(peripheral, Countdown(seconds: 10, closure: { - self.manager.cancelPeripheralConnection(self.state.peripheral!) - self.state = .disconnected - self.connected = false - Logger.services.info("Attempted connection to \(self.name) timed out") - })) - } - } - - // Discover Services of a device - func discoverServices(peripheral: CBPeripheral) { - Logger.services.info("Discovering Meshtastic OTA service") - peripheral.delegate = self - peripheral.discoverServices([meshtasticOTAServiceId]) - state = .discoveringServices(peripheral, Countdown(seconds: 10, closure: { - self.disconnect() - Logger.services.info("\(Date()) Could not discover services") - })) - } - - // Discover Characteristics of a Services - func discoverCharacteristics(peripheral: CBPeripheral) { - Logger.services.info("Discovering characteristics for Meshtastic OTA service") - guard let meshtasticOTAService = peripheral.meshtasticOTAService else { - self.disconnect() - return - } - peripheral.discoverCharacteristics([statusCharacteristicId], for: meshtasticOTAService) - state = .discoveringCharacteristics(peripheral, - Countdown(seconds: 10, - closure: { - self.disconnect() - Logger.services.info("\(Date()) Could not discover characteristics") - })) - } - - func setConnected(peripheral: CBPeripheral) { - Logger.services.info("Max write value with response: \(peripheral.maximumWriteValueLength(for: .withResponse))") - Logger.services.info("Max write value without response: \(peripheral.maximumWriteValueLength(for: .withoutResponse))") - guard let statusCharacteristic = peripheral.statusCharacteristic - else { - Logger.services.info("Missing status characteristic") - disconnect() - return - } - - peripheral.setNotifyValue(true, for: statusCharacteristic) - state = .connected(peripheral) - connected = true - name = String(peripheral.name ?? "unknown") + 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..." - checkStartTransfer() - } - - // Peripheral callback when its ready to receive more data without response - func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { - if transferOngoing && packageCounter < chunkCount { - writeDataToPeripheral(characteristic: otaCharacteristic!) - } - } - - func sendFile() { - Logger.services.info("Start sending .bin file to device") - - // 1. Get the data from the file(name) and copy data to dataBUffer - guard let fileUrl, let data: Data = try? Data(contentsOf: fileUrl) else { - Logger.services.info("Failed to open .bin file") - return - } - dataBuffer = data - dataLength = dataBuffer.count - - // 1. Get the peripheral and its transfer characteristic - guard let discoveredPeripheral = state.peripheral else { return } - // Send dataLength to the device - let sizeMessage = "OTA_SIZE:\(dataLength)" - if let sizeData = sizeMessage.data(using: .utf8) { - // Send sizeData to the peripheral - // Assuming writeCharacteristic is the characteristic for sending messages - discoveredPeripheral.writeValue(sizeData, for: otaCharacteristic!, type: .withoutResponse) - Logger.services.info("Sent OTA size message: \(sizeMessage)") - } else { - Logger.services.info("Failed to encode OTA size message") - return - } - - // Logger.services.info the total size of the data in hexadecimal format - Logger.services.info("Total data size (hexadecimal): \(String(format: "%02X", self.dataBuffer.count))") - transferOngoing = true - - packageCounter = 0 - // Send the first chunk - elapsedTime = 0.0 - sentBytes = 0 - firstAcknowledgeFromESP32 = false - startTime = CFAbsoluteTimeGetCurrent() - writeDataToPeripheral(characteristic: otaCharacteristic!) - } - - func writeDataToPeripheral(characteristic: CBCharacteristic) { - // 1. Get the peripheral and its transfer characteristic - guard let discoveredPeripheral = state.peripheral else { return } - // ATT MTU - 3 bytes - let maxWriteValueLength = discoveredPeripheral.maximumWriteValueLength(for: .withoutResponse) - chunkSize = maxWriteValueLength - 3 - Logger.services.info("Chunk size: \(self.chunkSize), 0x\(String(format: "%02X", self.chunkSize))") - // Get the data range - var range: Range - - // 2. Loop through and send each chunk to the BLE device - // check to see if the number of iterations completed and the peripheral can accept more data - // package counter allows only "chunkCount" of data to be sent per time. - while transferOngoing && packageCounter < chunkCount { - // 3. Create a range based on the length of data to return - range = (0.. \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.") + + // --- 5. Upload Stream --- + self.otaStatus = .transferring + self.statusMessage = "Uploading..." + + var offset = 0 + // Use MTU - 3 bytes overhead for chunk size. + let chunkSize = peripheral.maximumWriteValueLength(for: .withoutResponse) + + while offset < fileSize { + let endIndex = min(offset + chunkSize, fileSize) + let chunk = data.subdata(in: offset..= fileSize { + offset = nextOffset + self.transferProgress = 1.0 + self.otaStatus = .completed + self.statusMessage = "Success! Rebooting..." + Logger.services.info("OTA Success (OK received on last chunk)") + break // Exit loop + } else { + // OK received before we finished sending? Error. + throw OTAError.unexpectedResponse("Premature OK received at offset \(nextOffset)") + } + + } else { + // Likely ERR or garbage + throw OTAError.unexpectedResponse(trimmed) + } + } + + // Double check completion state + if self.otaStatus != .completed { + throw OTAError.unexpectedResponse("Stream ended without OK") + } + + } catch { + self.otaStatus = .error + self.statusMessage = "Error: \(error.localizedDescription)" + Logger.services.error("OTA Failed: \(error.localizedDescription)") } - if discoveredPeripheral.canSendWriteWithoutResponse { - Logger.services.info("BLE peripheral ready?: \(discoveredPeripheral.canSendWriteWithoutResponse)") - } - - // 6. Remove already sent data from buffer - dataBuffer.removeSubrange(range) - - // 7. calculate and Logger.services.info the transfer progress in % - transferProgress = (1 - (Double(dataBuffer.count) / Double(dataLength))) * 100 - Logger.services.info("File transfer progress: \(String(format: "%.02f", self.transferProgress))%") - sentBytes += chunkSize - elapsedTime = CFAbsoluteTimeGetCurrent() - startTime - let kbPs = Double(sentBytes) / elapsedTime - kBPerSecond = kbPs / 1000 + UIApplication.shared.isIdleTimerDisabled = false } } } - -extension CBPeripheral { - // Helper to find the service we're interested in. - var meshtasticOTAService: CBService? { - guard let services = services else { return nil } - return services.first { $0.uuid == meshtasticOTAServiceId } - } - // Helper to find the characteristic we're interested in. - var statusCharacteristic: CBCharacteristic? { - guard let characteristics = meshtasticOTAService?.characteristics else { - return nil - } - return characteristics.first { $0.uuid == statusCharacteristicId } - } -} - -class Countdown { - let timer: Timer - init(seconds: TimeInterval, closure: @escaping () -> Void) { - timer = Timer.scheduledTimer(withTimeInterval: seconds, repeats: false, block: { _ in closure() }) - } - deinit { - timer.invalidate() - } -} diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel2.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel2 2.swift similarity index 84% rename from Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel2.swift rename to Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel2 2.swift index e9156163..fc04476e 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel2.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/BLE/ESP32BLEOTAViewModel2 2.swift @@ -72,18 +72,37 @@ final class ESP32BLEOTAViewModel2: ObservableObject { // --- 4. Handshake --- self.statusMessage = "Negotiating..." - // Send command with response to ensure delivery before waiting for logic response + // Send command try await ble.writeValue(Data(command.utf8), for: otaChar, type: .withResponse, on: peripheral) - // Wait for "OK" response from ESP32 - guard let handshakeData = await iterator.next(), - let handshakeStr = String(data: handshakeData, encoding: .utf8), - handshakeStr.trimmingCharacters(in: .whitespacesAndNewlines) == "OK" else { - throw OTAError.unexpectedResponse("Handshake failed") + // 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..." diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift index 2bf7a685..9987bf92 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/ESP32OTAIntroSheet.swift @@ -124,9 +124,11 @@ struct ESP32OTAIntroSheet: View { } }.sheet(isPresented: $showWifiUpdater) { - ESP32WifiOTASheet(binFileURL: binFileURL).environmentObject(accessoryManager) + ESP32WifiOTASheet(binFileURL: binFileURL) + .environmentObject(accessoryManager) }.sheet(isPresented: $showBLEUpdater) { - ESP32BLEOTASheet(binFileURL: binFileURL).environmentObject(accessoryManager) + ESP32BLEOTASheet(binFileURL: binFileURL) + .environmentObject(accessoryManager) } .navigationTitle("ESP32 Update") .navigationBarTitleDisplayMode(.inline) diff --git a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTAViewModel.swift b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTAViewModel.swift index 13bbcc1f..fb5337d0 100644 --- a/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTAViewModel.swift +++ b/Meshtastic/Views/Settings/Firmware/ESP32 OTA/WiFi/ESP32WifiOTAViewModel.swift @@ -194,12 +194,24 @@ class ESP32WifiOTAViewModel: ObservableObject { // 3. Send Command try await connection.sendAsync(data: command.data(using: .utf8)!) - // 4. Wait for initial "OK\n" - let response = try await readLine(from: connection) - if response.trimmingCharacters(in: .whitespacesAndNewlines) != "OK" { - throw OTAError.unexpectedResponse(response) + // 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