OTA Protocol updates and clean-up

This commit is contained in:
Jake-B 2025-12-28 08:56:24 -05:00
parent 58826f65d2
commit e4a572a382
6 changed files with 209 additions and 534 deletions

View file

@ -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 = "<group>"; };
108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = "<group>"; };
230A983F2EF86A44004D87F1 /* AsyncCentral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncCentral.swift; sourceTree = "<group>"; };
230A98412EF86AA9004D87F1 /* ESP32BLEOTAViewModel2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32BLEOTAViewModel2.swift; sourceTree = "<group>"; };
230A98412EF86AA9004D87F1 /* ESP32BLEOTAViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32BLEOTAViewModel.swift; sourceTree = "<group>"; };
230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Discovery.swift"; sourceTree = "<group>"; };
231251372E3BC96400E6ED07 /* BLEAuthorizationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEAuthorizationHelper.swift; sourceTree = "<group>"; };
23148E2F2EE1CCE500F0DB2C /* MeshtasticAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAPI.swift; sourceTree = "<group>"; };
@ -413,7 +412,6 @@
237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimitedButton.swift; sourceTree = "<group>"; };
23825FF42EF6CF0B00C25543 /* NWHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWHost.swift; sourceTree = "<group>"; };
23825FF82EF6D9AA00C25543 /* ESP32WifiOTASheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32WifiOTASheet.swift; sourceTree = "<group>"; };
23825FFC2EF6E70B00C25543 /* ESP32BLEOTAViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32BLEOTAViewModel.swift; sourceTree = "<group>"; };
23825FFE2EF6E79C00C25543 /* ESP32BLEOTASheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESP32BLEOTASheet.swift; sourceTree = "<group>"; };
2388EC392EDF8A1400F6F982 /* DFUModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DFUModel.swift; sourceTree = "<group>"; };
23A1AFB62E42BD2500E46C96 /* RXTXIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RXTXIndicatorView.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 */,

View file

@ -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

View file

@ -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<CBError.Code> = [.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<Data.Index>
// 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..<min(chunkSize, dataBuffer.count))
// 4. Get a subcopy copy of data
let subData = dataBuffer.subdata(in: range)
// Logger.services.info the first byte of the subData package as hexadecimal
if let firstByte = subData.first {
Logger.services.info("First byte of subData package: \(String(format: "%02X", firstByte))")
}
// 5. Send data chunk to BLE peripheral, send EOF when buffer is empty.
if !dataBuffer.isEmpty {
discoveredPeripheral.writeValue(subData, for: characteristic, type: .withoutResponse)
packageCounter += 1
// Logger.services.info(" Packages: \(packageCounter) bytes: \(subData.count)")
} else {
transferOngoing = false
let services = try await ble.discoverServices([meshtasticOTAServiceId], on: peripheral)
guard let service = services.first(where: { $0.uuid == meshtasticOTAServiceId }) else { throw BLEError.serviceMissing }
let chars = try await ble.discoverCharacteristics([statusCharacteristicId, otaCharacteristicId],
in: service,
on: peripheral)
guard
let statusChar = chars.first(where: { $0.uuid == statusCharacteristicId }),
let otaChar = chars.first(where: { $0.uuid == otaCharacteristicId })
else { throw BLEError.characteristicMissing }
// --- 2. Setup Notification Stream ---
try await ble.setNotify(true, for: statusChar, on: peripheral)
let stream = ble.notifications(for: statusChar)
var iterator = stream.makeAsyncIterator()
// --- 3. Prepare Firmware & Command ---
let data = try Data(contentsOf: binURL)
let sha256Digest = SHA256.hash(data: data)
let fileHash = sha256Digest.map { String(format: "%02hhx", $0) }.joined()
let fileSize = data.count
Logger.services.info("Firmware Size: \(fileSize), Hash: \(fileHash)")
// Unified Protocol Command: "OTA <size> <hash>\n"
let command = "OTA \(fileSize) \(fileHash)\n"
// --- 4. Handshake ---
self.statusMessage = "Negotiating..."
// Send command
try await ble.writeValue(Data(command.utf8), for: otaChar, type: .withResponse, on: peripheral)
// Wait for "OK" response from ESP32, handling "ERASING" intermediate state
var handshakeComplete = false
while !handshakeComplete {
guard let handshakeData = await iterator.next(),
let handshakeStr = String(data: handshakeData, encoding: .utf8) else {
throw OTAError.unexpectedResponse("Connection lost during handshake")
}
let trimmed = handshakeStr.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "OK" {
handshakeComplete = true
} else if trimmed == "ERASING" {
// Update UI to let user know the device is busy erasing partition
self.statusMessage = "Erasing partition..."
Logger.services.info("Device is erasing flash...")
// Continue loop to wait for OK
} else {
// Any other response is an error
throw OTAError.unexpectedResponse(trimmed)
}
}
Logger.services.info("Handshake OK. Starting Stream.")
// --- 5. Upload Stream ---
self.otaStatus = .transferring
self.statusMessage = "Uploading..."
var offset = 0
// Use MTU - 3 bytes overhead for chunk size.
let chunkSize = peripheral.maximumWriteValueLength(for: .withoutResponse)
while offset < fileSize {
let endIndex = min(offset + chunkSize, fileSize)
let chunk = data.subdata(in: offset..<endIndex)
// Send chunk
try await ble.writeValue(chunk, for: otaChar, type: .withoutResponse, on: peripheral)
// Optimistically calculate new offset to determine if this was the last chunk
let nextOffset = offset + chunk.count
// [FLOW CONTROL]
// Wait for ACK (or OK if last packet) before proceeding.
// This ensures the ESP32 has written the chunk to flash.
guard let respData = await iterator.next(),
let respStr = String(data: respData, encoding: .utf8) else {
throw OTAError.unexpectedResponse("Connection lost waiting for ACK")
}
let trimmed = respStr.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "ACK" {
// Normal chunk processed successfully
offset = nextOffset
// Update UI occasionally
if offset % (chunkSize * 20) == 0 {
self.transferProgress = Double(offset) / Double(fileSize)
}
} else if trimmed == "OK" {
// "OK" indicates completion (hash verified, partition set).
// This should only happen on the very last chunk.
if nextOffset >= fileSize {
offset = nextOffset
self.transferProgress = 1.0
self.otaStatus = .completed
self.statusMessage = "Success! Rebooting..."
Logger.services.info("OTA Success (OK received on last chunk)")
break // Exit loop
} else {
// OK received before we finished sending? Error.
throw OTAError.unexpectedResponse("Premature OK received at offset \(nextOffset)")
}
} else {
// Likely ERR or garbage
throw OTAError.unexpectedResponse(trimmed)
}
}
// Double check completion state
if self.otaStatus != .completed {
throw OTAError.unexpectedResponse("Stream ended without OK")
}
} catch {
self.otaStatus = .error
self.statusMessage = "Error: \(error.localizedDescription)"
Logger.services.error("OTA Failed: \(error.localizedDescription)")
}
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()
}
}

View file

@ -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..."

View file

@ -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)

View file

@ -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