From 9d6367079c346238a9b1bf8e9f6df1bfbe121df4 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 17 May 2026 07:47:01 +0100 Subject: [PATCH 01/68] fix for https://github.com/kkonteh97/SwiftOBD2/issues/41 --- .../BLE/BLEPeripheralManager.swift | 9 ++++++++ .../Communication/BLE/BLEScanner.swift | 8 +++++++ .../Communication/BLE/bleManager.swift | 23 +++++++++++++++---- .../SwiftOBD2/Communication/wifiManager.swift | 5 ++++ Sources/SwiftOBD2/elm327.swift | 2 +- Sources/SwiftOBD2/obd2service.swift | 11 +++++++++ 6 files changed, 52 insertions(+), 6 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift b/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift index 60edf63..634d001 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift @@ -87,6 +87,15 @@ class BLEPeripheralManager: NSObject, ObservableObject { guard let data = characteristic.value else { return } characteristicHandler.handleUpdatedValue(data, from: characteristic) } + + func reset() { + connectedPeripheral?.delegate = nil + connectedPeripheral = nil + if let completion = connectionCompletion { + connectionCompletion = nil + completion?(nil, BLEManagerError.peripheralNotConnected) + } + } } extension BLEPeripheralManager: CBPeripheralDelegate { diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift b/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift index a324c74..dcb5bad 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift @@ -71,6 +71,14 @@ class BLEPeripheralScanner: ObservableObject { } } } + + func reset() { + foundPeripherals.removeAll() + if let completion = foundPeripheralCompletion { + foundPeripheralCompletion = nil + completion?(nil, BLEScannerError.scanTimeout) + } + } } // MARK: - CBPeripheralDelegate diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index 4e08699..7978590 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -224,8 +224,8 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { func willRestoreState(_: CBCentralManager, dict: [String: Any]) { if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral], let peripheral = peripherals.first { obdDebug("Restoring peripheral: \(peripherals[0].name ?? "Unnamed")", category: .bluetooth) - peripheralManager.setPeripheral(peripheral) - + peripheralManager.connectedPeripheral = peripheral + peripheral.delegate = peripheralManager } } @@ -238,9 +238,9 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { func connectAsync(timeout: TimeInterval, peripheral: CBPeripheral? = nil) async throws { try await waitForPoweredOn() - if connectionState.isConnected { - obdInfo("Already connected to peripheral", category: .bluetooth) - return + guard connectionState == .disconnected else { + obdWarning("Cannot connect - state is \(connectionState.description)", category: .bluetooth) + throw BLEManagerError.connectionInProgress } let targetPeripheral: CBPeripheral @@ -339,6 +339,9 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { private func resetConfigure() { characteristicHandler.reset() + messageProcessor.reset() + peripheralManager.reset() + peripheralScanner.reset() let oldState = connectionState connectionState = .disconnected @@ -350,6 +353,13 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { } } } + + /// Fully resets BLEManager state for clean reconnection + public func reset() { + disconnectPeripheral() + resetConfigure() + stopScan() + } } // MARK: - CBCentralManagerDelegate, CBPeripheralDelegate @@ -398,6 +408,7 @@ enum BLEManagerError: Error, CustomStringConvertible { case unknownError case unsupported case unauthorized + case connectionInProgress public var description: String { switch self { @@ -429,6 +440,8 @@ enum BLEManagerError: Error, CustomStringConvertible { return "Error: Device does not support Bluetooth Low Energy" case .unauthorized: return "Error: App not authorized to use Bluetooth Low Energy" + case .connectionInProgress: + return "Error: Connection already active or in progress. Please disconnect before attempting a new connection." } } } diff --git a/Sources/SwiftOBD2/Communication/wifiManager.swift b/Sources/SwiftOBD2/Communication/wifiManager.swift index e7b41f0..ac560ef 100644 --- a/Sources/SwiftOBD2/Communication/wifiManager.swift +++ b/Sources/SwiftOBD2/Communication/wifiManager.swift @@ -15,6 +15,7 @@ protocol CommProtocol { func disconnectPeripheral() func connectAsync(timeout: TimeInterval, peripheral: CBPeripheral?) async throws func scanForPeripherals() async throws + func reset() var connectionStatePublisher: Published.Publisher { get } var obdDelegate: OBDServiceDelegate? { get set } } @@ -150,4 +151,8 @@ class WifiManager: CommProtocol { } func scanForPeripherals() async throws {} + + func reset() { + disconnectPeripheral() + } } diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index ba03f93..bdee56e 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -84,7 +84,7 @@ class ELM327 { .sink { [weak self] state in self?.connectionState = state self?.obdDelegate?.connectionStateChanged(state: state) - self?.logger.debug("Connection state updated: \(state.hashValue)") + self?.logger.debug("Connection state updated: \(state.description)") } .store(in: &cancellables) } diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index 1a54703..f7f1278 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -121,6 +121,17 @@ public class OBDService: ObservableObject, OBDServiceDelegate { let duration = CFAbsoluteTimeGetCurrent() - startTime OBDLogger.shared.logPerformance("Connection failed", duration: duration, success: false) obdError("Connection failed: \(error.localizedDescription)", category: .connection) + + if let bleError = error as? BLEManagerError { + if bleError == .peripheralNotFound || bleError == .scanTimeout { + throw OBDServiceError.noAdapterFound + } + } else if let scanError = error as? BLEScannerError { + if scanError == .peripheralNotFound || scanError == .scanTimeout { + throw OBDServiceError.noAdapterFound + } + } + throw OBDServiceError.adapterConnectionFailed(underlyingError: error) // Propagate } } From 8055c82f6e8da7c64168be2d7a36f478bf58f427 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 17 May 2026 07:49:29 +0100 Subject: [PATCH 02/68] added new pids from https://github.com/kkonteh97/SwiftOBD2/issues/42 --- Sources/SwiftOBD2/commands.swift | 223 +++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) diff --git a/Sources/SwiftOBD2/commands.swift b/Sources/SwiftOBD2/commands.swift index 0829c70..181d77a 100644 --- a/Sources/SwiftOBD2/commands.swift +++ b/Sources/SwiftOBD2/commands.swift @@ -220,6 +220,120 @@ public enum OBDCommand: Codable, Hashable, Comparable, Identifiable { case fuelInjectionTiming case fuelRate case emissionsReq + + case pidsD // 0160 Supported PIDs [61-80] + case driversDemandTorque // 0161 + case actualEngineTorque // 0162 + case engineReferenceTorque // 0163 + case enginePercentTorqueData // 0164 + case auxInputOutputSupported // 0165 + case mafSensor // 0166 + case engineCoolantTempSensors // 0167 + case intakeAirTempSensors // 0168 + case egrActualCommandedError // 0169 + case dieselIntakeAirFlowControl // 016A + case egrTemperature // 016B + case throttleActuatorControl // 016C + case fuelPressureControlSystem // 016D + case injectionPressureControl // 016E + case turboInletPressure // 016F + case boostPressureControl // 0170 + case vgtControl // 0171 + case wastegateControl // 0172 + case exhaustPressure // 0173 + case turbochargerRPM // 0174 + case turboTemp1 // 0175 + case turboTemp2 // 0176 + case chargeAirCoolerTemp // 0177 + case egtBank1 // 0178 + case egtBank2 // 0179 + case dpfDiffPressure // 017A + case dpfStatus // 017B + case dpfTemperature // 017C + case noxNTEStatus // 017D + case pmNTEStatus // 017E + case engineRunTimeTotal // 017F + + case pidsE // 0180 Supported PIDs [81-A0] + case aecdRunTime1 // 0181 + case aecdRunTime2 // 0182 + case noxSensor // 0183 + case manifoldSurfaceTemp // 0184 + case noxReagentSystem // 0185 + case pmSensor // 0186 + case intakeManifoldPressure2 // 0187 + case scrInduceSystem // 0188 + case aecdRunTime11to15 // 0189 + case aecdRunTime16to20 // 018A + case dieselAftertreatment // 018B + case o2WideRange // 018C + case throttlePosG // 018D + case engineFrictionTorque // 018E + case pmSensorBank12 // 018F + case wwhOBDInfo1 // 0190 + case wwhOBDInfo2 // 0191 + case fuelSystemControl // 0192 + case wwhOBDCounters // 0193 + case noxWarningSystem // 0194 + + // Wikipedia placeholders + case pid95Reserved // 0195 + case pid96Reserved // 0196 + case pid97Reserved // 0197 + + case exhaustGasTempSensor1 // 0198 + case exhaustGasTempSensor2 // 0199 + case hybridBatteryData // 019A + case defSensorData // 019B + case o2SensorData // 019C + case engineFuelRateAlt // 019D + case engineExhaustFlowRate // 019E + case fuelSystemPercentUse // 019F + + case pidsF // 01A0 Supported PIDs [A1-C0] + case noxSensorCorrected // 01A1 + case cylinderFuelRate // 01A2 + case evapSystemPressureAlt2 // 01A3 + case transmissionActualGear // 01A4 + case commandedDEFdosing // 01A5 + case odometer // 01A6 + case noxSensorConc34 // 01A7 + case noxSensorCorrectedConc34 // 01A8 + case absDisableSwitch // 01A9 + + // Gap A A -> C0 placeholders + case pidAAReserved + case pidABReserved + case pidACReserved + case pidADReserved + case pidAEReserved + case pidAFReserved + case pidB0Reserved + case pidB1Reserved + case pidB2Reserved + case pidB3Reserved + case pidB4Reserved + case pidB5Reserved + case pidB6Reserved + case pidB7Reserved + case pidB8Reserved + case pidB9Reserved + case pidBAReserved + case pidBBReserved + case pidBCReserved + case pidBDReserved + case pidBEReserved + case pidBFReserved + + case pidsG // 01C0 Supported PIDs [C1-E0] + case pidC1Reserved + case pidC2Reserved + case fuelLevelInputAB // 01C3 + case exhaustParticulateDiag // 01C4 + case fuelPressureAB // 01C5 + case particulateControlStatus // 01C6 + case distanceSinceReflash // 01C7 + case noxPMWarningLamp // 01C8 } public enum Mode3: CaseIterable, Codable, Comparable { @@ -518,6 +632,115 @@ extension OBDCommand.Mode1 { case .fuelInjectionTiming: return CommandProperties("015D", "Fuel injection timing", 4, .injectTiming, true) case .fuelRate: return CommandProperties("015E", "Engine fuel rate", 4, .fuelRate, true) case .emissionsReq: return CommandProperties("015F", "Designed emission requirements", 3, .none) + + case .pidsD: return CommandProperties("0160", "Supported PIDs [61-80]", 5, .pid) + case .driversDemandTorque: return CommandProperties("0161", "Driver demand torque", 2, .percent, true) + case .actualEngineTorque: return CommandProperties("0162", "Actual engine torque", 2, .percent, true) + case .engineReferenceTorque: return CommandProperties("0163", "Engine reference torque", 3, .none, true) + case .enginePercentTorqueData: return CommandProperties("0164", "Engine percent torque data", 6, .none, true) + case .auxInputOutputSupported: return CommandProperties("0165", "Aux input/output supported", 3, .none) + case .mafSensor: return CommandProperties("0166", "Mass air flow sensor", 6, .none, true) + case .engineCoolantTempSensors: return CommandProperties("0167", "Coolant temp sensors", 4, .none, true) + case .intakeAirTempSensors: return CommandProperties("0168", "Intake air temp sensors", 4, .none, true) + case .egrActualCommandedError: return CommandProperties("0169", "EGR actual/commanded/error", 8, .none, true) + case .dieselIntakeAirFlowControl: return CommandProperties("016A", "Diesel intake air flow control", 6, .none, true) + case .egrTemperature: return CommandProperties("016B", "EGR temperature", 6, .none, true) + case .throttleActuatorControl: return CommandProperties("016C", "Throttle actuator control", 6, .none, true) + case .fuelPressureControlSystem: return CommandProperties("016D", "Fuel pressure control system", 12, .none, true) + case .injectionPressureControl: return CommandProperties("016E", "Injection pressure control system", 10, .none, true) + case .turboInletPressure: return CommandProperties("016F", "Turbo inlet pressure", 4, .pressure, true) + case .boostPressureControl: return CommandProperties("0170", "Boost pressure control", 11, .none, true) + case .vgtControl: return CommandProperties("0171", "VGT control", 7, .none, true) + case .wastegateControl: return CommandProperties("0172", "Wastegate control", 6, .none, true) + case .exhaustPressure: return CommandProperties("0173", "Exhaust pressure", 6, .none, true) + case .turbochargerRPM: return CommandProperties("0174", "Turbocharger RPM", 6, .none, true) + case .turboTemp1: return CommandProperties("0175", "Turbo temp 1", 8, .none, true) + case .turboTemp2: return CommandProperties("0176", "Turbo temp 2", 8, .none, true) + case .chargeAirCoolerTemp: return CommandProperties("0177", "Charge air cooler temp", 6, .temp, true) + case .egtBank1: return CommandProperties("0178", "EGT Bank 1", 10, .none, true) + case .egtBank2: return CommandProperties("0179", "EGT Bank 2", 10, .none, true) + case .dpfDiffPressure: return CommandProperties("017A", "DPF differential pressure", 8, .none, true) + case .dpfStatus: return CommandProperties("017B", "DPF status", 8, .none, true) + case .dpfTemperature: return CommandProperties("017C", "DPF temperature", 10, .temp, true) + case .noxNTEStatus: return CommandProperties("017D", "NOx NTE status", 2, .none) + case .pmNTEStatus: return CommandProperties("017E", "PM NTE status", 2, .none) + case .engineRunTimeTotal: return CommandProperties("017F", "Total engine run time", 14, .none, true) + + case .pidsE: return CommandProperties("0180", "Supported PIDs [81-A0]", 5, .pid) + case .aecdRunTime1: return CommandProperties("0181", "AECD run time", 42, .none, true) + case .aecdRunTime2: return CommandProperties("0182", "AECD run time", 42, .none, true) + case .noxSensor: return CommandProperties("0183", "NOx sensor", 10, .none, true) + case .manifoldSurfaceTemp: return CommandProperties("0184", "Manifold surface temp", 2, .temp, true) + case .noxReagentSystem: return CommandProperties("0185", "NOx reagent system", 11, .none, true) + case .pmSensor: return CommandProperties("0186", "PM sensor", 6, .none, true) + case .intakeManifoldPressure2: return CommandProperties("0187", "Intake manifold pressure", 6, .pressure, true) + case .scrInduceSystem: return CommandProperties("0188", "SCR induce system", 14, .none, true) + case .aecdRunTime11to15: return CommandProperties("0189", "AECD run time 11-15", 42, .none, true) + case .aecdRunTime16to20: return CommandProperties("018A", "AECD run time 16-20", 42, .none, true) + case .dieselAftertreatment: return CommandProperties("018B", "Diesel aftertreatment", 8, .none, true) + case .o2WideRange: return CommandProperties("018C", "Wide range O2 sensor", 18, .none, true) + case .throttlePosG: return CommandProperties("018D", "Throttle position G", 2, .percent, true) + case .engineFrictionTorque: return CommandProperties("018E", "Engine friction torque", 2, .percent, true) + case .pmSensorBank12: return CommandProperties("018F", "PM sensor bank 1/2", 8, .none, true) + case .wwhOBDInfo1: return CommandProperties("0190", "WWH-OBD info 1", 4, .none, true) + case .wwhOBDInfo2: return CommandProperties("0191", "WWH-OBD info 2", 6, .none, true) + case .fuelSystemControl: return CommandProperties("0192", "Fuel system control", 3, .none, true) + case .wwhOBDCounters: return CommandProperties("0193", "WWH-OBD counters", 4, .none, true) + case .noxWarningSystem: return CommandProperties("0194", "NOx warning system", 13, .none, true) + case .pid95Reserved: return CommandProperties("0195", "Reserved", 0, .none) + case .pid96Reserved: return CommandProperties("0196", "Reserved", 0, .none) + case .pid97Reserved: return CommandProperties("0197", "Reserved", 0, .none) + case .exhaustGasTempSensor1: return CommandProperties("0198", "Exhaust gas temp sensor 1", 10, .temp, true) + case .exhaustGasTempSensor2: return CommandProperties("0199", "Exhaust gas temp sensor 2", 10, .temp, true) + case .hybridBatteryData: return CommandProperties("019A", "Hybrid battery data", 7, .none, true) + case .defSensorData: return CommandProperties("019B", "DEF sensor data", 5, .none, true) + case .o2SensorData: return CommandProperties("019C", "O2 sensor data", 18, .none, true) + case .engineFuelRateAlt: return CommandProperties("019D", "Engine fuel rate", 5, .fuelRate, true) + case .engineExhaustFlowRate: return CommandProperties("019E", "Engine exhaust flow rate", 3, .none, true) + case .fuelSystemPercentUse: return CommandProperties("019F", "Fuel system % use", 10, .none, true) + + case .pidsF: return CommandProperties("01A0", "Supported PIDs [A1-C0]", 5, .pid) + case .noxSensorCorrected: return CommandProperties("01A1", "NOx sensor corrected data", 10, .none, true) + case .cylinderFuelRate: return CommandProperties("01A2", "Cylinder fuel rate", 3, .none, true) + case .evapSystemPressureAlt2: return CommandProperties("01A3", "Evap system vapor pressure", 10, .none, true) + case .transmissionActualGear: return CommandProperties("01A4", "Transmission actual gear", 5, .none, true) + case .commandedDEFdosing: return CommandProperties("01A5", "Commanded DEF dosing", 5, .none, true) + case .odometer: return CommandProperties("01A6", "Odometer", 5, .none, true) + case .noxSensorConc34: return CommandProperties("01A7", "NOx sensor concentration 3/4", 5, .none, true) + case .noxSensorCorrectedConc34: return CommandProperties("01A8", "NOx sensor corrected concentration 3/4", 5, .none, true) + case .absDisableSwitch: return CommandProperties("01A9", "ABS disable switch", 5, .none) + case .pidAAReserved: return CommandProperties("01AA", "Reserved", 0, .none) + case .pidABReserved: return CommandProperties("01AB", "Reserved", 0, .none) + case .pidACReserved: return CommandProperties("01AC", "Reserved", 0, .none) + case .pidADReserved: return CommandProperties("01AD", "Reserved", 0, .none) + case .pidAEReserved: return CommandProperties("01AE", "Reserved", 0, .none) + case .pidAFReserved: return CommandProperties("01AF", "Reserved", 0, .none) + case .pidB0Reserved: return CommandProperties("01B0", "Reserved", 0, .none) + case .pidB1Reserved: return CommandProperties("01B1", "Reserved", 0, .none) + case .pidB2Reserved: return CommandProperties("01B2", "Reserved", 0, .none) + case .pidB3Reserved: return CommandProperties("01B3", "Reserved", 0, .none) + case .pidB4Reserved: return CommandProperties("01B4", "Reserved", 0, .none) + case .pidB5Reserved: return CommandProperties("01B5", "Reserved", 0, .none) + case .pidB6Reserved: return CommandProperties("01B6", "Reserved", 0, .none) + case .pidB7Reserved: return CommandProperties("01B7", "Reserved", 0, .none) + case .pidB8Reserved: return CommandProperties("01B8", "Reserved", 0, .none) + case .pidB9Reserved: return CommandProperties("01B9", "Reserved", 0, .none) + case .pidBAReserved: return CommandProperties("01BA", "Reserved", 0, .none) + case .pidBBReserved: return CommandProperties("01BB", "Reserved", 0, .none) + case .pidBCReserved: return CommandProperties("01BC", "Reserved", 0, .none) + case .pidBDReserved: return CommandProperties("01BD", "Reserved", 0, .none) + case .pidBEReserved: return CommandProperties("01BE", "Reserved", 0, .none) + case .pidBFReserved: return CommandProperties("01BF", "Reserved", 0, .none) + + case .pidsG: return CommandProperties("01C0", "Supported PIDs [C1-E0]", 5, .pid) + case .pidC1Reserved: return CommandProperties("01C1", "Reserved", 0, .none) + case .pidC2Reserved: return CommandProperties("01C2", "Reserved", 0, .none) + case .fuelLevelInputAB: return CommandProperties("01C3", "Fuel level input A/B", 3, .none, true) + case .exhaustParticulateDiag: return CommandProperties("01C4", "Exhaust particulate diagnostic", 9, .none, true) + case .fuelPressureAB: return CommandProperties("01C5", "Fuel pressure A/B", 5, .none, true) + case .particulateControlStatus: return CommandProperties("01C6", "Particulate control status", 8, .none, true) + case .distanceSinceReflash: return CommandProperties("01C7", "Distance since reflash", 3, .none, true) + case .noxPMWarningLamp: return CommandProperties("01C8", "NOx / PM warning lamp", 2, .none) } } } From ba93b7c918790f848f988bad3f60fba78a9c221d Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 17 May 2026 08:38:10 +0100 Subject: [PATCH 03/68] fix optional --- Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift | 2 +- Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift b/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift index 634d001..39e208a 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift @@ -93,7 +93,7 @@ class BLEPeripheralManager: NSObject, ObservableObject { connectedPeripheral = nil if let completion = connectionCompletion { connectionCompletion = nil - completion?(nil, BLEManagerError.peripheralNotConnected) + completion(nil, BLEManagerError.peripheralNotConnected) } } } diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift b/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift index dcb5bad..1406d1d 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift @@ -76,7 +76,7 @@ class BLEPeripheralScanner: ObservableObject { foundPeripherals.removeAll() if let completion = foundPeripheralCompletion { foundPeripheralCompletion = nil - completion?(nil, BLEScannerError.scanTimeout) + completion(nil, BLEScannerError.scanTimeout) } } } From 2660fe7513eb4e1bde3fa1959bbf5b2644469c8a Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 17 May 2026 09:02:31 +0100 Subject: [PATCH 04/68] fix mock --- Sources/SwiftOBD2/Communication/mockManager.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/SwiftOBD2/Communication/mockManager.swift b/Sources/SwiftOBD2/Communication/mockManager.swift index 57461f8..ac04b6e 100644 --- a/Sources/SwiftOBD2/Communication/mockManager.swift +++ b/Sources/SwiftOBD2/Communication/mockManager.swift @@ -192,6 +192,10 @@ class MOCKComm: CommProtocol { func scanForPeripherals() async throws { } + + func reset() { + disconnectPeripheral() + } } extension OBDCommand { From 351ebd97cc449316dd4a261c87081c24b89adabd Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 17 May 2026 09:11:24 +0100 Subject: [PATCH 05/68] =?UTF-8?q?obd2service.swift=20=E2=80=94=20Added=20@?= =?UTF-8?q?Published=20public=20private(set)=20var=20peripherals:=20[CBPer?= =?UTF-8?q?ipheral]=20=3D=20[]=20to=20OBDService,=20added=20peripheralsUpd?= =?UTF-8?q?ated(=5F:)=20to=20OBDServiceDelegate=20(with=20a=20default=20no?= =?UTF-8?q?-op=20extension=20so=20existing=20conformers=20don't=20break),?= =?UTF-8?q?=20and=20implemented=20it=20in=20OBDService.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bleManager.swift — In didDiscover, after the scanner records a new peripheral, calls obdDelegate?.peripheralsUpdated(...) to push live updates. In resetConfigure, clears the list via obdDelegate?.peripheralsUpdated([]). $peripherals now exists on OBDService, so OBDService+Observation.swift will compile and OBDConnectionManager will receive live peripheral updates during scanning. --- Sources/SwiftOBD2/Communication/BLE/bleManager.swift | 4 ++++ Sources/SwiftOBD2/obd2service.swift | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index 7978590..dc8a975 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -171,6 +171,9 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { func didDiscover(_: CBCentralManager, peripheral: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) { peripheralScanner.addDiscoveredPeripheral(peripheral, advertisementData: advertisementData, rssi: rssi) + DispatchQueue.main.async { + self.obdDelegate?.peripheralsUpdated(self.peripheralScanner.foundPeripherals) + } } func connect(to peripheral: CBPeripheral) { @@ -342,6 +345,7 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { messageProcessor.reset() peripheralManager.reset() peripheralScanner.reset() + obdDelegate?.peripheralsUpdated([]) let oldState = connectionState connectionState = .disconnected diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index f7f1278..c23c0dc 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -10,6 +10,11 @@ public enum ConnectionType: String, CaseIterable { public protocol OBDServiceDelegate: AnyObject { func connectionStateChanged(state: ConnectionState) + func peripheralsUpdated(_ peripherals: [CBPeripheral]) +} + +extension OBDServiceDelegate { + public func peripheralsUpdated(_ peripherals: [CBPeripheral]) {} } struct Command: Codable { @@ -46,6 +51,7 @@ public class OBDService: ObservableObject, OBDServiceDelegate { @Published public private(set) var connectionState: ConnectionState = .disconnected @Published public private(set) var isScanning: Bool = false @Published public private(set) var connectedPeripheral: CBPeripheral? + @Published public private(set) var peripherals: [CBPeripheral] = [] @Published public var connectionType: ConnectionType { didSet { switchConnectionType(connectionType) @@ -93,6 +99,12 @@ public class OBDService: ObservableObject, OBDServiceDelegate { } } + public func peripheralsUpdated(_ peripherals: [CBPeripheral]) { + DispatchQueue.main.async { + self.peripherals = peripherals + } + } + /// Initiates the connection process to the OBD2 adapter and vehicle. /// /// - Parameter preferedProtocol: The optional OBD2 protocol to use (if supported). From 95da1b34010ba43812b9e59fd52a52e77002e30d Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 17 May 2026 09:37:28 +0100 Subject: [PATCH 06/68] bleManager.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit willRestoreState: Restored setPeripheral() call (was bypassing the discoverServices trigger needed for re-establishing characteristics after state restoration) connectAsync: Replaced the blanket guard == .disconnected with a switch — already-connected states (.connectedToAdapter, .connectedToVehicle) return silently, only in-progress/error states throw connectionInProgress reset() + resetConfigure(): Restructured public reset() to capture the peripheral reference before calling resetConfigure() (which nils it out), so cancelPeripheralConnection still gets a valid reference; moved peripheralsUpdated([]) inside the state-change guard so it's never called twice on idempotent resets commands.swift Added isReserved: Bool computed property to OBDCommand.Mode1 covering all 26 reserved placeholder cases — callers using allCases can filter with .filter { !$0.isReserved } --- .../Communication/BLE/bleManager.swift | 28 +++++++++++++------ Sources/SwiftOBD2/commands.swift | 17 +++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index dc8a975..4df1479 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -227,8 +227,7 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { func willRestoreState(_: CBCentralManager, dict: [String: Any]) { if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral], let peripheral = peripherals.first { obdDebug("Restoring peripheral: \(peripherals[0].name ?? "Unnamed")", category: .bluetooth) - peripheralManager.connectedPeripheral = peripheral - peripheral.delegate = peripheralManager + peripheralManager.setPeripheral(peripheral) } } @@ -241,7 +240,13 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { func connectAsync(timeout: TimeInterval, peripheral: CBPeripheral? = nil) async throws { try await waitForPoweredOn() - guard connectionState == .disconnected else { + switch connectionState { + case .connectedToAdapter, .connectedToVehicle: + obdInfo("Already connected to peripheral", category: .bluetooth) + return + case .disconnected: + break + default: obdWarning("Cannot connect - state is \(connectionState.description)", category: .bluetooth) throw BLEManagerError.connectionInProgress } @@ -345,24 +350,29 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { messageProcessor.reset() peripheralManager.reset() peripheralScanner.reset() - obdDelegate?.peripheralsUpdated([]) - + let oldState = connectionState connectionState = .disconnected if oldState != connectionState { OBDLogger.shared.logConnectionChange(from: oldState, to: connectionState) - + obdDelegate?.peripheralsUpdated([]) DispatchQueue.main.async { self.obdDelegate?.connectionStateChanged(state: .disconnected) } } } - /// Fully resets BLEManager state for clean reconnection + /// Fully resets BLEManager state for clean reconnection. + /// Captures the peripheral reference before clearing state so that + /// cancelPeripheralConnection is called with a valid reference, and the + /// subsequent didDisconnect callback is a safe no-op (all handlers already nil'd). public func reset() { - disconnectPeripheral() - resetConfigure() + let connectedPeripheral = peripheralManager.connectedPeripheral stopScan() + resetConfigure() + if let connectedPeripheral { + centralManager.cancelPeripheralConnection(connectedPeripheral) + } } } diff --git a/Sources/SwiftOBD2/commands.swift b/Sources/SwiftOBD2/commands.swift index 181d77a..f8a2804 100644 --- a/Sources/SwiftOBD2/commands.swift +++ b/Sources/SwiftOBD2/commands.swift @@ -743,6 +743,23 @@ extension OBDCommand.Mode1 { case .noxPMWarningLamp: return CommandProperties("01C8", "NOx / PM warning lamp", 2, .none) } } + + /// True for SAE-reserved PID slots that have no defined decoding. + /// Use this to skip these entries when iterating `allCases` for live queries. + public var isReserved: Bool { + switch self { + case .pid95Reserved, .pid96Reserved, .pid97Reserved, + .pidAAReserved, .pidABReserved, .pidACReserved, .pidADReserved, + .pidAEReserved, .pidAFReserved, .pidB0Reserved, .pidB1Reserved, + .pidB2Reserved, .pidB3Reserved, .pidB4Reserved, .pidB5Reserved, + .pidB6Reserved, .pidB7Reserved, .pidB8Reserved, .pidB9Reserved, + .pidBAReserved, .pidBBReserved, .pidBCReserved, .pidBDReserved, + .pidBEReserved, .pidBFReserved, .pidC1Reserved, .pidC2Reserved: + return true + default: + return false + } + } } extension OBDCommand.Mode6 { From f8e33ec5751dcefb7f05b54a3a95bd5693386935 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 17 May 2026 13:58:29 +0100 Subject: [PATCH 07/68] fix data decode --- Sources/SwiftOBD2/commands.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftOBD2/commands.swift b/Sources/SwiftOBD2/commands.swift index f8a2804..5cb36de 100644 --- a/Sources/SwiftOBD2/commands.swift +++ b/Sources/SwiftOBD2/commands.swift @@ -67,7 +67,7 @@ public struct CommandProperties: Encodable { guard let decoderInstance = decoder.getDecoder() else { return .failure(.unsupportedDecoder) } - return decoderInstance.decode(data: data.dropFirst(), unit: unit) + return decoderInstance.decode(data: data, unit: unit) } } From fe054b28abcb8c61f5ff758d063bf77a35738b61 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Thu, 21 May 2026 18:44:05 +0100 Subject: [PATCH 08/68] =?UTF-8?q?fix=20connection=20issues=20add=20Device?= =?UTF-8?q?=20Information=20Service=20(0x180A)=20=E2=80=94=20Bluetooth=20S?= =?UTF-8?q?IG=20standard,=20all=20readable=20UTF-8=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BLE/BLECharacteristicHandler.swift | 180 ++++++++++++------ .../Communication/BLE/bleManager.swift | 41 ++-- Sources/SwiftOBD2/obd2service.swift | 28 ++- 3 files changed, 176 insertions(+), 73 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift b/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift index 086b058..751cbe6 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift @@ -4,63 +4,100 @@ import CoreBluetooth class BLECharacteristicHandler { private var ecuReadCharacteristic: CBCharacteristic? - private var ecuWriteCharacteristic: CBCharacteristic? - private let messageProcessor: BLEMessageProcessor - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "BLECharacteristicHandler") + private var ecuWriteCharacteristic: CBCharacteristic? + private let messageProcessor: BLEMessageProcessor + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "BLECharacteristicHandler") + + // Device Information Service (0x180A) — Bluetooth SIG standard, all readable UTF-8 strings + // except 2A23 (System ID, 8-byte binary) and 2A2A (IEEE cert, binary). + private static let deviceInfoLabels: [String: String] = [ + "2A29": "Manufacturer", + "2A24": "Model", + "2A25": "Serial Number", + "2A27": "Hardware Revision", + "2A26": "Firmware Revision", + "2A28": "Software Revision", + "2A23": "System ID", + "2A2A": "IEEE Certification", + ] + + // ISSC/Microchip Transparent UART (service 49535343-FE7D-4AE5-8FA9-9FAFD205E455) + // Alternative ELM327 channel found on RN4870/ISP1807 modules — FFF0 is preferred. + private static let isscUUIDs: Set = [ + "49535343-6DAA-4D02-ABF6-19569ACA69FE", // TX / Notify + "49535343-ACA3-481C-91EC-D85E28A60318", // RX / Write Without Response + ] + + private(set) var deviceInfo: [String: String] = [:] + var onDeviceInfoUpdated: (([String: String]) -> Void)? + + var isReady: Bool { + ecuReadCharacteristic != nil && ecuWriteCharacteristic != nil + } + + init(messageProcessor: BLEMessageProcessor) { + self.messageProcessor = messageProcessor + } - var isReady: Bool { - ecuReadCharacteristic != nil && ecuWriteCharacteristic != nil - } + func setupCharacteristics(_ characteristics: [CBCharacteristic], on peripheral: CBPeripheral) { + for characteristic in characteristics { + let uuid = characteristic.uuid.uuidString.uppercased() + + // Device Information Service — read and store, don't treat as OBD channel + if Self.deviceInfoLabels[uuid] != nil { + if characteristic.properties.contains(.read) { + peripheral.readValue(for: characteristic) + } + continue + } - init(messageProcessor: BLEMessageProcessor) { - self.messageProcessor = messageProcessor - } + // ISSC UART — recognised, not used (FFF0 preferred) + if Self.isscUUIDs.contains(uuid) { + logger.debug("ISSC UART characteristic recognised (unused): \(uuid)") + continue + } + // OBD characteristics — subscribe to notify where supported + if characteristic.properties.contains(.notify) { + peripheral.setNotifyValue(true, for: characteristic) + } - func setupCharacteristics(_ characteristics: [CBCharacteristic], on peripheral: CBPeripheral) { - for characteristic in characteristics { - // Set up notifications for characteristics that support it - if characteristic.properties.contains(.notify) { - peripheral.setNotifyValue(true, for: characteristic) - } - - // Assign characteristics based on UUID and properties - switch characteristic.uuid.uuidString.uppercased() { - case "FFE1": // for service FFE0 (read and write) - if characteristic.properties.contains(.write) { - ecuWriteCharacteristic = characteristic - } - if characteristic.properties.contains(.read) || characteristic.properties.contains(.notify) { - ecuReadCharacteristic = characteristic - } - - case "FFF1": // for service FFF0 (read only) - if characteristic.properties.contains(.read) || characteristic.properties.contains(.notify) { - ecuReadCharacteristic = characteristic - } - - case "FFF2": // for service FFF0 (write only) - if characteristic.properties.contains(.write) { - ecuWriteCharacteristic = characteristic - } - - case "2AF0": // for service 18F0 (read) - if characteristic.properties.contains(.read) || characteristic.properties.contains(.notify) { - ecuReadCharacteristic = characteristic - } - - case "2AF1": // for service 18F0 (write) - if characteristic.properties.contains(.write) { - ecuWriteCharacteristic = characteristic - } - - default: - logger.debug("Unknown characteristic: \(characteristic.uuid.uuidString)") - } - } - - logger.info("Characteristics setup - Read: \(self.ecuReadCharacteristic != nil), Write: \(self.ecuWriteCharacteristic != nil)") - } + switch uuid { + case "FFE1": // FFE0 service — single characteristic handles both read and write + if characteristic.properties.contains(.write) { + ecuWriteCharacteristic = characteristic + } + if characteristic.properties.contains(.read) || characteristic.properties.contains(.notify) { + ecuReadCharacteristic = characteristic + } + + case "FFF1": // FFF0 service — notify (read) + if characteristic.properties.contains(.read) || characteristic.properties.contains(.notify) { + ecuReadCharacteristic = characteristic + } + + case "FFF2": // FFF0 service — write + if characteristic.properties.contains(.write) { + ecuWriteCharacteristic = characteristic + } + + case "2AF0": // 18F0 service — read + if characteristic.properties.contains(.read) || characteristic.properties.contains(.notify) { + ecuReadCharacteristic = characteristic + } + + case "2AF1": // 18F0 service — write + if characteristic.properties.contains(.write) { + ecuWriteCharacteristic = characteristic + } + + default: + logger.warning("Unknown characteristic: \(uuid) — properties: \(characteristic.properties.rawValue)") + } + } + + logger.info("Characteristics setup — Read: \(self.ecuReadCharacteristic != nil), Write: \(self.ecuWriteCharacteristic != nil)") + } func discoverCharacteristics(for service: CBService, on peripheral: CBPeripheral) { switch service.uuid { @@ -71,6 +108,7 @@ class BLECharacteristicHandler { case CBUUID(string: "18F0"): peripheral.discoverCharacteristics([CBUUID(string: "2AF0"), CBUUID(string: "2AF1")], for: service) default: + // Discover all characteristics for unknown services (Device Info, ISSC, etc.) peripheral.discoverCharacteristics(nil, for: service) } } @@ -80,15 +118,29 @@ class BLECharacteristicHandler { let data = "\(command)\r".data(using: .ascii) else { throw BLEManagerError.missingPeripheralOrCharacteristic } - peripheral.writeValue(data, for: characteristic, type: .withResponse) logger.info("Sent command: \(command)") } func handleUpdatedValue(_ data: Data, from characteristic: CBCharacteristic) { + let uuid = characteristic.uuid.uuidString.uppercased() + + // Device info read response + if let label = Self.deviceInfoLabels[uuid] { + let decoded = Self.decodeDeviceInfoValue(data: data, uuid: uuid) + if !decoded.isEmpty { + deviceInfo[label] = decoded + onDeviceInfoUpdated?(deviceInfo) + } + return + } + guard characteristic == ecuReadCharacteristic else { - if let responseString = String(data: data, encoding: .utf8) { - logger.info("Unknown characteristic: \(characteristic)\nResponse: \(responseString)") + // A characteristic we don't handle produced a notification — log and ignore + if let text = String(data: data, encoding: .utf8) { + logger.debug("Unhandled notification from \(uuid): \(text)") + } else { + logger.debug("Unhandled notification from \(uuid): \(data.map { String(format: "%02X", $0) }.joined(separator: " "))") } return } @@ -99,5 +151,21 @@ class BLECharacteristicHandler { func reset() { ecuReadCharacteristic = nil ecuWriteCharacteristic = nil + deviceInfo = [:] + } + + // MARK: - Decoding + + private static func decodeDeviceInfoValue(data: Data, uuid: String) -> String { + guard !data.isEmpty else { return "" } + switch uuid { + case "2A23": // System ID — 8-byte manufacturer-assigned binary identifier + return data.map { String(format: "%02X", $0) }.joined(separator: ":") + case "2A2A": // IEEE 11073 Regulatory Certification — binary, show as hex + return data.map { String(format: "%02X", $0) }.joined(separator: " ") + default: // All others are UTF-8 strings + return String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } } } diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index 4df1479..8f16460 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -107,6 +107,12 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { characteristicHandler = BLECharacteristicHandler(messageProcessor: messageProcessor) peripheralManager = BLEPeripheralManager(characteristicHandler: characteristicHandler) peripheralScanner = BLEPeripheralScanner() + + characteristicHandler.onDeviceInfoUpdated = { [weak self] info in + DispatchQueue.main.async { + self?.obdDelegate?.adapterInfoUpdated(info) + } + } } // MARK: - Central Manager Control Methods @@ -162,11 +168,9 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { } func centralManagerDidPowerOn() { - guard let device = peripheralManager.connectedPeripheral else { - startScanning(BLEPeripheralScanner.supportedServices) - return - } - connect(to: device) + // Never auto-connect on power-on — the user must explicitly initiate. + // A previously restored peripheral lands in the scan list via willRestoreState. + startScanning(nil) } func didDiscover(_: CBCentralManager, peripheral: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) { @@ -204,11 +208,14 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { let peripheralName = peripheral.name ?? "Unnamed" let errorMsg = error?.localizedDescription ?? "Unknown error" obdError("Connection failed to peripheral: \(peripheralName) - \(errorMsg)", category: .bluetooth) - + + // Clean up peripheral state so a retry can proceed from a fresh baseline. + peripheralManager.reset() + let oldState = connectionState connectionState = .error OBDLogger.shared.logConnectionChange(from: oldState, to: connectionState) - + DispatchQueue.main.async { self.obdDelegate?.connectionStateChanged(state: .error) } @@ -225,9 +232,13 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { } func willRestoreState(_: CBCentralManager, dict: [String: Any]) { - if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral], let peripheral = peripherals.first { - obdDebug("Restoring peripheral: \(peripherals[0].name ?? "Unnamed")", category: .bluetooth) - peripheralManager.setPeripheral(peripheral) + // Add restored peripherals to the discovered list so they appear in the UI, + // but do NOT set them as the managed peripheral — the user decides to connect. + if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] { + for peripheral in peripherals { + obdDebug("Restoring peripheral to scan list: \(peripheral.name ?? "Unnamed")", category: .bluetooth) + peripheralScanner.addDiscoveredPeripheral(peripheral, advertisementData: [:], rssi: -60) + } } } @@ -244,11 +255,13 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { case .connectedToAdapter, .connectedToVehicle: obdInfo("Already connected to peripheral", category: .bluetooth) return - case .disconnected: - break - default: - obdWarning("Cannot connect - state is \(connectionState.description)", category: .bluetooth) + case .connecting: + // Another connection attempt is genuinely in flight — don't stack on top. + obdWarning("Cannot connect - already connecting", category: .bluetooth) throw BLEManagerError.connectionInProgress + default: + // .disconnected and .error are both recoverable starting points. + break } let targetPeripheral: CBPeripheral diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index c23c0dc..cb9acb9 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -11,10 +11,12 @@ public enum ConnectionType: String, CaseIterable { public protocol OBDServiceDelegate: AnyObject { func connectionStateChanged(state: ConnectionState) func peripheralsUpdated(_ peripherals: [CBPeripheral]) + func adapterInfoUpdated(_ info: [String: String]) } extension OBDServiceDelegate { public func peripheralsUpdated(_ peripherals: [CBPeripheral]) {} + public func adapterInfoUpdated(_ info: [String: String]) {} } struct Command: Codable { @@ -52,6 +54,13 @@ public class OBDService: ObservableObject, OBDServiceDelegate { @Published public private(set) var isScanning: Bool = false @Published public private(set) var connectedPeripheral: CBPeripheral? @Published public private(set) var peripherals: [CBPeripheral] = [] + @Published public private(set) var adapterInfo: [String: String] = [:] + + // Plain Swift callbacks — consumed by the app layer without Combine. + public var onConnectionStateChanged: ((ConnectionState) -> Void)? + public var onPeripheralsUpdated: (([CBPeripheral]) -> Void)? + public var onScanningChanged: ((Bool) -> Void)? + public var onAdapterInfoUpdated: (([String: String]) -> Void)? @Published public var connectionType: ConnectionType { didSet { switchConnectionType(connectionType) @@ -96,12 +105,21 @@ public class OBDService: ObservableObject, OBDServiceDelegate { if oldState != state { OBDLogger.shared.logConnectionChange(from: oldState, to: state) } + self.onConnectionStateChanged?(state) } } public func peripheralsUpdated(_ peripherals: [CBPeripheral]) { DispatchQueue.main.async { self.peripherals = peripherals + self.onPeripheralsUpdated?(peripherals) + } + } + + public func adapterInfoUpdated(_ info: [String: String]) { + DispatchQueue.main.async { + self.adapterInfo = info + self.onAdapterInfoUpdated?(info) } } @@ -110,13 +128,13 @@ public class OBDService: ObservableObject, OBDServiceDelegate { /// - Parameter preferedProtocol: The optional OBD2 protocol to use (if supported). /// - Returns: Information about the connected vehicle (`OBDInfo`). /// - Throws: Errors that might occur during the connection process. - public func startConnection(preferedProtocol: PROTOCOL? = nil, timeout: TimeInterval = 7) async throws -> OBDInfo { + public func startConnection(preferedProtocol: PROTOCOL? = nil, timeout: TimeInterval = 7, peripheral: CBPeripheral? = nil) async throws -> OBDInfo { let startTime = CFAbsoluteTimeGetCurrent() obdInfo("Starting connection with timeout: \(timeout)s", category: .connection) - + do { obdDebug("Connecting to adapter...", category: .connection) - try await elm327.connectToAdapter(timeout: timeout) + try await elm327.connectToAdapter(timeout: timeout, peripheral: peripheral) obdDebug("Initializing adapter...", category: .connection) try await elm327.adapterInitialization() @@ -326,9 +344,13 @@ public class OBDService: ObservableObject, OBDServiceDelegate { public func scanForPeripherals() async throws { do { self.isScanning = true + onScanningChanged?(true) try await elm327.scanForPeripherals() self.isScanning = false + onScanningChanged?(false) } catch { + self.isScanning = false + onScanningChanged?(false) throw OBDServiceError.scanFailed(underlyingError: error) } } From 6d660ad6cfc47234dada751eb0bdd519f9c86432 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sat, 23 May 2026 21:57:58 +0100 Subject: [PATCH 09/68] remove scanning automatically --- Sources/SwiftOBD2/Communication/BLE/bleManager.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index 8f16460..f2f8d16 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -168,9 +168,7 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { } func centralManagerDidPowerOn() { - // Never auto-connect on power-on — the user must explicitly initiate. - // A previously restored peripheral lands in the scan list via willRestoreState. - startScanning(nil) + // Scanning is initiated explicitly by the caller (Dongle tab / scanForDevices). } func didDiscover(_: CBCentralManager, peripheral: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) { From 1ba97740b2ee351c0c22af0beaa1bc839c04aa79 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sat, 23 May 2026 22:56:33 +0100 Subject: [PATCH 10/68] added CVNDecoder and trim --- Sources/SwiftOBD2/decoders.swift | 13 +++++++++++++ Sources/SwiftOBD2/obd2service.swift | 1 + 2 files changed, 14 insertions(+) diff --git a/Sources/SwiftOBD2/decoders.swift b/Sources/SwiftOBD2/decoders.swift index 42af823..a7d19fa 100644 --- a/Sources/SwiftOBD2/decoders.swift +++ b/Sources/SwiftOBD2/decoders.swift @@ -323,6 +323,8 @@ public enum Decoders: Equatable, Encodable { return MonitorDecoder() case .encoded_string: return StringDecoder() + case .cvn: + return CVNDecoder() case .uas(let id): let decoder = UASDecoder(id: id) return decoder @@ -694,6 +696,17 @@ struct StringDecoder: Decoder { } } +// Formats the 4-byte Calibration Verification Number as an 8-char uppercase hex string. +// After sendCommand dropFirst, data layout: [PID(06), count(01), b0, b1, b2, b3] +struct CVNDecoder: Decoder { + func decode(data: Data, unit: MeasurementUnit) -> Result { + guard data.count >= 6 else { return .failure(.invalidData) } + let cvnBytes = data.dropFirst(2).prefix(4) + let hex = cvnBytes.map { String(format: "%02X", $0) }.joined() + return .success(.stringResult(hex)) + } +} + struct UASDecoder: Decoder { let id: UInt8 diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index cb9acb9..0e805f2 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -461,4 +461,5 @@ public struct VINInfo: Codable, Hashable { public let Model: String public let ModelYear: String public let EngineCylinders: String + public let Trim: String? } From 9497c8d23ebaa241bcdf5c80ecc83ad9954c197b Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 24 May 2026 10:03:56 +0100 Subject: [PATCH 11/68] add safe array lookup subscript --- Sources/SwiftOBD2/decoders.swift | 58 ++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/Sources/SwiftOBD2/decoders.swift b/Sources/SwiftOBD2/decoders.swift index a7d19fa..4609bf8 100644 --- a/Sources/SwiftOBD2/decoders.swift +++ b/Sources/SwiftOBD2/decoders.swift @@ -61,13 +61,25 @@ struct BitArray { func value(at range: Range) -> UInt8 { var value: UInt8 = 0 for bit in range { + guard let bitValue = binaryArray[safe: bit] else { return 0 } value = value << 1 - value = value | UInt8(binaryArray[bit]) + value = value | UInt8(bitValue) } return value } } +extension Collection where Index == Int { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } + + subscript(safe range: Range) -> SubSequence? { + guard range.lowerBound >= startIndex, range.upperBound <= endIndex else { return nil } + return self[range] + } +} + extension Unit { static let percent = Unit(symbol: "%") static let count = Unit(symbol: "count") @@ -367,10 +379,11 @@ struct MonitorDecoder: Decoder { } func parse_monitor_test(_ data: Data) -> MonitorTest? { + let bytes = Array(data) var test = MonitorTest() - let tid = data[1] - let cid = data[2] + let tid = bytes[1] + let cid = bytes[2] if let testInfo = TestIds[tid] { test.name = testInfo.0 @@ -445,27 +458,24 @@ struct AbsEvapPressureDecoder: Decoder { struct FuelTypeDecoder: Decoder { func decode(data: Data, unit: MeasurementUnit) -> Result { - guard data.count > 0 else { + let bytes = Array(data) + guard let i = bytes.first else { return .failure(.invalidData) } - let i = data[0] - var value: String? - if i < FuelTypes.count { - value = FuelTypes[Int(i)] - } - guard let value = value else { + guard Int(i) < FuelTypes.count else { return .failure(.invalidData) } - return .success(.stringResult((value))) + return .success(.stringResult(FuelTypes[Int(i)])) } } struct MaxMafDecoder: Decoder { func decode(data: Data, unit: MeasurementUnit) -> Result { - guard data.count > 0 else { + let bytes = Array(data) + guard let first = bytes.first else { return .failure(.invalidData) } - let value = data[0] * 10 + let value = first * 10 return .success((.measurementResult(MeasurementResult(value: Double(value), unit: Unit.gramsPerSecond)))) } } @@ -480,12 +490,13 @@ struct AbsoluteLoadDecoder: Decoder { struct EvapPressureDecoder: Decoder { func decode(data: Data, unit: MeasurementUnit) -> Result { - guard data.count > 1 else { + let bytes = Array(data) + guard bytes.count > 1 else { return .failure(.invalidData) } - - let a = twosComp(Int(data[0]), length: 8) - let b = twosComp(Int(data[1]), length: 8) + + let a = twosComp(Int(bytes[0]), length: 8) + let b = twosComp(Int(bytes[1]), length: 8) let value = ((Double(a) * 256.0) + Double(b)) / 4.0 return .success((.measurementResult(MeasurementResult(value: value, unit: UnitPressure.kilopascals)))) @@ -554,11 +565,12 @@ struct O2SensorsAltDecoder: Decoder { struct OBDComplianceDecoder: Decoder { func decode(data: Data, unit: MeasurementUnit) -> Result { - guard data.count > 1 else { + let bytes = Array(data) + guard bytes.count > 1 else { return .failure(.invalidData) } - - let i = data[1] + + let i = bytes[1] if i < OBD_COMPLIANCE.count { return .success(.stringResult((OBD_COMPLIANCE[Int(i)]))) @@ -736,11 +748,15 @@ struct StatusDecoder: Decoder { // convert to binaryarray let bits = BitArray(data: data) + guard bits.binaryArray.count >= 16 else { + return .failure(.invalidData) + } var output = Status() output.MIL = bits.binaryArray[0] == 1 output.dtcCount = bits.value(at: 1 ..< 8) - output.ignitionType = IGNITIONTYPE[bits.binaryArray[12]] + let ignitionBit = bits.binaryArray[12] + output.ignitionType = ignitionBit < IGNITIONTYPE.count ? IGNITIONTYPE[ignitionBit] : "Unknown" // load the 3 base tests that are always present From 195d9b2dd57c0d5103c32345030629f8430d0c99 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 24 May 2026 10:08:43 +0100 Subject: [PATCH 12/68] fix FuelTypes lookup --- Sources/SwiftOBD2/decoders.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftOBD2/decoders.swift b/Sources/SwiftOBD2/decoders.swift index 4609bf8..13e0a20 100644 --- a/Sources/SwiftOBD2/decoders.swift +++ b/Sources/SwiftOBD2/decoders.swift @@ -462,10 +462,10 @@ struct FuelTypeDecoder: Decoder { guard let i = bytes.first else { return .failure(.invalidData) } - guard Int(i) < FuelTypes.count else { + guard let value = FuelTypes[safe: Int(i)] else { return .failure(.invalidData) } - return .success(.stringResult(FuelTypes[Int(i)])) + return .success(.stringResult(value)) } } From e389d2e82cbdca66d555c05637d3d1ae49999faf Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 24 May 2026 14:46:54 +0100 Subject: [PATCH 13/68] fix BLEDataProcessor.swift:67: Assertion failed: Concurrent command detected --- .../Communication/BLE/BLEDataProcessor.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift index 4c9b1bb..81de7c1 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift @@ -60,13 +60,10 @@ class BLEMessageProcessor { func waitForResponse(timeout: TimeInterval) async throws -> [String] { - try await withTimeout(seconds: timeout, timeoutError: BLEMessageProcessorError.responseTimeout) { [self] in + try await withTimeout(seconds: timeout, timeoutError: BLEMessageProcessorError.responseTimeout) { [self] in + try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[String], Error>) in - - // Check if there's already a pending command assert(messageCompletion == nil, "Concurrent command detected") - - messageCompletion = { response, error in if let response = response { continuation.resume(returning: response) @@ -76,10 +73,14 @@ class BLEMessageProcessor { continuation.resume(throwing: BLEMessageProcessorError.responseTimeout) } } - } + } onCancel: { [self] in + let pending = messageCompletion + messageCompletion = nil + pending?(nil, BLEMessageProcessorError.responseTimeout) } } + } func reset() { buffer.removeAll() From 2738533cd1bf9f1acc545947f98f2c2a21381caa Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Mon, 25 May 2026 12:26:08 +0100 Subject: [PATCH 14/68] =?UTF-8?q?Silently=20adds=20a=20peripheral=20restor?= =?UTF-8?q?ed=20from=20CoreBluetooth=20state=20=E2=80=94=20does=20not=20lo?= =?UTF-8?q?g=20or=20publish=20a=20scan=20event.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift | 6 ++++++ Sources/SwiftOBD2/Communication/BLE/bleManager.swift | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift b/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift index 1406d1d..5fdf1a5 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift @@ -72,6 +72,12 @@ class BLEPeripheralScanner: ObservableObject { } } + /// Silently adds a peripheral restored from CoreBluetooth state — does not log or publish a scan event. + func restorePeripheral(_ peripheral: CBPeripheral) { + guard !foundPeripherals.contains(where: { $0.identifier == peripheral.identifier }) else { return } + foundPeripherals.append(peripheral) + } + func reset() { foundPeripherals.removeAll() if let completion = foundPeripheralCompletion { diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index f2f8d16..d51b13a 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -235,7 +235,7 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] { for peripheral in peripherals { obdDebug("Restoring peripheral to scan list: \(peripheral.name ?? "Unnamed")", category: .bluetooth) - peripheralScanner.addDiscoveredPeripheral(peripheral, advertisementData: [:], rssi: -60) + peripheralScanner.restorePeripheral(peripheral) } } } From b3db17b5ca2e16e9e65ae292b45c648bcb6e8628 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Fri, 29 May 2026 21:28:28 +0100 Subject: [PATCH 15/68] add monitoring --- .../Communication/BLE/BLEDataProcessor.swift | 48 +++++++++++++------ .../Communication/BLE/bleManager.swift | 17 ++++++- .../SwiftOBD2/Communication/mockManager.swift | 4 ++ .../SwiftOBD2/Communication/wifiManager.swift | 8 ++++ Sources/SwiftOBD2/elm327.swift | 4 ++ Sources/SwiftOBD2/obd2service.swift | 8 ++++ 6 files changed, 73 insertions(+), 16 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift index 81de7c1..505c744 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift @@ -7,6 +7,9 @@ class BLEMessageProcessor { private var buffer = Data() private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "BLEMessageProcessor") private var messageCompletion: (([String]?, Error?) -> Void)? + /// When true, a timeout in waitForResponse returns buffered data instead of throwing. + /// Used by sendMonitorCommand to capture ELM327 AT MA / AT MT streaming output. + var monitorMode = false func processReceivedData(_ data: Data) { buffer.append(data) @@ -60,25 +63,40 @@ class BLEMessageProcessor { func waitForResponse(timeout: TimeInterval) async throws -> [String] { - try await withTimeout(seconds: timeout, timeoutError: BLEMessageProcessorError.responseTimeout) { [self] in - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[String], Error>) in - assert(messageCompletion == nil, "Concurrent command detected") - messageCompletion = { response, error in - if let response = response { - continuation.resume(returning: response) - } else if let error = error { - continuation.resume(throwing: error) - } else { - continuation.resume(throwing: BLEMessageProcessorError.responseTimeout) + do { + return try await withTimeout(seconds: timeout, timeoutError: BLEMessageProcessorError.responseTimeout) { [self] in + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[String], Error>) in + assert(messageCompletion == nil, "Concurrent command detected") + messageCompletion = { response, error in + if let response = response { + continuation.resume(returning: response) + } else if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(throwing: BLEMessageProcessorError.responseTimeout) + } } } + } onCancel: { [self] in + let pending = messageCompletion + messageCompletion = nil + pending?(nil, BLEMessageProcessorError.responseTimeout) } - } onCancel: { [self] in - let pending = messageCompletion - messageCompletion = nil - pending?(nil, BLEMessageProcessorError.responseTimeout) } + } catch BLEMessageProcessorError.responseTimeout where monitorMode { + // In monitor mode the ELM327 streams frames without a '>' terminator; + // return whatever accumulated in the buffer rather than throwing. + monitorMode = false + let captured = buffer + buffer.removeAll() + messageCompletion = nil + guard let string = String(data: captured, encoding: .utf8), !string.isEmpty else { return [] } + return string + .replacingOccurrences(of: ">", with: "") + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } } } diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index d51b13a..23b62d2 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -337,7 +337,7 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { } obdDebug("Sending command: \(command)", category: .communication) - + do { try characteristicHandler.writeCommand(command, to: peripheral) let response = try await messageProcessor.waitForResponse(timeout: BLEConstants.defaultTimeout) @@ -349,6 +349,21 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { } } + func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] { + guard let peripheral = peripheralManager.connectedPeripheral else { + throw BLEManagerError.missingPeripheralOrCharacteristic + } + messageProcessor.monitorMode = true + // Always reset monitorMode when this call returns, whether via timeout or a + // normal response (e.g. the adapter replies "?" immediately with a ">"). + defer { messageProcessor.monitorMode = false } + try characteristicHandler.writeCommand(command, to: peripheral) + let frames = try await messageProcessor.waitForResponse(timeout: duration) + // Send a bare CR to stop ELM327 monitoring mode (ignored if already stopped) + try? characteristicHandler.writeCommand("", to: peripheral) + return frames + } + func scanForPeripherals() async throws { startScanning(nil) diff --git a/Sources/SwiftOBD2/Communication/mockManager.swift b/Sources/SwiftOBD2/Communication/mockManager.swift index ac04b6e..1512fd3 100644 --- a/Sources/SwiftOBD2/Communication/mockManager.swift +++ b/Sources/SwiftOBD2/Communication/mockManager.swift @@ -179,6 +179,10 @@ class MOCKComm: CommProtocol { } } + func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] { + return [] + } + func disconnectPeripheral() { connectionState = .disconnected obdDelegate?.connectionStateChanged(state: .disconnected) diff --git a/Sources/SwiftOBD2/Communication/wifiManager.swift b/Sources/SwiftOBD2/Communication/wifiManager.swift index ac560ef..97f15c6 100644 --- a/Sources/SwiftOBD2/Communication/wifiManager.swift +++ b/Sources/SwiftOBD2/Communication/wifiManager.swift @@ -12,6 +12,9 @@ import OSLog protocol CommProtocol { func sendCommand(_ command: String, retries: Int) async throws -> [String] + /// Sends a command that puts the adapter into streaming/monitor mode (e.g. AT MA, AT MT). + /// Collects frames for `duration` seconds, then stops and returns them. + func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] func disconnectPeripheral() func connectAsync(timeout: TimeInterval, peripheral: CBPeripheral?) async throws func scanForPeripherals() async throws @@ -73,6 +76,11 @@ class WifiManager: CommProtocol { return try await sendCommandInternal(data: data, retries: retries) } + func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] { + // WiFi uses a single-receive model; attempt a one-shot read with a generous timeout. + (try? await sendCommand(command, retries: 0)) ?? [] + } + private func sendCommandInternal(data: Data, retries: Int) async throws -> [String] { for attempt in 1 ... retries { do { diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index bdee56e..5134b66 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -255,6 +255,10 @@ class ELM327 { try await comm.sendCommand(message, retries: retries) } + func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] { + try await comm.sendMonitorCommand(command, duration: duration) + } + private func okResponse(_ message: String) async throws -> [String] { let response = try await sendCommand(message) if response.contains("OK") { diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index 0e805f2..a8b5c65 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -333,6 +333,14 @@ public class OBDService: ObservableObject, OBDServiceDelegate { } } + public func sendMonitorCommandInternal(_ command: String, duration: TimeInterval) async throws -> [String] { + do { + return try await elm327.sendMonitorCommand(command, duration: duration) + } catch { + throw OBDServiceError.commandFailed(command: command, error: error) + } + } + public func connectToPeripheral(peripheral: CBPeripheral) async throws { do { try await elm327.connectToAdapter(timeout: 5, peripheral: peripheral) From c2137759e99e06069d8c3a1472cb752faee26bb1 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sat, 30 May 2026 08:31:30 +0100 Subject: [PATCH 16/68] add sendable conformance --- .../SwiftOBD2/Communication/BLE/bleManager.swift | 7 +++++-- Sources/SwiftOBD2/Utils.swift | 2 +- Sources/SwiftOBD2/codes.swift | 2 +- Sources/SwiftOBD2/commands.swift | 14 +++++++------- Sources/SwiftOBD2/elm327.swift | 1 + Sources/SwiftOBD2/obd2service.swift | 2 +- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index 23b62d2..ba320ee 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -18,7 +18,7 @@ import Combine import CoreBluetooth import Foundation -public enum ConnectionState: Sendable { +public enum ConnectionState: Sendable, Equatable { case disconnected case connecting case connectedToAdapter @@ -359,8 +359,11 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { defer { messageProcessor.monitorMode = false } try characteristicHandler.writeCommand(command, to: peripheral) let frames = try await messageProcessor.waitForResponse(timeout: duration) - // Send a bare CR to stop ELM327 monitoring mode (ignored if already stopped) + // Send a bare CR to stop ELM327 monitoring mode, then drain the resulting + // "STOPPED\r>" acknowledgment. Without this drain, STOPPED can arrive after + // we return and corrupt the next command's waitForResponse. try? characteristicHandler.writeCommand("", to: peripheral) + _ = try? await messageProcessor.waitForResponse(timeout: 1.0) return frames } diff --git a/Sources/SwiftOBD2/Utils.swift b/Sources/SwiftOBD2/Utils.swift index 6583f05..76d7785 100644 --- a/Sources/SwiftOBD2/Utils.swift +++ b/Sources/SwiftOBD2/Utils.swift @@ -45,7 +45,7 @@ func bytesToInt(_ byteArray: Data) -> Int { // } // } -public enum PROTOCOL: String, Codable, CaseIterable { +public enum PROTOCOL: String, Codable, CaseIterable, Sendable { case protocol1 = "1", protocol2 = "2", diff --git a/Sources/SwiftOBD2/codes.swift b/Sources/SwiftOBD2/codes.swift index 7a3012e..7f60dd2 100644 --- a/Sources/SwiftOBD2/codes.swift +++ b/Sources/SwiftOBD2/codes.swift @@ -7,7 +7,7 @@ import Foundation -public struct TroubleCode: Codable, Hashable, Comparable { +public struct TroubleCode: Codable, Hashable, Comparable, Sendable { public static func < (lhs: TroubleCode, rhs: TroubleCode) -> Bool { lhs.code < rhs.code } diff --git a/Sources/SwiftOBD2/commands.swift b/Sources/SwiftOBD2/commands.swift index 5cb36de..bc0ff3b 100644 --- a/Sources/SwiftOBD2/commands.swift +++ b/Sources/SwiftOBD2/commands.swift @@ -71,7 +71,7 @@ public struct CommandProperties: Encodable { } } -public enum OBDCommand: Codable, Hashable, Comparable, Identifiable { +public enum OBDCommand: Codable, Hashable, Comparable, Identifiable, Sendable { case general(General) case mode1(Mode1) case mode3(Mode3) @@ -98,7 +98,7 @@ public enum OBDCommand: Codable, Hashable, Comparable, Identifiable { } } - public enum General: CaseIterable, Codable, Comparable { + public enum General: CaseIterable, Codable, Comparable, Sendable { case ATD case ATZ case ATRV @@ -111,7 +111,7 @@ public enum OBDCommand: Codable, Hashable, Comparable, Identifiable { case ATDPN } - public enum Protocols: CaseIterable, Codable, Comparable { + public enum Protocols: CaseIterable, Codable, Comparable, Sendable { case ATSP0 case ATSP6 public var properties: CommandProperties { @@ -123,7 +123,7 @@ public enum OBDCommand: Codable, Hashable, Comparable, Identifiable { } } - public enum Mode1: CaseIterable, Codable, Comparable { + public enum Mode1: CaseIterable, Codable, Comparable, Sendable { case pidsA case status case freezeDTC @@ -336,7 +336,7 @@ public enum OBDCommand: Codable, Hashable, Comparable, Identifiable { case noxPMWarningLamp // 01C8 } - public enum Mode3: CaseIterable, Codable, Comparable { + public enum Mode3: CaseIterable, Codable, Comparable, Sendable { case GET_DTC var properties: CommandProperties { switch self { @@ -354,7 +354,7 @@ public enum OBDCommand: Codable, Hashable, Comparable, Identifiable { } } - public enum Mode6: CaseIterable, Codable, Comparable { + public enum Mode6: CaseIterable, Codable, Comparable, Sendable { case MIDS_A case MONITOR_O2_B1S1 case MONITOR_O2_B1S2 @@ -445,7 +445,7 @@ public enum OBDCommand: Codable, Hashable, Comparable, Identifiable { case MONITOR_PM_FILTER_B2 } - public enum Mode9: CaseIterable, Codable, Comparable { + public enum Mode9: CaseIterable, Codable, Comparable, Sendable { case PIDS_9A case VIN_MESSAGE_COUNT case VIN diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index 5134b66..41c122d 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -81,6 +81,7 @@ class ELM327 { private func setupConnectionStateSubscriber() { comm.connectionStatePublisher .receive(on: DispatchQueue.main) + .removeDuplicates() .sink { [weak self] state in self?.connectionState = state self?.obdDelegate?.connectionStateChanged(state: state) diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index a8b5c65..6d6bfe4 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -49,7 +49,7 @@ public class ConfigurationService { /// - Sending and receiving OBD2 commands. /// - Providing information about the vehicle. /// - Managing the connection state. -public class OBDService: ObservableObject, OBDServiceDelegate { +public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendable { @Published public private(set) var connectionState: ConnectionState = .disconnected @Published public private(set) var isScanning: Bool = false @Published public private(set) var connectedPeripheral: CBPeripheral? From 13ba789dedb90b3728dd32df8511e57d475153b6 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sat, 30 May 2026 16:05:15 +0100 Subject: [PATCH 17/68] =?UTF-8?q?3.=20UAS=20multi-byte=20decoders=20now=20?= =?UTF-8?q?reject=201-byte=20garbage=20responses=20(decoders.swift)=20Adde?= =?UTF-8?q?d=20minBytes:=202=20to=20all=20UAS=20types=20that=20require=20?= =?UTF-8?q?=E2=89=A52=20bytes=20(distance=20km,=20speed,=20rpm,=20time,=20?= =?UTF-8?q?voltage,=20pressure,=20angle,=20ratio).=20If=20the=20vehicle=20?= =?UTF-8?q?returns=20only=20a=201-byte=200x11=20default=20response=20for?= =?UTF-8?q?=20unsupported=20Mode=2001=20PIDs,=20UASDecoder=20now=20returns?= =?UTF-8?q?=20.failure(.noData)=20instead=20of=20decoding=200x11=20=3D=201?= =?UTF-8?q?7.=20Single-byte=20UAS=20types=20(0x01=20count,=20used=20by=20w?= =?UTF-8?q?arm-up=20cycles)=20are=20unaffected.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4. UAS 0x34 (minutes) was missing entirely (decoders.swift) timeSinceDTCCleared (PID 0x4E) and runTimeMIL (PID 0x4D) use uas(0x34) but 0x34 wasn't in the uasIDS table — both always decoded as .failure(.invalidData). Added 0x34: UAS(signed: false, scale: 1, unit: UnitDuration.minutes, minBytes: 2). --- .../SwiftOBD2/Communication/mockManager.swift | 10 +++ Sources/SwiftOBD2/decoders.swift | 73 ++++++++++--------- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/mockManager.swift b/Sources/SwiftOBD2/Communication/mockManager.swift index 1512fd3..5c9cf33 100644 --- a/Sources/SwiftOBD2/Communication/mockManager.swift +++ b/Sources/SwiftOBD2/Communication/mockManager.swift @@ -346,6 +346,16 @@ extension OBDCommand { let warmUp = Int.random(in: 0...40) let hexWarmUp = String(format: "%02X", warmUp) return "30" + " 00 00 " + hexWarmUp + case .timeSinceDTCCleared: + let mins = Int.random(in: 0...6550) + let A = mins / 256 + let B = mins % 256 + return "4E" + " " + String(format: "%02X", A) + " " + String(format: "%02X", B) + case .runTimeMIL: + let mins = Int.random(in: 0...6550) + let A = mins / 256 + let B = mins % 256 + return "4D" + " " + String(format: "%02X", A) + " " + String(format: "%02X", B) case .hybridBatteryLife: let life = Int.random(in: 100...65500) diff --git a/Sources/SwiftOBD2/decoders.swift b/Sources/SwiftOBD2/decoders.swift index 13e0a20..4c578de 100644 --- a/Sources/SwiftOBD2/decoders.swift +++ b/Sources/SwiftOBD2/decoders.swift @@ -102,12 +102,14 @@ class UAS { let scale: Double var unit: Unit let offset: Double + let minBytes: Int - init(signed: Bool, scale: Double, unit: Unit, offset: Double = 0.0) { + init(signed: Bool, scale: Double, unit: Unit, offset: Double = 0.0, minBytes: Int = 1) { self.signed = signed self.scale = scale self.unit = unit self.offset = offset + self.minBytes = minBytes } func decode(bytes: Data, _ unit_: MeasurementUnit = .metric) -> MeasurementResult { @@ -159,44 +161,46 @@ func twosComp(_ value: Int, length: Int) -> Int { private var uasIDS: [UInt8: UAS] = { return [ - // Unsigned + // Unsigned — 1-byte types (minBytes defaults to 1) 0x01: UAS(signed: false, scale: 1.0, unit: Unit.count), 0x02: UAS(signed: false, scale: 0.1, unit: Unit.count), 0x03: UAS(signed: false, scale: 0.01, unit: Unit.count), 0x04: UAS(signed: false, scale: 0.001, unit: Unit.count), 0x05: UAS(signed: false, scale: 0.0000305, unit: Unit.count), 0x06: UAS(signed: false, scale: 0.000305, unit: Unit.count), - 0x07: UAS(signed: false, scale: 0.25, unit: Unit.rpm), - 0x09: UAS(signed: false, scale: 1, unit: UnitSpeed.kilometersPerHour), - - 0x0A: UAS(signed: false, scale: 0.122, unit: UnitElectricPotentialDifference.millivolts), - 0x0B: UAS(signed: false, scale: 0.001, unit: UnitElectricPotentialDifference.volts), - - 0x10: UAS(signed: false, scale: 1, unit: UnitDuration.milliseconds), - 0x11: UAS(signed: false, scale: 100, unit: UnitDuration.milliseconds), - 0x12: UAS(signed: false, scale: 1, unit: UnitDuration.seconds), - 0x13: UAS(signed: false, scale: 1, unit: UnitElectricResistance.microohms), - 0x14: UAS(signed: false, scale: 1, unit: UnitElectricResistance.ohms), - 0x15: UAS(signed: false, scale: 1, unit: UnitElectricResistance.kiloohms), - 0x16: UAS(signed: false, scale: 0.1, unit: UnitTemperature.celsius, offset: -40.0), - 0x17: UAS(signed: false, scale: 0.01, unit: UnitPressure.kilopascals), - 0x18: UAS(signed: false, scale: 0.0117, unit: UnitPressure.kilopascals), - 0x19: UAS(signed: false, scale: 0.079, unit: UnitPressure.kilopascals), - 0x1A: UAS(signed: false, scale: 1, unit: UnitPressure.kilopascals), - 0x1B: UAS(signed: false, scale: 10, unit: UnitPressure.kilopascals), - 0x1C: UAS(signed: false, scale: 0.01, unit: UnitAngle.degrees), - 0x1D: UAS(signed: false, scale: 0.5, unit: UnitAngle.degrees), - // unit ratio - 0x1E: UAS(signed: false, scale: 0.0000305, unit: Unit.ratio), - 0x1F: UAS(signed: false, scale: 0.05, unit: Unit.ratio), - 0x20: UAS(signed: false, scale: 0.00390625, unit: Unit.ratio), - 0x21: UAS(signed: false, scale: 1, unit: UnitFrequency.millihertz), - 0x22: UAS(signed: false, scale: 1, unit: UnitFrequency.hertz), - 0x23: UAS(signed: false, scale: 1, unit: UnitFrequency.kilohertz), - 0x24: UAS(signed: false, scale: 1, unit: Unit.count), - 0x25: UAS(signed: false, scale: 1, unit: UnitLength.kilometers), - - 0x27: UAS(signed: false, scale: 0.01, unit: Unit.gramsPerSecond), + // Multi-byte types — minBytes: 2 rejects garbage 1-byte default responses (e.g. 0x11) + 0x07: UAS(signed: false, scale: 0.25, unit: Unit.rpm, minBytes: 2), + 0x09: UAS(signed: false, scale: 1, unit: UnitSpeed.kilometersPerHour, minBytes: 2), + + 0x0A: UAS(signed: false, scale: 0.122, unit: UnitElectricPotentialDifference.millivolts, minBytes: 2), + 0x0B: UAS(signed: false, scale: 0.001, unit: UnitElectricPotentialDifference.volts, minBytes: 2), + + 0x10: UAS(signed: false, scale: 1, unit: UnitDuration.milliseconds, minBytes: 2), + 0x11: UAS(signed: false, scale: 100, unit: UnitDuration.milliseconds, minBytes: 2), + 0x12: UAS(signed: false, scale: 1, unit: UnitDuration.seconds, minBytes: 2), + 0x13: UAS(signed: false, scale: 1, unit: UnitElectricResistance.microohms, minBytes: 2), + 0x14: UAS(signed: false, scale: 1, unit: UnitElectricResistance.ohms, minBytes: 2), + 0x15: UAS(signed: false, scale: 1, unit: UnitElectricResistance.kiloohms, minBytes: 2), + 0x16: UAS(signed: false, scale: 0.1, unit: UnitTemperature.celsius, offset: -40.0, minBytes: 2), + 0x17: UAS(signed: false, scale: 0.01, unit: UnitPressure.kilopascals, minBytes: 2), + 0x18: UAS(signed: false, scale: 0.0117, unit: UnitPressure.kilopascals, minBytes: 2), + 0x19: UAS(signed: false, scale: 0.079, unit: UnitPressure.kilopascals, minBytes: 2), + 0x1A: UAS(signed: false, scale: 1, unit: UnitPressure.kilopascals, minBytes: 2), + 0x1B: UAS(signed: false, scale: 10, unit: UnitPressure.kilopascals, minBytes: 2), + 0x1C: UAS(signed: false, scale: 0.01, unit: UnitAngle.degrees, minBytes: 2), + 0x1D: UAS(signed: false, scale: 0.5, unit: UnitAngle.degrees, minBytes: 2), + // unit ratio — 4-byte lambda/voltage combos + 0x1E: UAS(signed: false, scale: 0.0000305, unit: Unit.ratio, minBytes: 2), + 0x1F: UAS(signed: false, scale: 0.05, unit: Unit.ratio, minBytes: 2), + 0x20: UAS(signed: false, scale: 0.00390625, unit: Unit.ratio, minBytes: 2), + 0x21: UAS(signed: false, scale: 1, unit: UnitFrequency.millihertz, minBytes: 2), + 0x22: UAS(signed: false, scale: 1, unit: UnitFrequency.hertz, minBytes: 2), + 0x23: UAS(signed: false, scale: 1, unit: UnitFrequency.kilohertz, minBytes: 2), + 0x24: UAS(signed: false, scale: 1, unit: Unit.count, minBytes: 2), + 0x25: UAS(signed: false, scale: 1, unit: UnitLength.kilometers, minBytes: 2), + + 0x27: UAS(signed: false, scale: 0.01, unit: Unit.gramsPerSecond, minBytes: 2), + 0x34: UAS(signed: false, scale: 1, unit: UnitDuration.minutes, minBytes: 2), // Signed 0x81: UAS(signed: true, scale: 1.0, unit: Unit.count), @@ -726,6 +730,9 @@ struct UASDecoder: Decoder { guard let uas = uasIDS[id] else { return .failure(.invalidData) } + guard data.count >= uas.minBytes else { + return .failure(.noData) + } return .success((.measurementResult(uas.decode(bytes: data, unit)))) } } From 861d69ed515f4d31a8120c61515ba9747ef4bf1e Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sat, 30 May 2026 19:55:02 +0100 Subject: [PATCH 18/68] add BECM and scanForUDSDTCs --- Sources/SwiftOBD2/elm327.swift | 23 +++++++++++++++++++++++ Sources/SwiftOBD2/obd2service.swift | 9 +++++++++ Sources/SwiftOBD2/parser.swift | 3 +++ 3 files changed, 35 insertions(+) diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index 41c122d..d246c68 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -309,6 +309,29 @@ class ELM327 { return dtcs } + func scanForUDSDTCs(header: String) async throws -> [TroubleCode] { + _ = try? await sendCommand("ATSH\(header)", retries: 1) + let response = try await sendCommand("19 02 FF") + guard let messages = try canProtocol?.parse(response) else { return [] } + return messages.compactMap(\.data).flatMap(parseUDS19Data) + } + + private func parseUDS19Data(_ data: Data) -> [TroubleCode] { + let bytes = Array(data) + // UDS $19/$02 response: 59 02 [status_mask] then 4-byte groups [b1 b2 b3 status] + guard bytes.count >= 3, bytes[0] == 0x59, bytes[1] == 0x02 else { return [] } + var result: [TroubleCode] = [] + var i = 3 + while i + 3 <= bytes.count { + let b1 = bytes[i], b2 = bytes[i + 1] + if (b1 != 0 || b2 != 0), let tc = parseDTC(Data([b1, b2])) { + result.append(tc) + } + i += 4 + } + return result + } + func clearTroubleCodes() async throws { let command = OBDCommand.Mode4.CLEAR_DTC _ = try await sendCommand(command.properties.command) diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index 6d6bfe4..a4a3140 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -295,6 +295,15 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab } } + /// Scans a specific ECU for DTCs using UDS Service $19 (readDTCByStatusMask). + public func scanForUDSDTCs(header: String) async throws -> [TroubleCode] { + do { + return try await elm327.scanForUDSDTCs(header: header) + } catch { + throw OBDServiceError.scanFailed(underlyingError: error) + } + } + /// Clears the trouble codes found on the vehicle. /// - Throws: Errors that might occur during the request process. /// - `OBDServiceError.notConnectedToVehicle` if the adapter is not connected to a vehicle. diff --git a/Sources/SwiftOBD2/parser.swift b/Sources/SwiftOBD2/parser.swift index 602abd8..89ff907 100644 --- a/Sources/SwiftOBD2/parser.swift +++ b/Sources/SwiftOBD2/parser.swift @@ -17,6 +17,7 @@ public enum ECUID: UInt8, Codable { case engine = 0x00 case transmission = 0x01 case unknown = 0x02 + case becm = 0x04 public var description: String { switch self { @@ -26,6 +27,8 @@ public enum ECUID: UInt8, Codable { return "Transmission" case .unknown: return "Unknown" + case .becm: + return "BECM" } } } From a8d053d85d76ceb82a35ec3d9c5ada255e8ebfc6 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sat, 30 May 2026 22:51:00 +0100 Subject: [PATCH 19/68] added serial support --- .../Communication/SerialManager.swift | 176 ++++++++++++++++++ Sources/SwiftOBD2/obd2service.swift | 13 ++ 2 files changed, 189 insertions(+) create mode 100644 Sources/SwiftOBD2/Communication/SerialManager.swift diff --git a/Sources/SwiftOBD2/Communication/SerialManager.swift b/Sources/SwiftOBD2/Communication/SerialManager.swift new file mode 100644 index 0000000..e28f264 --- /dev/null +++ b/Sources/SwiftOBD2/Communication/SerialManager.swift @@ -0,0 +1,176 @@ +#if os(iOS) +import Foundation +import ExternalAccessory +import CoreBluetooth +import Combine + +/// USB serial backend for MFi OBD adapters (e.g. OBDLink EX). +/// Connects via the ExternalAccessory framework using the OBDLink protocol string. +/// The adapter must be physically connected via USB-C/Lightning before calling connectAsync. +final class SerialManager: NSObject, CommProtocol, StreamDelegate { + + @Published var connectionState: ConnectionState = .disconnected + var connectionStatePublisher: Published.Publisher { $connectionState } + var obdDelegate: OBDServiceDelegate? + + private static let obdProtocol = "com.scantool.stnobd" + + private var session: EASession? + private var inputStream: InputStream? + private var outputStream: OutputStream? + + // Single-response path: accumulates bytes until ELM327 ">" prompt + private var receiveBuffer = "" + private var responseContinuation: CheckedContinuation? + + // Monitor-mode path: collects lines for a fixed duration + private var monitorFrames: [String] = [] + private var monitorContinuation: CheckedContinuation<[String], Error>? + private var monitorEndDate: Date? + + // MARK: - CommProtocol + + func scanForPeripherals() async throws { + // USB accessories are already connected — nothing to scan for + } + + func connectAsync(timeout: TimeInterval, peripheral: CBPeripheral? = nil) async throws { + let accessories = EAAccessoryManager.shared().connectedAccessories + guard let accessory = accessories.first(where: { + $0.protocolStrings.contains(Self.obdProtocol) + }) else { + throw CommunicationError.invalidData + } + + guard let s = EASession(accessory: accessory, forProtocol: Self.obdProtocol) else { + throw CommunicationError.invalidData + } + session = s + + let input = s.inputStream + let output = s.outputStream + inputStream = input + outputStream = output + + input?.delegate = self + output?.delegate = self + input?.schedule(in: .main, forMode: .common) + output?.schedule(in: .main, forMode: .common) + input?.open() + output?.open() + + connectionState = .connectedToAdapter + } + + func sendCommand(_ command: String, retries: Int) async throws -> [String] { + var lastError: Error = CommunicationError.invalidData + for attempt in 0 ..< max(1, retries) { + do { + let raw = try await sendRaw(command) + return parseLines(raw) + } catch { + lastError = error + if attempt < max(1, retries) - 1 { + try? await Task.sleep(nanoseconds: 50_000_000) + } + } + } + throw lastError + } + + func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] { + monitorFrames = [] + monitorEndDate = Date().addingTimeInterval(duration) + + return try await withCheckedThrowingContinuation { continuation in + monitorContinuation = continuation + writeBytes(command + "\r") + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in + guard let self else { return } + let frames = self.monitorFrames + self.monitorContinuation?.resume(returning: frames) + self.monitorContinuation = nil + self.monitorEndDate = nil + self.writeBytes("\r") // interrupt ELM327 monitor mode + } + } + } + + func disconnectPeripheral() { + inputStream?.remove(from: .main, forMode: .common) + outputStream?.remove(from: .main, forMode: .common) + inputStream?.close() + outputStream?.close() + inputStream = nil + outputStream = nil + session = nil + responseContinuation?.resume(throwing: CommunicationError.invalidData) + responseContinuation = nil + connectionState = .disconnected + } + + func reset() { disconnectPeripheral() } + + // MARK: - Private + + private func sendRaw(_ command: String) async throws -> String { + try await withCheckedThrowingContinuation { [weak self] continuation in + guard let self else { return } + self.responseContinuation = continuation + self.writeBytes(command + "\r") + } + } + + private func writeBytes(_ string: String) { + guard let output = outputStream, output.streamStatus == .open else { return } + let bytes = Array(string.utf8) + output.write(bytes, maxLength: bytes.count) + } + + private func parseLines(_ raw: String) -> [String] { + raw.components(separatedBy: CharacterSet(charactersIn: "\r\n")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && $0 != ">" } + } + + // MARK: - StreamDelegate + + func stream(_ aStream: Stream, handle eventCode: Stream.Event) { + guard aStream === inputStream else { return } + switch eventCode { + case .hasBytesAvailable: + drainInputStream() + case .errorOccurred: + let err = aStream.streamError ?? CommunicationError.invalidData + responseContinuation?.resume(throwing: CommunicationError.errorOccurred(err)) + responseContinuation = nil + monitorContinuation?.resume(returning: monitorFrames) + monitorContinuation = nil + connectionState = .disconnected + default: + break + } + } + + private func drainInputStream() { + var temp = [UInt8](repeating: 0, count: 512) + guard let stream = inputStream else { return } + let count = stream.read(&temp, maxLength: temp.count) + guard count > 0 else { return } + let chunk = String(bytes: temp.prefix(count), encoding: .ascii) ?? "" + + if monitorEndDate != nil { + let lines = parseLines(chunk) + monitorFrames.append(contentsOf: lines) + } else { + receiveBuffer += chunk + if receiveBuffer.contains(">") { + let raw = receiveBuffer + receiveBuffer = "" + responseContinuation?.resume(returning: raw) + responseContinuation = nil + } + } + } +} +#endif diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index a4a3140..62b53f2 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -5,6 +5,7 @@ import Foundation public enum ConnectionType: String, CaseIterable { case bluetooth = "Bluetooth" case wifi = "Wi-Fi" + case serial = "USB Serial" case demo = "Demo" } @@ -89,6 +90,12 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab elm327 = ELM327(comm: bleManager) case .wifi: elm327 = ELM327(comm: WifiManager()) + case .serial: + #if os(iOS) + elm327 = ELM327(comm: SerialManager()) + #else + elm327 = ELM327(comm: MOCKComm()) + #endif case .demo: elm327 = ELM327(comm: MOCKComm()) } @@ -196,6 +203,12 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab elm327 = ELM327(comm: bleManager) case .wifi: elm327 = ELM327(comm: WifiManager()) + case .serial: + #if os(iOS) + elm327 = ELM327(comm: SerialManager()) + #else + elm327 = ELM327(comm: MOCKComm()) + #endif case .demo: elm327 = ELM327(comm: MOCKComm()) } From e76629d0a66b873d4d09da5af52264ae8d08fdae Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 31 May 2026 07:54:38 +0100 Subject: [PATCH 20/68] wifi support changes --- .../SwiftOBD2/Communication/wifiManager.swift | 12 +++++++++-- Sources/SwiftOBD2/obd2service.swift | 20 ++++++++++++++----- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/wifiManager.swift b/Sources/SwiftOBD2/Communication/wifiManager.swift index 97f15c6..c438a30 100644 --- a/Sources/SwiftOBD2/Communication/wifiManager.swift +++ b/Sources/SwiftOBD2/Communication/wifiManager.swift @@ -39,9 +39,17 @@ class WifiManager: CommProtocol { var tcp: NWConnection? + private let hostString: String + private let portString: String + + init(host: String = "192.168.0.10", port: String = "35000") { + self.hostString = host + self.portString = port + } + func connectAsync(timeout _: TimeInterval, peripheral _: CBPeripheral? = nil) async throws { - let host = NWEndpoint.Host("192.168.0.10") - guard let port = NWEndpoint.Port("35000") else { + let host = NWEndpoint.Host(hostString) + guard let port = NWEndpoint.Port(portString) else { throw CommunicationError.invalidData } tcp = NWConnection(host: host, port: port, using: .tcp) diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index 62b53f2..7502f5a 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -30,9 +30,9 @@ struct Command: Codable { var minValue: Int } -public class ConfigurationService { - static var shared = ConfigurationService() - var connectionType: ConnectionType { +public class ConfigurationService: @unchecked Sendable { + public static let shared = ConfigurationService() + public var connectionType: ConnectionType { get { let rawValue = UserDefaults.standard.string(forKey: "connectionType") ?? "Bluetooth" return ConnectionType(rawValue: rawValue) ?? .bluetooth @@ -41,6 +41,14 @@ public class ConfigurationService { UserDefaults.standard.set(newValue.rawValue, forKey: "connectionType") } } + public var wifiHost: String { + get { UserDefaults.standard.string(forKey: "wifiHost") ?? "192.168.0.10" } + set { UserDefaults.standard.set(newValue, forKey: "wifiHost") } + } + public var wifiPort: String { + get { UserDefaults.standard.string(forKey: "wifiPort") ?? "35000" } + set { UserDefaults.standard.set(newValue, forKey: "wifiPort") } + } } /// A class that provides an interface to the ELM327 OBD2 adapter and the vehicle. @@ -89,7 +97,8 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab let bleManager = BLEManager() elm327 = ELM327(comm: bleManager) case .wifi: - elm327 = ELM327(comm: WifiManager()) + let config = ConfigurationService.shared + elm327 = ELM327(comm: WifiManager(host: config.wifiHost, port: config.wifiPort)) case .serial: #if os(iOS) elm327 = ELM327(comm: SerialManager()) @@ -202,7 +211,8 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab let bleManager = BLEManager() elm327 = ELM327(comm: bleManager) case .wifi: - elm327 = ELM327(comm: WifiManager()) + let config = ConfigurationService.shared + elm327 = ELM327(comm: WifiManager(host: config.wifiHost, port: config.wifiPort)) case .serial: #if os(iOS) elm327 = ELM327(comm: SerialManager()) From 7f5b4608770f74a1d1cdd2f4e0ddabe56205d8fe Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 31 May 2026 08:56:23 +0100 Subject: [PATCH 21/68] remove demo --- Sources/SwiftOBD2/obd2service.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index 7502f5a..a718c09 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -6,7 +6,6 @@ public enum ConnectionType: String, CaseIterable { case bluetooth = "Bluetooth" case wifi = "Wi-Fi" case serial = "USB Serial" - case demo = "Demo" } public protocol OBDServiceDelegate: AnyObject { @@ -105,8 +104,6 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab #else elm327 = ELM327(comm: MOCKComm()) #endif - case .demo: - elm327 = ELM327(comm: MOCKComm()) } #endif elm327.obdDelegate = self @@ -219,8 +216,6 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab #else elm327 = ELM327(comm: MOCKComm()) #endif - case .demo: - elm327 = ELM327(comm: MOCKComm()) } elm327.obdDelegate = self } From 56cc7fcf24653d5bbbe96d50cb47bb92e336a731 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 31 May 2026 10:24:16 +0100 Subject: [PATCH 22/68] remove ecuReadCharacteristic ecuWriteCharacteristic restrictions --- .../Communication/BLE/BLECharacteristicHandler.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift b/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift index 751cbe6..4959f3e 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift @@ -82,14 +82,10 @@ class BLECharacteristicHandler { } case "2AF0": // 18F0 service — read - if characteristic.properties.contains(.read) || characteristic.properties.contains(.notify) { - ecuReadCharacteristic = characteristic - } + ecuReadCharacteristic = characteristic case "2AF1": // 18F0 service — write - if characteristic.properties.contains(.write) { - ecuWriteCharacteristic = characteristic - } + ecuWriteCharacteristic = characteristic default: logger.warning("Unknown characteristic: \(uuid) — properties: \(characteristic.properties.rawValue)") From 5e1b7e62f5dfa4d8f5870635d7d3dd77f8cc1bb9 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 31 May 2026 11:26:22 +0100 Subject: [PATCH 23/68] add serialPath --- Sources/SwiftOBD2/obd2service.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index a718c09..257580a 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -48,6 +48,10 @@ public class ConfigurationService: @unchecked Sendable { get { UserDefaults.standard.string(forKey: "wifiPort") ?? "35000" } set { UserDefaults.standard.set(newValue, forKey: "wifiPort") } } + public var serialPath: String { + get { UserDefaults.standard.string(forKey: "serialPath") ?? "" } + set { UserDefaults.standard.set(newValue, forKey: "serialPath") } + } } /// A class that provides an interface to the ELM327 OBD2 adapter and the vehicle. From 58af93903deb1b07aa26680f5ad7d393d8e66bbf Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 31 May 2026 11:31:53 +0100 Subject: [PATCH 24/68] lookup safety --- Sources/SwiftOBD2/Communication/mockManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/mockManager.swift b/Sources/SwiftOBD2/Communication/mockManager.swift index 5c9cf33..4a2f37e 100644 --- a/Sources/SwiftOBD2/Communication/mockManager.swift +++ b/Sources/SwiftOBD2/Communication/mockManager.swift @@ -42,8 +42,8 @@ class MOCKComm: CommProtocol { header = "7E8" } for i in stride(from: 2, to: command.count, by: 2) { - let index = command.index(command.startIndex, offsetBy: i) - let nextIndex = command.index(command.startIndex, offsetBy: i + 2) + guard let index = command.index(command.startIndex, offsetBy: i, limitedBy: command.endIndex) else { break } + let nextIndex = command.index(index, offsetBy: 2, limitedBy: command.endIndex) ?? command.endIndex let subCommand = prefix + String(command[index.. Date: Sun, 31 May 2026 11:57:13 +0100 Subject: [PATCH 25/68] add serial manager --- .../Communication/MacSerialManager.swift | 182 ++++++++++++++++++ Sources/SwiftOBD2/obd2service.swift | 4 +- 2 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 Sources/SwiftOBD2/Communication/MacSerialManager.swift diff --git a/Sources/SwiftOBD2/Communication/MacSerialManager.swift b/Sources/SwiftOBD2/Communication/MacSerialManager.swift new file mode 100644 index 0000000..3b04633 --- /dev/null +++ b/Sources/SwiftOBD2/Communication/MacSerialManager.swift @@ -0,0 +1,182 @@ +#if os(macOS) +import Foundation +import CoreBluetooth + +/// macOS backend for serial OBD adapters (e.g., USB to Serial). +/// Uses POSIX file descriptors and termios for communication. +final class MacSerialManager: CommProtocol { + @Published var connectionState: ConnectionState = .disconnected + var connectionStatePublisher: Published.Publisher { $connectionState } + var obdDelegate: OBDServiceDelegate? + + private var fileDescriptor: Int32 = -1 + private var isMonitoring = false + private var monitorContinuation: CheckedContinuation<[String], Error>? + private var monitorFrames: [String] = [] + + private var readTask: Task? + private var responseContinuation: CheckedContinuation? + private var receiveBuffer = "" + + func scanForPeripherals() async throws { + // Not used, discovery is done via SerialPortDiscovery + } + + func connectAsync(timeout: TimeInterval, peripheral: CBPeripheral?) async throws { + // On macOS, the configuration passes the path via ConfigurationService + // Wait, how does the service get the path? + let path = UserDefaults.standard.string(forKey: "serialPath") ?? "" + guard !path.isEmpty else { + throw CommunicationError.invalidData + } + + fileDescriptor = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK) + guard fileDescriptor >= 0 else { + throw CommunicationError.errorOccurred(NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: nil)) + } + + var settings = termios() + tcgetattr(fileDescriptor, &settings) + + cfmakeraw(&settings) + cfsetspeed(&settings, speed_t(B38400)) // Standard ELM327 baud rate + + settings.c_cc.16 = 1 // VMIN + settings.c_cc.17 = 1 // VTIME + + let result = tcsetattr(fileDescriptor, TCSANOW, &settings) + if result != 0 { + close(fileDescriptor) + fileDescriptor = -1 + throw CommunicationError.errorOccurred(NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: nil)) + } + + connectionState = .connectedToAdapter + startReading() + } + + func sendCommand(_ command: String, retries: Int) async throws -> [String] { + var lastError: Error = CommunicationError.invalidData + for attempt in 0 ..< max(1, retries) { + do { + let raw = try await sendRaw(command) + return parseLines(raw) + } catch { + lastError = error + if attempt < max(1, retries) - 1 { + try? await Task.sleep(nanoseconds: 50_000_000) + } + } + } + throw lastError + } + + func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] { + isMonitoring = true + monitorFrames = [] + + return try await withCheckedThrowingContinuation { continuation in + monitorContinuation = continuation + writeBytes(command + "\r") + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in + guard let self = self else { return } + self.isMonitoring = false + let frames = self.monitorFrames + self.monitorContinuation?.resume(returning: frames) + self.monitorContinuation = nil + self.writeBytes("\r") // Interrupt ELM327 + } + } + } + + func disconnectPeripheral() { + if fileDescriptor >= 0 { + close(fileDescriptor) + fileDescriptor = -1 + } + readTask?.cancel() + readTask = nil + responseContinuation?.resume(throwing: CommunicationError.invalidData) + responseContinuation = nil + monitorContinuation?.resume(throwing: CommunicationError.invalidData) + monitorContinuation = nil + connectionState = .disconnected + } + + func reset() { + disconnectPeripheral() + } + + private func sendRaw(_ command: String) async throws -> String { + try await withCheckedThrowingContinuation { [weak self] continuation in + guard let self = self else { return } + self.responseContinuation?.resume(throwing: CommunicationError.invalidData) + self.responseContinuation = continuation + self.receiveBuffer = "" + self.writeBytes(command + "\r") + } + } + + private func writeBytes(_ string: String) { + guard fileDescriptor >= 0 else { return } + let bytes = Array(string.utf8) + bytes.withUnsafeBufferPointer { ptr in + _ = write(fileDescriptor, ptr.baseAddress, bytes.count) + } + } + + private func startReading() { + readTask = Task.detached(priority: .userInitiated) { [weak self] in + let bufferSize = 1024 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while let self = self, self.fileDescriptor >= 0, !Task.isCancelled { + var readyFileDescriptors = fd_set() + readyFileDescriptors.fds_bits.0 = Int32(1 << self.fileDescriptor) + + var timeout = timeval(tv_sec: 0, tv_usec: 100_000) // 100ms timeout + let result = select(self.fileDescriptor + 1, &readyFileDescriptors, nil, nil, &timeout) + + if result > 0 { + let bytesRead = read(self.fileDescriptor, buffer, bufferSize) + if bytesRead > 0 { + let chunk = String(bytes: UnsafeBufferPointer(start: buffer, count: bytesRead), encoding: .ascii) ?? "" + await self.handleReceivedData(chunk) + } else if bytesRead < 0 && errno != EAGAIN { + await self.handleError() + break + } + } + } + } + } + + @MainActor + private func handleReceivedData(_ chunk: String) { + if isMonitoring { + let lines = parseLines(chunk) + monitorFrames.append(contentsOf: lines) + } else { + receiveBuffer += chunk + if receiveBuffer.contains(">") { + let raw = receiveBuffer + receiveBuffer = "" + responseContinuation?.resume(returning: raw) + responseContinuation = nil + } + } + } + + @MainActor + private func handleError() { + disconnectPeripheral() + } + + private func parseLines(_ raw: String) -> [String] { + raw.components(separatedBy: CharacterSet(charactersIn: "\r\n")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && $0 != ">" } + } +} +#endif diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index 257580a..4f493d3 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -106,7 +106,7 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab #if os(iOS) elm327 = ELM327(comm: SerialManager()) #else - elm327 = ELM327(comm: MOCKComm()) + elm327 = ELM327(comm: MacSerialManager()) #endif } #endif @@ -218,7 +218,7 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab #if os(iOS) elm327 = ELM327(comm: SerialManager()) #else - elm327 = ELM327(comm: MOCKComm()) + elm327 = ELM327(comm: MacSerialManager()) #endif } elm327.obdDelegate = self From 726de315516d8e85dfd4aac4da93da1c20d8d33a Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 31 May 2026 16:22:07 +0100 Subject: [PATCH 26/68] =?UTF-8?q?WiFi=20ATZ=20fix=20=E2=80=94=20wifiManage?= =?UTF-8?q?r.swift:=20ATZ=20is=20now=20fire-and-forget.=20The=20old=20TCP?= =?UTF-8?q?=20connection=20is=20cancelled,=20the=20manager=20waits=201.5?= =?UTF-8?q?=20s=20for=20the=20adapter=20to=20reset,=20then=20reconnects.?= =?UTF-8?q?=20Returns=20a=20synthetic=20"ELM327=20v2.1"=20so=20the=20init?= =?UTF-8?q?=20sequence=20continues=20cleanly.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WiFi receive loop — wifiManager.swift: replaced single receive() call with an accumulating loop that keeps reading until the ELM327 > prompt is seen. Fixes partial-response issues with adapters that send the reply in multiple TCP packets. --- .../Communication/CommProtocol.swift | 23 ++++ .../Communication/SerialManager.swift | 9 ++ .../SwiftOBD2/Communication/wifiManager.swift | 109 ++++++++++++------ 3 files changed, 106 insertions(+), 35 deletions(-) create mode 100644 Sources/SwiftOBD2/Communication/CommProtocol.swift diff --git a/Sources/SwiftOBD2/Communication/CommProtocol.swift b/Sources/SwiftOBD2/Communication/CommProtocol.swift new file mode 100644 index 0000000..6b54930 --- /dev/null +++ b/Sources/SwiftOBD2/Communication/CommProtocol.swift @@ -0,0 +1,23 @@ +import CoreBluetooth +import Foundation + +// MARK: - Shared transport protocol + +/// Implemented by every OBD transport backend (BLE, WiFi TCP, USB serial). +protocol CommProtocol { + func sendCommand(_ command: String, retries: Int) async throws -> [String] + func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] + func disconnectPeripheral() + func connectAsync(timeout: TimeInterval, peripheral: CBPeripheral?) async throws + func scanForPeripherals() async throws + func reset() + var connectionStatePublisher: Published.Publisher { get } + var obdDelegate: OBDServiceDelegate? { get set } +} + +// MARK: - Transport-layer errors + +enum CommunicationError: Error { + case invalidData + case errorOccurred(Error) +} diff --git a/Sources/SwiftOBD2/Communication/SerialManager.swift b/Sources/SwiftOBD2/Communication/SerialManager.swift index e28f264..c8314bf 100644 --- a/Sources/SwiftOBD2/Communication/SerialManager.swift +++ b/Sources/SwiftOBD2/Communication/SerialManager.swift @@ -118,6 +118,15 @@ final class SerialManager: NSObject, CommProtocol, StreamDelegate { guard let self else { return } self.responseContinuation = continuation self.writeBytes(command + "\r") + + // 20-second hard deadline per command. handleReceivedData is @MainActor, + // so this asyncAfter on main and the receive path cannot race: whichever + // fires first nils responseContinuation and the other becomes a no-op. + DispatchQueue.main.asyncAfter(deadline: .now() + 20) { [weak self] in + guard let self, let cont = self.responseContinuation else { return } + self.responseContinuation = nil + cont.resume(throwing: CommunicationError.invalidData) + } } } diff --git a/Sources/SwiftOBD2/Communication/wifiManager.swift b/Sources/SwiftOBD2/Communication/wifiManager.swift index c438a30..e664203 100644 --- a/Sources/SwiftOBD2/Communication/wifiManager.swift +++ b/Sources/SwiftOBD2/Communication/wifiManager.swift @@ -10,23 +10,7 @@ import Foundation import Network import OSLog -protocol CommProtocol { - func sendCommand(_ command: String, retries: Int) async throws -> [String] - /// Sends a command that puts the adapter into streaming/monitor mode (e.g. AT MA, AT MT). - /// Collects frames for `duration` seconds, then stops and returns them. - func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] - func disconnectPeripheral() - func connectAsync(timeout: TimeInterval, peripheral: CBPeripheral?) async throws - func scanForPeripherals() async throws - func reset() - var connectionStatePublisher: Published.Publisher { get } - var obdDelegate: OBDServiceDelegate? { get set } -} - -enum CommunicationError: Error { - case invalidData - case errorOccurred(Error) -} +// CommProtocol and CommunicationError are defined in CommProtocol.swift class WifiManager: CommProtocol { @Published var connectionState: ConnectionState = .disconnected @@ -81,6 +65,19 @@ class WifiManager: CommProtocol { throw CommunicationError.invalidData } logger.info("Sending: \(command)") + + // ATZ resets the adapter hardware — most WiFi ELM327 adapters drop the TCP + // connection immediately after. Fire-and-forget the command, wait for the + // reset to complete, then re-establish the TCP connection. + if command.uppercased() == "ATZ" { + let old = tcp + old?.send(content: data, completion: .contentProcessed { _ in }) + try await Task.sleep(nanoseconds: 1_500_000_000) // 1.5 s for adapter reset + old?.cancel() + try await connectAsync(timeout: 10, peripheral: nil) + return ["ELM327 v2.1"] + } + return try await sendCommandInternal(data: data, retries: retries) } @@ -111,33 +108,75 @@ class WifiManager: CommProtocol { private func sendAndReceiveData(_ data: Data) async throws -> String { guard let tcpConnection = tcp else { - throw CommunicationError.invalidData - } - let logger = self.logger // Avoid capturing `self` directly + throw CommunicationError.invalidData + } + let logger = self.logger + + // NWConnection callbacks land outside Swift concurrency, so we gate all + // continuation resumes through ResumeOnce to guarantee exactly-one semantics + // even when the 15-second timeout and the receive callback race. + final class ResumeOnce: @unchecked Sendable { + private let lock = NSLock() + private var done = false + var continuation: CheckedContinuation? + func finish(returning value: String) { + lock.lock(); defer { lock.unlock() } + guard !done else { return } + done = true + continuation?.resume(returning: value) + } + func finish(throwing error: Error) { + lock.lock(); defer { lock.unlock() } + guard !done else { return } + done = true + continuation?.resume(throwing: error) + } + } + + let gate = ResumeOnce() + + return try await withCheckedThrowingContinuation { continuation in + gate.continuation = continuation + + // 15-second hard deadline — covers slow protocol auto-detection (SEARCHING...). + DispatchQueue.global().asyncAfter(deadline: .now() + 15) { + gate.finish(throwing: CommunicationError.invalidData) + } - return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in tcpConnection.send(content: data, completion: .contentProcessed { error in if let error = error { logger.error("Error sending data: \(error.localizedDescription)") - continuation.resume(throwing: CommunicationError.errorOccurred(error)) + gate.finish(throwing: CommunicationError.errorOccurred(error)) return } - tcpConnection.receive(minimumIncompleteLength: 1, maximumLength: 500) { data, _, _, error in - if let error = error { - logger.error("Error receiving data: \(error.localizedDescription)") - continuation.resume(throwing: CommunicationError.errorOccurred(error)) - return - } - - guard let response = data, let responseString = String(data: response, encoding: .utf8) else { - logger.warning("Received invalid or empty data") - continuation.resume(throwing: CommunicationError.invalidData) - return + // Accumulate TCP chunks until the ELM327 '>' prompt is received. + // A single receive() call may only return a partial response. + var accumulated = "" + + func readNext() { + tcpConnection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { chunk, _, isComplete, error in + if let error = error { + logger.error("Error receiving data: \(error.localizedDescription)") + gate.finish(throwing: accumulated.isEmpty + ? CommunicationError.errorOccurred(error) + : CommunicationError.invalidData) + return + } + + if let chunk, let str = String(data: chunk, encoding: .utf8) { + accumulated += str + } + + if accumulated.contains(">") || isComplete { + gate.finish(returning: accumulated) + } else { + readNext() + } } - - continuation.resume(returning: responseString) } + + readNext() }) } } From 7a26813d7cef36c0d090315b5887a7ee3f256347 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 31 May 2026 17:25:09 +0100 Subject: [PATCH 27/68] =?UTF-8?q?Logging=20(elm327=20=E2=86=92=20OBDServic?= =?UTF-8?q?e.onLog=20=E2=86=92=20ViewModel.log=20=E2=86=92=20connectionLog?= =?UTF-8?q?s):?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OBDServiceDelegate gets logMessage(_:) with default no-op OBDService adds onLog callback and implements logMessage to forward it on main ViewModel wires service.onLog = { self?.log($0) } in init adapterInitialization now logs each AT command and its response detectProtocol logs which path (preferred / auto / manual sweep) and why testProtocol logs the 0100 raw response and pass/fail for each protocol detectProtocolAutomatically logs ATSP0, 0100 response, and ATDPN Disconnect (ViewModel.stopConnection): Immediately resets isConnecting = false and connectingSecondsRemaining = nil so the UI snaps to "Connect" without waiting for the Task to fully unwind SerialManager.sendRaw fast-fails (throws immediately) if the output stream is already closed, so retries during disconnect don't each wait 20 s --- .../Communication/SerialManager.swift | 3 + Sources/SwiftOBD2/elm327.swift | 78 ++++++++++++++----- Sources/SwiftOBD2/obd2service.swift | 9 +++ 3 files changed, 71 insertions(+), 19 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/SerialManager.swift b/Sources/SwiftOBD2/Communication/SerialManager.swift index c8314bf..743db56 100644 --- a/Sources/SwiftOBD2/Communication/SerialManager.swift +++ b/Sources/SwiftOBD2/Communication/SerialManager.swift @@ -114,6 +114,9 @@ final class SerialManager: NSObject, CommProtocol, StreamDelegate { // MARK: - Private private func sendRaw(_ command: String) async throws -> String { + guard let output = outputStream, output.streamStatus == .open else { + throw CommunicationError.invalidData + } try await withCheckedThrowingContinuation { [weak self] continuation in guard let self else { return } self.responseContinuation = continuation diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index d246c68..0a0a3ab 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -141,21 +141,33 @@ class ELM327 { logger.info("Starting protocol detection...") if let protocolToTest = preferredProtocol { - logger.info("Attempting preferred protocol: \(protocolToTest.description)") + let msg = "Protocol detect: testing preferred \(protocolToTest.description)…" + logger.info("\(msg)") + obdDelegate?.logMessage(msg) if await testProtocol(protocolToTest) { + let found = "Protocol found: \(protocolToTest.description)" + logger.info("\(found)") + obdDelegate?.logMessage(found) return protocolToTest } else { - logger.warning("Preferred protocol \(protocolToTest.description) failed. Falling back to automatic detection.") + let fallback = "Preferred protocol \(protocolToTest.description) failed — falling back to auto-detect" + logger.warning("\(fallback)") + obdDelegate?.logMessage(fallback) } } else { + obdDelegate?.logMessage("Protocol detect: starting auto-detect (ATSP0 + 0100)…") do { return try await detectProtocolAutomatically() } catch { + let msg = "Auto-detect failed (\(error.localizedDescription)) — trying manual sweep…" + logger.warning("\(msg)") + obdDelegate?.logMessage(msg) return try await detectProtocolManually() } } logger.error("Failed to detect a compatible OBD protocol.") + obdDelegate?.logMessage("Protocol detect: no protocol found — giving up") throw ELM327Error.noProtocolFound } @@ -163,17 +175,30 @@ class ELM327 { /// - Returns: The detected protocol, or nil if none could be found. /// - Throws: Various setup-related errors. private func detectProtocolAutomatically() async throws -> PROTOCOL { + obdDelegate?.logMessage("Protocol detect: ATSP0 (auto-search)…") _ = try await okResponse("ATSP0") try? await Task.sleep(nanoseconds: 1_000_000_000) - _ = try await sendCommand("0100") + obdDelegate?.logMessage("Protocol detect: sending 0100 — waiting for vehicle…") + let resp100 = try? await sendCommand("0100") + logger.info("0100 raw response: \(String(describing: resp100))") + obdDelegate?.logMessage("0100 → \(resp100.map { $0.joined(separator: " ") } ?? "no response")") + + obdDelegate?.logMessage("Protocol detect: querying ATDPN…") let obdProtocolNumber = try await sendCommand("ATDPN") + logger.info("ATDPN response: \(obdProtocolNumber)") + obdDelegate?.logMessage("ATDPN → \(obdProtocolNumber.joined(separator: " "))") guard let obdProtocol = PROTOCOL(rawValue: String(obdProtocolNumber[0].dropFirst())) else { - throw ELM327Error.invalidResponse(message: "Invalid protocol number: \(obdProtocolNumber)") + let msg = "Protocol detect: invalid ATDPN value \(obdProtocolNumber)" + obdDelegate?.logMessage(msg) + throw ELM327Error.invalidResponse(message: msg) } - _ = await testProtocol(obdProtocol) + let valid = await testProtocol(obdProtocol) + let protocolMsg = "Detected protocol: \(obdProtocol.description) (valid=\(valid))" + logger.info("\(protocolMsg)") + obdDelegate?.logMessage(protocolMsg) return obdProtocol } @@ -201,16 +226,18 @@ class ELM327 { /// - Parameter obdProtocol: The protocol to test. /// - Throws: Various setup-related errors. private func testProtocol(_ obdProtocol: PROTOCOL) async -> Bool { - // test protocol by sending 0100 and checking for 41 00 response let response = try? await sendCommand("0100", retries: 3) - - if let response = response, - response.contains(where: { $0.range(of: #"41\s*00"#, options: .regularExpression) != nil }) { - logger.info("Protocol \(obdProtocol.description) is valid.") + let raw = response?.joined(separator: " ") ?? "no response" + if let response, response.contains(where: { $0.range(of: #"41\s*00"#, options: .regularExpression) != nil }) { + let msg = "Protocol \(obdProtocol.description) ✓ (0100 → \(raw))" + logger.info("\(msg)") + obdDelegate?.logMessage(msg) r100 = response return true } else { - logger.warning("Protocol \(obdProtocol.rawValue) did not return valid 0100 response.") + let msg = "Protocol \(obdProtocol.description) ✗ (0100 → \(raw))" + logger.warning("\(msg)") + obdDelegate?.logMessage(msg) return false } } @@ -225,18 +252,31 @@ class ELM327 { /// - Parameter setupOrder: A list of commands to send in order. /// - Throws: Various setup-related errors. func adapterInitialization() async throws { - // [.ATZ, .ATD, .ATL0, .ATE0, .ATH1, .ATAT1, .ATRV, .ATDPN] logger.info("Initializing ELM327 adapter...") + obdDelegate?.logMessage("Adapter init: sending ATZ (reset)…") do { - _ = try await sendCommand("ATZ") // Reset adapter - _ = try await okResponse("ATE0") // Echo off - _ = try await okResponse("ATL0") // Linefeeds off - _ = try await okResponse("ATS0") // Spaces off - _ = try await okResponse("ATH1") // Headers off - _ = try await okResponse("ATSP0") // Set protocol to automatic + let atzResp = try await sendCommand("ATZ") + logger.info("ATZ response: \(atzResp)") + obdDelegate?.logMessage("ATZ → \(atzResp.joined(separator: " | "))") + + obdDelegate?.logMessage("Adapter init: ATE0 (echo off)…") + _ = try await okResponse("ATE0") + obdDelegate?.logMessage("ATE0 → OK") + + obdDelegate?.logMessage("Adapter init: ATL0 ATH1 ATS0…") + _ = try await okResponse("ATL0") + _ = try await okResponse("ATS0") + _ = try await okResponse("ATH1") + obdDelegate?.logMessage("ATL0 / ATS0 / ATH1 → OK") + + obdDelegate?.logMessage("Adapter init: ATSP0 (auto protocol)…") + _ = try await okResponse("ATSP0") + obdDelegate?.logMessage("ATSP0 → OK — adapter ready") logger.info("ELM327 adapter initialized successfully.") } catch { - logger.error("Adapter initialization failed: \(error.localizedDescription)") + let msg = "Adapter init FAILED: \(error.localizedDescription)" + logger.error("\(msg)") + obdDelegate?.logMessage(msg) throw ELM327Error.adapterInitializationFailed } } diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index 4f493d3..aaceda5 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -12,11 +12,13 @@ public protocol OBDServiceDelegate: AnyObject { func connectionStateChanged(state: ConnectionState) func peripheralsUpdated(_ peripherals: [CBPeripheral]) func adapterInfoUpdated(_ info: [String: String]) + func logMessage(_ message: String) } extension OBDServiceDelegate { public func peripheralsUpdated(_ peripherals: [CBPeripheral]) {} public func adapterInfoUpdated(_ info: [String: String]) {} + public func logMessage(_ message: String) {} } struct Command: Codable { @@ -73,6 +75,7 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab public var onPeripheralsUpdated: (([CBPeripheral]) -> Void)? public var onScanningChanged: ((Bool) -> Void)? public var onAdapterInfoUpdated: (([String: String]) -> Void)? + public var onLog: ((String) -> Void)? @Published public var connectionType: ConnectionType { didSet { switchConnectionType(connectionType) @@ -140,6 +143,12 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab } } + public func logMessage(_ message: String) { + DispatchQueue.main.async { + self.onLog?(message) + } + } + /// Initiates the connection process to the OBD2 adapter and vehicle. /// /// - Parameter preferedProtocol: The optional OBD2 protocol to use (if supported). From d00d7f37c4d2a91b98a26b7ffbdff24a71904858 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 31 May 2026 21:48:12 +0100 Subject: [PATCH 28/68] add baud logging --- .../Communication/MacSerialManager.swift | 116 ++++++++++++------ 1 file changed, 80 insertions(+), 36 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/MacSerialManager.swift b/Sources/SwiftOBD2/Communication/MacSerialManager.swift index 3b04633..ec493a1 100644 --- a/Sources/SwiftOBD2/Communication/MacSerialManager.swift +++ b/Sources/SwiftOBD2/Communication/MacSerialManager.swift @@ -1,6 +1,7 @@ #if os(macOS) import Foundation import CoreBluetooth +import OSLog /// macOS backend for serial OBD adapters (e.g., USB to Serial). /// Uses POSIX file descriptors and termios for communication. @@ -18,41 +19,49 @@ final class MacSerialManager: CommProtocol { private var responseContinuation: CheckedContinuation? private var receiveBuffer = "" - func scanForPeripherals() async throws { - // Not used, discovery is done via SerialPortDiscovery - } + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example", category: "MacSerial") + + func scanForPeripherals() async throws {} func connectAsync(timeout: TimeInterval, peripheral: CBPeripheral?) async throws { - // On macOS, the configuration passes the path via ConfigurationService - // Wait, how does the service get the path? let path = UserDefaults.standard.string(forKey: "serialPath") ?? "" guard !path.isEmpty else { + logger.error("No serial path configured") throw CommunicationError.invalidData } fileDescriptor = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK) guard fileDescriptor >= 0 else { - throw CommunicationError.errorOccurred(NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: nil)) + let err = NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + logger.error("Failed to open \(path): \(err.localizedDescription)") + throw CommunicationError.errorOccurred(err) } - var settings = termios() - tcgetattr(fileDescriptor, &settings) - - cfmakeraw(&settings) - cfsetspeed(&settings, speed_t(B38400)) // Standard ELM327 baud rate - - settings.c_cc.16 = 1 // VMIN - settings.c_cc.17 = 1 // VTIME - - let result = tcsetattr(fileDescriptor, TCSANOW, &settings) - if result != 0 { - close(fileDescriptor) - fileDescriptor = -1 - throw CommunicationError.errorOccurred(NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: nil)) + // Try 38400 first (ELM327 default); fall back to 115200 (OBDLink SX/MX/vLinker). + // The probe sends ATZ and waits briefly — whichever rate returns data wins. + for baud in [B38400, B115200] { + if applyBaudRate(speed_t(baud)) { + logger.info("Opened \(path) at \(baud == B38400 ? 38400 : 115200) baud (fd=\(self.fileDescriptor))") + obdDelegate?.logMessage("Serial: opened \(path) at \(baud == B38400 ? 38400 : 115200) baud") + connectionState = .connectedToAdapter + startReading() + return + } } - connectionState = .connectedToAdapter - startReading() + close(fileDescriptor) + fileDescriptor = -1 + throw CommunicationError.invalidData + } + + private func applyBaudRate(_ baud: speed_t) -> Bool { + var settings = termios() + guard tcgetattr(fileDescriptor, &settings) == 0 else { return false } + cfmakeraw(&settings) + cfsetspeed(&settings, baud) + settings.c_cc.16 = 0 // VMIN — non-blocking read + settings.c_cc.17 = 10 // VTIME — 1 second inter-byte timeout + return tcsetattr(fileDescriptor, TCSANOW, &settings) == 0 } func sendCommand(_ command: String, retries: Int) async throws -> [String] { @@ -84,7 +93,7 @@ final class MacSerialManager: CommProtocol { let frames = self.monitorFrames self.monitorContinuation?.resume(returning: frames) self.monitorContinuation = nil - self.writeBytes("\r") // Interrupt ELM327 + self.writeBytes("\r") } } } @@ -108,20 +117,40 @@ final class MacSerialManager: CommProtocol { } private func sendRaw(_ command: String) async throws -> String { - try await withCheckedThrowingContinuation { [weak self] continuation in + guard fileDescriptor >= 0 else { + throw CommunicationError.invalidData + } + logger.info("→ \(command)") + obdDelegate?.logMessage("Serial TX: \(command)") + + return try await withCheckedThrowingContinuation { [weak self] continuation in guard let self = self else { return } self.responseContinuation?.resume(throwing: CommunicationError.invalidData) self.responseContinuation = continuation self.receiveBuffer = "" self.writeBytes(command + "\r") + + // 20-second per-command deadline — same thread as handleReceivedData (@MainActor) + // so whichever fires first nils responseContinuation, the other is a no-op. + DispatchQueue.main.asyncAfter(deadline: .now() + 20) { [weak self] in + guard let self, let cont = self.responseContinuation else { return } + self.logger.warning("Timeout waiting for response to: \(command)") + self.obdDelegate?.logMessage("Serial: 20s timeout waiting for '\(command)' response — no data received") + self.responseContinuation = nil + cont.resume(throwing: CommunicationError.invalidData) + } } } private func writeBytes(_ string: String) { guard fileDescriptor >= 0 else { return } let bytes = Array(string.utf8) - bytes.withUnsafeBufferPointer { ptr in - _ = write(fileDescriptor, ptr.baseAddress, bytes.count) + let written = bytes.withUnsafeBufferPointer { ptr in + write(fileDescriptor, ptr.baseAddress, bytes.count) + } + if written != bytes.count { + logger.warning("writeBytes: sent \(written)/\(bytes.count) bytes, errno=\(errno)") + obdDelegate?.logMessage("Serial TX warn: wrote \(written)/\(bytes.count) bytes") } } @@ -132,16 +161,26 @@ final class MacSerialManager: CommProtocol { defer { buffer.deallocate() } while let self = self, self.fileDescriptor >= 0, !Task.isCancelled { - var readyFileDescriptors = fd_set() - readyFileDescriptors.fds_bits.0 = Int32(1 << self.fileDescriptor) - - var timeout = timeval(tv_sec: 0, tv_usec: 100_000) // 100ms timeout - let result = select(self.fileDescriptor + 1, &readyFileDescriptors, nil, nil, &timeout) + // Use select with 100 ms timeout to avoid busy-spin. + var fds = fd_set() + let fd = self.fileDescriptor + // Manually set the bit for this fd in the fd_set. + let slot = Int(fd) / 32 + let bit = Int(fd) % 32 + withUnsafeMutableBytes(of: &fds) { ptr in + let words = ptr.bindMemory(to: Int32.self) + if slot < words.count { words[slot] |= Int32(bitPattern: 1 << bit) } + } + var tv = timeval(tv_sec: 0, tv_usec: 100_000) + let ready = select(fd + 1, &fds, nil, nil, &tv) - if result > 0 { - let bytesRead = read(self.fileDescriptor, buffer, bufferSize) + if ready > 0 { + let bytesRead = read(fd, buffer, bufferSize) if bytesRead > 0 { - let chunk = String(bytes: UnsafeBufferPointer(start: buffer, count: bytesRead), encoding: .ascii) ?? "" + let raw = UnsafeBufferPointer(start: buffer, count: bytesRead) + let chunk = String(bytes: raw, encoding: .ascii) + ?? String(bytes: raw, encoding: .isoLatin1) + ?? "<\(bytesRead) non-ASCII bytes>" await self.handleReceivedData(chunk) } else if bytesRead < 0 && errno != EAGAIN { await self.handleError() @@ -154,9 +193,12 @@ final class MacSerialManager: CommProtocol { @MainActor private func handleReceivedData(_ chunk: String) { + let printable = chunk.replacingOccurrences(of: "\r", with: "↵").replacingOccurrences(of: "\n", with: "↵") + logger.info("← \(printable)") + obdDelegate?.logMessage("Serial RX: \(printable)") + if isMonitoring { - let lines = parseLines(chunk) - monitorFrames.append(contentsOf: lines) + monitorFrames.append(contentsOf: parseLines(chunk)) } else { receiveBuffer += chunk if receiveBuffer.contains(">") { @@ -170,6 +212,8 @@ final class MacSerialManager: CommProtocol { @MainActor private func handleError() { + logger.error("Serial read error, disconnecting") + obdDelegate?.logMessage("Serial: read error — disconnecting") disconnectPeripheral() } From 2804d352d6660d5c6f8d887e8d76587c9d486cfc Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 31 May 2026 22:02:33 +0100 Subject: [PATCH 29/68] add baud probe --- .../Communication/MacSerialManager.swift | 53 ++++++++++++++++--- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/MacSerialManager.swift b/Sources/SwiftOBD2/Communication/MacSerialManager.swift index ec493a1..9c7f6b4 100644 --- a/Sources/SwiftOBD2/Communication/MacSerialManager.swift +++ b/Sources/SwiftOBD2/Communication/MacSerialManager.swift @@ -37,23 +37,64 @@ final class MacSerialManager: CommProtocol { throw CommunicationError.errorOccurred(err) } - // Try 38400 first (ELM327 default); fall back to 115200 (OBDLink SX/MX/vLinker). - // The probe sends ATZ and waits briefly — whichever rate returns data wins. - for baud in [B38400, B115200] { - if applyBaudRate(speed_t(baud)) { - logger.info("Opened \(path) at \(baud == B38400 ? 38400 : 115200) baud (fd=\(self.fileDescriptor))") - obdDelegate?.logMessage("Serial: opened \(path) at \(baud == B38400 ? 38400 : 115200) baud") + // Probe each baud rate: send '\r', wait 1 s, check if response is valid ASCII. + // tcsetattr always succeeds, so we must actually talk to the adapter to confirm. + let candidates: [(speed_t, Int)] = [ + (speed_t(B115200), 115200), + (speed_t(B38400), 38400), + (speed_t(B57600), 57600), + (speed_t(B9600), 9600), + ] + + for (baud, rate) in candidates { + guard applyBaudRate(baud) else { continue } + obdDelegate?.logMessage("Serial: probing \(path) at \(rate) baud…") + logger.info("Probing \(path) at \(rate) baud") + + if await probeRespondsValidASCII() { + logger.info("Baud rate confirmed: \(rate)") + obdDelegate?.logMessage("Serial: \(rate) baud confirmed — adapter responding") connectionState = .connectedToAdapter startReading() return + } else { + obdDelegate?.logMessage("Serial: no valid response at \(rate) baud") } } close(fileDescriptor) fileDescriptor = -1 + obdDelegate?.logMessage("Serial: no baud rate produced a valid response — check cable/adapter") throw CommunicationError.invalidData } + /// Sends a bare '\r' and returns true if the bytes that come back are all printable ASCII. + /// Garbage bytes (baud-rate mismatch) contain high-bit or control characters. + private func probeRespondsValidASCII() async -> Bool { + // Flush any stale bytes before probing. + tcflush(fileDescriptor, TCIOFLUSH) + + let cr = [UInt8(0x0D)] // '\r' + _ = cr.withUnsafeBufferPointer { write(fileDescriptor, $0.baseAddress, 1) } + + // Collect bytes for up to 1 second. + try? await Task.sleep(nanoseconds: 1_000_000_000) + + let bufSize = 64 + let buf = UnsafeMutablePointer.allocate(capacity: bufSize) + defer { buf.deallocate() } + let n = read(fileDescriptor, buf, bufSize) + guard n > 0 else { return false } + + let bytes = UnsafeBufferPointer(start: buf, count: n) + let printable = bytes.allSatisfy { b in + (b >= 0x20 && b <= 0x7E) || b == 0x0D || b == 0x0A + } + let preview = String(bytes: bytes, encoding: .ascii) ?? "" + logger.info("Probe at fd=\(self.fileDescriptor): \(n) bytes, valid=\(printable), preview=\(preview)") + return printable + } + private func applyBaudRate(_ baud: speed_t) -> Bool { var settings = termios() guard tcgetattr(fileDescriptor, &settings) == 0 else { return false } From 04f8a4df2aa6a71de40f79e94d8d65c4ddafe9f2 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 31 May 2026 22:21:55 +0100 Subject: [PATCH 30/68] add settings for logging --- .../Communication/MacSerialManager.swift | 30 +++++++++++++------ .../Communication/SerialManager.swift | 14 ++++++--- Sources/SwiftOBD2/elm327.swift | 8 ++++- Sources/SwiftOBD2/obd2service.swift | 8 +++++ 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/MacSerialManager.swift b/Sources/SwiftOBD2/Communication/MacSerialManager.swift index 9c7f6b4..3e6e4ab 100644 --- a/Sources/SwiftOBD2/Communication/MacSerialManager.swift +++ b/Sources/SwiftOBD2/Communication/MacSerialManager.swift @@ -17,6 +17,7 @@ final class MacSerialManager: CommProtocol { private var readTask: Task? private var responseContinuation: CheckedContinuation? + private var responseToken: UUID? private var receiveBuffer = "" private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example", category: "MacSerial") @@ -148,6 +149,7 @@ final class MacSerialManager: CommProtocol { readTask = nil responseContinuation?.resume(throwing: CommunicationError.invalidData) responseContinuation = nil + responseToken = nil monitorContinuation?.resume(throwing: CommunicationError.invalidData) monitorContinuation = nil connectionState = .disconnected @@ -161,23 +163,30 @@ final class MacSerialManager: CommProtocol { guard fileDescriptor >= 0 else { throw CommunicationError.invalidData } - logger.info("→ \(command)") - obdDelegate?.logMessage("Serial TX: \(command)") + if ConfigurationService.shared.serialVerboseLogging { + logger.info("→ \(command)") + obdDelegate?.logMessage("TX: \(command)") + } + let token = UUID() return try await withCheckedThrowingContinuation { [weak self] continuation in guard let self = self else { return } self.responseContinuation?.resume(throwing: CommunicationError.invalidData) self.responseContinuation = continuation + self.responseToken = token self.receiveBuffer = "" self.writeBytes(command + "\r") - // 20-second per-command deadline — same thread as handleReceivedData (@MainActor) - // so whichever fires first nils responseContinuation, the other is a no-op. + // 20-second per-command deadline. The token check ensures a stale timeout + // from a previous command cannot cancel a later command's continuation. DispatchQueue.main.asyncAfter(deadline: .now() + 20) { [weak self] in - guard let self, let cont = self.responseContinuation else { return } + guard let self, + self.responseToken == token, + let cont = self.responseContinuation else { return } self.logger.warning("Timeout waiting for response to: \(command)") - self.obdDelegate?.logMessage("Serial: 20s timeout waiting for '\(command)' response — no data received") + self.obdDelegate?.logMessage("Serial: 20s timeout waiting for '\(command)' — no data received") self.responseContinuation = nil + self.responseToken = nil cont.resume(throwing: CommunicationError.invalidData) } } @@ -191,7 +200,7 @@ final class MacSerialManager: CommProtocol { } if written != bytes.count { logger.warning("writeBytes: sent \(written)/\(bytes.count) bytes, errno=\(errno)") - obdDelegate?.logMessage("Serial TX warn: wrote \(written)/\(bytes.count) bytes") + logger.warning("writeBytes partial: \(written)/\(bytes.count) bytes") } } @@ -235,8 +244,10 @@ final class MacSerialManager: CommProtocol { @MainActor private func handleReceivedData(_ chunk: String) { let printable = chunk.replacingOccurrences(of: "\r", with: "↵").replacingOccurrences(of: "\n", with: "↵") - logger.info("← \(printable)") - obdDelegate?.logMessage("Serial RX: \(printable)") + if ConfigurationService.shared.serialVerboseLogging { + logger.info("← \(printable)") + obdDelegate?.logMessage("RX: \(printable)") + } if isMonitoring { monitorFrames.append(contentsOf: parseLines(chunk)) @@ -247,6 +258,7 @@ final class MacSerialManager: CommProtocol { receiveBuffer = "" responseContinuation?.resume(returning: raw) responseContinuation = nil + responseToken = nil } } } diff --git a/Sources/SwiftOBD2/Communication/SerialManager.swift b/Sources/SwiftOBD2/Communication/SerialManager.swift index 743db56..7606d9e 100644 --- a/Sources/SwiftOBD2/Communication/SerialManager.swift +++ b/Sources/SwiftOBD2/Communication/SerialManager.swift @@ -22,6 +22,7 @@ final class SerialManager: NSObject, CommProtocol, StreamDelegate { // Single-response path: accumulates bytes until ELM327 ">" prompt private var receiveBuffer = "" private var responseContinuation: CheckedContinuation? + private var responseToken: UUID? // Monitor-mode path: collects lines for a fixed duration private var monitorFrames: [String] = [] @@ -106,6 +107,7 @@ final class SerialManager: NSObject, CommProtocol, StreamDelegate { session = nil responseContinuation?.resume(throwing: CommunicationError.invalidData) responseContinuation = nil + responseToken = nil connectionState = .disconnected } @@ -117,17 +119,19 @@ final class SerialManager: NSObject, CommProtocol, StreamDelegate { guard let output = outputStream, output.streamStatus == .open else { throw CommunicationError.invalidData } + let token = UUID() try await withCheckedThrowingContinuation { [weak self] continuation in guard let self else { return } self.responseContinuation = continuation + self.responseToken = token self.writeBytes(command + "\r") - // 20-second hard deadline per command. handleReceivedData is @MainActor, - // so this asyncAfter on main and the receive path cannot race: whichever - // fires first nils responseContinuation and the other becomes a no-op. DispatchQueue.main.asyncAfter(deadline: .now() + 20) { [weak self] in - guard let self, let cont = self.responseContinuation else { return } + guard let self, + self.responseToken == token, + let cont = self.responseContinuation else { return } self.responseContinuation = nil + self.responseToken = nil cont.resume(throwing: CommunicationError.invalidData) } } @@ -156,6 +160,7 @@ final class SerialManager: NSObject, CommProtocol, StreamDelegate { let err = aStream.streamError ?? CommunicationError.invalidData responseContinuation?.resume(throwing: CommunicationError.errorOccurred(err)) responseContinuation = nil + responseToken = nil monitorContinuation?.resume(returning: monitorFrames) monitorContinuation = nil connectionState = .disconnected @@ -181,6 +186,7 @@ final class SerialManager: NSObject, CommProtocol, StreamDelegate { receiveBuffer = "" responseContinuation?.resume(returning: raw) responseContinuation = nil + responseToken = nil } } } diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index 0a0a3ab..0b78b30 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -293,7 +293,13 @@ class ELM327 { // MARK: - Message Sending func sendCommand(_ message: String, retries: Int = 1) async throws -> [String] { - try await comm.sendCommand(message, retries: retries) + let result = try await comm.sendCommand(message, retries: retries) + if ConfigurationService.shared.obdCommandLogging { + let response = result.joined(separator: " | ") + logger.info("CMD \(message) → \(response)") + obdDelegate?.logMessage("CMD \(message) → \(response)") + } + return result } func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] { diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index aaceda5..f48a007 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -54,6 +54,14 @@ public class ConfigurationService: @unchecked Sendable { get { UserDefaults.standard.string(forKey: "serialPath") ?? "" } set { UserDefaults.standard.set(newValue, forKey: "serialPath") } } + public var serialVerboseLogging: Bool { + get { UserDefaults.standard.bool(forKey: "serialVerboseLogging") } + set { UserDefaults.standard.set(newValue, forKey: "serialVerboseLogging") } + } + public var obdCommandLogging: Bool { + get { UserDefaults.standard.bool(forKey: "obdCommandLogging") } + set { UserDefaults.standard.set(newValue, forKey: "obdCommandLogging") } + } } /// A class that provides an interface to the ELM327 OBD2 adapter and the vehicle. From aa9bdede0f0f2632019750e03a267325a681a755 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 31 May 2026 23:03:42 +0100 Subject: [PATCH 31/68] add switchCANBus --- Sources/SwiftOBD2/Communication/SerialManager.swift | 2 +- Sources/SwiftOBD2/elm327.swift | 8 ++++++++ Sources/SwiftOBD2/obd2service.swift | 5 +++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftOBD2/Communication/SerialManager.swift b/Sources/SwiftOBD2/Communication/SerialManager.swift index 7606d9e..23f117a 100644 --- a/Sources/SwiftOBD2/Communication/SerialManager.swift +++ b/Sources/SwiftOBD2/Communication/SerialManager.swift @@ -120,7 +120,7 @@ final class SerialManager: NSObject, CommProtocol, StreamDelegate { throw CommunicationError.invalidData } let token = UUID() - try await withCheckedThrowingContinuation { [weak self] continuation in + return try await withCheckedThrowingContinuation { [weak self] continuation in guard let self else { return } self.responseContinuation = continuation self.responseToken = token diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index 0b78b30..742c22a 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -285,6 +285,14 @@ class ELM327 { _ = try await okResponse("AT SH " + header) } + /// Switches the dongle to a different CAN protocol without dropping the BT/Serial connection. + /// Sends ATSP and re-asserts ATH1. Bus-specific init commands (ATSH, ATFCSH, etc.) + /// are the caller's responsibility — they live in the app layer, not this package. + func switchProtocol(_ proto: PROTOCOL) async throws { + _ = try await okResponse(proto.cmd) + _ = try await okResponse("ATH1") + } + func stopConnection() { comm.disconnectPeripheral() connectionState = .disconnected diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index f48a007..5101e82 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -215,6 +215,11 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab elm327.stopConnection() } + /// Switches the dongle to a different CAN protocol without dropping the BT/Serial connection. + public func switchProtocol(_ proto: PROTOCOL) async throws { + try await elm327.switchProtocol(proto) + } + /// Switches the active connection type (between Bluetooth and Wi-Fi). /// /// - Parameter connectionType: The new desired connection type. From 5c02a1c77af0734ab6579486a5f15b146d52441d Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 31 May 2026 23:38:23 +0100 Subject: [PATCH 32/68] update readme --- Readme.md | 533 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 326 insertions(+), 207 deletions(-) diff --git a/Readme.md b/Readme.md index fff334f..6781ffa 100644 --- a/Readme.md +++ b/Readme.md @@ -12,8 +12,7 @@ ------------ - -SwiftOBD2 is a Swift package designed to simplify communication with vehicles using an ELM327 OBD2 adapter. It provides a straightforward and powerful interface for interacting with your vehicle's onboard diagnostics system, allowing you to retrieve real-time data and perform diagnostics. [Sample App](https://github.com/kkonteh97/SwiftOBD2App). +SwiftOBD2 is a Swift package designed to simplify communication with vehicles using an ELM327 OBD2 adapter. It provides a straightforward and powerful interface for interacting with your vehicle's onboard diagnostics system, allowing you to retrieve real-time data, perform diagnostics, and monitor raw CAN bus frames. [Sample App](https://github.com/kkonteh97/SwiftOBD2App). ## 🚗 See It In Action @@ -23,7 +22,7 @@ SwiftOBD2 is a Swift package designed to simplify communication with vehicles us - Real-time RPM, Speed, and Engine Load monitoring - Diagnostic Trouble Code (DTC) scanning and clearing - Live sensor data visualization -- Bluetooth connection management +- Bluetooth and USB Serial connection management *Screenshots and demo GIF will be added in the next release* @@ -32,263 +31,380 @@ SwiftOBD2 is a Swift package designed to simplify communication with vehicles us Get up and running in 2 minutes: ```swift -// 1. Add to your project via Swift Package Manager -// File > Add Packages... > https://github.com/kkonteh97/SwiftOBD2 - -// 2. Import and connect import SwiftOBD2 let obdService = OBDService(connectionType: .bluetooth) let obd2Info = try await obdService.startConnection() -// 3. Get real-time data obdService.startContinuousUpdates([.mode1(.rpm), .mode1(.speed)]) .sink { measurements in print("RPM: \(measurements[.mode1(.rpm)]?.value ?? 0)") - print("Speed: \(measurements[.mode1(.speed)]?.value ?? 0)") } ``` -**Expected Output:** -``` -RPM: 2150.0 -Speed: 65.0 -``` - ### Requirements - iOS 14.0+ / macOS 11.0+ - Xcode 13.0+ - Swift 5.0+ -### Key Features - -* Connection Management: - * Establishes connections to the OBD2 adapter via Bluetooth or Wi-Fi. - * Handles the initialization of the adapter and the vehicle connection process. - * Manages connection states (disconnected, connectedToAdapter, connectedToVehicle). - -* Command Interface: - * Send and receive OBD2 commands for powerful interaction with your vehicle. - -* Data Retrieval: - * Supports requests for real-time vehicle data (RPM, speed, etc.) using standard OBD2 PIDs (Parameter IDs). - * Provides functions to continuously poll and retrieve updated measurements. - * Can get a list of supported PIDs from the vehicle. - -* Diagnostics: - * Retrieves and clears diagnostic trouble codes (DTCs). - * Gets the overall status of the vehicle's onboard systems. - -* Sensor Monitoring: - * Retrieve and view data from various vehicle sensors in real time. - -* Adaptability and Configuration - * Can switch between Bluetooth and Wi-Fi communication seamlessly. - * Allows for testing and development with a demo mode. - - -### Roadmap - -- [x] Connect to an OBD2 adapter via Bluetooth Low Energy (BLE) -- [x] Retrieve error codes (DTCs) stored in the vehicle's OBD2 system -- [x] Retrieve various OBD2 Parameter IDs (PIDs) for monitoring vehicle parameters -- [x] Retrieve real-time vehicle data (RPM, speed, etc.) using standard OBD2 PIDs -- [x] Get supported PIDs from the vehicle -- [x] Clear error codes (DTCs) stored in the vehicle's OBD2 system -- [ ] Run tests on the OBD2 system -- [ ] Retrieve vehicle status since DTCs cleared -- [ ] Connect to an OBD2 adapter via WIFI -- [ ] Add support for custom PIDs - - -### Setting Up a Project - -1. Create a New Swift Project: - * Open Xcode and start a new iOS project (You can use a simple "App" template). - -2. Add the SwiftOBD2 Package: - * In Xcode, navigate to File > Add Packages... - * Enter this repository's URL: https://github.com/kkonteh97/SwiftOBD2/ - * Select the desired dependency rule (version, branch, or commit). - -3. Permissions and Capabilities: - * If your app will use Bluetooth, you need to request the appropriate permissions and capabilities: - * Add NSBluetoothAlwaysUsageDescription to your Info.plist file with a brief description of why your app needs to use Bluetooth. - * Navigate to the Signing & Capabilities tab in your project settings and add the Background Modes capability. Enable the Uses Bluetooth LE Accessories option. - -### Key Concepts - -* SwiftUI & Combine: Your code leverages the SwiftUI framework for building the user interface and Combine for reactive handling of updates from the OBDService. -* OBDService: This is the core class within the SwiftOBD2 package. It handles communication with the OBD-II adapter and processes data from the vehicle. -* OBDServiceDelegate: This protocol is crucial for receiving updates about the connection state and other events from the OBDService. -* OBDCommand: These represent specific requests you can make to the vehicle's ECU (Engine Control Unit) for data. - -### Usage - -1. Import and Setup - * Begin by importing the necessary modules: - - -```Swift -import SwiftUI -import SwiftOBD2 -import Combine -``` +--- + +## Key Features + +### Connection Management + +- Connects to ELM327 adapters via **Bluetooth LE**, **Wi-Fi (TCP)**, or **USB Serial**. +- Handles full adapter initialisation (reset, echo off, header on, auto-protocol) and vehicle handshake automatically. +- Manages connection states: `disconnected`, `connecting`, `connectedToAdapter`, `connectedToVehicle`, `error`. +- Exposes both `@Published` Combine properties and lightweight Swift closure callbacks so integrators can choose the reactive model that suits them. + +### USB Serial Support + +Two platform-native serial backends have been added, replacing the previous demo mode placeholder: + +**iOS — MFi USB Serial (`SerialManager`)** + +Connects to MFi-certified USB OBD adapters (e.g. OBDLink EX) using Apple's ExternalAccessory framework over the `com.scantool.stnobd` protocol string. The adapter must be physically connected via USB-C or Lightning before calling `startConnection`. No scanning step is required — the adapter is enumerated directly from the list of connected accessories. + +**macOS — POSIX Serial (`MacSerialManager`)** + +Connects to any USB-to-serial OBD adapter exposed as a `/dev/tty.*` device, using POSIX file descriptors and `termios` directly. The device path is read from `ConfigurationService.shared.serialPath`. On connect the manager automatically probes baud rates in the order 115200 → 38400 → 57600 → 9600, confirming each by checking whether the adapter returns printable ASCII. The first rate that produces a valid response is used and logged; the connection fails cleanly if none does. Both backends feed into the same `ELM327` initialisation flow as BLE and Wi-Fi. + +### Wi-Fi Improvements + +- Host and port are now fully configurable via `ConfigurationService.shared.wifiHost` and `.wifiPort` rather than being hardcoded. The defaults remain `192.168.0.10` and `35000`. +- `ATZ` (adapter reset) is handled specially: the command is sent fire-and-forget, the TCP connection is cancelled, the manager waits 1.5 seconds for the adapter to reboot, then reconnects transparently and returns a synthetic `ELM327 v2.1` so the init sequence continues without error. This fixes a class of timeout failures seen with common Wi-Fi ELM327 clones that drop the TCP socket on reset. +- The TCP receive loop now accumulates multiple chunks until the ELM327 `>` prompt arrives, fixing truncation on responses that span more than one TCP segment. +- A `ResumeOnce` gate ensures that exactly one resume fires even when the 15-second hard-deadline timeout races with a normal receive callback. + +### CAN Bus Monitor Mode + +`sendMonitorCommand(_ command: String, duration: TimeInterval)` is a new method on `OBDService` that puts the ELM327 into streaming monitor mode (e.g. `AT MA` — monitor all, or `AT MT hh` — monitor for header `hh`) for a fixed duration and returns all captured CAN frames as an array of hex strings. Each transport handles this differently: + +- **BLE**: sets a `monitorMode` flag on the message processor so that a timeout returns accumulated data instead of throwing. After the duration a bare carriage return is sent to stop monitoring and the resulting `STOPPED>` acknowledgment is drained before returning, preventing it from corrupting the next regular command. +- **Wi-Fi**: performs a single send-and-receive with a generous timeout. +- **Serial**: reads from the file descriptor until the duration expires. + +This capability enables passive CAN bus observation and forms the foundation for proprietary protocol work where raw frame capture is needed alongside standard OBD diagnostics. + +### Protocol Switching + +`switchProtocol(_ proto: PROTOCOL)` switches the ELM327 to a different CAN protocol (sends `ATSPn` and reasserts `ATH1`) without dropping the Bluetooth or serial connection. This is useful when a vehicle has multiple CAN buses operating on different protocols — the app layer can switch mid-session to target a specific bus. + +### UDS Diagnostic Trouble Codes (Service $19) + +`scanForUDSDTCs(header: String)` sends UDS Service $19 subfunction $02 (Read DTC by Status Mask, all statuses) to a specific ECU identified by its 11-bit or 29-bit CAN header. This extends DTC coverage beyond the standard OBD Mode 03 to manufacturer-specific ECUs that respond to UDS but not OBD. The response is parsed as 4-byte DTC groups (two DTC bytes, one status byte, one filler) and returned as the same `TroubleCode` type used by the standard scan. A new `ECUID.becm` case (raw value `0x04`) has been added to the ECU identifier enumeration for Battery ECU targeting. + +### Expanded Mode 1 PID Coverage + +Mode 1 now covers the full SAE J1979 PID space from `0x00` through `0xC8`. The additions include: + +- **PID group D (0x60–0x7F)**: driver and actual engine torque, reference torque, turbocharger RPM and temperatures, boost pressure control, VGT, wastegate, exhaust pressure, charge air cooler temperature, exhaust gas temperature (EGT) banks 1 and 2, DPF differential pressure, DPF status and temperature, NOx NTE and PM NTE control area status, total engine run time. +- **PID group E (0x80–0x9F)**: AECD run-time counters (up to 20 entries), NOx sensor concentration, manifold surface temperature, NOx reagent system, PM sensor banks 1 and 2, intake manifold pressure (secondary), SCR inducement system, diesel aftertreatment, wide-range O2 sensor, throttle position G, engine friction torque, WWH-OBD vehicle information and counters, fuel system control, NOx warning and inducement system. +- **PID group F / G (0xA0–0xC8)**: NOx sensor corrected concentrations, per-cylinder fuel rate, evap system pressure (alternate), transmission actual gear, commanded DEF dosing, odometer, NOx sensor concentrations at banks 3 and 4, ABS disable switch, fuel level inputs A and B, exhaust particulate diagnostics, fuel pressure A and B, particulate control status, distance since ECU reflash, NOx/PM warning lamp state. + +All new PIDs carry the appropriate `CommandProperties` entries (mode byte, description, expected byte count, decoder type, and a flag indicating whether the PID needs vehicle-running conditions). + +### Decoder Reliability Improvements + +- **`minBytes` guard on UAS multi-byte decoders**: many vehicles return a single-byte default response (`0x11`) for unsupported Mode 1 PIDs. All UAS decoder entries for physically meaningful quantities that require at least 2 bytes (RPM, speed, voltage, duration, resistance, temperature, pressure, angle, ratio, frequency, distance) now carry `minBytes: 2` and return `.failure(.noData)` instead of decoding the garbage byte as a real value. +- **Safe subscript extension**: a `subscript(safe:)` extension on `Collection` prevents out-of-bounds crashes when bit-array operations or decoder index arithmetic runs against unexpectedly short responses. +- **`CVNDecoder`**: a new decoder for Mode 9 Calibration Verification Numbers (CVN), used to verify ECU software integrity. +- **`UAS` entry 0x34**: adds `UnitDuration.minutes` support for elapsed-time quantities that return values in minutes. +- **`CommandProperties.decode`**: the spurious `.dropFirst()` that was stripping the first payload byte before decoding has been removed. All decoders now receive the full data slice. +- **`FuelTypeDecoder` and `MaxMafDecoder`**: now use the safe subscript rather than direct index access to guard against empty response data. +- **`MonitorDecoder`**: converts `Data` to `[UInt8]` before indexed access, avoiding `Data` index-offset pitfalls. + +### BLE Reliability Improvements + +**Scan and connection lifecycle** + +- `ConnectionState` now conforms to `Equatable`, enabling a `removeDuplicates()` operator in the Combine state publisher so consumers do not receive redundant state updates on reconnect cycles. +- Bluetooth power-on no longer auto-connects to a previously seen peripheral. Scanning is now always initiated explicitly by the caller, giving the app layer full control over when peripheral discovery begins. +- A new `connectionInProgress` guard prevents stacking a second connection attempt on top of one already in flight; the attempt throws `BLEManagerError.connectionInProgress` immediately rather than silently racing. +- State restoration (CoreBluetooth background reconnect) no longer promotes a restored peripheral to the managed slot automatically. Instead, restored peripherals are added to the discovered list so they appear in the UI, and the user chooses whether to connect. This prevents silent reconnects to a previously paired adapter the user may have switched away from. +- `peripheralManager.reset()` is now called on connection failure to clear the peripheral delegate and any pending completion handlers, so a retry starts from a clean baseline. + +**Device Information Service** + +On GATT service discovery the handler now reads all characteristics from the standard Bluetooth Device Information Service (UUID `0x180A`). Manufacturer name, model number, serial number, hardware revision, firmware revision, software revision, system ID, and IEEE certification are all decoded and published via the `adapterInfoUpdated` delegate callback and the `adapterInfo: [String: String]` published property on `OBDService`. Binary characteristics (System ID and IEEE cert) are formatted as colon-separated or space-separated hex. + +**ISSC/Microchip Transparent UART** + +The ISSC service (UUID `49535343-FE7D-...`) and its TX/RX characteristics are now explicitly recognised and gracefully skipped rather than generating unknown-characteristic warnings. This removes spurious log noise when connecting to adapters based on RN4870 or ISP1807 Bluetooth modules. + +**Concurrent command assertion** + +The assertion that guards against concurrent BLE commands is now handled through Swift's structured concurrency task cancellation handler, which correctly resolves the continuation when a task is cancelled rather than leaving it dangling. + +### Logging System + +A structured logging pipeline has been added end-to-end: + +- `OBDServiceDelegate` gains a `logMessage(_ message: String)` method with a default no-op implementation so existing conformances don't need to change. +- `OBDService` exposes an `onLog: ((String) -> Void)?` closure for apps that do not adopt the delegate pattern. +- Every step of the ELM327 initialisation sequence (`ATZ`, `ATE0`, `ATL0`, `ATS0`, `ATH1`, `ATSP0`) emits a log message with the raw response. +- Protocol detection emits messages at each stage: preferred protocol test, ATSP0, 0100, ATDPN query, and final result (including whether the detected protocol passed the 0100 validation test). +- All OBD commands can be logged via `ConfigurationService.shared.obdCommandLogging = true`, which causes every `sendCommand` call to emit `CMD ` to both the system log and the `onLog` callback. +- Serial verbose logging is gated separately via `ConfigurationService.shared.serialVerboseLogging`. + +This makes it straightforward to surface a live connection log in the UI, which is particularly valuable during development and for diagnosing adapter compatibility issues with unfamiliar vehicles. + +### `ConfigurationService` Expanded + +`ConfigurationService.shared` is now `public static let` (was `static var`) and all properties are public. New settings: + +| Property | Key | Default | Description | +|---|---|---|---| +| `wifiHost` | `wifiHost` | `192.168.0.10` | Wi-Fi adapter IP address | +| `wifiPort` | `wifiPort` | `35000` | Wi-Fi adapter TCP port | +| `serialPath` | `serialPath` | `""` | macOS serial device path (e.g. `/dev/tty.usbserial-110`) | +| `serialVerboseLogging` | `serialVerboseLogging` | `false` | Log every byte read/written on serial | +| `obdCommandLogging` | `obdCommandLogging` | `false` | Log every OBD command and response | + +All values are persisted in `UserDefaults.standard`. + +### `OBDService` New Public API + +**Published properties** + +- `peripherals: [CBPeripheral]` — updated in real time as BLE discovery finds adapters. Drives any adapter picker UI directly. +- `adapterInfo: [String: String]` — key/value map of device information characteristics read from the connected adapter's GATT Device Information Service. + +**Closure callbacks** + +In addition to the `OBDServiceDelegate` protocol, `OBDService` now exposes plain Swift closures for integrators that prefer a callback model over delegation: + +- `onConnectionStateChanged: ((ConnectionState) -> Void)?` +- `onPeripheralsUpdated: (([CBPeripheral]) -> Void)?` +- `onScanningChanged: ((Bool) -> Void)?` +- `onAdapterInfoUpdated: (([String: String]) -> Void)?` +- `onLog: ((String) -> Void)?` + +**New methods** + +- `startConnection(preferedProtocol:timeout:peripheral:)` — the `peripheral` parameter lets the caller connect directly to a specific `CBPeripheral` (e.g. one chosen from a scan list) rather than relying on the default scan-and-first-found behaviour. +- `switchProtocol(_ proto: PROTOCOL)` — switches the ELM327 CAN protocol mid-session without disconnecting. +- `scanForUDSDTCs(header: String)` — reads DTCs from a specific ECU using UDS Service $19. +- `sendMonitorCommandInternal(_ command: String, duration: TimeInterval)` — exposes the monitor-mode capture path. + +**`VINInfo`** gains an optional `Trim` field decoded from the NHTSA VIN lookup response. + +### `CommProtocol` Refactored + +The `CommProtocol` protocol and `CommunicationError` enum have been moved from `wifiManager.swift` into their own file (`CommProtocol.swift`). The protocol now includes: + +- `sendMonitorCommand(_ command: String, duration: TimeInterval)` — monitor mode capture. +- `reset()` — returns the transport to a clean disconnected state, aborting any in-flight continuation. + +All four transports (BLE, Wi-Fi, iOS Serial, macOS Serial) conform to the updated protocol. + +### Swift Concurrency (`Sendable`) Conformance + +`OBDCommand` and all its sub-enumerations (`General`, `Protocols`, `Mode1`, `Mode3`, `Mode6`, `Mode9`) now conform to `Sendable`. `OBDService` and `ConfigurationService` carry `@unchecked Sendable` to satisfy Swift 5.10 strict concurrency checks. These additions eliminate data-race warnings when using `OBDCommand` values across actor boundaries and enable the library to be used cleanly in `async` contexts. + +--- + +## Setting Up a Project + +1. **Create a New Swift Project** + Open Xcode and start a new iOS or macOS project. + +2. **Add the SwiftOBD2 Package** + In Xcode navigate to File > Add Packages... and enter this repository's URL: `https://github.com/kkonteh97/SwiftOBD2/` + +3. **Permissions and Capabilities** -2. ViewModel - * Create a ViewModel class that conforms to the ObservableObject protocol. This allows your SwiftUI views to observe changes in the ViewModel. - * Inside the ViewModel: - * Define a @Published property measurements to store the collected data. - * Initialize an OBDService instance, setting the desired connection type (e.g., Bluetooth, Wi-Fi). - -3. Connection Handling - * Implement the connectionStateChanged method from the OBDServiceDelegate protocol. Update the UI based on connection state changes (disconnected, connected, etc.) or handle any necessary logic. - -4. Starting the Connection - * Create a startConnection function (ideally using async/await) to initiate the connection process with the OBD-II adapter. The OBDService's startConnection method will return useful OBDInfo about the vehicle. Like the Supported PIDs, Protocol, etc. - -5. Stopping the Connection - * Create a stopConnection function to cleanly disconnect the service. - -6. Retrieving Information - * Use the OBDService's methods to retrieve data from the vehicle, such as getting the vehicle's status, scanning for trouble codes, or requesting specific PIDs. - * getTroubleCodes: Retrieve diagnostic trouble codes (DTCs) from the vehicle's OBD-II system. - * getStatus: Retrieves Status since DTCs cleared. - -7. Continuous Updates - * Use the startContinuousUpdates method to continuously poll and retrieve updated measurements from the vehicle. This method returns a Combine publisher that you can subscribe to for updates. - * Can also add PIDs to the continuous updates using the addPID method. - -### Code Example -```Swift + - **Bluetooth**: add `NSBluetoothAlwaysUsageDescription` to `Info.plist` and enable **Uses Bluetooth LE Accessories** under the Background Modes capability. + - **USB Serial (iOS, MFi)**: add `com.scantool.stnobd` to the `UISupportedExternalAccessoryProtocols` array in `Info.plist`. The MFi entitlement is also required for App Store distribution. + - **USB Serial (macOS)**: no entitlement is needed for `termios`/POSIX serial access. The user selects the `/dev/tty.*` path in your preferences UI and assigns it to `ConfigurationService.shared.serialPath`. + +--- + +## Key Concepts + +- **`OBDService`**: the primary entry point. Manages the selected transport, drives ELM327 initialisation, and exposes all vehicle interaction APIs. +- **`ConfigurationService`**: persists connection settings (type, Wi-Fi host/port, serial path, logging flags) to `UserDefaults`. +- **`CommProtocol`**: the internal transport abstraction. Implemented by `BLEManager`, `WifiManager`, `SerialManager` (iOS), `MacSerialManager` (macOS), and `MOCKComm`. Not part of the public API surface but useful to understand when building custom transports. +- **`OBDServiceDelegate`**: protocol for receiving connection state changes, peripheral list updates, adapter info, and log messages. Default no-op implementations are provided so conformances only need to implement the callbacks they care about. +- **`OBDCommand`**: typed enumeration of all supported OBD commands organised by mode. Each case carries a `CommandProperties` struct that encodes the wire bytes, human-readable description, expected response length, decoder, and whether running-engine conditions are required. +- **`ConnectionState`**: value describing the current transport state. Conforms to `Sendable` and `Equatable`. + +--- + +## Usage + +### 1. Configure Connection Type + +Set the desired connection type and any required settings before connecting: + +- For Wi-Fi, set `ConfigurationService.shared.wifiHost` and `.wifiPort` to match your adapter. +- For macOS Serial, set `ConfigurationService.shared.serialPath` to the `/dev/tty.*` device. +- For iOS USB Serial, ensure the adapter is physically connected; no path configuration is needed. + +### 2. Observing Connection State + +Subscribe to `obdService.$connectionState` (Combine) or assign `obdService.onConnectionStateChanged` to react to state transitions without adopting the delegate protocol. + +### 3. Starting the Connection + +Call `startConnection(preferedProtocol:timeout:peripheral:)`. The optional `peripheral` argument connects directly to a specific BLE device from a prior scan. The call returns `OBDInfo` containing the detected OBD protocol, a list of supported PIDs, and vehicle identification data. + +### 4. Scanning for BLE Adapters + +Call `scanForPeripherals()` to populate `obdService.peripherals`. Present the list in your UI and pass the chosen `CBPeripheral` to `startConnection(peripheral:)`. + +### 5. Requesting Real-Time Data + +Use `startContinuousUpdates(_ pids:)` to poll a set of PIDs at a regular interval. The returned publisher emits a `[OBDCommand: MeasurementResult]` dictionary on each update cycle. Use `addPID(_:)` and `removePID(_:)` to adjust the active set without restarting the update loop. + +### 6. Scanning for Trouble Codes + +- `scanForTroubleCodes()` reads standard OBD Mode 03 DTCs. +- `scanForUDSDTCs(header:)` reads manufacturer-specific DTCs from an ECU identified by its CAN header, using UDS Service $19. +- `clearTroubleCodes()` sends Mode 04 to erase stored DTCs. + +### 7. CAN Bus Monitoring + +Call `sendMonitorCommandInternal("AT MA", duration: 5.0)` to capture 5 seconds of raw CAN frames from all IDs. Use `"AT MT hh"` to monitor a specific header. The returned array contains raw hex frame strings as reported by the ELM327. + +### 8. Switching Protocols Mid-Session + +Use `switchProtocol(_ proto:)` to move between CAN buses (e.g. from `protocol6` ISO 15765-4 11-bit 500kbps to `protocol9` ISO 15765-4 29-bit 500kbps) without disconnecting from the adapter. + +### 9. Reading Adapter Information + +After connecting, `obdService.adapterInfo` contains a dictionary of GATT Device Information Service fields (`"Manufacturer"`, `"Model"`, `"Firmware Revision"`, etc.) read directly from the BLE adapter. Subscribe via `obdService.$adapterInfo` or the `onAdapterInfoUpdated` closure. + +### 10. Logging + +Enable `ConfigurationService.shared.obdCommandLogging = true` during development to see every OBD command and raw response. Assign `obdService.onLog` to route messages to your app's log view or console. + +--- + +## Code Example + +```swift class ViewModel: ObservableObject { @Published var measurements: [OBDCommand: MeasurementResult] = [:] @Published var connectionState: ConnectionState = .disconnected + @Published var connectionLogs: [String] = [] var cancellables = Set() - var requestingPIDs: [OBDCommand] = [.mode1(.rpm)] { - didSet { - addPID(command: requestingPIDs[-1]) - } - } - - init() { - obdService.$connectionState - .assign(to: &$connectionState) - } - let obdService = OBDService(connectionType: .bluetooth) - func startContinousUpdates() { - obdService.startContinuousUpdates([.mode1(.rpm)]) // You can add more PIDs - .sink { completion in - print(completion) - } receiveValue: { measurements in - self.measurements = measurements - } - .store(in: &cancellables) - } - - func addPID(command: OBDCommand) { - obdService.addPID(command) - } - - func stopContinuousUpdates() { - cancellables.removeAll() + init() { + obdService.$connectionState.assign(to: &$connectionState) + obdService.onLog = { [weak self] msg in + DispatchQueue.main.async { self?.connectionLogs.append(msg) } + } } - func startConnection() async throws { - let obd2info = try await obdService.startConnection(preferedProtocol: .protocol6) - print(obd2info) + func startConnection() async throws { + let info = try await obdService.startConnection(preferedProtocol: .protocol6) + print(info) + obdService.startContinuousUpdates([.mode1(.rpm), .mode1(.speed)]) + .sink { _ in } receiveValue: { self.measurements = $0 } + .store(in: &cancellables) } func stopConnection() { + cancellables.removeAll() obdService.stopConnection() } - func switchConnectionType() { - obdService.switchConnectionType(.wifi) + func getTroubleCodes() async { + let dtcs = try? await obdService.scanForTroubleCodes() + print(dtcs ?? "nil") } - func getStatus() async { - let status = try? await obdService.getStatus() - print(status ?? "nil") + func getUDSDTCs(ecuHeader: String) async { + let dtcs = try? await obdService.scanForUDSDTCs(header: ecuHeader) + print(dtcs ?? "nil") } - func getTroubleCodes() async { - let troubleCodes = try? await obdService.scanForTroubleCodes() - print(troubleCodes ?? "nil") + func monitorCANBus() async { + let frames = try? await obdService.sendMonitorCommandInternal("AT MA", duration: 5.0) + print(frames ?? []) } } +``` -struct ContentView: View { - @ObservedObject var viewModel = ViewModel() - var body: some View { - VStack(spacing: 20) { - Text("Connection State: \(viewModel.connectionState.rawValue)") - ForEach(viewModel.requestingPIDs, id: \.self) { pid in - Text("\(pid.properties.description): \(viewModel.measurements[pid]?.value ?? 0) \(viewModel.measurements[pid]?.unit.symbol ?? "")") - } - Button("Connect") { - Task { - do { - try await viewModel.startConnection() - viewModel.startContinousUpdates() - } catch { - print(error) - } - } - } - .buttonStyle(.bordered) - - Button("Stop") { - viewModel.stopContinuousUpdates() - } - .buttonStyle(.bordered) - - Button("Add PID") { - viewModel.requestingPIDs.append(.mode1(.speed)) - } - } - .padding() - } -} +--- -``` +## Supported Connection Types -### Supported OBD2 Commands +| Type | Platform | Adapter Examples | +|---|---|---| +| Bluetooth LE | iOS, macOS | OBDLink MX+, BAFX, Veepeak BLE | +| Wi-Fi TCP | iOS, macOS | Veepeak Mini WiFi, most clone adapters | +| USB Serial (MFi) | iOS only | OBDLink EX | +| USB Serial (POSIX) | macOS only | Any USB-to-serial adapter at a `/dev/tty.*` path | + +--- -A comprehensive list of supported OBD2 commands will be available in the full documentation (coming soon). +## Supported OBD Modes and Commands + +| Mode | Description | +|---|---| +| Mode 01 | Real-time data — PIDs 0x00–0xC8 (full SAE J1979 range) | +| Mode 03 | Stored DTCs | +| Mode 04 | Clear DTCs | +| Mode 06 | On-board monitoring test results (MIDs A–M) | +| Mode 09 | Vehicle information (VIN, calibration IDs, CVN) | +| UDS $19 | Manufacturer-specific DTCs via header targeting | + +A complete list of Mode 1 PID cases is in `OBDCommand.Mode1`. Each case maps directly to its SAE J1979 PID byte. + +--- ## 🛠️ Troubleshooting ### Common Issues -**Q: Bluetooth connection fails** -- Ensure Bluetooth permissions are granted in iOS Settings -- Verify your ELM327 adapter is in pairing mode -- Try restarting Bluetooth on your device +**Q: Bluetooth connection fails immediately** +- Ensure `NSBluetoothAlwaysUsageDescription` is in `Info.plist`. +- Make sure Bluetooth is on and permissions granted in iOS Settings. +- Verify your ELM327 adapter is powered (OBD port has ignition on). +- Try calling `scanForPeripherals()` first and passing the resulting peripheral to `startConnection(peripheral:)` rather than relying on auto-discovery. -**Q: No data received from vehicle** -- Check that your vehicle is OBD2 compatible (1996+ in US) -- Ensure the ELM327 adapter is properly connected to the OBD2 port -- Verify the vehicle is running (some data requires engine on) +**Q: Wi-Fi adapter times out during protocol detection** +- Some adapters take longer than 7 seconds on `SEARCHING...`. Increase the `timeout` parameter to `startConnection` (15–20 seconds is safe). +- Confirm host and port match your adapter — set them via `ConfigurationService.shared`. +- If connection works but `ATZ` causes a disconnect, this is handled automatically by the Wi-Fi reconnect logic in this release. + +**Q: macOS serial adapter not found** +- Run `ls /dev/tty.*` in Terminal after connecting the adapter to find the device path. +- Assign the path to `ConfigurationService.shared.serialPath` before calling `startConnection`. +- The baud auto-probe will try four rates; if none produces a valid response, check the cable and that the adapter is ELM327-compatible. + +**Q: iOS USB Serial adapter not detected** +- Confirm the adapter carries the `com.scantool.stnobd` MFi protocol string (OBDLink EX does; most clone adapters do not). +- Add `UISupportedExternalAccessoryProtocols` with `com.scantool.stnobd` to `Info.plist`. +- The adapter must be physically connected before calling `startConnection`. + +**Q: PIDs return zero or garbage values on some vehicles** +- Enable `ConfigurationService.shared.obdCommandLogging = true` and inspect the raw responses via `onLog`. +- Single-byte default responses (e.g. `0x11`) from unsupported PIDs now return `.failure(.noData)` rather than a decoded value — this is correct behaviour and means the vehicle ECU does not support that PID. -**Q: App crashes on connection** -- Update to the latest version of SwiftOBD2 -- Check that you've added required Bluetooth permissions to Info.plist +**Q: No data received from vehicle** +- Confirm the vehicle is OBD2 compatible (1996+ in the US). +- Some PIDs require the engine to be running — check the `requiresRunningEngine` flag on `CommandProperties`. +- Try connecting without a preferred protocol first (omit `preferedProtocol`) to let auto-detection run. ### Hardware Compatibility ✅ **Tested ELM327 Adapters:** - BAFX Products Bluetooth OBD2 - OBDLink MX+ Bluetooth +- OBDLink EX USB (iOS serial) - VEEPEAK Mini WiFi OBD2 +- Generic ELM327 BLE clones (FFE0/FFF0/18F0 GATT profiles) -⚠️ **Known Issues:** -- Some cheap ELM327 clones may have connectivity issues -- WiFi adapters require network configuration +⚠️ **Known Limitations:** +- Cheap ELM327 clones may drop the Wi-Fi TCP connection on ATZ; the automatic reconnect handles this transparently. +- iOS USB serial requires MFi certification — generic USB OBD adapters without the `com.scantool.stnobd` protocol string will not enumerate. ### Getting Help @@ -296,13 +412,16 @@ A comprehensive list of supported OBD2 commands will be available in the full do - 💡 [Start a discussion](https://github.com/kkonteh97/SwiftOBD2/discussions) for questions - 📱 Check out the [sample app](https://github.com/kkonteh97/SwiftOBD2App) for implementation examples -### Important Considerations +--- + +## Important Considerations -* Ensure you have a compatible ELM327 OBD2 adapter. -* Permissions: If using Bluetooth, your app may need to request Bluetooth permissions from the user. -* Error Handling: Implement robust error handling mechanisms to gracefully handle potential communication issues. -* Background Updates (Optional): If your app needs background OBD2 data updates, explore iOS background fetch capabilities and fine-tune your library and app to work effectively in the background. +- **Permissions**: Bluetooth requires `NSBluetoothAlwaysUsageDescription` in `Info.plist` and the Background Modes capability. USB serial on iOS additionally requires MFi entitlements. +- **Error Handling**: implement robust error handling — adapter timeouts, unsupported PIDs, and CAN bus errors all surface as typed Swift errors. +- **Thread Safety**: `OBDService` is `ObservableObject` and marshals `@Published` updates to the main thread. The `onLog` and other closures are also dispatched to the main queue. +- **Background Updates**: if your app needs OBD data in the background, enable the **Uses Bluetooth LE Accessories** background mode and handle the CoreBluetooth state restoration path (peripherals are now restored to the scan list rather than auto-connected). +--- ## Contributing From ede226c21752d3794a0254e467b3f849b83e9d71 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Tue, 2 Jun 2026 13:20:15 +0100 Subject: [PATCH 33/68] support bare '>' acknowledgment --- .../SwiftOBD2/Communication/BLE/BLEDataProcessor.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift index 505c744..826e46d 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift @@ -15,7 +15,6 @@ class BLEMessageProcessor { buffer.append(data) guard let string = String(data: buffer, encoding: .utf8) else { - // Only clear if buffer is getting too large if buffer.count > BLEConstants.maxBufferSize { logger.warning("Buffer exceeded max size, clearing") buffer.removeAll() @@ -23,7 +22,12 @@ class BLEMessageProcessor { return } - // Check for end of response marker + // In monitor mode (AT MA) the ELM327 streams frames without a prompt; the adapter + // may emit a bare '>' acknowledgment before the stream starts. Triggering completion + // on that early '>' stops the monitor before any frames arrive. Let the duration + // timeout path collect the full stream instead. + if monitorMode { return } + if string.contains(">") { let response = parseResponse(from: string) handleParsedResponse(response) From 0c75924ec4a34a66aeca60ec773b2a5f51a09e6a Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Thu, 4 Jun 2026 09:24:14 +0100 Subject: [PATCH 34/68] add code init --- Sources/SwiftOBD2/codes.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/SwiftOBD2/codes.swift b/Sources/SwiftOBD2/codes.swift index 7f60dd2..c8d7c82 100644 --- a/Sources/SwiftOBD2/codes.swift +++ b/Sources/SwiftOBD2/codes.swift @@ -14,6 +14,11 @@ public struct TroubleCode: Codable, Hashable, Comparable, Sendable { public let code: String public var description: String + + public init(code: String, description: String) { + self.code = code + self.description = description + } } let codes: [String: String] = [ From 116341b7b621006165b2208792e72f3101d443b4 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Tue, 9 Jun 2026 23:12:11 +0100 Subject: [PATCH 35/68] remove buggy peripheral restore --- .../Communication/BLE/BLEScanner.swift | 6 ------ .../Communication/BLE/bleManager.swift | 18 ------------------ 2 files changed, 24 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift b/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift index 5fdf1a5..1406d1d 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift @@ -72,12 +72,6 @@ class BLEPeripheralScanner: ObservableObject { } } - /// Silently adds a peripheral restored from CoreBluetooth state — does not log or publish a scan event. - func restorePeripheral(_ peripheral: CBPeripheral) { - guard !foundPeripherals.contains(where: { $0.identifier == peripheral.identifier }) else { return } - foundPeripherals.append(peripheral) - } - func reset() { foundPeripherals.removeAll() if let completion = foundPeripheralCompletion { diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index ba320ee..180a7b1 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -60,8 +60,6 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { private let peripheralSubject = PassthroughSubject() // Replaced with centralized logging - see connectionStateDidChange for usage - static let RestoreIdentifierKey: String = "OBD2Adapter" - // MARK: Properties @Published var connectionState: ConnectionState = .disconnected @@ -99,7 +97,6 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { queue: bleQueue, options: [ CBCentralManagerOptionShowPowerAlertKey: true, - CBCentralManagerOptionRestoreIdentifierKey: BLEManager.RestoreIdentifierKey, ] ) @@ -229,17 +226,6 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { resetConfigure() } - func willRestoreState(_: CBCentralManager, dict: [String: Any]) { - // Add restored peripherals to the discovered list so they appear in the UI, - // but do NOT set them as the managed peripheral — the user decides to connect. - if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] { - for peripheral in peripherals { - obdDebug("Restoring peripheral to scan list: \(peripheral.name ?? "Unnamed")", category: .bluetooth) - peripheralScanner.restorePeripheral(peripheral) - } - } - } - func connectionEventDidOccur(_: CBCentralManager, event: CBConnectionEvent, peripheral _: CBPeripheral) { obdError("Unexpected connection event: \(event.rawValue)", category: .bluetooth) } @@ -430,10 +416,6 @@ extension BLEManager: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { didDisconnect(central, peripheral: peripheral, error: error) } - - func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { - willRestoreState(central, dict: dict) - } } enum BLEManagerError: Error, CustomStringConvertible { From 4e0bb1774d06fcb4e9abe92c67579e97b145c9c2 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Wed, 10 Jun 2026 15:41:01 +0100 Subject: [PATCH 36/68] Audit fixes: WiFi monitor crash, BLE races, serial continuation safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S1 HIGH wifiManager.swift — sendMonitorCommand passed retries: 0 into the `for attempt in 1 ... retries` loop, an invalid range that traps at runtime; reachable from any AT MA capture over a WiFi adapter. The loop now clamps to max(1, retries). S6 MED wifiManager.swift — sendMonitorCommand is now a real duration-based capture: accumulates frames until the deadline, sends a bare CR to stop monitor mode, and drains the STOPPED acknowledgment. ResumeOnce hoisted to file scope with a lock-protected buffer shared by both receive loops. S2 MED BLEDataProcessor.swift — messageCompletion hand-off is now atomic (NSLock take/set), so a response racing the timeout-cancellation path can no longer double-resume the checked continuation. S3 MED bleManager.swift — a characteristics-setup timeout now resets the peripheral manager, cancels the half-open connection, and lands in .error instead of leaving the manager stuck in .connecting, where every subsequent connect threw connectionInProgress. S4 LOW SerialManager.swift — sendRaw no longer silently overwrites a pending continuation (the old waiter is failed, matching MacSerialManager); the errorOccurred path also clears monitorEndDate. S5 LOW MacSerialManager / SerialManager — continuation state is now main-queue confined (setup, timeout, disconnect); BLE didDiscover snapshots foundPeripherals on the BLE queue before publishing. Verification: swift build clean; package tests 26/26 passed. Co-Authored-By: Claude Fable 5 --- .../Communication/BLE/BLEDataProcessor.swift | 39 +++-- .../Communication/BLE/bleManager.swift | 24 +++- .../Communication/MacSerialManager.swift | 91 +++++++----- .../Communication/SerialManager.swift | 79 ++++++---- .../SwiftOBD2/Communication/wifiManager.swift | 136 +++++++++++++----- 5 files changed, 257 insertions(+), 112 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift index 826e46d..881acf6 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift @@ -6,7 +6,27 @@ import OSLog class BLEMessageProcessor { private var buffer = Data() private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "BLEMessageProcessor") + // messageCompletion is set from the waiting task and consumed from either the + // BLE queue (response arrived) or the cancellation handler (timeout). Those two + // can race; takeCompletion() makes the hand-off atomic so the continuation can + // never be resumed twice. + private let completionLock = NSLock() private var messageCompletion: (([String]?, Error?) -> Void)? + + private func setCompletion(_ completion: @escaping ([String]?, Error?) -> Void) { + completionLock.lock() + assert(messageCompletion == nil, "Concurrent command detected") + messageCompletion = completion + completionLock.unlock() + } + + private func takeCompletion() -> (([String]?, Error?) -> Void)? { + completionLock.lock() + defer { completionLock.unlock() } + let completion = messageCompletion + messageCompletion = nil + return completion + } /// When true, a timeout in waitForResponse returns buffered data instead of throwing. /// Used by sendMonitorCommand to capture ELM327 AT MA / AT MT streaming output. var monitorMode = false @@ -48,10 +68,7 @@ class BLEMessageProcessor { } private func handleParsedResponse(_ lines: [String]) { - let completion = messageCompletion - messageCompletion = nil - - guard let completion = completion else { + guard let completion = takeCompletion() else { logger.warning("Received response with no pending completion") return } @@ -71,8 +88,7 @@ class BLEMessageProcessor { return try await withTimeout(seconds: timeout, timeoutError: BLEMessageProcessorError.responseTimeout) { [self] in try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[String], Error>) in - assert(messageCompletion == nil, "Concurrent command detected") - messageCompletion = { response, error in + setCompletion { response, error in if let response = response { continuation.resume(returning: response) } else if let error = error { @@ -83,9 +99,7 @@ class BLEMessageProcessor { } } } onCancel: { [self] in - let pending = messageCompletion - messageCompletion = nil - pending?(nil, BLEMessageProcessorError.responseTimeout) + self.takeCompletion()?(nil, BLEMessageProcessorError.responseTimeout) } } } catch BLEMessageProcessorError.responseTimeout where monitorMode { @@ -94,7 +108,7 @@ class BLEMessageProcessor { monitorMode = false let captured = buffer buffer.removeAll() - messageCompletion = nil + _ = takeCompletion() guard let string = String(data: captured, encoding: .utf8), !string.isEmpty else { return [] } return string .replacingOccurrences(of: ">", with: "") @@ -106,11 +120,8 @@ class BLEMessageProcessor { func reset() { buffer.removeAll() - let completion = messageCompletion - messageCompletion = nil - // Call completion with error if it exists - completion?(nil, BLEManagerError.peripheralNotConnected) + takeCompletion()?(nil, BLEManagerError.peripheralNotConnected) } } diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index 180a7b1..bdbef75 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -170,8 +170,11 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { func didDiscover(_: CBCentralManager, peripheral: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) { peripheralScanner.addDiscoveredPeripheral(peripheral, advertisementData: advertisementData, rssi: rssi) + // Snapshot on the BLE queue (where the scanner mutates the array) so the + // main-queue delegate call doesn't read it mid-mutation. + let found = peripheralScanner.foundPeripherals DispatchQueue.main.async { - self.obdDelegate?.peripheralsUpdated(self.peripheralScanner.foundPeripherals) + self.obdDelegate?.peripheralsUpdated(found) } } @@ -258,7 +261,24 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { connect(to: targetPeripheral) - try await peripheralManager.waitForCharacteristicsSetup(timeout: timeout) + do { + try await peripheralManager.waitForCharacteristicsSetup(timeout: timeout) + } catch { + // CoreBluetooth's connect never times out on its own, so without this the + // manager stays .connecting forever and every retry throws + // connectionInProgress. Clear peripheral state (which also resumes the + // pending setup continuation), cancel the half-open connection, and land + // in .error — a recoverable starting point for the next attempt. + peripheralManager.reset() + centralManager.cancelPeripheralConnection(targetPeripheral) + let oldState = connectionState + connectionState = .error + OBDLogger.shared.logConnectionChange(from: oldState, to: connectionState) + DispatchQueue.main.async { + self.obdDelegate?.connectionStateChanged(state: .error) + } + throw error + } } func peripheralManager(_ manager: BLEPeripheralManager, didSetupCharacteristics peripheral: CBPeripheral) { diff --git a/Sources/SwiftOBD2/Communication/MacSerialManager.swift b/Sources/SwiftOBD2/Communication/MacSerialManager.swift index 3e6e4ab..5de880e 100644 --- a/Sources/SwiftOBD2/Communication/MacSerialManager.swift +++ b/Sources/SwiftOBD2/Communication/MacSerialManager.swift @@ -123,19 +123,26 @@ final class MacSerialManager: CommProtocol { } func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] { - isMonitoring = true - monitorFrames = [] - + // All monitor/continuation state is main-confined (handleReceivedData is + // @MainActor and the deadline fires on main), so set it up there too. return try await withCheckedThrowingContinuation { continuation in - monitorContinuation = continuation - writeBytes(command + "\r") - DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in - guard let self = self else { return } - self.isMonitoring = false - let frames = self.monitorFrames - self.monitorContinuation?.resume(returning: frames) - self.monitorContinuation = nil - self.writeBytes("\r") + DispatchQueue.main.async { [weak self] in + guard let self else { + continuation.resume(throwing: CommunicationError.invalidData) + return + } + self.isMonitoring = true + self.monitorFrames = [] + self.monitorContinuation = continuation + self.writeBytes(command + "\r") + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in + guard let self = self else { return } + self.isMonitoring = false + let frames = self.monitorFrames + self.monitorContinuation?.resume(returning: frames) + self.monitorContinuation = nil + self.writeBytes("\r") + } } } } @@ -147,12 +154,18 @@ final class MacSerialManager: CommProtocol { } readTask?.cancel() readTask = nil - responseContinuation?.resume(throwing: CommunicationError.invalidData) - responseContinuation = nil - responseToken = nil - monitorContinuation?.resume(throwing: CommunicationError.invalidData) - monitorContinuation = nil connectionState = .disconnected + // Continuation state is main-confined (sendRaw / timeout / read handler + // all run on main); fail any pending waiters there. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.responseContinuation?.resume(throwing: CommunicationError.invalidData) + self.responseContinuation = nil + self.responseToken = nil + self.isMonitoring = false + self.monitorContinuation?.resume(throwing: CommunicationError.invalidData) + self.monitorContinuation = nil + } } func reset() { @@ -169,25 +182,33 @@ final class MacSerialManager: CommProtocol { } let token = UUID() + // Continuation state is main-confined: handleReceivedData is @MainActor and + // the deadline fires on main, so registration must hop there too — otherwise + // setup races an in-flight read of the previous command. return try await withCheckedThrowingContinuation { [weak self] continuation in - guard let self = self else { return } - self.responseContinuation?.resume(throwing: CommunicationError.invalidData) - self.responseContinuation = continuation - self.responseToken = token - self.receiveBuffer = "" - self.writeBytes(command + "\r") - - // 20-second per-command deadline. The token check ensures a stale timeout - // from a previous command cannot cancel a later command's continuation. - DispatchQueue.main.asyncAfter(deadline: .now() + 20) { [weak self] in - guard let self, - self.responseToken == token, - let cont = self.responseContinuation else { return } - self.logger.warning("Timeout waiting for response to: \(command)") - self.obdDelegate?.logMessage("Serial: 20s timeout waiting for '\(command)' — no data received") - self.responseContinuation = nil - self.responseToken = nil - cont.resume(throwing: CommunicationError.invalidData) + DispatchQueue.main.async { + guard let self else { + continuation.resume(throwing: CommunicationError.invalidData) + return + } + self.responseContinuation?.resume(throwing: CommunicationError.invalidData) + self.responseContinuation = continuation + self.responseToken = token + self.receiveBuffer = "" + self.writeBytes(command + "\r") + + // 20-second per-command deadline. The token check ensures a stale timeout + // from a previous command cannot cancel a later command's continuation. + DispatchQueue.main.asyncAfter(deadline: .now() + 20) { [weak self] in + guard let self, + self.responseToken == token, + let cont = self.responseContinuation else { return } + self.logger.warning("Timeout waiting for response to: \(command)") + self.obdDelegate?.logMessage("Serial: 20s timeout waiting for '\(command)' — no data received") + self.responseContinuation = nil + self.responseToken = nil + cont.resume(throwing: CommunicationError.invalidData) + } } } } diff --git a/Sources/SwiftOBD2/Communication/SerialManager.swift b/Sources/SwiftOBD2/Communication/SerialManager.swift index 23f117a..938d807 100644 --- a/Sources/SwiftOBD2/Communication/SerialManager.swift +++ b/Sources/SwiftOBD2/Communication/SerialManager.swift @@ -80,19 +80,26 @@ final class SerialManager: NSObject, CommProtocol, StreamDelegate { } func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] { - monitorFrames = [] - monitorEndDate = Date().addingTimeInterval(duration) - + // Continuation state is main-confined: the stream delegate runs on the main + // RunLoop and the deadline fires on main, so set up there too. return try await withCheckedThrowingContinuation { continuation in - monitorContinuation = continuation - writeBytes(command + "\r") - DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in - guard let self else { return } - let frames = self.monitorFrames - self.monitorContinuation?.resume(returning: frames) - self.monitorContinuation = nil - self.monitorEndDate = nil - self.writeBytes("\r") // interrupt ELM327 monitor mode + DispatchQueue.main.async { [weak self] in + guard let self else { + continuation.resume(throwing: CommunicationError.invalidData) + return + } + self.monitorFrames = [] + self.monitorEndDate = Date().addingTimeInterval(duration) + self.monitorContinuation = continuation + self.writeBytes(command + "\r") + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in + guard let self else { return } + let frames = self.monitorFrames + self.monitorContinuation?.resume(returning: frames) + self.monitorContinuation = nil + self.monitorEndDate = nil + self.writeBytes("\r") // interrupt ELM327 monitor mode + } } } } @@ -105,10 +112,17 @@ final class SerialManager: NSObject, CommProtocol, StreamDelegate { inputStream = nil outputStream = nil session = nil - responseContinuation?.resume(throwing: CommunicationError.invalidData) - responseContinuation = nil - responseToken = nil connectionState = .disconnected + // Continuation state is main-confined; fail any pending waiters there. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.responseContinuation?.resume(throwing: CommunicationError.invalidData) + self.responseContinuation = nil + self.responseToken = nil + self.monitorContinuation?.resume(returning: self.monitorFrames) + self.monitorContinuation = nil + self.monitorEndDate = nil + } } func reset() { disconnectPeripheral() } @@ -120,19 +134,29 @@ final class SerialManager: NSObject, CommProtocol, StreamDelegate { throw CommunicationError.invalidData } let token = UUID() + // Main-confined setup, matching the stream delegate and the deadline. Any + // pending continuation from an overlapped call is failed, not silently + // dropped — overwriting it would leave that caller suspended forever. return try await withCheckedThrowingContinuation { [weak self] continuation in - guard let self else { return } - self.responseContinuation = continuation - self.responseToken = token - self.writeBytes(command + "\r") - - DispatchQueue.main.asyncAfter(deadline: .now() + 20) { [weak self] in - guard let self, - self.responseToken == token, - let cont = self.responseContinuation else { return } - self.responseContinuation = nil - self.responseToken = nil - cont.resume(throwing: CommunicationError.invalidData) + DispatchQueue.main.async { + guard let self else { + continuation.resume(throwing: CommunicationError.invalidData) + return + } + self.responseContinuation?.resume(throwing: CommunicationError.invalidData) + self.responseContinuation = continuation + self.responseToken = token + self.receiveBuffer = "" + self.writeBytes(command + "\r") + + DispatchQueue.main.asyncAfter(deadline: .now() + 20) { [weak self] in + guard let self, + self.responseToken == token, + let cont = self.responseContinuation else { return } + self.responseContinuation = nil + self.responseToken = nil + cont.resume(throwing: CommunicationError.invalidData) + } } } } @@ -163,6 +187,7 @@ final class SerialManager: NSObject, CommProtocol, StreamDelegate { responseToken = nil monitorContinuation?.resume(returning: monitorFrames) monitorContinuation = nil + monitorEndDate = nil connectionState = .disconnected default: break diff --git a/Sources/SwiftOBD2/Communication/wifiManager.swift b/Sources/SwiftOBD2/Communication/wifiManager.swift index e664203..a14762a 100644 --- a/Sources/SwiftOBD2/Communication/wifiManager.swift +++ b/Sources/SwiftOBD2/Communication/wifiManager.swift @@ -12,6 +12,46 @@ import OSLog // CommProtocol and CommunicationError are defined in CommProtocol.swift +// NWConnection callbacks land outside Swift concurrency, so all continuation +// resumes are gated through ResumeOnce to guarantee exactly-one semantics even +// when a deadline and a receive callback race. It also owns the lock-protected +// text buffer those callbacks accumulate into. +private final class ResumeOnce: @unchecked Sendable { + private let lock = NSLock() + private var done = false + private var buffer = "" + var continuation: CheckedContinuation? + + var isDone: Bool { + lock.lock(); defer { lock.unlock() } + return done + } + + func append(_ text: String) { + lock.lock(); defer { lock.unlock() } + buffer += text + } + + var accumulated: String { + lock.lock(); defer { lock.unlock() } + return buffer + } + + func finishWithAccumulated() { + lock.lock(); defer { lock.unlock() } + guard !done else { return } + done = true + continuation?.resume(returning: buffer) + } + + func finish(throwing error: Error) { + lock.lock(); defer { lock.unlock() } + guard !done else { return } + done = true + continuation?.resume(throwing: error) + } +} + class WifiManager: CommProtocol { @Published var connectionState: ConnectionState = .disconnected @@ -82,22 +122,72 @@ class WifiManager: CommProtocol { } func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] { - // WiFi uses a single-receive model; attempt a one-shot read with a generous timeout. - (try? await sendCommand(command, retries: 0)) ?? [] + guard let tcpConnection = tcp, let data = "\(command)\r".data(using: .ascii) else { + throw CommunicationError.invalidData + } + + let gate = ResumeOnce() + let raw: String = try await withCheckedThrowingContinuation { continuation in + gate.continuation = continuation + + // Monitor mode streams frames with no '>' terminator; the deadline is the + // only stop condition. Whatever accumulated by then is the capture. + DispatchQueue.global().asyncAfter(deadline: .now() + duration) { + gate.finishWithAccumulated() + } + + tcpConnection.send(content: data, completion: .contentProcessed { error in + if error != nil { + gate.finishWithAccumulated() + return + } + func readNext() { + tcpConnection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { chunk, _, isComplete, error in + // After the deadline this pending receive doubles as the drain + // for the "STOPPED >" acknowledgment — consume and stop. + if gate.isDone { return } + if let chunk, let str = String(data: chunk, encoding: .utf8) { + gate.append(str) + } + if error != nil || isComplete { + gate.finishWithAccumulated() + } else { + readNext() + } + } + } + readNext() + }) + } + + // A bare CR stops ELM327 monitor mode; the loop's still-pending receive + // drains the resulting "STOPPED >" so it can't corrupt the next command. + if let cr = "\r".data(using: .ascii) { + tcpConnection.send(content: cr, completion: .contentProcessed { _ in }) + } + + return raw + .replacingOccurrences(of: ">", with: "") + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && $0.uppercased() != "STOPPED" } } private func sendCommandInternal(data: Data, retries: Int) async throws -> [String] { - for attempt in 1 ... retries { + // Clamp so retries <= 0 still makes one attempt — `1 ... 0` is an invalid + // range and traps at runtime. + let attempts = max(1, retries) + for attempt in 1 ... attempts { do { let response = try await sendAndReceiveData(data) if let lines = processResponse(response) { return lines - } else if attempt < retries { - logger.info("No data received, retrying attempt \(attempt + 1) of \(retries)...") - try await Task.sleep(nanoseconds: 100_000_000) // 0.5 seconds delay + } else if attempt < attempts { + logger.info("No data received, retrying attempt \(attempt + 1) of \(attempts)...") + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 second delay } } catch { - if attempt == retries { + if attempt == attempts { throw error } logger.warning("Attempt \(attempt) failed, retrying: \(error.localizedDescription)") @@ -112,27 +202,6 @@ class WifiManager: CommProtocol { } let logger = self.logger - // NWConnection callbacks land outside Swift concurrency, so we gate all - // continuation resumes through ResumeOnce to guarantee exactly-one semantics - // even when the 15-second timeout and the receive callback race. - final class ResumeOnce: @unchecked Sendable { - private let lock = NSLock() - private var done = false - var continuation: CheckedContinuation? - func finish(returning value: String) { - lock.lock(); defer { lock.unlock() } - guard !done else { return } - done = true - continuation?.resume(returning: value) - } - func finish(throwing error: Error) { - lock.lock(); defer { lock.unlock() } - guard !done else { return } - done = true - continuation?.resume(throwing: error) - } - } - let gate = ResumeOnce() return try await withCheckedThrowingContinuation { continuation in @@ -152,24 +221,23 @@ class WifiManager: CommProtocol { // Accumulate TCP chunks until the ELM327 '>' prompt is received. // A single receive() call may only return a partial response. - var accumulated = "" - func readNext() { tcpConnection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { chunk, _, isComplete, error in + if gate.isDone { return } if let error = error { logger.error("Error receiving data: \(error.localizedDescription)") - gate.finish(throwing: accumulated.isEmpty + gate.finish(throwing: gate.accumulated.isEmpty ? CommunicationError.errorOccurred(error) : CommunicationError.invalidData) return } if let chunk, let str = String(data: chunk, encoding: .utf8) { - accumulated += str + gate.append(str) } - if accumulated.contains(">") || isComplete { - gate.finish(returning: accumulated) + if gate.accumulated.contains(">") || isComplete { + gate.finishWithAccumulated() } else { readNext() } From 9f8f2bae3eea26b3f410083ae6a8da192bd7c599 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sat, 13 Jun 2026 12:14:48 +0100 Subject: [PATCH 37/68] Fix BLE crash on overlapping commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setCompletion asserted when a command was already pending, which crashed debug builds ("Concurrent command detected") and, in release, silently overwrote the live completion — orphaning the in-flight continuation and letting a later takeCompletion resume the wrong one. It now atomically claims the completion slot and returns false if one is already pending, leaving the in-flight command intact. waitForResponse resumes its continuation exactly once with a new .commandInFlight error, so an overlapping command fails cleanly instead of crashing. Co-Authored-By: Claude Opus 4.8 --- .../Communication/BLE/BLEDataProcessor.swift | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift index 881acf6..ffd1ad7 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift @@ -13,11 +13,20 @@ class BLEMessageProcessor { private let completionLock = NSLock() private var messageCompletion: (([String]?, Error?) -> Void)? - private func setCompletion(_ completion: @escaping ([String]?, Error?) -> Void) { + /// Atomically claims the completion slot. Returns false (without touching the + /// in-flight completion) when a command is already pending, so an overlapping + /// command is rejected cleanly instead of clobbering the live continuation — + /// the old code asserted here, which crashed debug builds and silently + /// orphaned the pending continuation in release. + private func setCompletion(_ completion: @escaping ([String]?, Error?) -> Void) -> Bool { completionLock.lock() - assert(messageCompletion == nil, "Concurrent command detected") + defer { completionLock.unlock() } + guard messageCompletion == nil else { + logger.error("Concurrent command detected — rejecting overlapping BLE command") + return false + } messageCompletion = completion - completionLock.unlock() + return true } private func takeCompletion() -> (([String]?, Error?) -> Void)? { @@ -88,7 +97,7 @@ class BLEMessageProcessor { return try await withTimeout(seconds: timeout, timeoutError: BLEMessageProcessorError.responseTimeout) { [self] in try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[String], Error>) in - setCompletion { response, error in + let claimed = setCompletion { response, error in if let response = response { continuation.resume(returning: response) } else if let error = error { @@ -97,6 +106,12 @@ class BLEMessageProcessor { continuation.resume(throwing: BLEMessageProcessorError.responseTimeout) } } + // A command is already pending: don't store this completion + // (that would orphan the live one). Fail this call cleanly + // so the continuation resumes exactly once. + if !claimed { + continuation.resume(throwing: BLEMessageProcessorError.commandInFlight) + } } } onCancel: { [self] in self.takeCompletion()?(nil, BLEMessageProcessorError.responseTimeout) @@ -132,6 +147,7 @@ enum BLEMessageProcessorError: Error, LocalizedError { case writeOperationFailed case responseTimeout case invalidResponseData + case commandInFlight var errorDescription: String? { switch self { @@ -143,6 +159,8 @@ enum BLEMessageProcessorError: Error, LocalizedError { return "Timeout waiting for BLE response" case .invalidResponseData: return "Received invalid response data from BLE device" + case .commandInFlight: + return "A BLE command is already awaiting a response" } } } From ff897f11ad9924fb5bc4990e137b8b986fde7f83 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Fri, 19 Jun 2026 17:09:55 +0100 Subject: [PATCH 38/68] improve connection --- Sources/SwiftOBD2/Communication/MacSerialManager.swift | 6 ++++++ Sources/SwiftOBD2/elm327.swift | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftOBD2/Communication/MacSerialManager.swift b/Sources/SwiftOBD2/Communication/MacSerialManager.swift index 5de880e..fa0a8d7 100644 --- a/Sources/SwiftOBD2/Communication/MacSerialManager.swift +++ b/Sources/SwiftOBD2/Communication/MacSerialManager.swift @@ -55,6 +55,12 @@ final class MacSerialManager: CommProtocol { if await probeRespondsValidASCII() { logger.info("Baud rate confirmed: \(rate)") obdDelegate?.logMessage("Serial: \(rate) baud confirmed — adapter responding") + // The probe reads for a fixed 1 s, but a slow adapter can still be + // emitting its prompt afterwards. Drop any straggler bytes before + // the read loop starts, otherwise they land in the first command's + // receive buffer and can swallow / corrupt its response (seen as a + // first-connect "Timeout waiting for response to: ATZ"). + tcflush(fileDescriptor, TCIOFLUSH) connectionState = .connectedToAdapter startReading() return diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index 742c22a..fa9d38a 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -255,7 +255,10 @@ class ELM327 { logger.info("Initializing ELM327 adapter...") obdDelegate?.logMessage("Adapter init: sending ATZ (reset)…") do { - let atzResp = try await sendCommand("ATZ") + // ATZ is the first command after the port opens and the ELM327 is still + // settling, so the very first reset is occasionally lost. Retry it rather + // than failing the whole connection on a single dropped frame. + let atzResp = try await sendCommand("ATZ", retries: 3) logger.info("ATZ response: \(atzResp)") obdDelegate?.logMessage("ATZ → \(atzResp.joined(separator: " | "))") From f696e273b26475f6747f434ce0d4971dbc401f2a Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Tue, 30 Jun 2026 08:27:15 +0100 Subject: [PATCH 39/68] Skip individually-malformed frames rather than aborting the whole response: real adapter output interleaves padding, negative-response $7F) and the occasional truncated line, and one bad frame must not discard every valid ECU reply (which previously surfaced as an empty "no trouble codes" result). Frame.init still logs each rejection. --- Sources/SwiftOBD2/parser.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftOBD2/parser.swift b/Sources/SwiftOBD2/parser.swift index 89ff907..706a77e 100644 --- a/Sources/SwiftOBD2/parser.swift +++ b/Sources/SwiftOBD2/parser.swift @@ -47,11 +47,18 @@ public struct CANParser { .map { $0.replacingOccurrences(of: " ", with: "") } .filter(\.isHex) - frames = try obdLines.compactMap { try Frame(raw: $0, idBits: idBits) } + // Skip individually-malformed frames rather than aborting the whole + // response: real adapter output interleaves padding, negative-response + // ($7F) and the occasional truncated line, and one bad frame must not + // discard every valid ECU reply (which previously surfaced as an empty + // "no trouble codes" result). Frame.init still logs each rejection. + frames = obdLines.compactMap { try? Frame(raw: $0, idBits: idBits) } let framesByECU = Dictionary(grouping: frames) { $0.txID } - messages = try framesByECU.values.compactMap { try Message(frames: $0) } + // Likewise tolerate one ECU's frames failing to assemble without losing + // the others. + messages = framesByECU.values.compactMap { try? Message(frames: $0) } } } From 3e1cf54aa0f7c6017997815de9ca3dabebb022eb Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Tue, 30 Jun 2026 08:39:28 +0100 Subject: [PATCH 40/68] =?UTF-8?q?codes.swift=20=E2=80=94=20new=20DTCStatus?= =?UTF-8?q?=20enum=20(confirmed/pending/permanent)=20carrying=20mode=20("0?= =?UTF-8?q?3"/"07"/"0A"),=20label,=20and=20a=20priority=20for=20merge=20pr?= =?UTF-8?q?ecedence.=20TroubleCode=20gains=20a=20status=20field=20(default?= =?UTF-8?q?s=20to=20.confirmed),=20with=20a=20custom=20init(from:)=20so=20?= =?UTF-8?q?reports=20persisted=20before=20this=20change=20still=20decode?= =?UTF-8?q?=20(legacy=20codes=20=E2=86=92=20confirmed).=20I=20had=20to=20q?= =?UTF-8?q?ualify=20Swift.Decoder=20because=20the=20module=20already=20def?= =?UTF-8?q?ines=20its=20own=20Decoder=20protocol.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commands.swift — added Mode7.GET_PENDING_DTC ("07") and Mode10.GET_PERMANENT_DTC ("0A"), both reusing the .dtc decoder since the payload layout is identical to Mode 03. elm327.swift — scanForTroubleCodes now scans all three services: Mode 03 errors still propagate (a dropped connection surfaces, doesn't read as clean). Mode 07 / 0A are best-effort (try?) — a vehicle that doesn't support a service answers NO DATA/$7F and must not fail the whole scan. A merge helper de-dupes by code per ECU, keeping the highest-priority status (permanent > confirmed > pending) when a code shows up in more than one service. --- Sources/SwiftOBD2/codes.swift | 63 +++++++++++++++++++++++++++++- Sources/SwiftOBD2/commands.swift | 22 +++++++++++ Sources/SwiftOBD2/elm327.swift | 67 +++++++++++++++++++++++++------- 3 files changed, 136 insertions(+), 16 deletions(-) diff --git a/Sources/SwiftOBD2/codes.swift b/Sources/SwiftOBD2/codes.swift index c8d7c82..5661292 100644 --- a/Sources/SwiftOBD2/codes.swift +++ b/Sources/SwiftOBD2/codes.swift @@ -7,6 +7,48 @@ import Foundation +/// Which diagnostic service reported a DTC, i.e. how "mature"/persistent the +/// fault is. Lets the UI badge a code as Confirmed (Mode $03), Pending (Mode +/// $07) or Permanent (Mode $0A) rather than presenting every code the same way. +public enum DTCStatus: String, Codable, Hashable, Sendable, CaseIterable { + /// Mode $03 — a matured, confirmed emission-related fault (MIL on). + case confirmed + /// Mode $07 — detected this drive cycle but not yet confirmed; clears on its + /// own if the fault doesn't recur. + case pending + /// Mode $0A — confirmed fault the ECU will retain until it self-verifies the + /// repair over several drive cycles; a scan-tool clear won't remove it. + case permanent + + /// SAE mode that produces this status, e.g. "03" / "07" / "0A". + public var mode: String { + switch self { + case .confirmed: return "03" + case .pending: return "07" + case .permanent: return "0A" + } + } + + /// Human label for the badge. + public var label: String { + switch self { + case .confirmed: return "Confirmed" + case .pending: return "Pending" + case .permanent: return "Permanent" + } + } + + /// Merge precedence when the same code surfaces from more than one mode: + /// permanent (most persistent) wins over confirmed, which wins over pending. + public var priority: Int { + switch self { + case .permanent: return 3 + case .confirmed: return 2 + case .pending: return 1 + } + } +} + public struct TroubleCode: Codable, Hashable, Comparable, Sendable { public static func < (lhs: TroubleCode, rhs: TroubleCode) -> Bool { lhs.code < rhs.code @@ -14,10 +56,29 @@ public struct TroubleCode: Codable, Hashable, Comparable, Sendable { public let code: String public var description: String + /// How the code was reported. Defaults to `.confirmed` so existing call + /// sites (and decoded Mode $03 results) keep their prior meaning. + public var status: DTCStatus - public init(code: String, description: String) { + public init(code: String, description: String, status: DTCStatus = .confirmed) { self.code = code self.description = description + self.status = status + } + + private enum CodingKeys: String, CodingKey { + case code, description, status + } + + // Custom decode so reports persisted before `status` existed still load, + // defaulting those legacy codes to `.confirmed` (they came from Mode $03). + // `Decoder` is shadowed by this module's OBD `Decoder` protocol, so qualify + // the Swift standard-library one. + public init(from decoder: Swift.Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + code = try container.decode(String.self, forKey: .code) + description = try container.decode(String.self, forKey: .description) + status = try container.decodeIfPresent(DTCStatus.self, forKey: .status) ?? .confirmed } } diff --git a/Sources/SwiftOBD2/commands.swift b/Sources/SwiftOBD2/commands.swift index bc0ff3b..4267375 100644 --- a/Sources/SwiftOBD2/commands.swift +++ b/Sources/SwiftOBD2/commands.swift @@ -354,6 +354,28 @@ public enum OBDCommand: Codable, Hashable, Comparable, Identifiable, Sendable { } } + public enum Mode7: CaseIterable, Codable, Comparable, Sendable { + case GET_PENDING_DTC + var properties: CommandProperties { + switch self { + // Mode $07 — pending DTCs from the current/last drive cycle. Same + // 2-byte DTC payload layout as Mode $03, so it reuses `.dtc`. + case .GET_PENDING_DTC: return CommandProperties("07", "Get Pending DTCs", 0, .dtc) + } + } + } + + public enum Mode10: CaseIterable, Codable, Comparable, Sendable { + case GET_PERMANENT_DTC + var properties: CommandProperties { + switch self { + // Mode $0A — permanent DTCs that survive a fault clear. Same payload + // layout as Mode $03. + case .GET_PERMANENT_DTC: return CommandProperties("0A", "Get Permanent DTCs", 0, .dtc) + } + } + } + public enum Mode6: CaseIterable, Codable, Comparable, Sendable { case MIDS_A case MONITOR_O2_B1S1 diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index fa9d38a..81a99be 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -339,31 +339,68 @@ class ELM327 { } func scanForTroubleCodes() async throws -> [ECUID: [TroubleCode]] { - var dtcs: [ECUID: [TroubleCode]] = [:] logger.info("Scanning for trouble codes") - let dtcCommand = OBDCommand.Mode3.GET_DTC - let dtcResponse = try await sendCommand(dtcCommand.properties.command) + var dtcs: [ECUID: [TroubleCode]] = [:] - guard let messages = try canProtocol?.parse(dtcResponse) else { - return [:] + // Mode $03 — confirmed codes. This is the primary scan; let its errors + // propagate so a dropped connection surfaces rather than reading as clean. + let confirmed = try await scanDTCs(command: OBDCommand.Mode3.GET_DTC.properties.command, + status: .confirmed) + merge(confirmed, into: &dtcs) + + // Mode $07 (pending) and Mode $0A (permanent) are best-effort: a vehicle + // that doesn't support a service answers "NO DATA" or a $7F negative + // response, which must not fail the whole scan. + if let pending = try? await scanDTCs(command: OBDCommand.Mode7.GET_PENDING_DTC.properties.command, + status: .pending) { + merge(pending, into: &dtcs) } - for message in messages { - guard let dtcData = message.data else { - continue - } - let decodedResult = dtcCommand.properties.decode(data: dtcData) + if let permanent = try? await scanDTCs(command: OBDCommand.Mode10.GET_PERMANENT_DTC.properties.command, + status: .permanent) { + merge(permanent, into: &dtcs) + } + + return dtcs + } - let ecuId = message.ecu - switch decodedResult { - case let .success(result): - dtcs[ecuId] = result.troubleCode + /// Sends a single DTC service command ($03/$07/$0A) and decodes the per-ECU + /// codes, tagging each with the originating `status`. The three services + /// share the same 2-byte DTC payload, so they all decode via `.dtc`. + private func scanDTCs(command: String, status: DTCStatus) async throws -> [ECUID: [TroubleCode]] { + let response = try await sendCommand(command) + guard let messages = try canProtocol?.parse(response) else { return [:] } + var result: [ECUID: [TroubleCode]] = [:] + for message in messages { + guard let data = message.data else { continue } + switch OBDCommand.Mode3.GET_DTC.properties.decode(data: data) { + case let .success(decoded): + let tagged = (decoded.troubleCode ?? []).map { + TroubleCode(code: $0.code, description: $0.description, status: status) + } + result[message.ecu, default: []].append(contentsOf: tagged) case let .failure(error): logger.error("Failed to decode DTC: \(error)") } } + return result + } - return dtcs + /// Merges one mode's results into the running set, de-duplicating by code per + /// ECU and keeping the highest-priority status (permanent > confirmed > + /// pending) when the same code is reported by more than one service. + private func merge(_ source: [ECUID: [TroubleCode]], into dest: inout [ECUID: [TroubleCode]]) { + for (ecu, codes) in source { + for code in codes { + if let index = dest[ecu]?.firstIndex(where: { $0.code == code.code }) { + if code.status.priority > dest[ecu]![index].status.priority { + dest[ecu]![index] = code + } + } else { + dest[ecu, default: []].append(code) + } + } + } } func scanForUDSDTCs(header: String) async throws -> [TroubleCode] { From 5e63ed5b01c760ae39c5aa69003ff9b896f17c93 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Fri, 3 Jul 2026 08:49:06 +0100 Subject: [PATCH 41/68] connectAsync now honors the timeout, routes every outcome through an exactly-once guard (ConnectOnce), resumes on .cancelled, and leaves .waiting to the deadline (so the permission prompt still has time to be answered). ATZ reconnect inherits it. Added CommunicationError.timeout / .cancelled. Added WifiManagerTests (unreachable-host timeout, prompt cancel, invalid port). --- .../Communication/CommProtocol.swift | 4 ++ .../SwiftOBD2/Communication/wifiManager.swift | 62 ++++++++++++++-- Tests/SwiftOBD2Tests/wifiManagerTests.swift | 71 +++++++++++++++++++ 3 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 Tests/SwiftOBD2Tests/wifiManagerTests.swift diff --git a/Sources/SwiftOBD2/Communication/CommProtocol.swift b/Sources/SwiftOBD2/Communication/CommProtocol.swift index 6b54930..fc8c77e 100644 --- a/Sources/SwiftOBD2/Communication/CommProtocol.swift +++ b/Sources/SwiftOBD2/Communication/CommProtocol.swift @@ -20,4 +20,8 @@ protocol CommProtocol { enum CommunicationError: Error { case invalidData case errorOccurred(Error) + /// The connect attempt exceeded the caller's timeout without reaching `.ready`. + case timeout + /// The connection was cancelled (e.g. user disconnect or app-side timeout) before it was established. + case cancelled } diff --git a/Sources/SwiftOBD2/Communication/wifiManager.swift b/Sources/SwiftOBD2/Communication/wifiManager.swift index a14762a..4dac6a2 100644 --- a/Sources/SwiftOBD2/Communication/wifiManager.swift +++ b/Sources/SwiftOBD2/Communication/wifiManager.swift @@ -52,6 +52,34 @@ private final class ResumeOnce: @unchecked Sendable { } } +// Void variant of the exactly-once guard, used by connectAsync. The stateUpdateHandler and the +// timeout deadline resolve on different threads and race to a terminal outcome; whichever arrives +// first resumes, the loser is a no-op. `finish` returns whether it actually resumed so the timeout +// can cancel the socket only when it genuinely won the race. +private final class ConnectOnce: @unchecked Sendable { + private let lock = NSLock() + private var done = false + var continuation: CheckedContinuation? + + @discardableResult + func finishSuccess() -> Bool { + lock.lock(); defer { lock.unlock() } + guard !done else { return false } + done = true + continuation?.resume(returning: ()) + return true + } + + @discardableResult + func finish(throwing error: Error) -> Bool { + lock.lock(); defer { lock.unlock() } + guard !done else { return false } + done = true + continuation?.resume(throwing: error) + return true + } +} + class WifiManager: CommProtocol { @Published var connectionState: ConnectionState = .disconnected @@ -71,32 +99,54 @@ class WifiManager: CommProtocol { self.portString = port } - func connectAsync(timeout _: TimeInterval, peripheral _: CBPeripheral? = nil) async throws { + func connectAsync(timeout: TimeInterval, peripheral _: CBPeripheral? = nil) async throws { let host = NWEndpoint.Host(hostString) guard let port = NWEndpoint.Port(portString) else { throw CommunicationError.invalidData } - tcp = NWConnection(host: host, port: port, using: .tcp) + let connection = NWConnection(host: host, port: port, using: .tcp) + tcp = connection + + let gate = ConnectOnce() try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - tcp?.stateUpdateHandler = { [weak self] newState in + gate.continuation = continuation + + // Honor the caller's timeout. A wrong IP, or a phone that isn't on the adapter's Wi-Fi + // network, leaves NWConnection parked in `.waiting` indefinitely — without this deadline + // the await never returns and the command gate stays wedged. Cancelling drives the + // handler to `.cancelled`; the gate ensures only the race winner resumes. + DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak connection] in + if gate.finish(throwing: CommunicationError.timeout) { + connection?.cancel() + } + } + + connection.stateUpdateHandler = { [weak self] newState in guard let self = self else { return } switch newState { case .ready: self.logger.info("Connected to \(host.debugDescription):\(port.debugDescription)") self.connectionState = .connectedToAdapter - continuation.resume(returning: ()) + gate.finishSuccess() case let .waiting(error): + // The Local Network permission prompt parks the connection here until the user + // answers, so don't fail fast — the timeout above is the only stop condition. self.logger.warning("Connection waiting: \(error.localizedDescription)") case let .failed(error): self.logger.error("Connection failed: \(error.localizedDescription)") self.connectionState = .disconnected - continuation.resume(throwing: CommunicationError.errorOccurred(error)) + gate.finish(throwing: CommunicationError.errorOccurred(error)) + case .cancelled: + // Reached via disconnectPeripheral() or the app-side timeout cancelling before + // we ever became ready. Resume the waiter so the connect attempt unwinds. + self.connectionState = .disconnected + gate.finish(throwing: CommunicationError.cancelled) default: break } } - tcp?.start(queue: .main) + connection.start(queue: .main) } } diff --git a/Tests/SwiftOBD2Tests/wifiManagerTests.swift b/Tests/SwiftOBD2Tests/wifiManagerTests.swift new file mode 100644 index 0000000..4c63fd6 --- /dev/null +++ b/Tests/SwiftOBD2Tests/wifiManagerTests.swift @@ -0,0 +1,71 @@ +@testable import SwiftOBD2 +import XCTest + +/// Regression tests for the connect path that previously hung forever when a Wi-Fi OBD adapter +/// was unreachable (wrong IP, phone not on the adapter's network, adapter asleep). `connectAsync` +/// now honors its timeout and resumes on cancellation instead of leaking the continuation. +final class WifiManagerTests: XCTestCase { + // 192.0.2.1 is RFC 5737 TEST-NET-1 — guaranteed unroutable, so the TCP connect never + // completes and NWConnection parks in `.waiting`/`.preparing`. Before the fix this hung + // indefinitely; now the deadline must fail the attempt. + private static let unreachableHost = "192.0.2.1" + + func testConnectTimesOutOnUnreachableHost() async { + let wifi = WifiManager(host: Self.unreachableHost, port: "35000") + let start = Date() + do { + try await wifi.connectAsync(timeout: 2) + XCTFail("connectAsync should not succeed against an unreachable host") + } catch { + let elapsed = Date().timeIntervalSince(start) + // Core guarantee: it unwinds instead of hanging past the timeout. + XCTAssertLessThan(elapsed, 6, "connectAsync hung past its 2s timeout (\(elapsed)s)") + // Expected shape: our deadline fired (.timeout), or the stack rejected the route + // outright (.errorOccurred). Anything else is wrong. + switch error { + case CommunicationError.timeout, CommunicationError.errorOccurred: + break + default: + XCTFail("expected .timeout or .errorOccurred, got \(error)") + } + } + wifi.disconnectPeripheral() + } + + // A user disconnect / app-side timeout cancels the socket mid-connect. The `.cancelled` + // state must resume the waiter (previously it fell through `default:` and hung). + func testCancelDuringConnectThrowsPromptly() async throws { + let wifi = WifiManager(host: Self.unreachableHost, port: "35000") + let connectTask = Task { try await wifi.connectAsync(timeout: 8) } + + // Let the connection enter its waiting state, then cancel it out from under the connect. + try await Task.sleep(nanoseconds: 300_000_000) + let cancelledAt = Date() + wifi.disconnectPeripheral() + + do { + try await connectTask.value + XCTFail("connectAsync should throw after cancellation") + } catch { + let elapsed = Date().timeIntervalSince(cancelledAt) + XCTAssertLessThan(elapsed, 4, "cancel did not unblock connectAsync promptly (\(elapsed)s)") + guard case CommunicationError.cancelled = error else { + return XCTFail("expected .cancelled, got \(error)") + } + } + } + + // A non-numeric / out-of-range port can't build an NWEndpoint.Port and must fail fast rather + // than opening a socket. (The app now also blocks this in the UI, but the transport stays safe.) + func testInvalidPortThrowsInvalidData() async { + let wifi = WifiManager(host: "192.168.0.10", port: "notaport") + do { + try await wifi.connectAsync(timeout: 2) + XCTFail("connectAsync should reject an invalid port") + } catch { + guard case CommunicationError.invalidData = error else { + return XCTFail("expected .invalidData, got \(error)") + } + } + } +} From a5badae6a630ca470a54e80fa964a7d94c2f6f2c Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Fri, 3 Jul 2026 21:15:49 +0100 Subject: [PATCH 42/68] BLE connect/disconnect hardening + pending-connect API for auto-reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bleManager: disconnectPeripheral() now also cancels an in-flight connect attempt (new pendingConnectPeripheral) and lands the state machine in .disconnected. Previously it was a no-op during the connecting window, leaving BLEManager stuck in .connecting while ELM327 said .disconnected — after which every retry failed with connectionInProgress (error 14) until the stale attempt timed out. - New TakeOnceCompletion: NSLock-guarded take-once completion slot, now used by the characteristics and scan waiters (same pattern as BLEMessageProcessor's completionLock and WifiManager's ResumeOnce). Fixes the double-resume trap when reset() races a CoreBluetooth callback, the never-cleared completion on the characteristics error path, and adds cancellation handlers so withTimeout's cancelAll actually resumes the waiters. A characteristics success that loses the race against the timeout no longer emits a phantom .connectedToAdapter for a torn-down connection. - connectAsync: a scan timeout now stops the scan and resets the scanner (both used to leak); the setup catch path lands .disconnected on cancellation instead of stomping a deliberate disconnect with .error. - Single state-emission channel: removed the direct obdDelegate dispatches in BLEManager and the duplicate delegate call in ELM327's sink — the @Published publisher → ELM327 didSet is the only path — and OBDService now drops repeated states, so consumers only see genuine transitions. - didUpdateState: .poweredOff runs the full resetConfigure() (resumes waiters and emits .disconnected so app cleanup fires); transient unknown central states no longer set a sticky .error. - Pending-connect API for auto-reconnect: retrievePeripheral(withIdentifier:) on CommProtocol/ELM327/OBDService (nil on non-BLE transports), and startConnection(timeout: .infinity, peripheral:) now waits indefinitely for a known dongle to come in range (withTimeout runs bare on .infinity). - Tests: TakeOnceCompletion exactly-once under contention, scanner timeout→reset no-trap, prompt cancellation of a waiting scan, and withTimeout(.infinity); full suite green (35 tests). --- .../BLE/BLEPeripheralManager.swift | 49 +++++--- .../Communication/BLE/BLEScanner.swift | 49 +++++--- .../BLE/TakeOnceCompletion.swift | 35 ++++++ .../Communication/BLE/bleManager.swift | 105 ++++++++++++------ .../Communication/CommProtocol.swift | 8 ++ Sources/SwiftOBD2/elm327.swift | 9 +- Sources/SwiftOBD2/obd2service.swift | 17 ++- .../takeOnceCompletionTests.swift | 99 +++++++++++++++++ 8 files changed, 296 insertions(+), 75 deletions(-) create mode 100644 Sources/SwiftOBD2/Communication/BLE/TakeOnceCompletion.swift create mode 100644 Tests/SwiftOBD2Tests/takeOnceCompletionTests.swift diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift b/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift index 39e208a..1e2e323 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift @@ -17,7 +17,10 @@ class BLEPeripheralManager: NSObject, ObservableObject { private let characteristicHandler: BLECharacteristicHandler weak var delegate: BLEPeripheralManagerDelegate? - private var connectionCompletion: ((CBPeripheral?, Error?) -> Void)? + // Resumed from the CB queue (characteristics ready/failed), reset() on an + // arbitrary thread, or the cancellation handler — take-once so those racing + // paths can never double-resume the waiting continuation. + private let setupCompletion = TakeOnceCompletion() init(characteristicHandler: BLECharacteristicHandler) { self.characteristicHandler = characteristicHandler @@ -34,18 +37,28 @@ class BLEPeripheralManager: NSObject, ObservableObject { } } + /// timeout may be .infinity (pending-connect mode: CoreBluetooth holds the + /// connect until the dongle appears, so cancellation is the only way out — + /// withTimeout runs the operation with no timeout child in that case). func waitForCharacteristicsSetup(timeout: TimeInterval) async throws { try await withTimeout(seconds: timeout) { [self] in - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - self.connectionCompletion = { peripheral, error in - if peripheral != nil { - continuation.resume() - } else if let error = error { - continuation.resume(throwing: error) - } else { - continuation.resume(throwing: BLEManagerError.unknownError) + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let claimed = setupCompletion.set { peripheral, error in + if peripheral != nil { + continuation.resume() + } else if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(throwing: BLEManagerError.unknownError) + } + } + if !claimed { + continuation.resume(throwing: BLEManagerError.connectionInProgress) } } + } onCancel: { [self] in + setupCompletion.take()?(nil, CancellationError()) } } } @@ -60,7 +73,7 @@ class BLEPeripheralManager: NSObject, ObservableObject { func didDiscoverCharacteristics(_ peripheral: CBPeripheral, service: CBService, error: Error?) { if let error = error { logger.error("Error discovering characteristics: \(error.localizedDescription)") - connectionCompletion?(nil, error) + setupCompletion.take()?(nil, error) return } @@ -70,10 +83,13 @@ class BLEPeripheralManager: NSObject, ObservableObject { // Check if all required characteristics are set up if characteristicHandler.isReady { - connectionCompletion?(peripheral, nil) - connectionCompletion = nil - - // Notify delegate + // Claim first: if the timeout/cancel/reset path already took the + // slot, this late success must not resume again — and must not + // announce .connectedToAdapter for a connection the caller has + // already torn down. Also swallows repeat isReady callbacks from + // additional services. + guard let completion = setupCompletion.take() else { return } + completion(peripheral, nil) delegate?.peripheralManager(self, didSetupCharacteristics: peripheral) } } @@ -91,10 +107,7 @@ class BLEPeripheralManager: NSObject, ObservableObject { func reset() { connectedPeripheral?.delegate = nil connectedPeripheral = nil - if let completion = connectionCompletion { - connectionCompletion = nil - completion(nil, BLEManagerError.peripheralNotConnected) - } + setupCompletion.take()?(nil, BLEManagerError.peripheralNotConnected) } } diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift b/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift index 1406d1d..7606420 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift @@ -31,7 +31,10 @@ class BLEPeripheralScanner: ObservableObject { CBUUID(string: "18F0"), // e.g. VGate iCar Pro ] - private var foundPeripheralCompletion: ((CBPeripheral?, Error?) -> Void)? + // Resumed from the CB queue (discovery), reset() on an arbitrary thread + // (disconnect), or the cancellation handler — take-once so those racing + // paths can never double-resume the waiting continuation. + private let foundPeripheralCompletion = TakeOnceCompletion() func addDiscoveredPeripheral(_ peripheral: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) { // Filter out peripherals with invalid RSSI @@ -46,8 +49,7 @@ class BLEPeripheralScanner: ObservableObject { } // Complete waiting continuation if exists - foundPeripheralCompletion?(peripheral, nil) - foundPeripheralCompletion = nil // Clear after calling + foundPeripheralCompletion.take()?(peripheral, nil) } func waitForFirstPeripheral(timeout: TimeInterval) async throws -> CBPeripheral { @@ -57,27 +59,36 @@ class BLEPeripheralScanner: ObservableObject { } // Otherwise wait for discovery - return try await withTimeout(seconds: timeout, timeoutError: BLEScannerError.scanTimeout) { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - self.foundPeripheralCompletion = { peripheral, error in - if let peripheral = peripheral { - continuation.resume(returning: peripheral) - } else if let error = error { - continuation.resume(throwing: error) - } else { + return try await withTimeout(seconds: timeout, timeoutError: BLEScannerError.scanTimeout) { [self] in + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let claimed = foundPeripheralCompletion.set { peripheral, error in + if let peripheral = peripheral { + continuation.resume(returning: peripheral) + } else if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(throwing: BLEScannerError.peripheralNotFound) + } + } + if !claimed { continuation.resume(throwing: BLEScannerError.peripheralNotFound) + } else if let first = foundPeripherals.first { + // A discovery that landed between the early-return check + // and set() found no waiter to resume — claim our own + // slot rather than hanging until the timeout. + foundPeripheralCompletion.take()?(first, nil) } } + } onCancel: { [self] in + foundPeripheralCompletion.take()?(nil, CancellationError()) } } } func reset() { foundPeripherals.removeAll() - if let completion = foundPeripheralCompletion { - foundPeripheralCompletion = nil - completion(nil, BLEScannerError.scanTimeout) - } + foundPeripheralCompletion.take()?(nil, BLEScannerError.scanTimeout) } } // MARK: - CBPeripheralDelegate @@ -111,7 +122,13 @@ func withTimeout( onTimeout: (() -> Void)? = nil, operation: @escaping @Sendable () async throws -> R ) async throws -> R { - try await withThrowingTaskGroup(of: R.self) { group in + // .infinity = no deadline (pending-connect mode): run the operation bare — + // the nanosecond conversion below would trap on a non-finite value, and a + // timeout child that never fires is pointless. + guard seconds.isFinite else { + return try await operation() + } + return try await withThrowingTaskGroup(of: R.self) { group in group.addTask { let result = try await operation() try Task.checkCancellation() diff --git a/Sources/SwiftOBD2/Communication/BLE/TakeOnceCompletion.swift b/Sources/SwiftOBD2/Communication/BLE/TakeOnceCompletion.swift new file mode 100644 index 0000000..e7d7053 --- /dev/null +++ b/Sources/SwiftOBD2/Communication/BLE/TakeOnceCompletion.swift @@ -0,0 +1,35 @@ +import Foundation + +/// NSLock-guarded single-consumer completion slot for bridging delegate +/// callbacks to a waiting CheckedContinuation. +/// +/// The registered completion can be fired from the CoreBluetooth queue +/// (callback arrived), a reset() on an arbitrary thread (disconnect), or a +/// task-cancellation handler — any two of which can race. take() makes the +/// hand-off atomic so the continuation can never be resumed twice, and set() +/// refuses to clobber a live waiter. Same semantics as BLEMessageProcessor's +/// completionLock and WifiManager's ResumeOnce. +final class TakeOnceCompletion: @unchecked Sendable { + private let lock = NSLock() + private var completion: ((Value?, Error?) -> Void)? + + /// Registers a completion. Returns false — leaving the pending waiter + /// untouched — when one is already registered. + func set(_ newCompletion: @escaping (Value?, Error?) -> Void) -> Bool { + lock.lock() + defer { lock.unlock() } + guard completion == nil else { return false } + completion = newCompletion + return true + } + + /// Atomically claims the pending completion; nil if none is registered + /// or another caller already took it. + func take() -> ((Value?, Error?) -> Void)? { + lock.lock() + defer { lock.unlock() } + let claimed = completion + completion = nil + return claimed + } +} diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index bdbef75..af681bf 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -77,7 +77,14 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { private var peripheralScanner: BLEPeripheralScanner! private var cancellables = Set() - + + // The peripheral a centralManager.connect() is in flight for, before + // didConnect hands it to peripheralManager. Without it, a disconnect + // during the connecting window has nothing to cancel: CoreBluetooth keeps + // the attempt alive forever and the state machine stays .connecting, + // failing every retry with .connectionInProgress. + private var pendingConnectPeripheral: CBPeripheral? + deinit { // Clean up resources cancellables.removeAll() @@ -135,8 +142,19 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { } func disconnectPeripheral() { - guard let peripheral = peripheralManager.connectedPeripheral else { return } - centralManager.cancelPeripheralConnection(peripheral) + stopScan() + // Cancel a pending attempt too: during the connecting window the + // connected slot is still empty, and skipping the cancel here is what + // used to wedge the manager in .connecting after a Stop mid-connect. + let target = peripheralManager.connectedPeripheral ?? pendingConnectPeripheral + if let target { + centralManager.cancelPeripheralConnection(target) + } + // Cancelling a never-connected attempt produces no didDisconnect + // callback, so land the state machine ourselves. resetConfigure also + // resumes any scan/characteristics waiters and is idempotent — a real + // link's later didDisconnect just runs it again as a no-op. + resetConfigure() } // MARK: - Central Manager Delegate Methods @@ -147,10 +165,10 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { centralManagerDidPowerOn() case .poweredOff: obdWarning("Bluetooth powered off", category: .bluetooth) - peripheralManager.connectedPeripheral = nil - let oldState = connectionState - connectionState = .disconnected - OBDLogger.shared.logConnectionChange(from: oldState, to: connectionState) + // Full teardown, not just dropping the peripheral: resumes any + // scan/characteristics waiters and emits .disconnected so the + // consumer's disconnect cleanup runs. + resetConfigure() case .unsupported: obdError("Device does not support Bluetooth Low Energy", category: .bluetooth) case .unauthorized: @@ -158,9 +176,9 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { case .resetting: obdWarning("Bluetooth is resetting", category: .bluetooth) default: + // .unknown can fire transiently at startup; setting .error here + // would stick (nothing transitions it back) and mask the real state. obdError("Bluetooth in unexpected state: \(central.state.rawValue)", category: .bluetooth) - connectionState = .error - obdDelegate?.connectionStateChanged(state: .error) } } @@ -185,11 +203,8 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { let oldState = connectionState connectionState = .connecting OBDLogger.shared.logConnectionChange(from: oldState, to: connectionState) - - DispatchQueue.main.async { - self.obdDelegate?.connectionStateChanged(state: .connecting) - } - + + pendingConnectPeripheral = peripheral centralManager.connect(peripheral, options: [CBConnectPeripheralOptionNotifyOnDisconnectionKey: true]) if centralManager.isScanning { centralManager.stopScan() @@ -198,6 +213,7 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { func didConnect(_: CBCentralManager, peripheral: CBPeripheral) { obdInfo("Connected to peripheral: \(peripheral.name ?? "Unnamed")", category: .bluetooth) + pendingConnectPeripheral = nil peripheralManager.setPeripheral(peripheral) // Note: connectionState will be set to .connectedToAdapter in peripheralManager delegate } @@ -208,15 +224,12 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { obdError("Connection failed to peripheral: \(peripheralName) - \(errorMsg)", category: .bluetooth) // Clean up peripheral state so a retry can proceed from a fresh baseline. + pendingConnectPeripheral = nil peripheralManager.reset() let oldState = connectionState connectionState = .error OBDLogger.shared.logConnectionChange(from: oldState, to: connectionState) - - DispatchQueue.main.async { - self.obdDelegate?.connectionStateChanged(state: .error) - } } func didDisconnect(_: CBCentralManager, peripheral: CBPeripheral, error: Error?) { @@ -255,8 +268,21 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { if let peripheral = peripheral { targetPeripheral = peripheral } else { + // Pending-connect mode (timeout: .infinity) requires a known + // peripheral — never scan forever. + guard timeout.isFinite else { + throw BLEManagerError.peripheralNotFound + } startScanning(BLEPeripheralScanner.supportedServices) - targetPeripheral = try await peripheralScanner.waitForFirstPeripheral(timeout: timeout) + do { + targetPeripheral = try await peripheralScanner.waitForFirstPeripheral(timeout: timeout) + } catch { + // Without this the radio keeps scanning and the scanner's + // waiter slot stays armed after a scan timeout. + stopScan() + peripheralScanner.reset() + throw error + } } connect(to: targetPeripheral) @@ -267,15 +293,19 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { // CoreBluetooth's connect never times out on its own, so without this the // manager stays .connecting forever and every retry throws // connectionInProgress. Clear peripheral state (which also resumes the - // pending setup continuation), cancel the half-open connection, and land - // in .error — a recoverable starting point for the next attempt. + // pending setup continuation) and cancel the half-open connection. + pendingConnectPeripheral = nil peripheralManager.reset() centralManager.cancelPeripheralConnection(targetPeripheral) + // A cancelled attempt (caller tore the task down deliberately) lands + // .disconnected; a genuine failure lands .error — both recoverable + // starting points. Never stomp a .disconnected another path already + // reached (e.g. disconnectPeripheral during this attempt). let oldState = connectionState - connectionState = .error - OBDLogger.shared.logConnectionChange(from: oldState, to: connectionState) - DispatchQueue.main.async { - self.obdDelegate?.connectionStateChanged(state: .error) + let newState: ConnectionState = error is CancellationError ? .disconnected : .error + if oldState != .disconnected, oldState != newState { + connectionState = newState + OBDLogger.shared.logConnectionChange(from: oldState, to: newState) } throw error } @@ -285,12 +315,6 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { let oldState = connectionState connectionState = .connectedToAdapter OBDLogger.shared.logConnectionChange(from: oldState, to: connectionState) - - // Dispatch delegate call to main queue since it might update UI - DispatchQueue.main.async { - self.obdDelegate?.connectionStateChanged(state: .connectedToAdapter) - } - obdInfo("Characteristics setup complete, connected to adapter", category: .bluetooth) } @@ -380,7 +404,17 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { stopScan() } + /// Looks up a previously connected peripheral by its system identifier so + /// the consumer can issue a pending connect without scanning. Returns nil + /// when Bluetooth never powers on or the system no longer knows the UUID. + func retrievePeripheral(withIdentifier identifier: UUID) async -> CBPeripheral? { + // Retrieval before the central reaches .poweredOn always returns []. + try? await waitForPoweredOn() + return centralManager.retrievePeripherals(withIdentifiers: [identifier]).first + } + private func resetConfigure() { + pendingConnectPeripheral = nil characteristicHandler.reset() messageProcessor.reset() peripheralManager.reset() @@ -391,9 +425,6 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { if oldState != connectionState { OBDLogger.shared.logConnectionChange(from: oldState, to: connectionState) obdDelegate?.peripheralsUpdated([]) - DispatchQueue.main.async { - self.obdDelegate?.connectionStateChanged(state: .disconnected) - } } } @@ -402,11 +433,11 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { /// cancelPeripheralConnection is called with a valid reference, and the /// subsequent didDisconnect callback is a safe no-op (all handlers already nil'd). public func reset() { - let connectedPeripheral = peripheralManager.connectedPeripheral + let target = peripheralManager.connectedPeripheral ?? pendingConnectPeripheral stopScan() resetConfigure() - if let connectedPeripheral { - centralManager.cancelPeripheralConnection(connectedPeripheral) + if let target { + centralManager.cancelPeripheralConnection(target) } } } diff --git a/Sources/SwiftOBD2/Communication/CommProtocol.swift b/Sources/SwiftOBD2/Communication/CommProtocol.swift index fc8c77e..715bdfd 100644 --- a/Sources/SwiftOBD2/Communication/CommProtocol.swift +++ b/Sources/SwiftOBD2/Communication/CommProtocol.swift @@ -13,6 +13,14 @@ protocol CommProtocol { func reset() var connectionStatePublisher: Published.Publisher { get } var obdDelegate: OBDServiceDelegate? { get set } + /// Looks up a previously connected peripheral by system identifier for a + /// no-scan pending connect. Only meaningful for BLE transports. + func retrievePeripheral(withIdentifier identifier: UUID) async -> CBPeripheral? +} + +extension CommProtocol { + // Non-BLE transports (WiFi, serial, mock) have no peripheral registry. + func retrievePeripheral(withIdentifier identifier: UUID) async -> CBPeripheral? { nil } } // MARK: - Transport-layer errors diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index 81a99be..1e65a08 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -83,8 +83,9 @@ class ELM327 { .receive(on: DispatchQueue.main) .removeDuplicates() .sink { [weak self] state in + // The assignment's didSet already notifies obdDelegate — this + // sink is the single delivery channel for transport states. self?.connectionState = state - self?.obdDelegate?.connectionStateChanged(state: state) self?.logger.debug("Connection state updated: \(state.description)") } .store(in: &cancellables) @@ -248,6 +249,12 @@ class ELM327 { try await comm.connectAsync(timeout: timeout, peripheral: peripheral) } + /// Looks up a previously connected BLE peripheral by system identifier for + /// a no-scan pending connect. Nil on non-BLE transports. + func retrievePeripheral(withIdentifier identifier: UUID) async -> CBPeripheral? { + await comm.retrievePeripheral(withIdentifier: identifier) + } + /// Initializes the adapter by sending a series of commands. /// - Parameter setupOrder: A list of commands to send in order. /// - Throws: Various setup-related errors. diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index 5101e82..c85e5c4 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -129,10 +129,12 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab public func connectionStateChanged(state: ConnectionState) { DispatchQueue.main.async { let oldState = self.connectionState + // The transport layers can still deliver the same state twice + // (e.g. a manual stop followed by the publisher's .disconnected); + // consumers must only see genuine transitions. + guard oldState != state else { return } self.connectionState = state - if oldState != state { - OBDLogger.shared.logConnectionChange(from: oldState, to: state) - } + OBDLogger.shared.logConnectionChange(from: oldState, to: state) self.onConnectionStateChanged?(state) } } @@ -215,6 +217,15 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab elm327.stopConnection() } + /// Looks up a previously connected BLE peripheral by its system identifier + /// so the caller can start a no-scan pending connect + /// (`startConnection(timeout: .infinity, peripheral:)` waits until the + /// dongle comes in range). Nil on non-BLE transports or when the system + /// no longer knows the identifier. + public func retrievePeripheral(identifier: UUID) async -> CBPeripheral? { + await elm327.retrievePeripheral(withIdentifier: identifier) + } + /// Switches the dongle to a different CAN protocol without dropping the BT/Serial connection. public func switchProtocol(_ proto: PROTOCOL) async throws { try await elm327.switchProtocol(proto) diff --git a/Tests/SwiftOBD2Tests/takeOnceCompletionTests.swift b/Tests/SwiftOBD2Tests/takeOnceCompletionTests.swift new file mode 100644 index 0000000..0af915b --- /dev/null +++ b/Tests/SwiftOBD2Tests/takeOnceCompletionTests.swift @@ -0,0 +1,99 @@ +@testable import SwiftOBD2 +import XCTest + +/// Regression tests for the take-once completion hand-off used by the BLE +/// connect path. Before this primitive, `waitForCharacteristicsSetup` and +/// `waitForFirstPeripheral` stored bare closures that a `reset()` racing a +/// CoreBluetooth callback could invoke twice — double-resuming (trapping) the +/// waiting continuation — or, on the characteristics error path, never nil out. +final class TakeOnceCompletionTests: XCTestCase { + func testSetThenTakeReturnsCompletionOnce() { + let slot = TakeOnceCompletion() + XCTAssertTrue(slot.set { _, _ in }) + XCTAssertNotNil(slot.take()) + XCTAssertNil(slot.take(), "second take must find the slot empty") + } + + func testSetWhilePendingIsRejected() { + let slot = TakeOnceCompletion() + XCTAssertTrue(slot.set { _, _ in }) + XCTAssertFalse(slot.set { _, _ in }, "a live waiter must not be clobbered") + // The original waiter is still claimable. + XCTAssertNotNil(slot.take()) + } + + func testConcurrentTakesClaimExactlyOnce() { + // The real-world race: a delegate callback on the CB queue and a + // reset()/cancellation on another thread both try to fire the waiter. + for _ in 0..<500 { + let slot = TakeOnceCompletion() + let fired = ManagedAtomicCounter() + XCTAssertTrue(slot.set { _, _ in fired.increment() }) + + let group = DispatchGroup() + for _ in 0..<4 { + group.enter() + DispatchQueue.global().async { + slot.take()?(nil, nil) + group.leave() + } + } + group.wait() + XCTAssertEqual(fired.value, 1, "completion must fire exactly once under contention") + } + } + + func testScannerTimeoutThenResetDoesNotDoubleResume() async { + // Scan times out (nothing discovered), then a disconnect calls reset(). + // Previously reset() would re-fire the stale closure into an already + // resumed continuation; now the cancellation handler drained the slot. + let scanner = BLEPeripheralScanner() + do { + _ = try await scanner.waitForFirstPeripheral(timeout: 0.1) + XCTFail("expected scanTimeout with no peripherals") + } catch { + guard case BLEScannerError.scanTimeout = error else { + return XCTFail("expected scanTimeout, got \(error)") + } + } + scanner.reset() // must be a safe no-op, not a second resume + } + + func testScannerWaitCancellationResumesPromptly() async { + let scanner = BLEPeripheralScanner() + let task = Task { + try await scanner.waitForFirstPeripheral(timeout: 30) + } + try? await Task.sleep(nanoseconds: 100_000_000) + task.cancel() + let start = Date() + do { + _ = try await task.value + XCTFail("cancelled wait should throw") + } catch { + XCTAssertLessThan(Date().timeIntervalSince(start), 5, + "cancellation must resume the waiter, not run out the 30s timeout") + } + } + + func testWithTimeoutInfinityRunsOperationBare() async throws { + // .infinity must skip the timeout child entirely — the nanosecond + // conversion would trap on a non-finite value. + let value = try await withTimeout(seconds: .infinity) { 42 } + XCTAssertEqual(value, 42) + } +} + +/// Minimal lock-guarded counter for asserting exactly-once semantics. +private final class ManagedAtomicCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + var value: Int { + lock.lock(); defer { lock.unlock() } + return count + } + func increment() { + lock.lock(); defer { lock.unlock() } + count += 1 + } +} From e5a5c8baf6a6ee731a371f33526d6d70c4356697 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Mon, 6 Jul 2026 18:55:04 +0100 Subject: [PATCH 43/68] =?UTF-8?q?Fix=201=20=E2=80=94=20getStatus()=20off-b?= =?UTF-8?q?y-one=20(elm327.swift:345):=20added=20the=20compensating=20.dro?= =?UTF-8?q?pFirst()=20so=20StatusDecoder=20reads=20readiness=20byte=20A=20?= =?UTF-8?q?instead=20of=20the=20PID=20byte=20=E2=80=94=20matching=20the=20?= =?UTF-8?q?pattern=20obd2service.sendCommand=20already=20uses.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 2 — parseUDS19Data 3rd DTC byte (elm327.swift:420): now reuses parseDTC for the base P/C/B/U code and appends the ISO 14229 failure-type byte b3 as a -XX suffix, so P0420-64 and P0420-00 no longer collapse into one code. Dropped the redundant b1/b2 != 0 guard since parseDTC already rejects 00 00. --- Sources/SwiftOBD2/elm327.swift | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index 1e65a08..c7432e0 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -342,7 +342,10 @@ class ELM327 { guard let statusData = try canProtocol?.parse(statusResponse).first?.data else { return .failure(.noData) } - return statusCommand.properties.decode(data: statusData) + // message.data is [PID, A, B, C, D]; decode() no longer strips the PID + // byte, so drop it here (as sendCommand does) before StatusDecoder reads + // A as the MIL/DTC-count byte. + return statusCommand.properties.decode(data: statusData.dropFirst()) } func scanForTroubleCodes() async throws -> [ECUID: [TroubleCode]] { @@ -419,14 +422,19 @@ class ELM327 { private func parseUDS19Data(_ data: Data) -> [TroubleCode] { let bytes = Array(data) - // UDS $19/$02 response: 59 02 [status_mask] then 4-byte groups [b1 b2 b3 status] + // UDS $19/$02 response: 59 02 [status availability mask] then 4-byte + // records [b1 b2 b3 statusMask] — a 3-byte DTC plus its status byte. guard bytes.count >= 3, bytes[0] == 0x59, bytes[1] == 0x02 else { return [] } var result: [TroubleCode] = [] var i = 3 while i + 3 <= bytes.count { - let b1 = bytes[i], b2 = bytes[i + 1] - if (b1 != 0 || b2 != 0), let tc = parseDTC(Data([b1, b2])) { - result.append(tc) + let b1 = bytes[i], b2 = bytes[i + 1], b3 = bytes[i + 2] + // parseDTC builds the base P/C/B/U code from b1,b2 (and rejects 00 00). + if let base = parseDTC(Data([b1, b2])) { + // b3 is the ISO 14229 failure-type byte; append as "-XX" so distinct + // sub-faults of the same base code stay distinct. + let code = base.code + String(format: "-%02X", b3) + result.append(TroubleCode(code: code, description: base.description)) } i += 4 } From 955b70b58afde35c5ef6ed6d1878a8ec1abaca71 Mon Sep 17 00:00:00 2001 From: Alexander Shekhovtsov Date: Tue, 7 Jul 2026 16:12:40 +0200 Subject: [PATCH 44/68] Fix multi-frame CAN gap detection + expose Status fields publicly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-hardware testing over a noisy BLE ELM327 clone kept producing implausible trouble codes (e.g. P0D00) that would come and go across repeated re-reads on the same vehicle with no real fault behind them. parseMultiFrameMessage assembled ISO-TP consecutive frames in receive order with no check that the sequence was complete — a single dropped BLE notification mid-transfer silently shifted every byte after the gap, and extractDataFromFrame's short-data fallback returned the truncated result instead of failing. Both now throw instead of degrading silently: a gap in the 1,2,3.. consecutive-frame sequence, or a final assembly shorter than the length the first frame promised, is a corrupt read, not a partial one to make the best of. Also: `Status` (PID 0101 — MIL + confirmed DTC count + per-monitor readiness) had every field but `dtcCount` non-public despite the struct itself being public, and `StatusTest` wasn't public at all — so a consuming app could physically not read the check-engine-light state or monitor readiness it decoded. Made both fully public. --- Sources/SwiftOBD2/decoders.swift | 22 +++++++++++++--------- Sources/SwiftOBD2/parser.swift | 24 +++++++++++++++++++++++- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/Sources/SwiftOBD2/decoders.swift b/Sources/SwiftOBD2/decoders.swift index 4c578de..c509ff0 100644 --- a/Sources/SwiftOBD2/decoders.swift +++ b/Sources/SwiftOBD2/decoders.swift @@ -17,19 +17,23 @@ public enum MeasurementUnit: String, Codable { } public struct Status: Codable, Hashable { - var MIL: Bool = false + // Only `dtcCount` was public before — MIL (the check-engine light itself) and the + // per-monitor readiness fields were unreachable from outside this module despite + // `Status` being public, silently blocking any consumer from building a "check + // engine / inspection readiness" feature on top of PID 0101. + public var MIL: Bool = false public var dtcCount: UInt8 = 0 - var ignitionType: String = "" + public var ignitionType: String = "" - var misfireMonitoring = StatusTest() - var fuelSystemMonitoring = StatusTest() - var componentMonitoring = StatusTest() + public var misfireMonitoring = StatusTest() + public var fuelSystemMonitoring = StatusTest() + public var componentMonitoring = StatusTest() } -struct StatusTest: Codable, Hashable { - var name: String = "" - var supported: Bool = false - var ready: Bool = false +public struct StatusTest: Codable, Hashable { + public var name: String = "" + public var supported: Bool = false + public var ready: Bool = false init(_ name: String = "", _ supported: Bool = false, _ ready: Bool = false) { self.name = name diff --git a/Sources/SwiftOBD2/parser.swift b/Sources/SwiftOBD2/parser.swift index 706a77e..ce407a2 100644 --- a/Sources/SwiftOBD2/parser.swift +++ b/Sources/SwiftOBD2/parser.swift @@ -97,9 +97,27 @@ public struct Message: MessageProtocol { throw ParserError.error("Failed to parse multi frame message") } let consecutiveFrames = frames.filter { $0.type == .consecutiveFrame } + try validateSequence(consecutiveFrames) return try assembleData(firstFrame: firstFrame, consecutiveFrames: consecutiveFrames) } + /// ISO-TP consecutive frames are numbered 1, 2, 3, … (wrapping 15→0) with no + /// gaps. A BLE notification dropped mid-transfer used to go unnoticed here — + /// `assembleData` just concatenated whatever frames DID arrive, in receive + /// order, silently shifting every byte after the gap. That produces a + /// plausible-looking but wrong result (e.g. a bogus trouble code) instead of + /// a clean failure. Reject anything but a complete, in-order run. + private func validateSequence(_ consecutiveFrames: [Frame]) throws { + guard !consecutiveFrames.isEmpty else { return } + var expected: UInt8 = 1 + for frame in consecutiveFrames { + guard frame.seqIndex == expected else { + throw ParserError.error("Consecutive-frame gap: expected sequence \(expected), got \(frame.seqIndex)") + } + expected = expected == 15 ? 0 : expected + 1 + } + } + private func assembleData(firstFrame: Frame, consecutiveFrames: [Frame]) throws -> Data { var assembledFrame: Frame = firstFrame // Extract data from consecutive frames, skipping the PCI byte @@ -114,8 +132,12 @@ public struct Message: MessageProtocol { throw ParserError.error("Failed to extract data from frame") } let endIndex = startIndex + Int(frameDataLen) - 1 + // A short assembly (a trailing consecutive frame never arrived) used to + // fall through and return whatever partial bytes were on hand — a + // truncated-but-plausible byte string that decoders would happily + // misinterpret. Incomplete data must fail, not degrade silently. guard endIndex <= frame.data.count else { - return frame.data[startIndex...] + throw ParserError.error("Incomplete frame: expected \(endIndex) bytes, got \(frame.data.count)") } return frame.data[startIndex ..< endIndex] } From f1728f4f22fd61f87c06ea550cdf2f300e6903da Mon Sep 17 00:00:00 2001 From: Alexander Shekhovtsov Date: Tue, 7 Jul 2026 16:41:08 +0200 Subject: [PATCH 45/68] Fix multi-ECU PID under-reporting, WiFi line-drop, error visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broader audit pass after a real-hardware report of "sensors missing again" and a DTC scan failing with an opaque "OBDServiceError error 1" even over WiFi. elm327.swift: getSupportedPIDs' parseResponse took only the FIRST ECU's reply to each supported-PID bitmap request (0100/0120/...) on this vehicle's two-ECU bus. Any PID advertised only by the second ECU was silently absent from OBDInfo.supportedPIDs — and because "first" came from a Dictionary's iteration order, WHICH ecu won wasn't even stable connect to connect, so the missing set could differ each time. Now unions the bitmap across every ECU that answered. wifiManager.swift: processResponse dropped the entire last line whenever it contained the '>' prompt, instead of just the prompt character. A WiFi clone that appends '>' directly onto the last data line with no preceding newline (common on cheap ELM327 emulators) lost that whole line — including real DTC/measurement bytes — while BLE's equivalent path already stripped just the character. Also normalized per-line trimming before the "no data" check, which an untrimmed trailing \r could dodge. Root cause of the opaque error: OBDServiceError, ParserError, CommunicationError, DecodeError conformed to Error but not LocalizedError, and BLEManagerError's CustomStringConvertible.description was never wired to errorDescription — so .localizedDescription on any of them (or anything wrapping them, which is everything OBDService throws) produced only "TypeName error N", the exact opaque message this session hit. Every one of these now surfaces its real message, recursively through underlying errors. Co-Authored-By: Claude Fable 5 --- .../Communication/BLE/bleManager.swift | 7 +++++- .../Communication/CommProtocol.swift | 11 ++++++++- .../SwiftOBD2/Communication/wifiManager.swift | 18 +++++++++++---- Sources/SwiftOBD2/decoders.swift | 11 ++++++++- Sources/SwiftOBD2/elm327.swift | 17 +++++++++++--- Sources/SwiftOBD2/obd2service.swift | 23 +++++++++++++++++++ Sources/SwiftOBD2/parser.swift | 8 ++++++- 7 files changed, 83 insertions(+), 12 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index af681bf..bab144a 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -469,7 +469,7 @@ extension BLEManager: CBCentralManagerDelegate { } } -enum BLEManagerError: Error, CustomStringConvertible { +enum BLEManagerError: Error, CustomStringConvertible, LocalizedError { case missingPeripheralOrCharacteristic case unknownCharacteristic case scanTimeout @@ -520,4 +520,9 @@ enum BLEManagerError: Error, CustomStringConvertible { return "Error: Connection already active or in progress. Please disconnect before attempting a new connection." } } + + // `CustomStringConvertible.description` alone isn't picked up by `Error.localizedDescription` — + // without this, every one of the messages above was unreachable through normal error handling + // and callers saw the generic "BLEManagerError error N." instead. + public var errorDescription: String? { description } } diff --git a/Sources/SwiftOBD2/Communication/CommProtocol.swift b/Sources/SwiftOBD2/Communication/CommProtocol.swift index 715bdfd..068d24c 100644 --- a/Sources/SwiftOBD2/Communication/CommProtocol.swift +++ b/Sources/SwiftOBD2/Communication/CommProtocol.swift @@ -25,11 +25,20 @@ extension CommProtocol { // MARK: - Transport-layer errors -enum CommunicationError: Error { +enum CommunicationError: Error, LocalizedError { case invalidData case errorOccurred(Error) /// The connect attempt exceeded the caller's timeout without reaching `.ready`. case timeout /// The connection was cancelled (e.g. user disconnect or app-side timeout) before it was established. case cancelled + + var errorDescription: String? { + switch self { + case .invalidData: return "Invalid data received from the adapter." + case .errorOccurred(let underlying): return underlying.localizedDescription + case .timeout: return "The connection attempt timed out." + case .cancelled: return "The connection attempt was cancelled." + } + } } diff --git a/Sources/SwiftOBD2/Communication/wifiManager.swift b/Sources/SwiftOBD2/Communication/wifiManager.swift index 4dac6a2..d76c237 100644 --- a/Sources/SwiftOBD2/Communication/wifiManager.swift +++ b/Sources/SwiftOBD2/Communication/wifiManager.swift @@ -301,17 +301,25 @@ class WifiManager: CommProtocol { private func processResponse(_ response: String) -> [String]? { logger.info("Processing response: \(response)") - var lines = response.components(separatedBy: .newlines).filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + // Strip the '>' prompt character itself rather than dropping whichever line + // contains it: some WiFi ELM327 clones append the prompt directly onto the + // last data line with no preceding newline (e.g. "43 00 00 00 00 00 00>" as + // one line). The previous `lines.last?.contains(">") → removeLast()` logic + // discarded that entire line — including real trouble-code/measurement + // bytes — whenever the adapter happened to frame it that way. Also trim + // each line before the "no data" check: an untrimmed trailing \r made + // "no data\r" fail to match "no data" and read as a real (garbage) line. + let lines = response + .replacingOccurrences(of: ">", with: "") + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } guard !lines.isEmpty else { logger.warning("Empty response lines") return nil } - if lines.last?.contains(">") == true { - lines.removeLast() - } - if lines.first?.lowercased() == "no data" { return nil } diff --git a/Sources/SwiftOBD2/decoders.swift b/Sources/SwiftOBD2/decoders.swift index c509ff0..5bc2378 100644 --- a/Sources/SwiftOBD2/decoders.swift +++ b/Sources/SwiftOBD2/decoders.swift @@ -233,11 +233,20 @@ private var uasIDS: [UInt8: UAS] = { 0xFE: UAS(signed: true, scale: 0.25, unit: Unit.Pascal) ]}() -public enum DecodeError: Error { +public enum DecodeError: Error, LocalizedError { case invalidData case noData case decodingFailed(reason: String) case unsupportedDecoder + + public var errorDescription: String? { + switch self { + case .invalidData: return "Invalid data received for decoding." + case .noData: return "No data received." + case .decodingFailed(let reason): return "Decoding failed: \(reason)" + case .unsupportedDecoder: return "No decoder available for this command." + } + } } protocol Decoder { diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index 1e65a08..d0370ae 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -559,12 +559,23 @@ extension ELM327 { return Array(Set(supportedPIDs)) } + /// Unions the supported-PID bitmap across every ECU that answered, instead of trusting + /// only the first one. On a vehicle with more than one ECU on the bus (e.g. a separate + /// module handling body/transmission PIDs), `.first` silently discarded any PID that only + /// the *other* ECU advertised — and since `.first` here comes from a `Dictionary`'s + /// iteration order, which ECU "won" wasn't even guaranteed to be the same one from one + /// connection to the next, so the set of sensors that showed up could vary connect to + /// connect on the exact same vehicle. private func parseResponse(_ response: [String]) -> Set? { - guard let ecuData = try? canProtocol?.parse(response).first?.data else { + guard let messages = try? canProtocol?.parse(response), !messages.isEmpty else { return nil } - let binaryData = BitArray(data: ecuData.dropFirst()).binaryArray - return extractSupportedPIDs(binaryData) + var combined = Set() + for message in messages { + guard let data = message.data else { continue } + combined.formUnion(extractSupportedPIDs(BitArray(data: data.dropFirst()).binaryArray)) + } + return combined.isEmpty ? nil : combined } func extractSupportedPIDs(_ binaryData: [Int]) -> Set { diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index c85e5c4..3b9680b 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -490,6 +490,29 @@ public enum OBDServiceError: Error { case commandFailed(command: String, error: Error) } +extension OBDServiceError: LocalizedError { + // Without this, every consumer's `.localizedDescription` produced the useless generic + // "OBDServiceError error N." — every case here wraps a real underlying transport/parse + // error (BLEManagerError, ELM327Error, ParserError, ...) that already describes itself + // properly; this was the one place in the chain that discarded it. + public var errorDescription: String? { + switch self { + case .noAdapterFound: + return "No OBD adapter found." + case .notConnectedToVehicle: + return "Connected to the adapter, but not to the vehicle." + case .adapterConnectionFailed(let underlying): + return "Adapter connection failed: \(underlying.localizedDescription)" + case .scanFailed(let underlying): + return "Trouble-code scan failed: \(underlying.localizedDescription)" + case .clearFailed(let underlying): + return "Clearing trouble codes failed: \(underlying.localizedDescription)" + case .commandFailed(let command, let underlying): + return "Command '\(command)' failed: \(underlying.localizedDescription)" + } + } +} + public struct MeasurementResult: Equatable { public var value: Double public let unit: Unit diff --git a/Sources/SwiftOBD2/parser.swift b/Sources/SwiftOBD2/parser.swift index ce407a2..6d43258 100644 --- a/Sources/SwiftOBD2/parser.swift +++ b/Sources/SwiftOBD2/parser.swift @@ -194,6 +194,12 @@ struct Frame { } } -enum ParserError: Error { +enum ParserError: Error, LocalizedError { case error(String) + + var errorDescription: String? { + switch self { + case .error(let message): return message + } + } } From adcc3e5ef0a08c8cf1621189f64c8678b4ae20b9 Mon Sep 17 00:00:00 2001 From: Alexander Shekhovtsov Date: Tue, 7 Jul 2026 17:08:52 +0200 Subject: [PATCH 46/68] Fix pedal-position label, legacy frame gap-check, enable ATAT1/ATCAF1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Label fix: PIDs 49-4B are the accelerator PEDAL sensor per SAE J1979 ("Accelerator pedal position D/E/F"), not the throttle plate — the library described them as "Absolute throttle position D/E/F" (copy- pasted from 47/48, which really are throttle position). Same 0-100% linear decode either way, so no vehicle ever saw a wrong number, only a wrong label. Verified against the Wikipedia OBD-II PID reference table rather than assumed. Legacy protocols (J1850 PWM/VPW, ISO9141-2, ISO14230 KWP — used by pre-CAN vehicles) had the exact same gap-blind assembly bug this session already fixed for CAN: the generic multi-frame path checked only that the lowest order byte was 1, not that the whole sequence was contiguous, so a dropped frame produced a silently-truncated, shifted response instead of a failure. Now checks every index. Adapter init now sends ATAT1 (adaptive timing — Elm's own recommendation for noisy links, growing the per-command timeout from observed bus response time instead of a fixed one) and ATCAF1 (CAN auto-formatting — makes explicit the framing assumption every CAN parser in this package already depends on implicitly). Both best-effort (not `okResponse`): an older/cheap clone that doesn't recognize either command must not fail the whole connection over an optional reliability improvement. Co-Authored-By: Claude Fable 5 --- Sources/SwiftOBD2/commands.swift | 9 ++++++--- Sources/SwiftOBD2/elm327.swift | 14 ++++++++++++++ Sources/SwiftOBD2/protocols/protocol_legacy.swift | 11 ++++++++--- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/Sources/SwiftOBD2/commands.swift b/Sources/SwiftOBD2/commands.swift index 4267375..0be5f12 100644 --- a/Sources/SwiftOBD2/commands.swift +++ b/Sources/SwiftOBD2/commands.swift @@ -631,9 +631,12 @@ extension OBDCommand.Mode1 { case .ambientAirTemp: return CommandProperties("0146", "Ambient air temperature", 4, .temp, true) case .throttlePosB: return CommandProperties("0147", "Absolute throttle position B", 4, .percent, true) case .throttlePosC: return CommandProperties("0148", "Absolute throttle position C", 4, .percent, true) - case .throttlePosD: return CommandProperties("0149", "Absolute throttle position D", 4, .percent, true) - case .throttlePosE: return CommandProperties("014A", "Absolute throttle position E", 4, .percent, true) - case .throttlePosF: return CommandProperties("014B", "Absolute throttle position F", 4, .percent, true) + // PIDs 49-4B are the accelerator PEDAL sensor (SAE J1979 calls them exactly that) — + // a different physical sensor from the throttle PLATE position (0111/0147/0148). + // Same 0-100% linear encoding, so the decoded value was never wrong, only the label. + case .throttlePosD: return CommandProperties("0149", "Accelerator pedal position D", 4, .percent, true) + case .throttlePosE: return CommandProperties("014A", "Accelerator pedal position E", 4, .percent, true) + case .throttlePosF: return CommandProperties("014B", "Accelerator pedal position F", 4, .percent, true) case .throttleActuator: return CommandProperties("014C", "Commanded throttle actuator", 4, .percent, true) case .runTimeMIL: return CommandProperties("014D", "Time run with MIL on", 4, .uas(0x34), true) case .timeSinceDTCCleared: return CommandProperties("014E", "Time since trouble codes cleared", 4, .uas(0x34), true) diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index d0370ae..3ff288b 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -279,6 +279,20 @@ class ELM327 { _ = try await okResponse("ATH1") obdDelegate?.logMessage("ATL0 / ATS0 / ATH1 → OK") + // Best-effort, not `okResponse`: both are v1.3+/v2.x features that a cheap or + // older clone may not implement, and an unrecognized command ("?") must not + // abort the whole connection over what's an optional reliability improvement. + // ATAT1 (adaptive timing) is Elm Electronics' own recommendation for noisy + // links — it grows the per-command timeout based on observed bus response + // time instead of a fixed one, exactly the failure mode this session kept + // chasing on both adapters. ATCAF1 (CAN auto-formatting) makes explicit an + // assumption every parser in this package already makes implicitly: that the + // adapter — not us — strips CAN padding/PCI framing before handing us lines. + obdDelegate?.logMessage("Adapter init: ATAT1 (adaptive timing) / ATCAF1 (CAN auto-format)…") + let atatResp = try? await sendCommand("ATAT1") + let atcafResp = try? await sendCommand("ATCAF1") + obdDelegate?.logMessage("ATAT1 → \(atatResp?.joined(separator: " | ") ?? "no response (unsupported?)"), ATCAF1 → \(atcafResp?.joined(separator: " | ") ?? "no response (unsupported?)")") + obdDelegate?.logMessage("Adapter init: ATSP0 (auto protocol)…") _ = try await okResponse("ATSP0") obdDelegate?.logMessage("ATSP0 → OK — adapter ready") diff --git a/Sources/SwiftOBD2/protocols/protocol_legacy.swift b/Sources/SwiftOBD2/protocols/protocol_legacy.swift index 6a85a33..4947776 100644 --- a/Sources/SwiftOBD2/protocols/protocol_legacy.swift +++ b/Sources/SwiftOBD2/protocols/protocol_legacy.swift @@ -105,9 +105,14 @@ struct LegacyMessage: MessageProtocol { // sort the frames by the order byte let sortedFrames = frames.sorted { $0.data[2] < $1.data[2] } - // check contiguity - guard sortedFrames.first?.data[2] == 1 else { - throw ParserError.error("Invalid order byte") + // Check the sequence is complete, not just that it starts at 1 — the same class + // of gap the CAN parser used to miss (see parser.swift's validateSequence): a + // dropped frame here left `sortedFrames` short but still "starting at 1", so this + // used to accumulate a truncated response instead of failing it outright. + for (index, frame) in sortedFrames.enumerated() { + guard frame.data[2] == index + 1 else { + throw ParserError.error("Order-byte gap: expected \(index + 1), got \(frame.data[2])") + } } // now that they're in order, accumulate the data from each frame From d098938d70902e5ed5e2c437c77fe3a77daaac6e Mon Sep 17 00:00:00 2001 From: Alexander Shekhovtsov Date: Tue, 7 Jul 2026 17:18:52 +0200 Subject: [PATCH 47/68] Decode all 8 PID 0101 emissions monitors, CAN-first protocol sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status/StatusDecoder only ever decoded bytes A and B of PID 0101 (MIL, DTC count, and the 3 "continuous" monitors: misfire/fuel system/ components). Bytes C and D — the 8 "non-continuous" monitors — were never even read, despite being present in every compliant 4-byte response. Those are exactly the ones an emissions/smog inspection readiness check actually depends on: catalyst, evaporative system, oxygen sensor, secondary air, EGR/VVT. Verified the exact bit layout against the SAE J1979-derived reference table (Wikipedia's OBD-II PIDs article) rather than guessing: byte C = availability (1 = available), byte D = completion (0 = complete), same polarity as the existing 3, just at bit offset 16-31 instead of 8-15. The 8 fields use spark- ignition (gasoline) semantics as their canonical meaning; the app layer relabels for compression-ignition (diesel) using the same bit positions' differing meaning where that's reliably documented. detectProtocolManually (the fallback sweep used only when the ELM327's own ATSP0 auto-search fails) tried protocols in raw enum-declaration order — 5 legacy protocols before ever reaching CAN. Since MY2008+ US / mid-2000s+ EU vehicles are essentially all CAN, that spent up to 5 full round-trips (ATSPn + 0100 + timeout each) on protocols that were never going to answer first. Now sweeps CAN (6-9) first, then legacy (1-5), then J1939/user CAN (A-C) last. Also: the generic legacy-protocol multi-frame assembler had the same frame-gap blindness the CAN parser did (fixed last commit) — checked only that the lowest order byte was 1, not that the sequence was contiguous. Same fix applied. Co-Authored-By: Claude Fable 5 --- Sources/SwiftOBD2/decoders.swift | 40 ++++++++++++++++++++++++++++++++ Sources/SwiftOBD2/elm327.swift | 16 ++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftOBD2/decoders.swift b/Sources/SwiftOBD2/decoders.swift index 5bc2378..958c9fe 100644 --- a/Sources/SwiftOBD2/decoders.swift +++ b/Sources/SwiftOBD2/decoders.swift @@ -28,6 +28,21 @@ public struct Status: Codable, Hashable { public var misfireMonitoring = StatusTest() public var fuelSystemMonitoring = StatusTest() public var componentMonitoring = StatusTest() + + // Bytes C (availability) and D (completion) of PID 0101 — 8 more monitors that were + // never decoded at all (the old decoder only looked at bytes A/B). Field names use + // spark-ignition (gasoline) semantics per SAE J1979 since that covers the vast + // majority of consumer vehicles; on a compression-ignition (diesel) vehicle the same + // 8 bit slots carry different real-world meaning (the consuming app relabels using + // `ignitionType` — see `OBDReadinessStatus` in HiAuto). + public var catalystMonitoring = StatusTest() + public var heatedCatalystMonitoring = StatusTest() + public var evapSystemMonitoring = StatusTest() + public var secondaryAirSystemMonitoring = StatusTest() + public var auxInputMonitoring = StatusTest() // gasoline particulate filter, on GPF-equipped vehicles + public var oxygenSensorMonitoring = StatusTest() + public var oxygenSensorHeaterMonitoring = StatusTest() + public var egrOrVvtMonitoring = StatusTest() } public struct StatusTest: Codable, Hashable { @@ -783,6 +798,14 @@ struct StatusDecoder: Decoder { for (index, name) in baseTests.reversed().enumerated() { processBaseTest(name, index, bits, &output) } + + // Bytes C/D — only when the response actually carries all 4 bytes (it always + // should per spec, but a non-compliant adapter/ECU truncating the reply must not + // crash on an out-of-bounds bit index). + if bits.binaryArray.count >= 32 { + decodeNonContinuousTests(bits, &output) + } + return .success(.statusResult(output)) } @@ -799,6 +822,23 @@ struct StatusDecoder: Decoder { break } } + + /// Byte C (bits 16...23, C7 first) = availability, 1 = available. Byte D (bits + /// 24...31, D7 first) = completion, 0 = complete — same polarity as the 3 base tests + /// above, just at a different bit offset. + private func decodeNonContinuousTests(_ bits: BitArray, _ output: inout Status) { + func test(availBit: Int, completeBit: Int) -> StatusTest { + StatusTest("", bits.binaryArray[availBit] != 0, bits.binaryArray[completeBit] == 0) + } + output.catalystMonitoring = test(availBit: 23, completeBit: 31) // C0 / D0 + output.heatedCatalystMonitoring = test(availBit: 22, completeBit: 30) // C1 / D1 + output.evapSystemMonitoring = test(availBit: 21, completeBit: 29) // C2 / D2 + output.secondaryAirSystemMonitoring = test(availBit: 20, completeBit: 28) // C3 / D3 + output.auxInputMonitoring = test(availBit: 19, completeBit: 27) // C4 / D4 + output.oxygenSensorMonitoring = test(availBit: 18, completeBit: 26) // C5 / D5 + output.oxygenSensorHeaterMonitoring = test(availBit: 17, completeBit: 25) // C6 / D6 + output.egrOrVvtMonitoring = test(availBit: 16, completeBit: 24) // C7 / D7 + } } func parseDTC(_ data: Data) -> TroubleCode? { diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index 3ff288b..c3de0be 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -204,12 +204,26 @@ class ELM327 { return obdProtocol } + /// CAN first: the overwhelming majority of vehicles on the road (MY2008+ in the US, + /// mid-2000s+ in the EU) use one of the four ISO 15765-4 variants, so probing legacy + /// protocols ahead of them — the previous order, `PROTOCOL.allCases` in declaration + /// order — spent up to 5 full round-trips (ATSPn + 0100 + timeout each) on protocols + /// that were never going to answer before ever reaching the one that would. Legacy + /// (pre-CAN) protocols come next, then J1939/user-defined CAN last since they're both + /// rare for a consumer passenger vehicle. Only reached at all when the ELM327's own + /// ATSP0 auto-search (`detectProtocolAutomatically`) already failed. + private static let manualSweepOrder: [PROTOCOL] = [ + .protocol6, .protocol7, .protocol8, .protocol9, + .protocol1, .protocol2, .protocol3, .protocol4, .protocol5, + .protocolA, .protocolB, .protocolC, + ] + /// Attempts to detect the OBD protocol manually. /// - Parameter desiredProtocol: An optional preferred protocol to attempt first. /// - Returns: The detected protocol, or nil if none could be found. /// - Throws: Various setup-related errors. private func detectProtocolManually() async throws -> PROTOCOL { - for protocolOption in PROTOCOL.allCases where protocolOption != .NONE { + for protocolOption in Self.manualSweepOrder { self.logger.info("Testing protocol: \(protocolOption.description)") _ = try await okResponse(protocolOption.cmd) if await testProtocol(protocolOption) { From f9d8171e3da163fc77986f1040e2185f9ea4b8ce Mon Sep 17 00:00:00 2001 From: Alexander Shekhovtsov Date: Tue, 7 Jul 2026 17:27:03 +0200 Subject: [PATCH 48/68] Fix supported-PID block offset, twosComp no-op, and 4 more from audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A background audit of the files this session hadn't yet reviewed found the real dominant cause of "sensors missing again" — bigger than the multi-ECU union fixed earlier today: extractSupportedPIDs always labeled bits as PID 01-20 (`index + 1`), regardless of which of the 6 supported-PID getters (0100/0120/0140/ 0160/0180/01A0) produced the bitmap. Every getter after the first reported its bits under the wrong (already-covered) PID numbers, so anything from PID 0x21 up — fuel level, ambient air temp, control module voltage, fuel type, fuel rate, throttle position B-F, and most of what makes a live-sensor dashboard interesting — could never be recognized as supported, on ANY vehicle, single- or multi-ECU. configureSensors' fallback probe never caught this either, since discovery wasn't empty (PIDs 01-20 alone are enough to populate several groups) — it just silently capped there. Fixed by deriving each getter's block offset from its own command string and adding it before formatting the hex PID label. Also fixed, in priority order: - twosComp(_:length:) masked to `length` bits but never subtracted 2^length for the top half of the range — structurally could never return a negative number. Affects EvapPressureDecoder (PID 0132) and every `signed: true` UAS entry (Mode 6 monitor test values). - getStatus()/requestVin() had the same non-deterministic "only .first ECU" bug the multi-ECU PID fix addressed elsewhere — now prefer the engine ECU like scanDTCs already does. - WiFi sendAndReceiveData treated a TCP EOF mid-response the same as a clean, prompt-terminated one — a dropped connection returned whatever partial bytes had arrived as if they were a complete response. New CommunicationError.connectionClosed distinguishes it. - OBDLogger's minimumLogLevel comparison used OSLogType's raw values directly, which aren't ordered by severity (debug=2 sorts above info=1) — inverted the filter so info/warning were dropped by default while debug passed. Added an explicit severity-rank map. - mockManager's (Simulator-only) multi-frame length calculation added raw hex-character count instead of byte count for the consecutive- frame portion, inflating the declared length ~2x — harmless until this session's stricter parser.swift bounds check started throwing on the now-detectably-wrong length for any multi-PID mock response. - protocol_legacy.swift's order-byte path could index frame.data[2] out of bounds and crash on a truncated frame; now throws instead. All 35 existing package tests still pass. Co-Authored-By: Claude Fable 5 --- .../Communication/CommProtocol.swift | 5 +++ .../SwiftOBD2/Communication/mockManager.swift | 9 ++++- .../SwiftOBD2/Communication/wifiManager.swift | 9 ++++- Sources/SwiftOBD2/Logging/OBDLogger.swift | 18 ++++++++- Sources/SwiftOBD2/decoders.swift | 8 +++- Sources/SwiftOBD2/elm327.swift | 37 +++++++++++++++---- .../SwiftOBD2/protocols/protocol_legacy.swift | 8 ++++ 7 files changed, 83 insertions(+), 11 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/CommProtocol.swift b/Sources/SwiftOBD2/Communication/CommProtocol.swift index 068d24c..3145985 100644 --- a/Sources/SwiftOBD2/Communication/CommProtocol.swift +++ b/Sources/SwiftOBD2/Communication/CommProtocol.swift @@ -32,6 +32,10 @@ enum CommunicationError: Error, LocalizedError { case timeout /// The connection was cancelled (e.g. user disconnect or app-side timeout) before it was established. case cancelled + /// The TCP connection reached EOF mid-response — the peer closed the socket before + /// sending the ELM327 '>' prompt, so whatever bytes arrived are a truncated fragment, + /// not a complete reply. + case connectionClosed var errorDescription: String? { switch self { @@ -39,6 +43,7 @@ enum CommunicationError: Error, LocalizedError { case .errorOccurred(let underlying): return underlying.localizedDescription case .timeout: return "The connection attempt timed out." case .cancelled: return "The connection attempt was cancelled." + case .connectionClosed: return "The connection closed before the adapter finished responding." } } } diff --git a/Sources/SwiftOBD2/Communication/mockManager.swift b/Sources/SwiftOBD2/Communication/mockManager.swift index 4a2f37e..8731e26 100644 --- a/Sources/SwiftOBD2/Communication/mockManager.swift +++ b/Sources/SwiftOBD2/Communication/mockManager.swift @@ -68,7 +68,14 @@ class MOCKComm: CommProtocol { Totallength += ffLength var cf = Array(chunks.dropFirst()) - Totallength += cf.joined().replacingOccurrences(of: " ", with: "").count + // Same hex-chars → bytes conversion as `ffLength` above (÷2) — this was + // adding raw hex-character count instead of byte count, roughly doubling + // the declared ISO-TP length. Harmless while `parser.swift`'s multi-frame + // assembly silently accepted a short/mismatched length, but this session's + // stricter bounds check there (`extractDataFromFrame` now throws instead of + // truncating) turned that inflated length into every multi-PID mock + // response failing to decode in the Simulator. + Totallength += cf.joined().replacingOccurrences(of: " ", with: "").count / 2 var lengthHex = String(format: "%02X", Totallength - 1) diff --git a/Sources/SwiftOBD2/Communication/wifiManager.swift b/Sources/SwiftOBD2/Communication/wifiManager.swift index d76c237..77e9368 100644 --- a/Sources/SwiftOBD2/Communication/wifiManager.swift +++ b/Sources/SwiftOBD2/Communication/wifiManager.swift @@ -286,8 +286,15 @@ class WifiManager: CommProtocol { gate.append(str) } - if gate.accumulated.contains(">") || isComplete { + if gate.accumulated.contains(">") { gate.finishWithAccumulated() + } else if isComplete { + // `isComplete` here means the TCP stream reached EOF — the + // adapter (or the WiFi link) closed the connection before ever + // sending the closing prompt. Treating this as success used to + // hand whatever partial bytes arrived to the parser as if they + // were a complete, well-formed response. + gate.finish(throwing: CommunicationError.connectionClosed) } else { readNext() } diff --git a/Sources/SwiftOBD2/Logging/OBDLogger.swift b/Sources/SwiftOBD2/Logging/OBDLogger.swift index 1fcbf3d..21402a9 100644 --- a/Sources/SwiftOBD2/Logging/OBDLogger.swift +++ b/Sources/SwiftOBD2/Logging/OBDLogger.swift @@ -69,8 +69,24 @@ public class OBDLogger { log(message, level: .fault, category: category, file: file, function: function, line: line) } + /// `OSLogType`'s raw values aren't ordered by severity (debug=2, info=1, default=0, + /// error=16, fault=17) — comparing `.rawValue` directly against the default + /// `minimumLogLevel = .debug` inverted the filter: `.info`/`.default` (warning) were + /// silently dropped while `.debug` passed, the opposite of "show this level and + /// everything more severe." + private func severityRank(_ level: OSLogType) -> Int { + switch level { + case .debug: return 0 + case .info: return 1 + case .default: return 2 + case .error: return 3 + case .fault: return 4 + default: return 2 + } + } + private func log(_ message: String, level: OSLogType, category: Category, file: String, function: String, line: Int) { - guard isLoggingEnabled && level.rawValue >= minimumLogLevel.rawValue else { return } + guard isLoggingEnabled && severityRank(level) >= severityRank(minimumLogLevel) else { return } guard let logger = loggers[category] else { return } let fileName = URL(fileURLWithPath: file).lastPathComponent diff --git a/Sources/SwiftOBD2/decoders.swift b/Sources/SwiftOBD2/decoders.swift index 958c9fe..8d66a99 100644 --- a/Sources/SwiftOBD2/decoders.swift +++ b/Sources/SwiftOBD2/decoders.swift @@ -174,8 +174,14 @@ class UAS { } func twosComp(_ value: Int, length: Int) -> Int { + // `value` always arrives already masked to `length` bits (from `bytesToInt`, which + // only ever returns 0...2^length-1), so `value & mask` was a pure no-op — this could + // never actually produce a negative number. The top half of the range must fold back + // negative: e.g. for an 8-bit value, 0x80...0xFF (128...255) means -128...-1. let mask = (1 << length) - 1 - return value & mask + let masked = value & mask + let signBit = 1 << (length - 1) + return masked >= signBit ? masked - (1 << length) : masked } private var uasIDS: [UInt8: UAS] = { diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index c3de0be..5b8bee9 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -367,7 +367,15 @@ class ELM327 { let statusCommand = OBDCommand.Mode1.status let statusResponse = try await sendCommand(statusCommand.properties.command) logger.debug("Status response: \(statusResponse)") - guard let statusData = try canProtocol?.parse(statusResponse).first?.data else { + guard let messages = try canProtocol?.parse(statusResponse), !messages.isEmpty else { + return .failure(.noData) + } + // MIL / DTC count / monitor readiness are a real per-ECU reading, not a bitmap to + // union — but `.first` (a Dictionary's iteration order) was non-deterministic + // across connects on this vehicle's two-ECU bus. Prefer the engine ECU, which is + // authoritative for powertrain MIL status; fall back to whichever answered if this + // vehicle's engine controller doesn't tag itself that way. + guard let statusData = (messages.first { $0.ecu == .engine } ?? messages.first)?.data else { return .failure(.noData) } return statusCommand.properties.decode(data: statusData) @@ -476,7 +484,10 @@ class ELM327 { return nil } - guard let data = try? canProtocol?.parse(vinResponse).first?.data, + // Same non-deterministic `.first` issue as `getStatus()` — prefer the engine ECU + // (the one that actually owns Mode 09 on most vehicles) over Dictionary order. + guard let messages = try? canProtocol?.parse(vinResponse), !messages.isEmpty, + let data = (messages.first { $0.ecu == .engine } ?? messages.first)?.data, var vinString = String(bytes: data, encoding: .utf8) else { return nil @@ -567,7 +578,16 @@ extension ELM327 { // Ex. // || || // 7E8 06 41 00 BE 7F B8 13 - guard let supportedPidsByECU = parseResponse(response) else { + // + // Each getter's bitmap only covers ITS OWN 32-PID block (0100→01-20, + // 0120→21-40, 0140→41-60, ...) — the block base must be added to the bit + // index, or every getter after the first reports its bits as PIDs 01-20 + // again. That silently capped every vehicle's live-sensor list at the + // first 32 standard PIDs regardless of what the ECU actually supports — + // anything from 0x21 up (fuel level, ambient temp, control module voltage, + // fuel type, fuel rate, ...) could never be recognized as supported. + let baseOffset = UInt8(pidGetter.properties.command.dropFirst(2), radix: 16) ?? 0 + guard let supportedPidsByECU = parseResponse(response, baseOffset: baseOffset) else { continue } @@ -594,24 +614,27 @@ extension ELM327 { /// iteration order, which ECU "won" wasn't even guaranteed to be the same one from one /// connection to the next, so the set of sensors that showed up could vary connect to /// connect on the exact same vehicle. - private func parseResponse(_ response: [String]) -> Set? { + private func parseResponse(_ response: [String], baseOffset: UInt8 = 0) -> Set? { guard let messages = try? canProtocol?.parse(response), !messages.isEmpty else { return nil } var combined = Set() for message in messages { guard let data = message.data else { continue } - combined.formUnion(extractSupportedPIDs(BitArray(data: data.dropFirst()).binaryArray)) + combined.formUnion(extractSupportedPIDs(BitArray(data: data.dropFirst()).binaryArray, baseOffset: baseOffset)) } return combined.isEmpty ? nil : combined } - func extractSupportedPIDs(_ binaryData: [Int]) -> Set { + /// `baseOffset` is the PID number the response's bit 0 represents (0 for 0100's + /// PIDs 01-20, 0x20 for 0120's PIDs 21-40, etc.) — defaults to 0 so existing callers + /// (and the `0100`-only unit test) are unaffected. + func extractSupportedPIDs(_ binaryData: [Int], baseOffset: UInt8 = 0) -> Set { var supportedPIDs: Set = [] for (index, value) in binaryData.enumerated() { if value == 1 { - let pid = String(format: "%02X", index + 1) + let pid = String(format: "%02X", Int(baseOffset) + index + 1) supportedPIDs.insert(pid) } } diff --git a/Sources/SwiftOBD2/protocols/protocol_legacy.swift b/Sources/SwiftOBD2/protocols/protocol_legacy.swift index 4947776..b95a09d 100644 --- a/Sources/SwiftOBD2/protocols/protocol_legacy.swift +++ b/Sources/SwiftOBD2/protocols/protocol_legacy.swift @@ -102,6 +102,14 @@ struct LegacyMessage: MessageProtocol { /// | [ ] [ ] [ ] /// order byte is removed + // `LegacyFrame.init` only requires 2 bytes of payload after stripping the + // header/checksum, but every access below assumes at least 3 (the order byte + // at index 2) — a short/truncated frame (plausible on a noisy K-line) must + // throw here, not crash on an out-of-bounds subscript. + guard frames.allSatisfy({ $0.data.count >= 3 }) else { + throw ParserError.error("Frame too short to carry an order byte") + } + // sort the frames by the order byte let sortedFrames = frames.sorted { $0.data[2] < $1.data[2] } From ecf6e46c685dce83d298aab24884631cad37ff97 Mon Sep 17 00:00:00 2001 From: Alexander Shekhovtsov Date: Tue, 7 Jul 2026 17:52:29 +0200 Subject: [PATCH 49/68] Fix ECU grouping collision on 29-bit CAN (protocol 7/9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-hardware test against a 2016 Jeep Cherokee (ISO 15765-4, 29-bit ID, protocol 7) came back with zero sensors, no VIN, and every supported-PID query empty — a total regression from the Mustang (11-bit, protocol 6), which works fine. CANParser grouped frames by `Frame.txID`, which masks the address byte with `& 0x07`. That's only meaningful for the 11-bit SAE J1979 functional range (responses 0x7E8-0x7EF, where the low nibble IS the 0-7 ECU index by construction) — it has nothing to do with 29-bit extended addressing, where this vehicle's two ECUs answer from 0x10 and 0x18. Both mask to 0 and collapsed onto the same ECUID bucket, so two independent single-frame replies to one request got merged into a 2-frame group; `Message.init` then tried to decode that as an ISO-TP multi-frame sequence (no `.firstFrame` to anchor on), threw, and silently dropped both ECUs' data — for every request, not just one. Added `Frame.rawAddress` (the untouched address byte) and group by that instead. `txID`/`ECUID` is unchanged and still used for display labels — it just doesn't have to be correct to keep frames from different ECUs apart anymore. All 35 existing tests still pass. Co-Authored-By: Claude Fable 5 --- Sources/SwiftOBD2/parser.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftOBD2/parser.swift b/Sources/SwiftOBD2/parser.swift index 6d43258..6ded516 100644 --- a/Sources/SwiftOBD2/parser.swift +++ b/Sources/SwiftOBD2/parser.swift @@ -54,7 +54,18 @@ public struct CANParser { // "no trouble codes" result). Frame.init still logs each rejection. frames = obdLines.compactMap { try? Frame(raw: $0, idBits: idBits) } - let framesByECU = Dictionary(grouping: frames) { $0.txID } + // Group by the raw address byte, not `txID` — `txID`'s `& 0x07` mask only means + // anything for the 11-bit SAE J1979 functional range (0x7E8-0x7EF, where the low + // nibble directly IS the 0-7 ECU index). On a 29-bit bus (ISO 15765-4 29-bit, + // protocol 7/9 — common on Chrysler/Jeep/FCA and others), source addresses like + // 0x10 and 0x18 both mask to 0 and collapse onto the same `ECUID.engine` bucket: + // two physically distinct ECUs' single-frame replies to the same request got + // merged into one 2-frame group, which `Message.init` then tried to decode as a + // multi-frame ISO-TP sequence instead of two separate single-frame messages — + // failing outright (no `.firstFrame` to anchor on) and silently discarding both + // ECUs' data. Grouping by the untouched byte keeps distinct addresses distinct + // regardless of ID width; `txID` is still computed below for display purposes. + let framesByECU = Dictionary(grouping: frames) { $0.rawAddress } // Likewise tolerate one ECU's frames failing to assemble without losing // the others. @@ -149,6 +160,10 @@ struct Frame { var priority: UInt8 var addrMode: UInt8 var rxID: UInt8 + /// The untouched source-address byte (`dataBytes[3]`) — used to group frames by ECU. + /// Unlike `txID`, this stays distinct across every possible address regardless of + /// ID width, which is what frame reassembly actually depends on being correct. + var rawAddress: UInt8 var txID: ECUID var type: FrameType var seqIndex: UInt8 = 0 // Only used when type = CF @@ -180,6 +195,7 @@ struct Frame { priority = dataBytes[2] & 0x0F addrMode = dataBytes[3] & 0xF0 rxID = dataBytes[2] + rawAddress = dataBytes[3] txID = ECUID(rawValue: dataBytes[3] & 0x07) ?? .unknown self.type = type From abaa0c211d67c8b52b5310eb6279ad55a53bbc46 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Tue, 7 Jul 2026 16:55:27 +0100 Subject: [PATCH 50/68] Routed through OBDLogger so the consuming app's log-level preference can silence this per-command line (a raw os.Logger call can't be gated). --- .../Communication/BLE/BLECharacteristicHandler.swift | 4 +++- Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift b/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift index 4959f3e..9f10fdd 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift @@ -115,7 +115,9 @@ class BLECharacteristicHandler { throw BLEManagerError.missingPeripheralOrCharacteristic } peripheral.writeValue(data, for: characteristic, type: .withResponse) - logger.info("Sent command: \(command)") + // Routed through OBDLogger so the consuming app's log-level preference + // can silence this per-command line (a raw os.Logger call can't be gated). + obdDebug("Sent command: \(command)", category: .communication) } func handleUpdatedValue(_ data: Data, from characteristic: CBCharacteristic) { diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift index ffd1ad7..1da679d 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift @@ -72,7 +72,9 @@ class BLEMessageProcessor { .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } - logger.debug("Parsed response: \(lines)") + // Routed through OBDLogger so the consuming app's log-level preference + // can silence this per-command line (a raw os.Logger call can't be gated). + obdDebug("Parsed response: \(lines)", category: .parsing) return lines } From 2fd2cbbad4d23056bc6aeb40251ae5bfd3b80084 Mon Sep 17 00:00:00 2001 From: Alexander Shekhovtsov Date: Tue, 7 Jul 2026 17:57:52 +0200 Subject: [PATCH 51/68] Apply the same ECU-grouping fix + frame resilience to legacy protocols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Researched the real addressing conventions rather than assuming the 11-bit fix generalized on its own: - 11-bit (SAE J1979): responses are always 0x7E8-0x7EF — the low nibble directly IS a 0-7 ECU index by construction (ECU assigned 0x7E0-0x7E7 responds at assigned-ID+8). `& 0x07` happens to be exactly right here, and only here. - 29-bit (ISO 15765-4 extended): responses are 0x18DAF1XX where XX is a full, OEM-assigned byte with no fixed range — confirmed this is what broke the Jeep Cherokee (0x10 and 0x18 both mask to 0). - Legacy (ISO 9141-2 / ISO 14230 KWP): per SAE J2178, source address bytes are likewise OEM/tester-assigned, not a small fixed range — same collision risk as 29-bit CAN, just never hit yet on real hardware. `LegacyFrame`/`LegacyParcer` had the identical `txID`-based (`& 0x07`-masked) grouping as the CAN parser did before this session's fix, so applied the same one: added `LegacyFrame.rawAddress` (the untouched source byte) and group by that instead. Also brought the legacy parser's fault-tolerance up to parity with the CAN parser's (which already does this, per its own comment): a single malformed frame or one ECU's frames failing to assemble now drops just that piece instead of throwing and discarding the entire response — `try?` instead of `try` in both `compactMap`s. ECUID's small 4-case enum (engine/transmission/unknown/becm) is intentionally left as a best-effort *label* only, not touched here — OEMs choose their own 29-bit/legacy addresses freely (confirmed via research, not assumed), so there's no universal byte-to-name mapping to encode. Grouping no longer depends on the label being correct; only display does. All 35 tests still pass. Co-Authored-By: Claude Fable 5 --- .../SwiftOBD2/protocols/protocol_legacy.swift | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/Sources/SwiftOBD2/protocols/protocol_legacy.swift b/Sources/SwiftOBD2/protocols/protocol_legacy.swift index b95a09d..38d41c8 100644 --- a/Sources/SwiftOBD2/protocols/protocol_legacy.swift +++ b/Sources/SwiftOBD2/protocols/protocol_legacy.swift @@ -22,13 +22,21 @@ public struct LegacyParcer { .compactMap { $0.replacingOccurrences(of: " ", with: "") } .filter(\.isHex) - frames = try obdLines.compactMap { - try LegacyFrame(raw: $0) + // `try?`, not `try` — matches the CAN parser's resilience (see its own comment): + // one malformed frame from a noisy K-line must not discard every other frame in + // the response. This previously aborted the whole parse on a single bad frame. + frames = obdLines.compactMap { + try? LegacyFrame(raw: $0) } - let framesByECU = Dictionary(grouping: frames) { $0.txID } - messages = try framesByECU.values.compactMap { - try LegacyMessage(frames: $0) + // Group by the raw source-address byte, not `txID` — same reasoning as the CAN + // parser (see `CANParser.init`): legacy (ISO 9141-2 / ISO 14230 KWP) source + // addresses are manufacturer-assigned per SAE J2178, not constrained to a small + // fixed range, so `txID`'s `& 0x07` mask can (in principle, same as the 29-bit CAN + // case this session hit on real hardware) collide two distinct ECUs together. + let framesByECU = Dictionary(grouping: frames) { $0.rawAddress } + messages = framesByECU.values.compactMap { + try? LegacyMessage(frames: $0) } } } @@ -153,6 +161,9 @@ struct LegacyFrame { var data = Data() var priority: UInt8 var rxID: UInt8 + /// The untouched source-address byte — see `Frame.rawAddress` (parser.swift) for why + /// this, not `txID`, is what frame grouping actually uses. + var rawAddress: UInt8 var txID: ECUID init(raw: String) throws { @@ -161,13 +172,14 @@ struct LegacyFrame { let dataBytes = rawData.hexBytes - data = Data(dataBytes.dropFirst(3).dropLast()) guard dataBytes.count >= 6, dataBytes.count <= 12 else { throw ParserError.error("Invalid frame size") } + data = Data(dataBytes.dropFirst(3).dropLast()) priority = dataBytes[0] rxID = dataBytes[1] + rawAddress = dataBytes[2] txID = ECUID(rawValue: dataBytes[2] & 0x07) ?? .unknown } } From b443e160f74914bec0b99258a08b84758cc6a443 Mon Sep 17 00:00:00 2001 From: Alexander Shekhovtsov Date: Tue, 7 Jul 2026 18:06:14 +0200 Subject: [PATCH 52/68] Fix 29-bit CAN frames being parsed with 11-bit padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actual root cause of the 2016 Jeep Cherokee (protocol 7) reading nothing at all — deeper than the ECU-grouping collision fixed in ecf6e46, which was necessary but not sufficient. Every CAN protocol class passed idBits: 11 to the parser, including the 29-bit variants (7, 9) and J1939 (A). Frame.init prepends "00000" padding for 11-bit frames, whose printed header is only 3 hex chars — applying that to an already-full 29-bit line ("18DAF118...", 8 header chars, 24 chars total) produces a 29-char odd-length hex string. hexBytes walks it two chars at a time from index 0, so every byte boundary lands half a nibble off: 12 real bytes become 14 garbage bytes, the 6...12 size guard rejects the frame, and compactMap silently drops it. Result: EVERY frame from a 29-bit vehicle discarded — no VIN, no supported PIDs, no sensors, no DTCs — while protocol detection still "succeeded" because testProtocol greps the raw text for "41 00" without parsing. Protocols 7/9/A now pass idBits: 29. Added a regression test built from the real capture in the Jeep's connection log (two ECUs, 0x10 and 0x18, single-frame replies to 0100) — it locks in both this fix and the rawAddress grouping fix, and documents the single-frame payload convention (PCI + mode echo dropped, trailing pad kept). 36 tests pass. Co-Authored-By: Claude Fable 5 --- .../SwiftOBD2/protocols/protocol_can.swift | 17 ++++++++-- Tests/SwiftOBD2Tests/test_protocol_can.swift | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftOBD2/protocols/protocol_can.swift b/Sources/SwiftOBD2/protocols/protocol_can.swift index c108d5b..f1107ad 100644 --- a/Sources/SwiftOBD2/protocols/protocol_can.swift +++ b/Sources/SwiftOBD2/protocols/protocol_can.swift @@ -33,11 +33,19 @@ class ISO_15765_4_11bit_500k: CANProtocol { } } +// The 29-bit variants MUST pass idBits: 29. `Frame.init` prepends "00000" padding for +// 11-bit frames (whose printed header is only 3 hex chars); applying that to an +// already-full 29-bit line ("18DAF118...", 8 header chars) makes the hex string an odd +// length, shifts every byte boundary by half a nibble, and inflates a 12-byte frame to +// 14 garbage bytes — which the 6...12 size guard then rejects. Net effect before this +// fix: EVERY frame from a 29-bit vehicle (e.g. FCA/Jeep) was silently discarded — no +// VIN, no supported PIDs, no sensors — while protocol detection still "succeeded" +// because it greps the raw text for "41 00" without parsing. class ISO_15765_4_29bit_500k: CANProtocol { let elmID = "7" let name = "ISO 15765-4 (CAN 29/500)" func parse(_ lines: [String]) throws -> [MessageProtocol] { - try parseDefault(lines, idBits: 11) + try parseDefault(lines, idBits: 29) } } @@ -53,7 +61,7 @@ class ISO_15765_4_29bit_250k: CANProtocol { let elmID = "9" let name = "ISO 15765-4 (CAN 29/250)" func parse(_ lines: [String]) throws -> [MessageProtocol] { - try parseDefault(lines, idBits: 11) + try parseDefault(lines, idBits: 29) } } @@ -61,6 +69,9 @@ class SAE_J1939: CANProtocol { let elmID = "A" let name = "SAE J1939 (CAN 29/250)" func parse(_ lines: [String]) throws -> [MessageProtocol] { - try parseDefault(lines, idBits: 11) + // J1939 IDs are 29-bit too. Note frame slicing is the only thing this fixes — + // J1939's application layer (PGN/SPN) is a different world from J1979 PIDs and + // is not otherwise supported by this package. + try parseDefault(lines, idBits: 29) } } diff --git a/Tests/SwiftOBD2Tests/test_protocol_can.swift b/Tests/SwiftOBD2Tests/test_protocol_can.swift index ffcba4f..c4617e7 100644 --- a/Tests/SwiftOBD2Tests/test_protocol_can.swift +++ b/Tests/SwiftOBD2Tests/test_protocol_can.swift @@ -39,4 +39,35 @@ final class test_protocol_can: XCTestCase { // to long } } + + /// Real capture from a 2016 Jeep Cherokee KL (protocol 7, ISO 15765-4 29-bit): + /// two ECUs (source addresses 0x10 and 0x18) each answering 0100 with a single + /// frame. Regression-locks two bugs at once: 29-bit frames being fed through the + /// 11-bit "00000" padding path (odd-length hex → every byte boundary shifted → + /// every frame rejected by the size guard → zero data from the whole vehicle), + /// and distinct ECUs collapsing into one group via the `& 0x07` txID mask + /// (0x10 and 0x18 both mask to 0), which merged their single-frame replies into + /// a bogus multi-frame group that failed to assemble. + func test_29bit_two_ecus() { + for canprotocol in CAN_29_PROTOCOLS { + let messages = (try? canprotocol.parse([ + "18DAF11806410098180001AA", + "18DAF110064100983B201300", + ])) ?? [] + XCTAssertEqual(messages.count, 2, "each 29-bit ECU must produce its own message") + + // Single-frame extraction drops the PCI byte and the mode echo (0x41) but + // keeps trailing CAN padding — same convention the 11-bit test above locks in. + let payloads = Set(messages.compactMap { $0.data.map { Data($0) } }) + XCTAssertEqual(payloads, [ + Data([0x00, 0x98, 0x18, 0x00, 0x01, 0xAA]), + Data([0x00, 0x98, 0x3B, 0x20, 0x13, 0x00]), + ]) + } + } } + +let CAN_29_PROTOCOLS: [CANProtocol] = [ + ISO_15765_4_29bit_500k(), + ISO_15765_4_29bit_250k(), +] From e469363ebc7b0f669af04093240fce82c14f17f0 Mon Sep 17 00:00:00 2001 From: Alexander Shekhovtsov Date: Tue, 7 Jul 2026 18:10:59 +0200 Subject: [PATCH 53/68] BLE: actually honor the retries parameter in sendCommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parameter was declared and silently discarded (`retries _:`), making every BLE command exactly one 3-second attempt. Two real consequences, both protocol-wide: - One dropped BLE notification failed the whole read instead of re-asking — a per-read coin flip on a noisy in-car link, and a plausible contributor to the sensor flakiness chased earlier this session. - K-line protocol detection (ISO 9141 / KWP 5-baud init runs 5-10 s inside the ELM327 while it prints "SEARCHING...") could never fit a single 3 s window over BLE, while the WiFi transport honors its retries — the same vehicle would connect over WiFi and fail over BLE for no visible reason. Re-sending after a timeout is safe with the exactly-once completion gate: the timed-out attempt's completion was already consumed, and a late reply to attempt N carries the same payload attempt N+1 awaits. "NO DATA" is deliberately NOT retried — it's the adapter's well-formed "vehicle didn't answer", not a comm failure, and re-asking an unsupported PID three times would burn the live-polling cycle's budget for nothing. 36 tests pass. Co-Authored-By: Claude Fable 5 --- .../Communication/BLE/bleManager.swift | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index bab144a..32e3188 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -360,7 +360,7 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { /// `BLEManagerError.peripheralNotConnected` if the peripheral is not connected. /// `BLEManagerError.timeout` if the operation times out. /// `BLEManagerError.unknownError` if an unknown error occurs. - func sendCommand(_ command: String, retries _: Int = 3) async throws -> [String] { + func sendCommand(_ command: String, retries: Int = 3) async throws -> [String] { guard let peripheral = peripheralManager.connectedPeripheral else { obdError("Missing peripheral or ECU characteristic", category: .bluetooth) throw BLEManagerError.missingPeripheralOrCharacteristic @@ -368,15 +368,38 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { obdDebug("Sending command: \(command)", category: .communication) - do { - try characteristicHandler.writeCommand(command, to: peripheral) - let response = try await messageProcessor.waitForResponse(timeout: BLEConstants.defaultTimeout) - obdDebug("Command response: \(response.joined(separator: " | "))", category: .communication) - return response - } catch { - obdError("Command failed: \(command) - \(error.localizedDescription)", category: .communication) - throw error + // The `retries` parameter used to be declared and silently ignored (`retries _:`), + // making every BLE command a single 3-second attempt. Two real consequences: + // one dropped BLE notification failed the whole read instead of re-asking, and + // K-line protocol detection (ISO 9141 / KWP 5-baud init takes 5-10 s inside the + // ELM327 while it prints "SEARCHING...") could never fit one 3 s window — the + // WiFi transport honors retries, so the same vehicle behaved differently per + // transport. Re-sending the same command after a timeout is safe: the pending + // completion was already taken by the timeout path, and a late reply to attempt + // N carries the same payload attempt N+1 is waiting for. + let attempts = max(1, retries) + var lastError: Error? + for attempt in 1 ... attempts { + do { + try characteristicHandler.writeCommand(command, to: peripheral) + let response = try await messageProcessor.waitForResponse(timeout: BLEConstants.defaultTimeout) + obdDebug("Command response: \(response.joined(separator: " | "))", category: .communication) + return response + } catch BLEManagerError.noData { + // "NO DATA" is the adapter's well-formed answer ("the vehicle didn't + // respond to this request"), not a comm failure — re-asking an + // unsupported PID 3 times would just burn the polling cycle's budget. + throw BLEManagerError.noData + } catch { + lastError = error + obdWarning("Command \(command) attempt \(attempt)/\(attempts) failed: \(error.localizedDescription)", category: .communication) + if attempt < attempts { + try? await Task.sleep(nanoseconds: 150_000_000) // let the adapter settle before re-sending + } + } } + obdError("Command failed after \(attempts) attempts: \(command)", category: .communication) + throw lastError ?? BLEManagerError.timeout } func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] { From f14a2f3deaf6bfd27c8d072b6d995446213bb756 Mon Sep 17 00:00:00 2001 From: Alexander Shekhovtsov Date: Tue, 7 Jul 2026 18:24:51 +0200 Subject: [PATCH 54/68] Add Mode 02 freeze-frame API (requestFreezeFrame) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mode 02 returns the snapshot of live values the ECU stored at the moment an emissions DTC set — it stays in memory until codes are cleared, so it applies to codes already stored, not just ones that appear while connected. Which DTC owns the stored frame is already readable via Mode 01 PID 02 (Mode1.freezeDTC, present since forever). Request format is 02 ; the response payload matches the Mode 01 layout with one extra frame-number byte after the PID echo, so each PID reuses its own Mode 01 decoder on payload.dropFirst(2). Unsupported/uncaptured PIDs answer NO DATA (single attempt, no retries — that's a well-formed "not stored") and are omitted from the result rather than failing the whole snapshot. Co-Authored-By: Claude Fable 5 --- Sources/SwiftOBD2/obd2service.swift | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index 3b9680b..51258ef 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -339,6 +339,37 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab await elm327.getSupportedPIDs() } + /// Mode 02 — the freeze frame: the snapshot of live values the ECU stored at the + /// moment an emissions DTC set. It stays stored (frame 00) until codes are cleared, + /// so this works for codes already in memory, not just ones that appear while + /// connected. Which DTC owns the stored frame is a separate read: Mode 01 PID 02 + /// (`OBDCommand.Mode1.freezeDTC`). + /// + /// Request format is `02 `; the response payload is laid out like the + /// Mode 01 equivalent with one extra frame-number byte after the PID echo, so each + /// PID's own Mode 01 decoder applies to the payload after dropping [PID][frame#]. + /// PIDs the vehicle didn't capture answer NO DATA and are simply omitted. + public func requestFreezeFrame(_ pids: [OBDCommand.Mode1], frame: UInt8 = 0) async -> [OBDCommand.Mode1: MeasurementResult] { + var snapshot: [OBDCommand.Mode1: MeasurementResult] = [:] + for pid in pids { + let mode1Command = OBDCommand.mode1(pid) + let pidHex = String(mode1Command.properties.command.dropFirst(2)) + let command = String(format: "02%@%02X", pidHex, frame) + guard let response = try? await elm327.sendCommand(command, retries: 1), + let messages = try? elm327.canProtocol?.parse(response), + let data = (messages.first { $0.ecu == .engine } ?? messages.first)?.data, + data.count > 2 + else { continue } + // message.data has already dropped the mode echo (0x42); what remains is + // [PID echo][frame #][payload...] — the Mode 01 decoder wants just payload. + if case let .success(decoded) = mode1Command.properties.decode(data: data.dropFirst(2)), + let measurement = decoded.measurementResult { + snapshot[pid] = measurement + } + } + return snapshot + } + /// Scans for trouble codes and returns the result. /// - Returns: The trouble codes found on the vehicle. /// - Throws: Errors that might occur during the request process. From 31a00d8a2ca05680f270baf38b98075a02de9833 Mon Sep 17 00:00:00 2001 From: Alexander Shekhovtsov Date: Tue, 7 Jul 2026 18:36:53 +0200 Subject: [PATCH 55/68] Truncate single-frame payload to its declared length (drop CAN padding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by reading through icanhack.nl's ISO-TP reference rather than re-deriving from scratch: "if the CAN frame is less than 8 bytes, it can be padded — the spec calls for 0xCC, but 0xAA or 0x55 are common in practice." parseSingleFrameMessage returned everything after the PCI+mode-echo bytes with no regard for the PCI's own declared length, so a padded frame's trailing filler bytes rode along into the decoded data. Harmless for the ordinary Mode 01 measurement decoders — they only ever read fixed byte offsets from the front, so trailing bytes are never touched. Not harmless for DTCDecoder (Mode 03/07/0A), which walks the ENTIRE data length two bytes at a time: a non-zero pad byte pairs up with whatever follows it (another pad byte, or nothing, zero-extended) and decodes as a plausible-looking trouble code that was never actually reported by the vehicle. This is a strong candidate for at least some of the intermittent phantom-DTC reports from earlier this session (P0D00 appearing/disappearing across re-reads) — a separate, independent cause from the frame-sequence-gap bug already fixed, since this one doesn't require a dropped BLE packet at all, just an adapter that pads with anything other than zeros. Regression test built directly from this session's own capture: the 29-bit two-ECU test data (real bytes off a 2016 Jeep Cherokee) turns out to end in exactly this kind of non-zero padding (0xAA, 0x00) — updated its expected payloads to the correctly-truncated 5 bytes (PID-echo + 4-byte supported-PID bitmap, matching the 0100 spec exactly) instead of the 6 bytes the old buggy behavior produced. Added a second, minimal test isolating just the padding-strip behavior against a synthetic Mode 03 single-frame response. 37 tests pass. Co-Authored-By: Claude Fable 5 --- Sources/SwiftOBD2/parser.swift | 10 ++++++++- Tests/SwiftOBD2Tests/test_protocol_can.swift | 23 ++++++++++++++++---- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/Sources/SwiftOBD2/parser.swift b/Sources/SwiftOBD2/parser.swift index 6ded516..896520b 100644 --- a/Sources/SwiftOBD2/parser.swift +++ b/Sources/SwiftOBD2/parser.swift @@ -100,7 +100,15 @@ public struct Message: MessageProtocol { else { // Pre-validate the length throw ParserError.error("Frame validation failed") } - return frame.data.dropFirst(2) + // The PCI length nibble counts [mode-echo byte + real payload] and says nothing + // about what follows — a CAN frame shorter than 8 bytes gets padded (ISO 15765-2 + // specifies 0xCC, though 0xAA/0x55 are common in practice), and this used to + // return everything after the mode echo, padding included. Harmless for + // fixed-offset PID decoders (they only ever read the bytes they need), but + // DTCDecoder walks the ENTIRE length in 2-byte strides — non-zero padding bytes + // there decode as a phantom trouble code that has nothing to do with the vehicle. + let payloadLength = Int(dataLen) - 1 + return frame.data.dropFirst(2).prefix(payloadLength) } private func parseMultiFrameMessage(_ frames: [Frame]) throws -> Data { diff --git a/Tests/SwiftOBD2Tests/test_protocol_can.swift b/Tests/SwiftOBD2Tests/test_protocol_can.swift index c4617e7..4686471 100644 --- a/Tests/SwiftOBD2Tests/test_protocol_can.swift +++ b/Tests/SwiftOBD2Tests/test_protocol_can.swift @@ -40,6 +40,19 @@ final class test_protocol_can: XCTestCase { } } + /// A single-frame response padded out to 8 bytes with a NON-zero byte (0xAA — "common + /// in practice" for ISO-TP even though the spec calls for 0xCC) must not leak that + /// byte into the decoded payload. DTCDecoder walks the full length two bytes at a + /// time, so an unstripped pad byte pairs with whatever follows and can decode as a + /// trouble code that has nothing to do with the vehicle. + func test_single_frame_padding_stripped() { + for canprotocol in CAN_11_PROTOCOLS { + // PCI 0x03 = mode byte + 2 real payload bytes; 3 bytes of 0xAA padding follow. + let data = try? canprotocol.parse(["7E8 03 43 01 23 AA AA AA"]).first?.data + XCTAssertEqual(data, Data([0x01, 0x23]), "padding must be truncated, not returned as payload") + } + } + /// Real capture from a 2016 Jeep Cherokee KL (protocol 7, ISO 15765-4 29-bit): /// two ECUs (source addresses 0x10 and 0x18) each answering 0100 with a single /// frame. Regression-locks two bugs at once: 29-bit frames being fed through the @@ -56,12 +69,14 @@ final class test_protocol_can: XCTestCase { ])) ?? [] XCTAssertEqual(messages.count, 2, "each 29-bit ECU must produce its own message") - // Single-frame extraction drops the PCI byte and the mode echo (0x41) but - // keeps trailing CAN padding — same convention the 11-bit test above locks in. + // Single-frame extraction drops the PCI byte and the mode echo (0x41), then + // truncates to the PCI's declared length — dropping the trailing CAN pad + // byte (0xAA / 0x00 here) instead of leaking it into the payload. What's left + // is exactly PID-echo (0x00) + a 4-byte supported-PID bitmap, as it should be. let payloads = Set(messages.compactMap { $0.data.map { Data($0) } }) XCTAssertEqual(payloads, [ - Data([0x00, 0x98, 0x18, 0x00, 0x01, 0xAA]), - Data([0x00, 0x98, 0x3B, 0x20, 0x13, 0x00]), + Data([0x00, 0x98, 0x18, 0x00, 0x01]), + Data([0x00, 0x98, 0x3B, 0x20, 0x13]), ]) } } From 28e3e5548c6a2e20ef8c95c202c221be9087442e Mon Sep 17 00:00:00 2001 From: Alexander Shekhovtsov Date: Tue, 7 Jul 2026 18:49:49 +0200 Subject: [PATCH 56/68] Fix BLE buffer surviving a timeout, add missing PID sanity bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-hardware test against the Jeep produced clearly-impossible readings (Engine Run Time = 1,124,073,472 s; Control Module Voltage = 237,559,786 V; Distance w/MIL = 11,184,810 km) alongside a wall of otherwise-normal PIDs timing out repeatedly. Decoding the garbage values to hex was the tell: 1124073472 = 0x43000000 — 0x43 is literally the Mode 3 (GET_DTC) response echo byte — and 11184810 = 0xAAAAAA, the exact non-zero CAN pad byte this session's previous commit just learned to strip from single-frame payloads. Root cause: BLEMessageProcessor's `buffer` was never cleared when a command timed out — only the completion handler slot was reset. A response (or partial response) that arrived just after we gave up waiting for it sat in `buffer` untouched, waiting to be silently prepended onto whichever command's response came next — a stale Mode 3 echo byte or leftover pad byte corrupting a completely unrelated PID's decode. Every command boundary must start from an empty buffer, timeout or not. Fixed by clearing it in the same cancellation handler that resets the completion, and moved every `buffer` touch behind the lock already used for the completion hand-off — the buffer is written from CoreBluetooth's delegate queue and cleared from a Task cancellation handler, two contexts Swift does not guarantee share a queue. Also added missing sanity-check bounds to PIDs this session's own garbage output happened to touch (intake/ambient/oil/manifold temp, catalyst temp x4, MAF, engine run time, distance w/MIL, warm-ups count, control module voltage, direct-inject fuel rail pressure) — they had no declared min/max at all, so the app's own clamp (which treats the exact default 0...100 as "no metadata, don't clamp") never had anything to check against and let any decoded value through regardless of magnitude. This is a safety net on top of the buffer fix, not a replacement for it — every other UAS/temperature PID in the table has the same latent gap and would show the same class of garbage if it hit a similar corruption. 37 tests pass. Co-Authored-By: Claude Fable 5 --- .../Communication/BLE/BLEDataProcessor.swift | 50 ++++++++++++++++--- Sources/SwiftOBD2/commands.swift | 28 +++++------ 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift index ffd1ad7..f4b1748 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift @@ -36,17 +36,44 @@ class BLEMessageProcessor { messageCompletion = nil return completion } + + // `buffer` is mutated from two different execution contexts: CoreBluetooth's delegate + // queue (via processReceivedData, on every notification) and a Task cancellation + // handler (onCancel below), which Swift does not guarantee runs on that same queue. + // Reusing `completionLock` — already here for exactly this kind of cross-context + // hand-off — for every buffer touch avoids a second, easy-to-miss lock. + private func appendAndSnapshotBuffer(_ data: Data) -> Data { + completionLock.lock() + defer { completionLock.unlock() } + buffer.append(data) + return buffer + } + + private func clearBuffer() { + completionLock.lock() + defer { completionLock.unlock() } + buffer.removeAll() + } + + private func takeBuffer() -> Data { + completionLock.lock() + defer { completionLock.unlock() } + let captured = buffer + buffer.removeAll() + return captured + } + /// When true, a timeout in waitForResponse returns buffered data instead of throwing. /// Used by sendMonitorCommand to capture ELM327 AT MA / AT MT streaming output. var monitorMode = false func processReceivedData(_ data: Data) { - buffer.append(data) + let snapshot = appendAndSnapshotBuffer(data) - guard let string = String(data: buffer, encoding: .utf8) else { - if buffer.count > BLEConstants.maxBufferSize { + guard let string = String(data: snapshot, encoding: .utf8) else { + if snapshot.count > BLEConstants.maxBufferSize { logger.warning("Buffer exceeded max size, clearing") - buffer.removeAll() + clearBuffer() } return } @@ -60,7 +87,7 @@ class BLEMessageProcessor { if string.contains(">") { let response = parseResponse(from: string) handleParsedResponse(response) - buffer.removeAll() + clearBuffer() } } @@ -115,14 +142,21 @@ class BLEMessageProcessor { } } onCancel: { [self] in self.takeCompletion()?(nil, BLEMessageProcessorError.responseTimeout) + // The critical fix: a response that arrives just after we gave up + // waiting for it used to sit in `buffer` untouched, waiting to be + // silently prepended onto whatever the NEXT command's real response + // turned out to be — a stray Mode 3 echo byte or a leftover pad byte + // from an abandoned read, corrupting a completely unrelated PID's + // decoded value. Every command boundary must start from an empty + // buffer, timeout or not. + self.clearBuffer() } } } catch BLEMessageProcessorError.responseTimeout where monitorMode { // In monitor mode the ELM327 streams frames without a '>' terminator; // return whatever accumulated in the buffer rather than throwing. monitorMode = false - let captured = buffer - buffer.removeAll() + let captured = takeBuffer() _ = takeCompletion() guard let string = String(data: captured, encoding: .utf8), !string.isEmpty else { return [] } return string @@ -134,7 +168,7 @@ class BLEMessageProcessor { } func reset() { - buffer.removeAll() + clearBuffer() // Call completion with error if it exists takeCompletion()?(nil, BLEManagerError.peripheralNotConnected) } diff --git a/Sources/SwiftOBD2/commands.swift b/Sources/SwiftOBD2/commands.swift index 0be5f12..649da4f 100644 --- a/Sources/SwiftOBD2/commands.swift +++ b/Sources/SwiftOBD2/commands.swift @@ -573,8 +573,8 @@ extension OBDCommand.Mode1 { case .rpm: return CommandProperties("010C", "RPM", 3, .uas(0x07), true, maxValue: 8000) case .speed: return CommandProperties("010D", "Vehicle Speed", 2, .uas(0x09), true, maxValue: 280) case .timingAdvance: return CommandProperties("010E", "Timing Advance", 2, .timingAdvance, true, maxValue: 64, minValue: -64) - case .intakeTemp: return CommandProperties("010F", "Intake Air Temp", 2, .temp, true) - case .maf: return CommandProperties("0110", "Air Flow Rate (MAF)", 3, .uas(0x27), true) + case .intakeTemp: return CommandProperties("010F", "Intake Air Temp", 2, .temp, true, maxValue: 215, minValue: -40) + case .maf: return CommandProperties("0110", "Air Flow Rate (MAF)", 3, .uas(0x27), true, maxValue: 655.35) case .throttlePos: return CommandProperties("0111", "Throttle Position", 2, .percent, true) case .airStatus: return CommandProperties("0112", "Secondary Air Status", 2, .airStatus) case .O2Sensor: return CommandProperties("0113", "O2 Sensors Present", 2, .o2Sensors) @@ -589,11 +589,11 @@ extension OBDCommand.Mode1 { case .obdcompliance: return CommandProperties("011C", "OBD Standards Compliance", 2, .obdCompliance) case .O2SensorsALT: return CommandProperties("011D", "O2 Sensors Present (alternate)", 2, .o2SensorsAlt) case .auxInputStatus: return CommandProperties("011E", "Auxiliary input status (power take off)", 2, .auxInputStatus) - case .runTime: return CommandProperties("011F", "Engine Run Time", 3, .uas(0x12), true) + case .runTime: return CommandProperties("011F", "Engine Run Time", 3, .uas(0x12), true, maxValue: 65535) case .pidsB: return CommandProperties("0120", "Supported PIDs [21-40]", 5, .pid) - case .distanceWMIL: return CommandProperties("0121", "Distance Traveled with MIL on", 4, .uas(0x25), true) + case .distanceWMIL: return CommandProperties("0121", "Distance Traveled with MIL on", 4, .uas(0x25), true, maxValue: 65535) case .fuelRailPressureVac: return CommandProperties("0122", "Fuel Rail Pressure (relative to vacuum)", 4, .uas(0x19), true) - case .fuelRailPressureDirect: return CommandProperties("0123", "Fuel Rail Pressure (direct inject)", 4, .uas(0x1B), true) + case .fuelRailPressureDirect: return CommandProperties("0123", "Fuel Rail Pressure (direct inject)", 4, .uas(0x1B), true, maxValue: 50000) case .O2Sensor1WRVolatage: return CommandProperties("0124", "02 Sensor 1 WR Lambda Voltage", 6, .sensorVoltageBig, true, maxValue: 8.192) case .O2Sensor2WRVolatage: return CommandProperties("0125", "02 Sensor 2 WR Lambda Voltage", 6, .sensorVoltageBig, true, maxValue: 8.192) case .O2Sensor3WRVolatage: return CommandProperties("0126", "02 Sensor 3 WR Lambda Voltage", 6, .sensorVoltageBig, true, maxValue: 8.192) @@ -606,7 +606,7 @@ extension OBDCommand.Mode1 { case .EGRError: return CommandProperties("012D", "EGR Error", 4, .percentCentered, true) case .evaporativePurge: return CommandProperties("012E", "Commanded Evaporative Purge", 4, .percent, true) case .fuelLevel: return CommandProperties("012F", "Fuel Tank Level Input", 4, .percent, true) - case .warmUpsSinceDTCCleared: return CommandProperties("0130", "Number of warm-ups since codes cleared", 4, .uas(0x01), true) + case .warmUpsSinceDTCCleared: return CommandProperties("0130", "Number of warm-ups since codes cleared", 4, .uas(0x01), true, maxValue: 255) case .distanceSinceDTCCleared: return CommandProperties("0131", "Distance traveled since codes cleared", 4, .uas(0x25), true, maxValue: 65535.0) case .evapVaporPressure: return CommandProperties("0132", "Evaporative system vapor pressure", 4, .evapPressure, true) case .barometricPressure: return CommandProperties("0133", "Barometric Pressure", 4, .pressure, true, maxValue: 255.0) @@ -618,17 +618,17 @@ extension OBDCommand.Mode1 { case .O2Sensor6WRCurrent: return CommandProperties("0139", "02 Sensor 6 WR Lambda Current", 4, .currentCentered, true, maxValue: 128, minValue: -128) case .O2Sensor7WRCurrent: return CommandProperties("013A", "02 Sensor 7 WR Lambda Current", 4, .currentCentered, true, maxValue: 128, minValue: -128) case .O2Sensor8WRCurrent: return CommandProperties("013B", "02 Sensor 8 WR Lambda Current", 4, .currentCentered, true, maxValue: 128, minValue: -128) - case .catalystTempB1S1: return CommandProperties("013C", "Catalyst Temperature: Bank 1 - Sensor 1", 4, .uas(0x16), true) - case .catalystTempB2S1: return CommandProperties("013D", "Catalyst Temperature: Bank 2 - Sensor 1", 4, .uas(0x16), true) - case .catalystTempB1S2: return CommandProperties("013E", "Catalyst Temperature: Bank 1 - Sensor 2", 4, .uas(0x16), true) - case .catalystTempB2S2: return CommandProperties("013F", "Catalyst Temperature: Bank 1 - Sensor 2", 4, .uas(0x16), true) + case .catalystTempB1S1: return CommandProperties("013C", "Catalyst Temperature: Bank 1 - Sensor 1", 4, .uas(0x16), true, maxValue: 6513.5, minValue: -40) + case .catalystTempB2S1: return CommandProperties("013D", "Catalyst Temperature: Bank 2 - Sensor 1", 4, .uas(0x16), true, maxValue: 6513.5, minValue: -40) + case .catalystTempB1S2: return CommandProperties("013E", "Catalyst Temperature: Bank 1 - Sensor 2", 4, .uas(0x16), true, maxValue: 6513.5, minValue: -40) + case .catalystTempB2S2: return CommandProperties("013F", "Catalyst Temperature: Bank 1 - Sensor 2", 4, .uas(0x16), true, maxValue: 6513.5, minValue: -40) case .pidsC: return CommandProperties("0140", "Supported PIDs [41-60]", 6, .pid) case .statusDriveCycle: return CommandProperties("0141", "Monitor status this drive cycle", 6, .status) - case .controlModuleVoltage: return CommandProperties("0142", "Control module voltage", 4, .uas(0x0B), true) + case .controlModuleVoltage: return CommandProperties("0142", "Control module voltage", 4, .uas(0x0B), true, maxValue: 80) case .absoluteLoad: return CommandProperties("0143", "Absolute load value", 4, .percent, true) case .commandedEquivRatio: return CommandProperties("0144", "Commanded equivalence ratio", 4, .uas(0x1E), true) case .relativeThrottlePos: return CommandProperties("0145", "Relative throttle position", 4, .percent, true) - case .ambientAirTemp: return CommandProperties("0146", "Ambient air temperature", 4, .temp, true) + case .ambientAirTemp: return CommandProperties("0146", "Ambient air temperature", 4, .temp, true, maxValue: 215, minValue: -40) case .throttlePosB: return CommandProperties("0147", "Absolute throttle position B", 4, .percent, true) case .throttlePosC: return CommandProperties("0148", "Absolute throttle position C", 4, .percent, true) // PIDs 49-4B are the accelerator PEDAL sensor (SAE J1979 calls them exactly that) — @@ -653,7 +653,7 @@ extension OBDCommand.Mode1 { case .fuelRailPressureAbs: return CommandProperties("0159", "Fuel rail pressure (absolute)", 4, .uas(0x1B), true) case .relativeAccelPos: return CommandProperties("015A", "Relative accelerator pedal position", 3, .percent, true) case .hybridBatteryLife: return CommandProperties("015B", "Hybrid battery pack remaining life", 3, .percent) - case .engineOilTemp: return CommandProperties("015C", "Engine oil temperature", 3, .temp, true) + case .engineOilTemp: return CommandProperties("015C", "Engine oil temperature", 3, .temp, true, maxValue: 215, minValue: -40) case .fuelInjectionTiming: return CommandProperties("015D", "Fuel injection timing", 4, .injectTiming, true) case .fuelRate: return CommandProperties("015E", "Engine fuel rate", 4, .fuelRate, true) case .emissionsReq: return CommandProperties("015F", "Designed emission requirements", 3, .none) @@ -695,7 +695,7 @@ extension OBDCommand.Mode1 { case .aecdRunTime1: return CommandProperties("0181", "AECD run time", 42, .none, true) case .aecdRunTime2: return CommandProperties("0182", "AECD run time", 42, .none, true) case .noxSensor: return CommandProperties("0183", "NOx sensor", 10, .none, true) - case .manifoldSurfaceTemp: return CommandProperties("0184", "Manifold surface temp", 2, .temp, true) + case .manifoldSurfaceTemp: return CommandProperties("0184", "Manifold surface temp", 2, .temp, true, maxValue: 215, minValue: -40) case .noxReagentSystem: return CommandProperties("0185", "NOx reagent system", 11, .none, true) case .pmSensor: return CommandProperties("0186", "PM sensor", 6, .none, true) case .intakeManifoldPressure2: return CommandProperties("0187", "Intake manifold pressure", 6, .pressure, true) From a2fdbfd0350b99bd8f1bc450099e368190102c40 Mon Sep 17 00:00:00 2001 From: Alexander Shekhovtsov Date: Tue, 7 Jul 2026 18:59:04 +0200 Subject: [PATCH 57/68] Deterministic ECU selection for single-response reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a two-ECU vehicle (e.g. the 2016 Jeep Cherokee, source addresses 0x10 and 0x18), every single-answer read path — the app's per-PID live-sensor polling (OBDService.sendCommand), freeze frame, getStatus, requestVin — picked its response via `.first` on a Dictionary-ordered message list: a different module from one poll to the next. Live values could alternate between two ECUs' answers, and the earlier `.ecu == .engine` preference (getStatus/requestVin) didn't actually disambiguate on 29-bit buses, where the `& 0x07` label mask maps every module to "engine". New `preferredECUMessage(_:pidEcho:)`: 1. keeps only messages whose first payload byte echoes the requested PID (when given) — discards stale/foreign responses outright; 2. of those, takes the lowest raw source address — the primary engine ECM on BOTH addressing schemes (0x7E8 < 0x7E9... on 11-bit, and 0x10 < 0x18... on 29-bit per SAE J2178). MessageProtocol gains `sourceAddress` (the untouched address byte both Message and LegacyMessage already carried per-frame) to make that possible without leaning on the degenerate ECUID label. 37 tests pass. Co-Authored-By: Claude Fable 5 --- Sources/SwiftOBD2/elm327.swift | 16 ++++++------- Sources/SwiftOBD2/obd2service.swift | 12 ++++++++-- Sources/SwiftOBD2/parser.swift | 4 ++++ .../SwiftOBD2/protocols/protocol_legacy.swift | 24 +++++++++++++++++++ 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index 5b8bee9..6c69677 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -371,11 +371,11 @@ class ELM327 { return .failure(.noData) } // MIL / DTC count / monitor readiness are a real per-ECU reading, not a bitmap to - // union — but `.first` (a Dictionary's iteration order) was non-deterministic - // across connects on this vehicle's two-ECU bus. Prefer the engine ECU, which is - // authoritative for powertrain MIL status; fall back to whichever answered if this - // vehicle's engine controller doesn't tag itself that way. - guard let statusData = (messages.first { $0.ecu == .engine } ?? messages.first)?.data else { + // union — and `.first` (Dictionary order) was non-deterministic on a two-ECU bus. + // `preferredECUMessage` keys off the raw source address (lowest = primary ECM on + // both addressing schemes) — the `.ecu == .engine` label used before degenerates + // on 29-bit buses, where every module's address masks to the same "engine" label. + guard let statusData = preferredECUMessage(messages, pidEcho: 0x01)?.data else { return .failure(.noData) } return statusCommand.properties.decode(data: statusData) @@ -484,10 +484,10 @@ class ELM327 { return nil } - // Same non-deterministic `.first` issue as `getStatus()` — prefer the engine ECU - // (the one that actually owns Mode 09 on most vehicles) over Dictionary order. + // Same non-deterministic `.first` issue as `getStatus()` — prefer the primary + // (lowest-source-address) ECM's answer; the Mode 09 PID echo for VIN is 0x02. guard let messages = try? canProtocol?.parse(vinResponse), !messages.isEmpty, - let data = (messages.first { $0.ecu == .engine } ?? messages.first)?.data, + let data = preferredECUMessage(messages, pidEcho: 0x02)?.data, var vinString = String(bytes: data, encoding: .utf8) else { return nil diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index 51258ef..3928edc 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -323,7 +323,15 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab public func sendCommand(_ command: OBDCommand) async throws -> Result { do { let response = try await sendCommandInternal(command.properties.command, retries: 3) - guard let responseData = try elm327.canProtocol?.parse(response).first?.data else { + guard let messages = try elm327.canProtocol?.parse(response), !messages.isEmpty else { + return .failure(.noData) + } + // This is the app's per-PID live-sensor read path — on a two-ECU vehicle the + // old Dictionary-order `.first` picked a different module from one poll to + // the next, making values flicker between two sources. Prefer the response + // that echoes the requested PID, from the primary (lowest-address) ECM. + let pidEcho = UInt8(command.properties.command.dropFirst(2).prefix(2), radix: 16) + guard let responseData = preferredECUMessage(messages, pidEcho: pidEcho)?.data else { return .failure(.noData) } return command.properties.decode(data: responseData.dropFirst()) @@ -357,7 +365,7 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab let command = String(format: "02%@%02X", pidHex, frame) guard let response = try? await elm327.sendCommand(command, retries: 1), let messages = try? elm327.canProtocol?.parse(response), - let data = (messages.first { $0.ecu == .engine } ?? messages.first)?.data, + let data = preferredECUMessage(messages, pidEcho: UInt8(pidHex, radix: 16))?.data, data.count > 2 else { continue } // message.data has already dropped the mode echo (0x42); what remains is diff --git a/Sources/SwiftOBD2/parser.swift b/Sources/SwiftOBD2/parser.swift index 896520b..039ed41 100644 --- a/Sources/SwiftOBD2/parser.swift +++ b/Sources/SwiftOBD2/parser.swift @@ -81,6 +81,10 @@ public struct Message: MessageProtocol { frames.first?.txID ?? .unknown } + public var sourceAddress: UInt8 { + frames.first?.rawAddress ?? 0 + } + init(frames: [Frame]) throws { self.frames = frames switch frames.count { diff --git a/Sources/SwiftOBD2/protocols/protocol_legacy.swift b/Sources/SwiftOBD2/protocols/protocol_legacy.swift index 38d41c8..8e9e063 100644 --- a/Sources/SwiftOBD2/protocols/protocol_legacy.swift +++ b/Sources/SwiftOBD2/protocols/protocol_legacy.swift @@ -46,6 +46,7 @@ struct LegacyMessage: MessageProtocol { public var data: Data? public var ecu: ECUID + public var sourceAddress: UInt8 { frames.first?.rawAddress ?? 0 } init(frames: [LegacyFrame]) throws { // guard !frames.isEmpty else { @@ -187,6 +188,29 @@ struct LegacyFrame { public protocol MessageProtocol { var data: Data? { get } var ecu: ECUID { get } + /// The untouched source-address byte the responding ECU used. Unlike `ecu` (whose + /// `& 0x07`-derived label is only meaningful for 11-bit SAE J1979 addressing and + /// degenerates on 29-bit buses, where e.g. 0x10 and 0x18 both label "engine"), this + /// stays distinct per module — and on both addressing schemes the PRIMARY engine ECM + /// is the numerically lowest responder (0x7E8 on 11-bit, 0x10 on 29-bit per SAE + /// J2178), which is what "which ECU's answer is authoritative" decisions key off. + var sourceAddress: UInt8 { get } +} + +/// Picks the authoritative response when several ECUs answered one request: +/// 1. Keep only messages whose first payload byte echoes the requested PID (when given) — +/// discards a stale/foreign response that happens to share the buffer. +/// 2. Of those, take the lowest source address — the primary engine ECM on both 11-bit +/// (0x7E8 < 0x7E9...) and 29-bit (0x10 < 0x18...) addressing. +/// Deterministic, unlike Dictionary-order `.first`, which on a two-ECU vehicle picked a +/// different module from one read to the next. +func preferredECUMessage(_ messages: [MessageProtocol], pidEcho: UInt8? = nil) -> MessageProtocol? { + var candidates = messages + if let pidEcho { + let matching = messages.filter { $0.data?.first == pidEcho } + if !matching.isEmpty { candidates = matching } + } + return candidates.min { $0.sourceAddress < $1.sourceAddress } } class SAE_J1850_PWM: CANProtocol { From 8483f8c98a88dd797f6205195dc8d4e458fd2b8d Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Tue, 7 Jul 2026 21:03:20 +0100 Subject: [PATCH 58/68] =?UTF-8?q?NO=20DATA=20is=20a=20routine=20reply=20(m?= =?UTF-8?q?odule=20asleep,=20unsupported=20PID),=20not=20a=20=20transport?= =?UTF-8?q?=20failure=20=E2=80=94=20keep=20it=20at=20debug=20so=20a=20park?= =?UTF-8?q?ed=20car=20polling=20its=20ignition=20probe=20doesn't=20flood?= =?UTF-8?q?=20the=20console=20with=20error-level=20lines.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/SwiftOBD2/Communication/BLE/bleManager.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index af681bf..f3668c7 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -374,7 +374,14 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { obdDebug("Command response: \(response.joined(separator: " | "))", category: .communication) return response } catch { - obdError("Command failed: \(command) - \(error.localizedDescription)", category: .communication) + // NO DATA is a routine reply (module asleep, unsupported PID), not a + // transport failure — keep it at debug so a parked car polling its + // ignition probe doesn't flood the console with error-level lines. + if case BLEManagerError.noData = error { + obdDebug("No data: \(command)", category: .communication) + } else { + obdError("Command failed: \(command) - \(error.localizedDescription)", category: .communication) + } throw error } } From ac0d3382651f89c61bc71ccb86fe9e8fa7bc2e8f Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Thu, 9 Jul 2026 22:06:08 +0100 Subject: [PATCH 59/68] Retries fix (SwiftOBD2/Sources/SwiftOBD2/Communication/BLE/bleManager.swift:363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendCommand(_:retries:) previously discarded the parameter (retries _: Int) and only ever made one attempt. It now actually retries up to retries times, backing off by BLEConstants.retryDelay (0.5s — an existing constant that was already defined but never used, which is a good sign the retry loop was originally intended and just never got wired up). NO DATA still throws immediately without retrying, since that's a legitimate "unsupported PID" answer, not a dropped response — retrying it would just add latency for no benefit. Verified with swift build on the package directly (can't go through build_sim/the app scheme for this one, since per CLAUDE.md the app builds against the remote pinned revision, not this local clone — this change won't reach the app until it's committed, pushed to origin/develop, and the revision bumped in Package.resolved). --- .../Communication/BLE/bleManager.swift | 50 ++++++++++++------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index f3668c7..6e2ac1e 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -360,30 +360,46 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { /// `BLEManagerError.peripheralNotConnected` if the peripheral is not connected. /// `BLEManagerError.timeout` if the operation times out. /// `BLEManagerError.unknownError` if an unknown error occurs. - func sendCommand(_ command: String, retries _: Int = 3) async throws -> [String] { + func sendCommand(_ command: String, retries: Int = 3) async throws -> [String] { guard let peripheral = peripheralManager.connectedPeripheral else { obdError("Missing peripheral or ECU characteristic", category: .bluetooth) throw BLEManagerError.missingPeripheralOrCharacteristic } - obdDebug("Sending command: \(command)", category: .communication) - - do { - try characteristicHandler.writeCommand(command, to: peripheral) - let response = try await messageProcessor.waitForResponse(timeout: BLEConstants.defaultTimeout) - obdDebug("Command response: \(response.joined(separator: " | "))", category: .communication) - return response - } catch { - // NO DATA is a routine reply (module asleep, unsupported PID), not a - // transport failure — keep it at debug so a parked car polling its - // ignition probe doesn't flood the console with error-level lines. - if case BLEManagerError.noData = error { - obdDebug("No data: \(command)", category: .communication) - } else { - obdError("Command failed: \(command) - \(error.localizedDescription)", category: .communication) + // `retries` used to be discarded here (`retries _: Int`), so every BLE command + // was single-shot regardless of what the caller asked for — a dropped/timed-out + // response just failed instead of getting a second attempt. + let attempts = max(1, retries) + for attempt in 1...attempts { + obdDebug(attempt == 1 ? "Sending command: \(command)" + : "Sending command: \(command) (attempt \(attempt)/\(attempts))", + category: .communication) + do { + try characteristicHandler.writeCommand(command, to: peripheral) + let response = try await messageProcessor.waitForResponse(timeout: BLEConstants.defaultTimeout) + obdDebug("Command response: \(response.joined(separator: " | "))", category: .communication) + return response + } catch { + // NO DATA is a routine reply (module asleep, unsupported PID), not a + // transport failure — keep it at debug so a parked car polling its + // ignition probe doesn't flood the console with error-level lines. + // It's also a definitive answer rather than a dropped response, so + // retrying it wouldn't change the outcome. + if case BLEManagerError.noData = error { + obdDebug("No data: \(command)", category: .communication) + throw error + } + guard attempt < attempts else { + obdError("Command failed: \(command) - \(error.localizedDescription)", category: .communication) + throw error + } + obdDebug("Retrying after error (attempt \(attempt)/\(attempts)): \(command) - \(error.localizedDescription)", + category: .communication) + try? await Task.sleep(nanoseconds: UInt64(BLEConstants.retryDelay * 1_000_000_000)) } - throw error } + // Unreachable — the loop above always returns or throws on its last iteration. + throw BLEManagerError.timeout } func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] { From 6ac70e921d40ed9c5cfb03766cca86f2385671d9 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Fri, 10 Jul 2026 13:43:23 +0100 Subject: [PATCH 60/68] =?UTF-8?q?Transient=20serial=20dropout=20(attempts?= =?UTF-8?q?=201=20&=202=20=E2=80=94=20connection=20died=20right=20after=20?= =?UTF-8?q?ATZ=20succeeded):?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit elm327.swift — adapterInitialization()'s post-ATZ commands (ATE0/ATL0/ATS0/ATH1/ATSP0) now get retries: 3, matching the resilience ATZ already had. Previously only ATZ was retried; a single dropped frame on any command after it failed the whole connection. obd2service.swift — startConnection now retries the full connect+init sequence once automatically if it fails with .adapterConnectionFailed (the transient-transport class we saw), so a one-off USB/BLE hiccup no longer requires you to manually tap Connect again. .noAdapterFound (genuine BLE scan timeout) is deliberately excluded — retrying that would just double an already-full wait. 0100 timeout despite ignition on (attempt 3): the specific 0100 probe that timed out is the best-effort one right after ATSP0 — its result is discarded (try?) and ATDPN + a separately-retried testProtocol 0100 do the real work, so this alone likely wasn't fatal. But since it happened live, I bumped the post-ATSP0 settle delay 1s→2s and gave that probe 2 retries — cuts down the false-negative window where the ELM327 hasn't finished settling onto the bus yet. --- Sources/SwiftOBD2/elm327.swift | 27 ++++++++++++++++++--------- Sources/SwiftOBD2/obd2service.swift | 23 +++++++++++++++++++---- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index c7432e0..6c8b366 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -178,10 +178,15 @@ class ELM327 { private func detectProtocolAutomatically() async throws -> PROTOCOL { obdDelegate?.logMessage("Protocol detect: ATSP0 (auto-search)…") _ = try await okResponse("ATSP0") - try? await Task.sleep(nanoseconds: 1_000_000_000) + // ELM327 auto-search needs a moment to settle onto the bus before the + // ECU reliably answers the first query — 1s was occasionally too tight + // over serial and produced a spurious "no response" here even with the + // vehicle live (the ATDPN/testProtocol path below still recovers via + // its own retries, but this cuts down false-negative noise). + try? await Task.sleep(nanoseconds: 2_000_000_000) obdDelegate?.logMessage("Protocol detect: sending 0100 — waiting for vehicle…") - let resp100 = try? await sendCommand("0100") + let resp100 = try? await sendCommand("0100", retries: 2) logger.info("0100 raw response: \(String(describing: resp100))") obdDelegate?.logMessage("0100 → \(resp100.map { $0.joined(separator: " ") } ?? "no response")") @@ -269,18 +274,22 @@ class ELM327 { logger.info("ATZ response: \(atzResp)") obdDelegate?.logMessage("ATZ → \(atzResp.joined(separator: " | "))") + // The port can still be settling for the first few commands after ATZ + // (same class of transient drop the ATZ retry above guards against), so + // give the rest of the init sequence the same resilience rather than + // failing the whole connection on one dropped frame. obdDelegate?.logMessage("Adapter init: ATE0 (echo off)…") - _ = try await okResponse("ATE0") + _ = try await okResponse("ATE0", retries: 3) obdDelegate?.logMessage("ATE0 → OK") obdDelegate?.logMessage("Adapter init: ATL0 ATH1 ATS0…") - _ = try await okResponse("ATL0") - _ = try await okResponse("ATS0") - _ = try await okResponse("ATH1") + _ = try await okResponse("ATL0", retries: 3) + _ = try await okResponse("ATS0", retries: 3) + _ = try await okResponse("ATH1", retries: 3) obdDelegate?.logMessage("ATL0 / ATS0 / ATH1 → OK") obdDelegate?.logMessage("Adapter init: ATSP0 (auto protocol)…") - _ = try await okResponse("ATSP0") + _ = try await okResponse("ATSP0", retries: 3) obdDelegate?.logMessage("ATSP0 → OK — adapter ready") logger.info("ELM327 adapter initialized successfully.") } catch { @@ -324,8 +333,8 @@ class ELM327 { try await comm.sendMonitorCommand(command, duration: duration) } - private func okResponse(_ message: String) async throws -> [String] { - let response = try await sendCommand(message) + private func okResponse(_ message: String, retries: Int = 1) async throws -> [String] { + let response = try await sendCommand(message, retries: retries) if response.contains("OK") { return response } else { diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index c85e5c4..5bc0240 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -165,16 +165,31 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab /// - Returns: Information about the connected vehicle (`OBDInfo`). /// - Throws: Errors that might occur during the connection process. public func startConnection(preferedProtocol: PROTOCOL? = nil, timeout: TimeInterval = 7, peripheral: CBPeripheral? = nil) async throws -> OBDInfo { + do { + return try await attemptConnection(preferedProtocol: preferedProtocol, timeout: timeout, peripheral: peripheral) + } catch OBDServiceError.adapterConnectionFailed { + // A transient link drop mid-handshake (the adapter/port still settling + // right after open) is common on the very first connect and otherwise + // forces the user to manually retry — one clean retry here covers it. + obdWarning("Connection attempt failed — retrying once", category: .connection) + elm327.stopConnection() + return try await attemptConnection(preferedProtocol: preferedProtocol, timeout: timeout, peripheral: peripheral) + } + // .noAdapterFound already waited out a full BLE scan timeout — retrying + // immediately would just double that wait for no benefit, so it propagates as-is. + } + + private func attemptConnection(preferedProtocol: PROTOCOL?, timeout: TimeInterval, peripheral: CBPeripheral?) async throws -> OBDInfo { let startTime = CFAbsoluteTimeGetCurrent() obdInfo("Starting connection with timeout: \(timeout)s", category: .connection) do { obdDebug("Connecting to adapter...", category: .connection) try await elm327.connectToAdapter(timeout: timeout, peripheral: peripheral) - + obdDebug("Initializing adapter...", category: .connection) try await elm327.adapterInitialization() - + obdDebug("Initializing vehicle connection...", category: .connection) let vehicleInfo = try await initializeVehicle(preferedProtocol) @@ -187,7 +202,7 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab let duration = CFAbsoluteTimeGetCurrent() - startTime OBDLogger.shared.logPerformance("Connection failed", duration: duration, success: false) obdError("Connection failed: \(error.localizedDescription)", category: .connection) - + if let bleError = error as? BLEManagerError { if bleError == .peripheralNotFound || bleError == .scanTimeout { throw OBDServiceError.noAdapterFound @@ -197,7 +212,7 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab throw OBDServiceError.noAdapterFound } } - + throw OBDServiceError.adapterConnectionFailed(underlyingError: error) // Propagate } } From f48f868f73eab9cb7131071b20635ff81e3629ee Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 19 Jul 2026 07:15:02 +0100 Subject: [PATCH 61/68] =?UTF-8?q?SwiftOBD2=20package=20(wifiManager.swift)?= =?UTF-8?q?:=20the=20socket=20is=20now=20pinned=20to=20the=20Wi-Fi=20inter?= =?UTF-8?q?face=20(requiredInterfaceType=20=3D=20.wifi=20=E2=80=94=20this?= =?UTF-8?q?=20is=20the=20fix=20for=20"no=20internet"=20routing),=20TCP=20k?= =?UTF-8?q?eepalive=20detects=20a=20dead=20adapter=20in=20~8=20s,=20fatal?= =?UTF-8?q?=20socket=20errors=20publish=20a=20real=20disconnect,=20and=20t?= =?UTF-8?q?he=20ATZ=20reconnect=20no=20longer=20emits=20a=20phantom=20disc?= =?UTF-8?q?onnect=20mid-handshake.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SwiftOBD2/Communication/wifiManager.swift | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftOBD2/Communication/wifiManager.swift b/Sources/SwiftOBD2/Communication/wifiManager.swift index 4dac6a2..fb469ad 100644 --- a/Sources/SwiftOBD2/Communication/wifiManager.swift +++ b/Sources/SwiftOBD2/Communication/wifiManager.swift @@ -104,7 +104,23 @@ class WifiManager: CommProtocol { guard let port = NWEndpoint.Port(portString) else { throw CommunicationError.invalidData } - let connection = NWConnection(host: host, port: port, using: .tcp) + // Keepalive turns a silently dead adapter (power pulled, car off) into a + // real .failed transition within ~8 s; without it a half-open TCP link + // just times out command-by-command and connectionState never drops. + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.enableKeepalive = true + tcpOptions.keepaliveIdle = 2 + tcpOptions.keepaliveInterval = 2 + tcpOptions.keepaliveCount = 3 + tcpOptions.connectionTimeout = 10 + let params = NWParameters(tls: nil, tcp: tcpOptions) + #if os(iOS) + // The adapter's AP has no internet, so iOS keeps the default route on + // cellular/another network; pinning to the Wi-Fi interface is what makes + // traffic flow while the status bar says "No Internet Connection". + params.requiredInterfaceType = .wifi + #endif + let connection = NWConnection(host: host, port: port, using: params) tcp = connection let gate = ConnectOnce() @@ -163,6 +179,10 @@ class WifiManager: CommProtocol { let old = tcp old?.send(content: data, completion: .contentProcessed { _ in }) try await Task.sleep(nanoseconds: 1_500_000_000) // 1.5 s for adapter reset + // Detach the handler first: this cancel is a planned swap, and the + // .cancelled arm would otherwise publish a transient .disconnected + // that the app treats as a link drop mid-handshake. + old?.stateUpdateHandler = nil old?.cancel() try await connectAsync(timeout: 10, peripheral: nil) return ["ELM327 v2.1"] @@ -266,6 +286,9 @@ class WifiManager: CommProtocol { if let error = error { logger.error("Error sending data: \(error.localizedDescription)") gate.finish(throwing: CommunicationError.errorOccurred(error)) + // The socket is broken — cancel so the stateUpdateHandler + // publishes .disconnected and the app can react to the drop. + tcpConnection.cancel() return } @@ -279,6 +302,7 @@ class WifiManager: CommProtocol { gate.finish(throwing: gate.accumulated.isEmpty ? CommunicationError.errorOccurred(error) : CommunicationError.invalidData) + tcpConnection.cancel() return } From 0e965f77f2ac6c9ebf15a0ffa4c45a9d32a6f902 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 19 Jul 2026 12:18:18 +0100 Subject: [PATCH 62/68] Changes in MacSerialManager.swift: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probe sends ATI (side-effect-free, also interrupts an in-progress SEARCHING) instead of bare CR, and validity now requires the > prompt on top of the printable-ASCII check. A command timeout sets a needsResync flag; the next send does tcflush(TCIFLUSH) right before writing, so a late reply can't be read as the next command's response. Read errors now log errno + strerror — next time a drop happens, the log will say whether it was a USB detach (ENXIO/EIO) or something else. Changes in elm327.swift: The supported-PID sweep aborts when the transport disconnects instead of burning a full timeout per remaining getter against a closed fd, and setupVehicle now throws rather than overwriting a disconnect with "Connected to Vehicle". The manual protocol sweep tries CAN protocols (6, 7, 8, 9) first with single attempts — worst case for a CAN car drops from minutes to ≤ ~20 s. Test fix: testSetupVehicle was silently skipping its assertion (its catch just printed); it now connects the mock adapter first — required by the new guard — and fails on error. --- .../Communication/MacSerialManager.swift | 50 ++++++++++++++----- Sources/SwiftOBD2/elm327.swift | 29 +++++++++-- Tests/SwiftOBD2Tests/elm327Test.swift | 6 ++- 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/MacSerialManager.swift b/Sources/SwiftOBD2/Communication/MacSerialManager.swift index fa0a8d7..fa510f5 100644 --- a/Sources/SwiftOBD2/Communication/MacSerialManager.swift +++ b/Sources/SwiftOBD2/Communication/MacSerialManager.swift @@ -19,6 +19,11 @@ final class MacSerialManager: CommProtocol { private var responseContinuation: CheckedContinuation? private var responseToken: UUID? private var receiveBuffer = "" + // Set when a command times out: the adapter may still deliver that command's + // reply late, so the next send must drop pending input first or the stale + // reply is read as the new command's response. Main-confined like the rest + // of the continuation state. + private var needsResync = false private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example", category: "MacSerial") @@ -38,8 +43,9 @@ final class MacSerialManager: CommProtocol { throw CommunicationError.errorOccurred(err) } - // Probe each baud rate: send '\r', wait 1 s, check if response is valid ASCII. - // tcsetattr always succeeds, so we must actually talk to the adapter to confirm. + // Probe each baud rate: send ATI, wait 1 s, check if response is valid ASCII + // and contains the prompt. tcsetattr always succeeds, so we must actually + // talk to the adapter to confirm. let candidates: [(speed_t, Int)] = [ (speed_t(B115200), 115200), (speed_t(B38400), 38400), @@ -75,14 +81,22 @@ final class MacSerialManager: CommProtocol { throw CommunicationError.invalidData } - /// Sends a bare '\r' and returns true if the bytes that come back are all printable ASCII. - /// Garbage bytes (baud-rate mismatch) contain high-bit or control characters. + /// Sends ATI and returns true if the reply is all printable ASCII and contains + /// the '>' prompt. Garbage bytes (baud-rate mismatch) contain high-bit or + /// control characters and never produce a prompt. + /// + /// ATI specifically, not a bare '\r': the ELM327 treats a lone CR as "repeat + /// last command", so a CR probe re-executes whatever a previous session left + /// in the adapter's command buffer (an ATZ re-reset, or a live 0100 query to + /// the vehicle) and the probe then reads that command's output as its own + /// response. ATI is side-effect-free, answers instantly with the version + /// banner, and any received character also interrupts an in-progress + /// protocol SEARCHING ("STOPPED") instead of replaying it. private func probeRespondsValidASCII() async -> Bool { // Flush any stale bytes before probing. tcflush(fileDescriptor, TCIOFLUSH) - let cr = [UInt8(0x0D)] // '\r' - _ = cr.withUnsafeBufferPointer { write(fileDescriptor, $0.baseAddress, 1) } + writeBytes("ATI\r") // Collect bytes for up to 1 second. try? await Task.sleep(nanoseconds: 1_000_000_000) @@ -97,9 +111,11 @@ final class MacSerialManager: CommProtocol { let printable = bytes.allSatisfy { b in (b >= 0x20 && b <= 0x7E) || b == 0x0D || b == 0x0A } + let hasPrompt = bytes.contains(UInt8(ascii: ">")) + let valid = printable && hasPrompt let preview = String(bytes: bytes, encoding: .ascii) ?? "" - logger.info("Probe at fd=\(self.fileDescriptor): \(n) bytes, valid=\(printable), preview=\(preview)") - return printable + logger.info("Probe at fd=\(self.fileDescriptor): \(n) bytes, valid=\(valid), preview=\(preview)") + return valid } private func applyBaudRate(_ baud: speed_t) -> Bool { @@ -200,6 +216,13 @@ final class MacSerialManager: CommProtocol { self.responseContinuation?.resume(throwing: CommunicationError.invalidData) self.responseContinuation = continuation self.responseToken = token + if self.needsResync { + // A previous command timed out; its late reply may be sitting in + // the tty input queue. Drop it right before writing so it can't + // be prepended to this command's response. + tcflush(self.fileDescriptor, TCIFLUSH) + self.needsResync = false + } self.receiveBuffer = "" self.writeBytes(command + "\r") @@ -213,6 +236,7 @@ final class MacSerialManager: CommProtocol { self.obdDelegate?.logMessage("Serial: 20s timeout waiting for '\(command)' — no data received") self.responseContinuation = nil self.responseToken = nil + self.needsResync = true cont.resume(throwing: CommunicationError.invalidData) } } @@ -260,7 +284,8 @@ final class MacSerialManager: CommProtocol { ?? "<\(bytesRead) non-ASCII bytes>" await self.handleReceivedData(chunk) } else if bytesRead < 0 && errno != EAGAIN { - await self.handleError() + let err = errno + await self.handleError(errno: err) break } } @@ -291,9 +316,10 @@ final class MacSerialManager: CommProtocol { } @MainActor - private func handleError() { - logger.error("Serial read error, disconnecting") - obdDelegate?.logMessage("Serial: read error — disconnecting") + private func handleError(errno err: Int32) { + let reason = String(cString: strerror(err)) + logger.error("Serial read error (errno \(err): \(reason)), disconnecting") + obdDelegate?.logMessage("Serial: read error — errno \(err) (\(reason)) — disconnecting") disconnectPeripheral() } diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index 6c8b366..100e058 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -128,6 +128,11 @@ class ELM327 { let ecuMap = populateECUMap(messages) + // The transport can drop mid-setup (the PID sweep swallows per-command + // errors); don't overwrite that Disconnected with Connected to Vehicle. + guard connectionState != .disconnected else { + throw ELM327Error.connectionFailed(reason: "Connection lost during vehicle setup") + } connectionState = .connectedToVehicle return OBDInfo(vin: vin, supportedPIDs: supportedPIDs, obdProtocol: detectedProtocol, ecuMap: ecuMap) } @@ -214,10 +219,19 @@ class ELM327 { /// - Returns: The detected protocol, or nil if none could be found. /// - Throws: Various setup-related errors. private func detectProtocolManually() async throws -> PROTOCOL { - for protocolOption in PROTOCOL.allCases where protocolOption != .NONE { + // CAN protocols first: virtually every vehicle since ~2008 is ISO 15765-4, + // and each miss costs a full command timeout — starting from J1850 makes + // the common case the slowest. Single attempt per protocol for the same + // reason; the sweep is already the fallback path. + let sweepOrder: [PROTOCOL] = [ + .protocol6, .protocol7, .protocol8, .protocol9, + .protocol1, .protocol2, .protocol3, .protocol4, .protocol5, + .protocolA, .protocolB, .protocolC, + ] + for protocolOption in sweepOrder { self.logger.info("Testing protocol: \(protocolOption.description)") _ = try await okResponse(protocolOption.cmd) - if await testProtocol(protocolOption) { + if await testProtocol(protocolOption, retries: 1) { return protocolOption } } @@ -231,8 +245,8 @@ class ELM327 { /// Tests a given protocol by sending a 0100 command and checking for a valid response. /// - Parameter obdProtocol: The protocol to test. /// - Throws: Various setup-related errors. - private func testProtocol(_ obdProtocol: PROTOCOL) async -> Bool { - let response = try? await sendCommand("0100", retries: 3) + private func testProtocol(_ obdProtocol: PROTOCOL, retries: Int = 3) async -> Bool { + let response = try? await sendCommand("0100", retries: retries) let raw = response?.joined(separator: " ") ?? "no response" if let response, response.contains(where: { $0.range(of: #"41\s*00"#, options: .regularExpression) != nil }) { let msg = "Protocol \(obdProtocol.description) ✓ (0100 → \(raw))" @@ -567,6 +581,13 @@ extension ELM327 { supportedPIDs.append(contentsOf: supportedCommands) } catch { logger.error("\(error.localizedDescription)") + // A transport drop fails every remaining getter the same way + // (each one burning its full command timeout against a dead fd) + // — stop the sweep instead of grinding through them. + if connectionState == .disconnected { + obdDelegate?.logMessage("Supported-PID sweep aborted — connection lost") + break + } } } // filter out pidGetters diff --git a/Tests/SwiftOBD2Tests/elm327Test.swift b/Tests/SwiftOBD2Tests/elm327Test.swift index b541a9f..0bebd5e 100644 --- a/Tests/SwiftOBD2Tests/elm327Test.swift +++ b/Tests/SwiftOBD2Tests/elm327Test.swift @@ -35,12 +35,16 @@ final class ELM327Test: XCTestCase { Task { // When do { + // setupVehicle requires an adapter-level connection (it refuses to + // report connectedToVehicle over a dropped transport), so establish + // the mock connection first like the real flow does. + try await sut.connectToAdapter(timeout: 5) let obdInfo = try await sut.setupVehicle(preferredProtocol: nil) XCTAssertEqual(obdInfo.obdProtocol, .protocol6, "Expected obdProtocol to be .protocol6 but got \(String(describing: obdInfo.obdProtocol))") // XCTAssertEqual(sut.obdProtocol, .protocol6, "Expected obdProtocol to be .protocol6 but got \(String(describing: sut.obdProtocol))") exp.fulfill() } catch { - print(error.localizedDescription) + XCTFail("setupVehicle threw: \(error.localizedDescription)") exp.fulfill() } } From f94d880e6183f1d184804150d279c8736ef3eed3 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Mon, 20 Jul 2026 07:09:21 +0100 Subject: [PATCH 63/68] =?UTF-8?q?bleManager.swift:495=20=E2=80=94=20BLEMan?= =?UTF-8?q?agerError=20now=20conforms=20to=20LocalizedError=20with=20error?= =?UTF-8?q?Description=20=3D=20description.=20Fixes=20readability=20at=20e?= =?UTF-8?q?very=20.localizedDescription=20site=20that=20can=20carry=20a=20?= =?UTF-8?q?BLEManagerError,=20in=20one=20edit.=20elm327.swift:567=20?= =?UTF-8?q?=E2=80=94=20per-getter=20progress=20dropped=20to=20.debug;=20th?= =?UTF-8?q?e=20catch=20now=20logs=20BLEManagerError.noData=20("not=20suppo?= =?UTF-8?q?rted")=20at=20.debug,=20keeps=20the=20disconnect-abort=20branch?= =?UTF-8?q?=20at=20.error,=20and=20logs=20genuine=20errors=20via=20"\(erro?= =?UTF-8?q?r)"=20at=20.error.=20WiFi's=20"no=20data"=20already=20returns?= =?UTF-8?q?=20nil=20and=20is=20skipped=20silently,=20so=20the=20BLE-specif?= =?UTF-8?q?ic=20check=20is=20complete.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/SwiftOBD2/Communication/BLE/bleManager.swift | 6 +++++- Sources/SwiftOBD2/elm327.swift | 11 +++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index 6e2ac1e..26b46cd 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -492,7 +492,7 @@ extension BLEManager: CBCentralManagerDelegate { } } -enum BLEManagerError: Error, CustomStringConvertible { +enum BLEManagerError: Error, CustomStringConvertible, LocalizedError { case missingPeripheralOrCharacteristic case unknownCharacteristic case scanTimeout @@ -543,4 +543,8 @@ enum BLEManagerError: Error, CustomStringConvertible { return "Error: Connection already active or in progress. Please disconnect before attempting a new connection." } } + + // Route localizedDescription through `description` so logs show the human message + // ("Error: No Data") instead of the bridged-NSError fallback ("… error 5."). + var errorDescription: String? { description } } diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index 100e058..ff1639a 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -564,7 +564,7 @@ extension ELM327 { for pidGetter in pidGetters { do { - logger.info("Getting supported PIDs for \(pidGetter.properties.command)") + logger.debug("Getting supported PIDs for \(pidGetter.properties.command)") let response = try await sendCommand(pidGetter.properties.command) // find first instance of 41 plus command sent, from there we determine the position of everything else // Ex. @@ -580,14 +580,21 @@ extension ELM327 { supportedPIDs.append(contentsOf: supportedCommands) } catch { - logger.error("\(error.localizedDescription)") // A transport drop fails every remaining getter the same way // (each one burning its full command timeout against a dead fd) // — stop the sweep instead of grinding through them. if connectionState == .disconnected { + logger.error("Supported-PID sweep aborted — connection lost: \(error)") obdDelegate?.logMessage("Supported-PID sweep aborted — connection lost") break } + // A "no data" refusal just means the car doesn't support this PID range / + // Mode — expected, so keep it at debug. Anything else is a real error. + if case BLEManagerError.noData = error { + logger.debug("\(pidGetter.properties.command): not supported (\(error))") + } else { + logger.error("\(pidGetter.properties.command): \(error)") + } } } // filter out pidGetters From bb877ad73172cb15a3518232c556935a3ff8df50 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sun, 26 Jul 2026 22:06:37 +0100 Subject: [PATCH 64/68] =?UTF-8?q?elm327.swift=20=E2=80=94=20guards=20an=20?= =?UTF-8?q?empty=20reply,=20strips=20the=20auto-mode=20A=20prefix=20only?= =?UTF-8?q?=20when=20present=20(accepts=20bare=20digits),=20and=20treats?= =?UTF-8?q?=20A0/0=20as=20noProtocolFound=20with=20an=20accurate=20log=20l?= =?UTF-8?q?ine=20instead=20of=20the=20misleading=20invalid=20ATDPN=20value?= =?UTF-8?q?.=20Failure=20behavior=20is=20unchanged=20(both=20still=20fall?= =?UTF-8?q?=20through=20to=20the=20manual=20sweep).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/SwiftOBD2/elm327.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index ff1639a..4b800d5 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -200,10 +200,20 @@ class ELM327 { logger.info("ATDPN response: \(obdProtocolNumber)") obdDelegate?.logMessage("ATDPN → \(obdProtocolNumber.joined(separator: " "))") - guard let obdProtocol = PROTOCOL(rawValue: String(obdProtocolNumber[0].dropFirst())) else { - let msg = "Protocol detect: invalid ATDPN value \(obdProtocolNumber)" + guard let first = obdProtocolNumber.first, !first.isEmpty else { + throw ELM327Error.invalidResponse(message: "Protocol detect: empty ATDPN response") + } + // ATDPN reports the active protocol, in auto mode prefixed with "A" (e.g. "A6"). + // Strip that marker; a bare digit ("6") is equally valid. "A0"/"0" means the + // auto-search hasn't latched onto a live bus — not an error, just nothing to + // return here, so let the manual sweep take over instead of surfacing a + // misleading "invalid ATDPN" message. (PROTOCOL.NONE's raw value is "NONE", + // not "0", so "0" correctly maps to nil.) + let token = first.hasPrefix("A") ? String(first.dropFirst()) : first + guard let obdProtocol = PROTOCOL(rawValue: token) else { + let msg = "Protocol detect: auto-search found no protocol (ATDPN \(obdProtocolNumber.joined(separator: " ")))" obdDelegate?.logMessage(msg) - throw ELM327Error.invalidResponse(message: msg) + throw ELM327Error.noProtocolFound } let valid = await testProtocol(obdProtocol) From 8535db80c96f498f552a1f892e07edf92dace41c Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Sat, 8 Aug 2026 22:39:29 +0100 Subject: [PATCH 65/68] make ECUID Sendable --- Sources/SwiftOBD2/parser.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftOBD2/parser.swift b/Sources/SwiftOBD2/parser.swift index 706a77e..b24147f 100644 --- a/Sources/SwiftOBD2/parser.swift +++ b/Sources/SwiftOBD2/parser.swift @@ -13,7 +13,11 @@ enum FrameType: UInt8, Codable { case consecutiveFrame = 0x20 } -public enum ECUID: UInt8, Codable { +/// `Sendable`: a `UInt8` raw enum with no associated values, so it is inherently +/// safe to pass across isolation domains. Declared explicitly because consumers +/// build with strict concurrency and default main-actor isolation, where a +/// `[ECUID: …]` dictionary otherwise cannot cross off the main actor. +public enum ECUID: UInt8, Codable, Sendable { case engine = 0x00 case transmission = 0x01 case unknown = 0x02 From 48fd01fee3efabc97a97fe42705e806d63e8c700 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Fri, 21 Aug 2026 08:35:28 +0100 Subject: [PATCH 66/68] Route all logging through OBDLogger 84 call sites used private Logger instances that OBDLogger.minimumLogLevel can't filter, so the consuming app's log-level preference only reached 56. Per-command traffic is now obdDebug, session milestones obdInfo, failures obdError. Warnings triaged one by one: OSLogType raw values are non-monotonic, so .error as a minimum also suppresses obdWarning (.default = 0). --- .../BLE/BLECharacteristicHandler.swift | 12 ++- .../Communication/BLE/BLEConnection.swift | 84 +++++++++---------- .../Communication/BLE/BLEDataProcessor.swift | 8 +- .../BLE/BLEPeripheralManager.swift | 8 +- .../Communication/BLE/BLEScanner.swift | 4 +- .../Communication/MacSerialManager.swift | 24 +++--- .../SwiftOBD2/Communication/mockManager.swift | 4 +- .../SwiftOBD2/Communication/wifiManager.swift | 25 +++--- Sources/SwiftOBD2/elm327.swift | 60 +++++++------ 9 files changed, 104 insertions(+), 125 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift b/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift index 9f10fdd..3c3be9a 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift @@ -1,12 +1,10 @@ import Foundation -import OSLog import CoreBluetooth class BLECharacteristicHandler { private var ecuReadCharacteristic: CBCharacteristic? private var ecuWriteCharacteristic: CBCharacteristic? private let messageProcessor: BLEMessageProcessor - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "BLECharacteristicHandler") // Device Information Service (0x180A) — Bluetooth SIG standard, all readable UTF-8 strings // except 2A23 (System ID, 8-byte binary) and 2A2A (IEEE cert, binary). @@ -53,7 +51,7 @@ class BLECharacteristicHandler { // ISSC UART — recognised, not used (FFF0 preferred) if Self.isscUUIDs.contains(uuid) { - logger.debug("ISSC UART characteristic recognised (unused): \(uuid)") + obdDebug("ISSC UART characteristic recognised (unused): \(uuid)", category: .bluetooth) continue } @@ -88,11 +86,11 @@ class BLECharacteristicHandler { ecuWriteCharacteristic = characteristic default: - logger.warning("Unknown characteristic: \(uuid) — properties: \(characteristic.properties.rawValue)") + obdInfo("Unknown characteristic: \(uuid) — properties: \(characteristic.properties.rawValue)", category: .bluetooth) } } - logger.info("Characteristics setup — Read: \(self.ecuReadCharacteristic != nil), Write: \(self.ecuWriteCharacteristic != nil)") + obdInfo("Characteristics setup — Read: \(self.ecuReadCharacteristic != nil), Write: \(self.ecuWriteCharacteristic != nil)", category: .bluetooth) } func discoverCharacteristics(for service: CBService, on peripheral: CBPeripheral) { @@ -136,9 +134,9 @@ class BLECharacteristicHandler { guard characteristic == ecuReadCharacteristic else { // A characteristic we don't handle produced a notification — log and ignore if let text = String(data: data, encoding: .utf8) { - logger.debug("Unhandled notification from \(uuid): \(text)") + obdDebug("Unhandled notification from \(uuid): \(text)", category: .bluetooth) } else { - logger.debug("Unhandled notification from \(uuid): \(data.map { String(format: "%02X", $0) }.joined(separator: " "))") + obdDebug("Unhandled notification from \(uuid): \(data.map { String(format: "%02X", $0) }.joined(separator: " "))", category: .bluetooth) } return } diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEConnection.swift b/Sources/SwiftOBD2/Communication/BLE/BLEConnection.swift index d9e0aa5..50dbeef 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEConnection.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEConnection.swift @@ -1,7 +1,6 @@ import Combine import CoreBluetooth import Foundation -import OSLog /// Protocol for BLE connection operations protocol BLEConnectionProtocol { @@ -19,7 +18,6 @@ protocol BLEConnectionProtocol { class BLEConnection: NSObject, BLEConnectionProtocol { // MARK: - Properties - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.swiftobd2.app", category: "BLEConnection") private weak var centralManager: CBCentralManager? private let supportedServices: [CBUUID] @@ -51,7 +49,7 @@ class BLEConnection: NSObject, BLEConnectionProtocol { self.centralManager = centralManager self.supportedServices = supportedServices super.init() - logger.debug("BLEConnection initialized with services: \(supportedServices.map(\.uuidString))") + obdDebug("BLEConnection initialized with services: \(supportedServices.map(\.uuidString))", category: .bluetooth) } static let defaultServices = [ @@ -75,7 +73,7 @@ class BLEConnection: NSObject, BLEConnectionProtocol { throw BLEConnectionError.alreadyConnected } - logger.info("Attempting to connect to peripheral: \(peripheral.name ?? peripheral.identifier.uuidString) with timeout: \(timeout)s") + obdInfo("Attempting to connect to peripheral: \(peripheral.name ?? peripheral.identifier.uuidString) with timeout: \(timeout)s", category: .bluetooth) return try await withTimeout( seconds: timeout, @@ -85,7 +83,7 @@ class BLEConnection: NSObject, BLEConnectionProtocol { if let completion = self?.connectionCompletion { completion(nil, BLEConnectionError.connectionTimeout) } - self?.logger.error("Connection timed out after \(timeout) seconds") + obdError("Connection timed out after \(timeout) seconds", category: .bluetooth) centralManager.cancelPeripheralConnection(peripheral) self?.resetConnectionState() // Clear the completion handler to prevent double-resuming @@ -99,20 +97,20 @@ class BLEConnection: NSObject, BLEConnectionProtocol { self.connectionCompletion = { [weak self] connectedPeripheral, error in // Ensure we only resume once guard !hasResumed else { - self?.logger.debug("Connection completion called but continuation already resumed") + obdDebug("Connection completion called but continuation already resumed", category: .bluetooth) return } hasResumed = true if let connectedPeripheral = connectedPeripheral { - self?.logger.info("Successfully connected and configured: \(connectedPeripheral.name ?? connectedPeripheral.identifier.uuidString)") + obdInfo("Successfully connected and configured: \(connectedPeripheral.name ?? connectedPeripheral.identifier.uuidString)", category: .bluetooth) continuation.resume(returning: ()) } else if let error = error { - self?.logger.error("Connection failed: \(error.localizedDescription)") + obdError("Connection failed: \(error.localizedDescription)", category: .bluetooth) self?.resetConnectionState() continuation.resume(throwing: error) } else { - self?.logger.error("Connection failed with unknown error") + obdError("Connection failed with unknown error", category: .bluetooth) self?.resetConnectionState() continuation.resume(throwing: BLEConnectionError.connectionFailed) } @@ -129,7 +127,7 @@ class BLEConnection: NSObject, BLEConnectionProtocol { // Stop scanning to avoid interference if centralManager.isScanning { centralManager.stopScan() - self.logger.debug("Stopped scanning to focus on connection") + obdDebug("Stopped scanning to focus on connection", category: .bluetooth) } } } @@ -138,11 +136,11 @@ class BLEConnection: NSObject, BLEConnectionProtocol { func disconnect() { guard let peripheral = connectedPeripheral else { - logger.debug("No peripheral connected to disconnect") + obdDebug("No peripheral connected to disconnect", category: .bluetooth) return } - logger.info("Disconnecting from peripheral: \(peripheral.name ?? peripheral.identifier.uuidString)") + obdInfo("Disconnecting from peripheral: \(peripheral.name ?? peripheral.identifier.uuidString)", category: .bluetooth) centralManager?.cancelPeripheralConnection(peripheral) } @@ -154,7 +152,7 @@ class BLEConnection: NSObject, BLEConnectionProtocol { // Accept if we have at least one characteristic, or if read/write are the same (like FFE1) let hasCharacteristics = hasReadChar && (hasWriteChar || ecuReadCharacteristic == ecuWriteCharacteristic) - logger.debug("isReady check - Connection: \(hasConnection), Read: \(hasReadChar), Write: \(hasWriteChar), Same: \(self.ecuReadCharacteristic == self.ecuWriteCharacteristic)") + obdDebug("isReady check - Connection: \(hasConnection), Read: \(hasReadChar), Write: \(hasWriteChar), Same: \(self.ecuReadCharacteristic == self.ecuWriteCharacteristic)", category: .bluetooth) return hasConnection && hasCharacteristics } @@ -162,7 +160,7 @@ class BLEConnection: NSObject, BLEConnectionProtocol { // MARK: - Internal Connection Handling func handleDidConnect(_ peripheral: CBPeripheral) { - logger.info("Connected to peripheral: \(peripheral.name ?? "Unnamed")") + obdInfo("Connected to peripheral: \(peripheral.name ?? "Unnamed")", category: .bluetooth) connectedPeripheral = peripheral connectionState = .connectedToAdapter @@ -187,9 +185,9 @@ class BLEConnection: NSObject, BLEConnectionProtocol { func handleDidDisconnect(_ peripheral: CBPeripheral, error: Error?) { if let error = error { - logger.warning("Disconnected from peripheral with error: \(error.localizedDescription)") + obdError("Disconnected from peripheral with error: \(error.localizedDescription)", category: .bluetooth) } else { - logger.info("Disconnected from peripheral: \(peripheral.name ?? "Unnamed")") + obdInfo("Disconnected from peripheral: \(peripheral.name ?? "Unnamed")", category: .bluetooth) } resetConnectionState() @@ -197,13 +195,13 @@ class BLEConnection: NSObject, BLEConnectionProtocol { func handleDidFailToConnect(_: CBPeripheral, error: Error?) { let errorMessage = error?.localizedDescription ?? "Unknown error" - logger.error("Failed to connect to peripheral: \(errorMessage)") + obdError("Failed to connect to peripheral: \(errorMessage)", category: .bluetooth) // Only call completion if it hasn't been cleared by timeout if let completion = connectionCompletion { completion(nil, error ?? BLEConnectionError.connectionFailed) } else { - logger.debug("Connection failure handled but completion was already cleared (likely by timeout)") + obdDebug("Connection failure handled but completion was already cleared (likely by timeout)", category: .bluetooth) } } @@ -211,44 +209,44 @@ class BLEConnection: NSObject, BLEConnectionProtocol { func handleDidDiscoverServices(_ peripheral: CBPeripheral, error: Error?) { if let error = error { - logger.error("Service discovery failed: \(error.localizedDescription)") + obdError("Service discovery failed: \(error.localizedDescription)", category: .bluetooth) connectionTimeout?.cancel() // Only call completion if it hasn't been cleared by timeout if let completion = connectionCompletion { completion(nil, error) } else { - logger.debug("Service discovery failure handled but completion was already cleared (likely by timeout)") + obdDebug("Service discovery failure handled but completion was already cleared (likely by timeout)", category: .bluetooth) } return } guard let services = peripheral.services, !services.isEmpty else { - logger.error("No services found on peripheral") + obdError("No services found on peripheral", category: .bluetooth) connectionTimeout?.cancel() // Only call completion if it hasn't been cleared by timeout if let completion = connectionCompletion { completion(nil, BLEConnectionError.noServicesFound) } else { - logger.debug("No services found but completion was already cleared (likely by timeout)") + obdDebug("No services found but completion was already cleared (likely by timeout)", category: .bluetooth) } return } - logger.info("Discovered \(services.count) services") + obdInfo("Discovered \(services.count) services", category: .bluetooth) var compatibleServices = 0 for service in services { - logger.info("Discovered service: \(service.uuid.uuidString)") + obdInfo("Discovered service: \(service.uuid.uuidString)", category: .bluetooth) if supportedServices.contains(service.uuid) { compatibleServices += 1 discoverCharacteristicsForService(service, on: peripheral) } else { - logger.debug("Service \(service.uuid.uuidString) not in supported list, skipping") + obdDebug("Service \(service.uuid.uuidString) not in supported list, skipping", category: .bluetooth) } } if compatibleServices == 0 { - logger.warning("No compatible services found, but continuing anyway") + obdInfo("No compatible services found, but continuing anyway", category: .bluetooth) // Still try to discover characteristics for all services as fallback for service in services { discoverCharacteristicsForService(service, on: peripheral) @@ -258,12 +256,12 @@ class BLEConnection: NSObject, BLEConnectionProtocol { func handleDidDiscoverCharacteristics(_ peripheral: CBPeripheral, service: CBService, error: Error?) { if let error = error { - logger.error("Characteristic discovery failed: \(error.localizedDescription)") + obdError("Characteristic discovery failed: \(error.localizedDescription)", category: .bluetooth) return } guard let characteristics = service.characteristics, !characteristics.isEmpty else { - logger.warning("No characteristics found for service: \(service.uuid.uuidString)") + obdInfo("No characteristics found for service: \(service.uuid.uuidString)", category: .bluetooth) return } @@ -277,17 +275,17 @@ class BLEConnection: NSObject, BLEConnectionProtocol { // For some adapters, the same characteristic handles both read/write (like FFE1) if hasReadCharacteristic && (hasWriteCharacteristic || ecuReadCharacteristic == ecuWriteCharacteristic) { - logger.info("Required characteristics discovered and configured") + obdInfo("Required characteristics discovered and configured", category: .bluetooth) connectionTimeout?.cancel() // Cancel timeout since we succeeded connectionTimeout = nil // Only call completion if it hasn't been cleared by timeout if let completion = connectionCompletion { completion(peripheral, nil) } else { - logger.debug("Characteristics discovered but completion was already cleared (likely by timeout)") + obdDebug("Characteristics discovered but completion was already cleared (likely by timeout)", category: .bluetooth) } } else { - logger.debug("Still waiting for characteristics - Read: \(hasReadCharacteristic), Write: \(hasWriteCharacteristic)") + obdDebug("Still waiting for characteristics - Read: \(hasReadCharacteristic), Write: \(hasWriteCharacteristic)", category: .bluetooth) } } @@ -314,12 +312,12 @@ class BLEConnection: NSObject, BLEConnectionProtocol { let uuid = characteristic.uuid.uuidString.uppercased() let properties = characteristic.properties - logger.debug("Configuring characteristic \(uuid) with properties: \(String(describing: properties))") + obdDebug("Configuring characteristic \(uuid) with properties: \(String(describing: properties))", category: .bluetooth) // Enable notifications if supported if properties.contains(.notify) { peripheral.setNotifyValue(true, for: characteristic) - logger.debug("Enabled notifications for characteristic: \(uuid)") + obdDebug("Enabled notifications for characteristic: \(uuid)", category: .bluetooth) } // Assign characteristics based on UUID and properties @@ -327,47 +325,47 @@ class BLEConnection: NSObject, BLEConnectionProtocol { case "FFE1": // For service FFE0 - typically both read/write ecuWriteCharacteristic = characteristic ecuReadCharacteristic = characteristic - logger.info("Configured FFE1 as both read and write characteristic") + obdInfo("Configured FFE1 as both read and write characteristic", category: .bluetooth) case "FFF1": // For service FFF0 - typically read if properties.contains(.read) || properties.contains(.notify) { ecuReadCharacteristic = characteristic - logger.info("Configured FFF1 as read characteristic") + obdInfo("Configured FFF1 as read characteristic", category: .bluetooth) } case "FFF2": // For service FFF0 - typically write if properties.contains(.write) || properties.contains(.writeWithoutResponse) { ecuWriteCharacteristic = characteristic - logger.info("Configured FFF2 as write characteristic") + obdInfo("Configured FFF2 as write characteristic", category: .bluetooth) } case "2AF0": // For service 18F0 - typically read ecuReadCharacteristic = characteristic - logger.info("Configured 2AF0 as read characteristic") + obdInfo("Configured 2AF0 as read characteristic", category: .bluetooth) case "2AF1": // For service 18F0 - typically write ecuWriteCharacteristic = characteristic - logger.info("Configured 2AF1 as write characteristic") + obdInfo("Configured 2AF1 as write characteristic", category: .bluetooth) default: - logger.debug("Unknown characteristic \(uuid), attempting auto-assignment based on properties") + obdDebug("Unknown characteristic \(uuid), attempting auto-assignment based on properties", category: .bluetooth) // Fallback: auto-assign based on properties if we don't have characteristics yet if ecuReadCharacteristic == nil && (properties.contains(.read) || properties.contains(.notify)) { ecuReadCharacteristic = characteristic - logger.info("Auto-assigned \(uuid) as read characteristic based on properties") + obdInfo("Auto-assigned \(uuid) as read characteristic based on properties", category: .bluetooth) } if ecuWriteCharacteristic == nil && (properties.contains(.write) || properties.contains(.writeWithoutResponse)) { ecuWriteCharacteristic = characteristic - logger.info("Auto-assigned \(uuid) as write characteristic based on properties") + obdInfo("Auto-assigned \(uuid) as write characteristic based on properties", category: .bluetooth) } // If it supports both, assign as both (like FFE1) if properties.contains(.read) && properties.contains(.write) && ecuReadCharacteristic == nil && ecuWriteCharacteristic == nil { ecuReadCharacteristic = characteristic ecuWriteCharacteristic = characteristic - logger.info("Auto-assigned \(uuid) as both read and write characteristic") + obdInfo("Auto-assigned \(uuid) as both read and write characteristic", category: .bluetooth) } } } @@ -387,7 +385,7 @@ class BLEConnection: NSObject, BLEConnectionProtocol { deinit { disconnect() connectionTimeout?.cancel() - logger.debug("BLEConnection deinitialized") + obdDebug("BLEConnection deinitialized", category: .bluetooth) } } diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift index 1da679d..8590136 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift @@ -1,11 +1,9 @@ import Combine import CoreBluetooth import Foundation -import OSLog class BLEMessageProcessor { private var buffer = Data() - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "BLEMessageProcessor") // messageCompletion is set from the waiting task and consumed from either the // BLE queue (response arrived) or the cancellation handler (timeout). Those two // can race; takeCompletion() makes the hand-off atomic so the continuation can @@ -22,7 +20,7 @@ class BLEMessageProcessor { completionLock.lock() defer { completionLock.unlock() } guard messageCompletion == nil else { - logger.error("Concurrent command detected — rejecting overlapping BLE command") + obdError("Concurrent command detected — rejecting overlapping BLE command", category: .bluetooth) return false } messageCompletion = completion @@ -45,7 +43,7 @@ class BLEMessageProcessor { guard let string = String(data: buffer, encoding: .utf8) else { if buffer.count > BLEConstants.maxBufferSize { - logger.warning("Buffer exceeded max size, clearing") + obdError("Buffer exceeded max size, clearing", category: .bluetooth) buffer.removeAll() } return @@ -80,7 +78,7 @@ class BLEMessageProcessor { private func handleParsedResponse(_ lines: [String]) { guard let completion = takeCompletion() else { - logger.warning("Received response with no pending completion") + obdError("Received response with no pending completion", category: .bluetooth) return } diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift b/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift index 1e2e323..04534d0 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift @@ -1,5 +1,4 @@ import Foundation -import OSLog import CoreBluetooth import Combine @@ -13,7 +12,6 @@ class BLEPeripheralManager: NSObject, ObservableObject { } @Published var connectedPeripheral: CBPeripheral? - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "BLEPeripheralManager") private let characteristicHandler: BLECharacteristicHandler weak var delegate: BLEPeripheralManagerDelegate? @@ -65,14 +63,14 @@ class BLEPeripheralManager: NSObject, ObservableObject { func didDiscoverServices(_ peripheral: CBPeripheral, error: Error?) { for service in peripheral.services ?? [] { - logger.info("Discovered service: \(service.uuid.uuidString)") + obdInfo("Discovered service: \(service.uuid.uuidString)", category: .bluetooth) characteristicHandler.discoverCharacteristics(for: service, on: peripheral) } } func didDiscoverCharacteristics(_ peripheral: CBPeripheral, service: CBService, error: Error?) { if let error = error { - logger.error("Error discovering characteristics: \(error.localizedDescription)") + obdError("Error discovering characteristics: \(error.localizedDescription)", category: .bluetooth) setupCompletion.take()?(nil, error) return } @@ -96,7 +94,7 @@ class BLEPeripheralManager: NSObject, ObservableObject { func didUpdateValue(_: CBPeripheral, characteristic: CBCharacteristic, error: Error?) { if let error = error { - logger.error("Error reading characteristic value: \(error.localizedDescription)") + obdError("Error reading characteristic value: \(error.localizedDescription)", category: .bluetooth) return } diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift b/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift index 7606420..09b0a7f 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift @@ -1,7 +1,6 @@ import Combine import CoreBluetooth import Foundation -import OSLog /// Protocol for BLE scanning operations protocol BLEScannerProtocol { @@ -19,7 +18,6 @@ class BLEPeripheralScanner: ObservableObject { private let peripheralSubject = PassthroughSubject() - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "BLEPeripheralScanner") var peripheralPublisher: AnyPublisher { peripheralSubject.eraseToAnyPublisher() @@ -45,7 +43,7 @@ class BLEPeripheralScanner: ObservableObject { } else { foundPeripherals.append(peripheral) peripheralSubject.send(peripheral) - logger.info("Found new peripheral: \(peripheral.name ?? "Unnamed") - RSSI: \(rssi)") + obdInfo("Found new peripheral: \(peripheral.name ?? "Unnamed") - RSSI: \(rssi)", category: .bluetooth) } // Complete waiting continuation if exists diff --git a/Sources/SwiftOBD2/Communication/MacSerialManager.swift b/Sources/SwiftOBD2/Communication/MacSerialManager.swift index fa510f5..7a74a07 100644 --- a/Sources/SwiftOBD2/Communication/MacSerialManager.swift +++ b/Sources/SwiftOBD2/Communication/MacSerialManager.swift @@ -1,7 +1,6 @@ #if os(macOS) import Foundation import CoreBluetooth -import OSLog /// macOS backend for serial OBD adapters (e.g., USB to Serial). /// Uses POSIX file descriptors and termios for communication. @@ -25,21 +24,20 @@ final class MacSerialManager: CommProtocol { // of the continuation state. private var needsResync = false - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example", category: "MacSerial") func scanForPeripherals() async throws {} func connectAsync(timeout: TimeInterval, peripheral: CBPeripheral?) async throws { let path = UserDefaults.standard.string(forKey: "serialPath") ?? "" guard !path.isEmpty else { - logger.error("No serial path configured") + obdError("No serial path configured", category: .connection) throw CommunicationError.invalidData } fileDescriptor = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK) guard fileDescriptor >= 0 else { let err = NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) - logger.error("Failed to open \(path): \(err.localizedDescription)") + obdError("Failed to open \(path): \(err.localizedDescription)", category: .connection) throw CommunicationError.errorOccurred(err) } @@ -56,10 +54,10 @@ final class MacSerialManager: CommProtocol { for (baud, rate) in candidates { guard applyBaudRate(baud) else { continue } obdDelegate?.logMessage("Serial: probing \(path) at \(rate) baud…") - logger.info("Probing \(path) at \(rate) baud") + obdInfo("Probing \(path) at \(rate) baud", category: .connection) if await probeRespondsValidASCII() { - logger.info("Baud rate confirmed: \(rate)") + obdInfo("Baud rate confirmed: \(rate)", category: .connection) obdDelegate?.logMessage("Serial: \(rate) baud confirmed — adapter responding") // The probe reads for a fixed 1 s, but a slow adapter can still be // emitting its prompt afterwards. Drop any straggler bytes before @@ -114,7 +112,7 @@ final class MacSerialManager: CommProtocol { let hasPrompt = bytes.contains(UInt8(ascii: ">")) let valid = printable && hasPrompt let preview = String(bytes: bytes, encoding: .ascii) ?? "" - logger.info("Probe at fd=\(self.fileDescriptor): \(n) bytes, valid=\(valid), preview=\(preview)") + obdInfo("Probe at fd=\(self.fileDescriptor): \(n) bytes, valid=\(valid), preview=\(preview)", category: .connection) return valid } @@ -199,7 +197,7 @@ final class MacSerialManager: CommProtocol { throw CommunicationError.invalidData } if ConfigurationService.shared.serialVerboseLogging { - logger.info("→ \(command)") + obdInfo("→ \(command)", category: .connection) obdDelegate?.logMessage("TX: \(command)") } @@ -232,7 +230,7 @@ final class MacSerialManager: CommProtocol { guard let self, self.responseToken == token, let cont = self.responseContinuation else { return } - self.logger.warning("Timeout waiting for response to: \(command)") + obdError("Timeout waiting for response to: \(command)", category: .connection) self.obdDelegate?.logMessage("Serial: 20s timeout waiting for '\(command)' — no data received") self.responseContinuation = nil self.responseToken = nil @@ -250,8 +248,8 @@ final class MacSerialManager: CommProtocol { write(fileDescriptor, ptr.baseAddress, bytes.count) } if written != bytes.count { - logger.warning("writeBytes: sent \(written)/\(bytes.count) bytes, errno=\(errno)") - logger.warning("writeBytes partial: \(written)/\(bytes.count) bytes") + obdError("writeBytes: sent \(written)/\(bytes.count) bytes, errno=\(errno)", category: .connection) + obdError("writeBytes partial: \(written)/\(bytes.count) bytes", category: .connection) } } @@ -297,7 +295,7 @@ final class MacSerialManager: CommProtocol { private func handleReceivedData(_ chunk: String) { let printable = chunk.replacingOccurrences(of: "\r", with: "↵").replacingOccurrences(of: "\n", with: "↵") if ConfigurationService.shared.serialVerboseLogging { - logger.info("← \(printable)") + obdInfo("← \(printable)", category: .connection) obdDelegate?.logMessage("RX: \(printable)") } @@ -318,7 +316,7 @@ final class MacSerialManager: CommProtocol { @MainActor private func handleError(errno err: Int32) { let reason = String(cString: strerror(err)) - logger.error("Serial read error (errno \(err): \(reason)), disconnecting") + obdError("Serial read error (errno \(err): \(reason)), disconnecting", category: .connection) obdDelegate?.logMessage("Serial: read error — errno \(err) (\(reason)) — disconnecting") disconnectPeripheral() } diff --git a/Sources/SwiftOBD2/Communication/mockManager.swift b/Sources/SwiftOBD2/Communication/mockManager.swift index 4a2f37e..65daa6c 100644 --- a/Sources/SwiftOBD2/Communication/mockManager.swift +++ b/Sources/SwiftOBD2/Communication/mockManager.swift @@ -6,7 +6,6 @@ // import Foundation -import OSLog import CoreBluetooth enum CommandAction { @@ -23,7 +22,6 @@ struct MockECUSettings { } class MOCKComm: CommProtocol { - let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "MOCKComm") @Published var connectionState: ConnectionState = .disconnected var connectionStatePublisher: Published.Publisher { $connectionState } @@ -32,7 +30,7 @@ class MOCKComm: CommProtocol { var ecuSettings: MockECUSettings = .init() func sendCommand(_ command: String, retries: Int = 3) async throws -> [String] { - logger.info("Sending command: \(command)") + obdInfo("Sending command: \(command)", category: .service) var header = "" let prefix = String(command.prefix(2)) diff --git a/Sources/SwiftOBD2/Communication/wifiManager.swift b/Sources/SwiftOBD2/Communication/wifiManager.swift index fb469ad..bfa19f6 100644 --- a/Sources/SwiftOBD2/Communication/wifiManager.swift +++ b/Sources/SwiftOBD2/Communication/wifiManager.swift @@ -8,7 +8,6 @@ import CoreBluetooth import Foundation import Network -import OSLog // CommProtocol and CommunicationError are defined in CommProtocol.swift @@ -83,8 +82,6 @@ private final class ConnectOnce: @unchecked Sendable { class WifiManager: CommProtocol { @Published var connectionState: ConnectionState = .disconnected - let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "wifiManager") - var obdDelegate: OBDServiceDelegate? var connectionStatePublisher: Published.Publisher { $connectionState } @@ -142,15 +139,15 @@ class WifiManager: CommProtocol { guard let self = self else { return } switch newState { case .ready: - self.logger.info("Connected to \(host.debugDescription):\(port.debugDescription)") + obdInfo("Connected to \(host.debugDescription):\(port.debugDescription)", category: .wifi) self.connectionState = .connectedToAdapter gate.finishSuccess() case let .waiting(error): // The Local Network permission prompt parks the connection here until the user // answers, so don't fail fast — the timeout above is the only stop condition. - self.logger.warning("Connection waiting: \(error.localizedDescription)") + obdInfo("Connection waiting: \(error.localizedDescription)", category: .wifi) case let .failed(error): - self.logger.error("Connection failed: \(error.localizedDescription)") + obdError("Connection failed: \(error.localizedDescription)", category: .connection) self.connectionState = .disconnected gate.finish(throwing: CommunicationError.errorOccurred(error)) case .cancelled: @@ -170,7 +167,7 @@ class WifiManager: CommProtocol { guard let data = "\(command)\r".data(using: .ascii) else { throw CommunicationError.invalidData } - logger.info("Sending: \(command)") + obdDebug("Sending: \(command)", category: .communication) // ATZ resets the adapter hardware — most WiFi ELM327 adapters drop the TCP // connection immediately after. Fire-and-forget the command, wait for the @@ -253,14 +250,14 @@ class WifiManager: CommProtocol { if let lines = processResponse(response) { return lines } else if attempt < attempts { - logger.info("No data received, retrying attempt \(attempt + 1) of \(attempts)...") + obdDebug("No data received, retrying attempt \(attempt + 1) of \(attempts)...", category: .communication) try await Task.sleep(nanoseconds: 100_000_000) // 0.1 second delay } } catch { if attempt == attempts { throw error } - logger.warning("Attempt \(attempt) failed, retrying: \(error.localizedDescription)") + obdDebug("Attempt \(attempt) failed, retrying: \(error.localizedDescription)", category: .communication) } } throw CommunicationError.invalidData @@ -270,8 +267,6 @@ class WifiManager: CommProtocol { guard let tcpConnection = tcp else { throw CommunicationError.invalidData } - let logger = self.logger - let gate = ResumeOnce() return try await withCheckedThrowingContinuation { continuation in @@ -284,7 +279,7 @@ class WifiManager: CommProtocol { tcpConnection.send(content: data, completion: .contentProcessed { error in if let error = error { - logger.error("Error sending data: \(error.localizedDescription)") + obdError("Error sending data: \(error.localizedDescription)", category: .communication) gate.finish(throwing: CommunicationError.errorOccurred(error)) // The socket is broken — cancel so the stateUpdateHandler // publishes .disconnected and the app can react to the drop. @@ -298,7 +293,7 @@ class WifiManager: CommProtocol { tcpConnection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { chunk, _, isComplete, error in if gate.isDone { return } if let error = error { - logger.error("Error receiving data: \(error.localizedDescription)") + obdError("Error receiving data: \(error.localizedDescription)", category: .communication) gate.finish(throwing: gate.accumulated.isEmpty ? CommunicationError.errorOccurred(error) : CommunicationError.invalidData) @@ -324,11 +319,11 @@ class WifiManager: CommProtocol { } private func processResponse(_ response: String) -> [String]? { - logger.info("Processing response: \(response)") + obdDebug("Processing response: \(response)", category: .communication) var lines = response.components(separatedBy: .newlines).filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } guard !lines.isEmpty else { - logger.warning("Empty response lines") + obdDebug("Empty response lines", category: .communication) return nil } diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index 4b800d5..085de9a 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -16,7 +16,6 @@ import Combine import CoreBluetooth import Foundation -import OSLog enum ELM327Error: Error, LocalizedError { case noProtocolFound @@ -54,7 +53,6 @@ class ELM327 { // private var obdProtocol: PROTOCOL = .NONE var canProtocol: CANProtocol? - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.com", category: "ELM327") private var comm: CommProtocol private var cancellables = Set() @@ -86,7 +84,7 @@ class ELM327 { // The assignment's didSet already notifies obdDelegate — this // sink is the single delivery channel for transport states. self?.connectionState = state - self?.logger.debug("Connection state updated: \(state.description)") + obdDebug("Connection state updated: \(state.description)", category: .connection) } .store(in: &cancellables) } @@ -144,20 +142,20 @@ class ELM327 { /// - Returns: The detected `PROTOCOL`. /// - Throws: `ELM327Error` if detection fails. private func detectProtocol(preferredProtocol: PROTOCOL? = nil) async throws -> PROTOCOL { - logger.info("Starting protocol detection...") + obdInfo("Starting protocol detection...", category: .protocol) if let protocolToTest = preferredProtocol { let msg = "Protocol detect: testing preferred \(protocolToTest.description)…" - logger.info("\(msg)") + obdInfo(msg, category: .protocol) obdDelegate?.logMessage(msg) if await testProtocol(protocolToTest) { let found = "Protocol found: \(protocolToTest.description)" - logger.info("\(found)") + obdInfo(found, category: .protocol) obdDelegate?.logMessage(found) return protocolToTest } else { let fallback = "Preferred protocol \(protocolToTest.description) failed — falling back to auto-detect" - logger.warning("\(fallback)") + obdInfo(fallback, category: .protocol) obdDelegate?.logMessage(fallback) } } else { @@ -166,13 +164,13 @@ class ELM327 { return try await detectProtocolAutomatically() } catch { let msg = "Auto-detect failed (\(error.localizedDescription)) — trying manual sweep…" - logger.warning("\(msg)") + obdInfo(msg, category: .protocol) obdDelegate?.logMessage(msg) return try await detectProtocolManually() } } - logger.error("Failed to detect a compatible OBD protocol.") + obdError("Failed to detect a compatible OBD protocol.", category: .protocol) obdDelegate?.logMessage("Protocol detect: no protocol found — giving up") throw ELM327Error.noProtocolFound } @@ -192,12 +190,12 @@ class ELM327 { obdDelegate?.logMessage("Protocol detect: sending 0100 — waiting for vehicle…") let resp100 = try? await sendCommand("0100", retries: 2) - logger.info("0100 raw response: \(String(describing: resp100))") + obdInfo("0100 raw response: \(String(describing: resp100))", category: .protocol) obdDelegate?.logMessage("0100 → \(resp100.map { $0.joined(separator: " ") } ?? "no response")") obdDelegate?.logMessage("Protocol detect: querying ATDPN…") let obdProtocolNumber = try await sendCommand("ATDPN") - logger.info("ATDPN response: \(obdProtocolNumber)") + obdInfo("ATDPN response: \(obdProtocolNumber)", category: .protocol) obdDelegate?.logMessage("ATDPN → \(obdProtocolNumber.joined(separator: " "))") guard let first = obdProtocolNumber.first, !first.isEmpty else { @@ -218,7 +216,7 @@ class ELM327 { let valid = await testProtocol(obdProtocol) let protocolMsg = "Detected protocol: \(obdProtocol.description) (valid=\(valid))" - logger.info("\(protocolMsg)") + obdInfo(protocolMsg, category: .protocol) obdDelegate?.logMessage(protocolMsg) return obdProtocol @@ -239,14 +237,14 @@ class ELM327 { .protocolA, .protocolB, .protocolC, ] for protocolOption in sweepOrder { - self.logger.info("Testing protocol: \(protocolOption.description)") + obdInfo("Testing protocol: \(protocolOption.description)", category: .protocol) _ = try await okResponse(protocolOption.cmd) if await testProtocol(protocolOption, retries: 1) { return protocolOption } } /// If we reach this point, no protocol was found - logger.error("No protocol found") + obdError("No protocol found", category: .protocol) throw ELM327Error.noProtocolFound } @@ -260,13 +258,13 @@ class ELM327 { let raw = response?.joined(separator: " ") ?? "no response" if let response, response.contains(where: { $0.range(of: #"41\s*00"#, options: .regularExpression) != nil }) { let msg = "Protocol \(obdProtocol.description) ✓ (0100 → \(raw))" - logger.info("\(msg)") + obdInfo(msg, category: .protocol) obdDelegate?.logMessage(msg) r100 = response return true } else { let msg = "Protocol \(obdProtocol.description) ✗ (0100 → \(raw))" - logger.warning("\(msg)") + obdInfo(msg, category: .protocol) obdDelegate?.logMessage(msg) return false } @@ -288,14 +286,14 @@ class ELM327 { /// - Parameter setupOrder: A list of commands to send in order. /// - Throws: Various setup-related errors. func adapterInitialization() async throws { - logger.info("Initializing ELM327 adapter...") + obdInfo("Initializing ELM327 adapter...", category: .connection) obdDelegate?.logMessage("Adapter init: sending ATZ (reset)…") do { // ATZ is the first command after the port opens and the ELM327 is still // settling, so the very first reset is occasionally lost. Retry it rather // than failing the whole connection on a single dropped frame. let atzResp = try await sendCommand("ATZ", retries: 3) - logger.info("ATZ response: \(atzResp)") + obdInfo("ATZ response: \(atzResp)", category: .connection) obdDelegate?.logMessage("ATZ → \(atzResp.joined(separator: " | "))") // The port can still be settling for the first few commands after ATZ @@ -315,10 +313,10 @@ class ELM327 { obdDelegate?.logMessage("Adapter init: ATSP0 (auto protocol)…") _ = try await okResponse("ATSP0", retries: 3) obdDelegate?.logMessage("ATSP0 → OK — adapter ready") - logger.info("ELM327 adapter initialized successfully.") + obdInfo("ELM327 adapter initialized successfully.", category: .connection) } catch { let msg = "Adapter init FAILED: \(error.localizedDescription)" - logger.error("\(msg)") + obdError(msg, category: .connection) obdDelegate?.logMessage(msg) throw ELM327Error.adapterInitializationFailed } @@ -347,7 +345,7 @@ class ELM327 { let result = try await comm.sendCommand(message, retries: retries) if ConfigurationService.shared.obdCommandLogging { let response = result.joined(separator: " | ") - logger.info("CMD \(message) → \(response)") + obdInfo("CMD \(message) → \(response)", category: .communication) obdDelegate?.logMessage("CMD \(message) → \(response)") } return result @@ -362,16 +360,16 @@ class ELM327 { if response.contains("OK") { return response } else { - logger.error("Invalid response: \(response)") + obdError("Invalid response: \(response)", category: .communication) throw ELM327Error.invalidResponse(message: "message: \(message), \(String(describing: response.first))") } } func getStatus() async throws -> Result { - logger.info("Getting status") + obdDebug("Getting status", category: .service) let statusCommand = OBDCommand.Mode1.status let statusResponse = try await sendCommand(statusCommand.properties.command) - logger.debug("Status response: \(statusResponse)") + obdDebug("Status response: \(statusResponse)", category: .service) guard let statusData = try canProtocol?.parse(statusResponse).first?.data else { return .failure(.noData) } @@ -382,7 +380,7 @@ class ELM327 { } func scanForTroubleCodes() async throws -> [ECUID: [TroubleCode]] { - logger.info("Scanning for trouble codes") + obdInfo("Scanning for trouble codes", category: .service) var dtcs: [ECUID: [TroubleCode]] = [:] // Mode $03 — confirmed codes. This is the primary scan; let its errors @@ -423,7 +421,7 @@ class ELM327 { } result[message.ecu, default: []].append(contentsOf: tagged) case let .failure(error): - logger.error("Failed to decode DTC: \(error)") + obdError("Failed to decode DTC: \(error)", category: .parsing) } } return result @@ -542,7 +540,7 @@ extension ELM327 { for message in messages { guard let bits = message.data?.bitCount() else { - logger.error("parse_frame failed to extract data") + obdError("parse_frame failed to extract data", category: .parsing) continue } if bits > bestBits { @@ -574,7 +572,7 @@ extension ELM327 { for pidGetter in pidGetters { do { - logger.debug("Getting supported PIDs for \(pidGetter.properties.command)") + obdDebug("Getting supported PIDs for \(pidGetter.properties.command)", category: .protocol) let response = try await sendCommand(pidGetter.properties.command) // find first instance of 41 plus command sent, from there we determine the position of everything else // Ex. @@ -594,16 +592,16 @@ extension ELM327 { // (each one burning its full command timeout against a dead fd) // — stop the sweep instead of grinding through them. if connectionState == .disconnected { - logger.error("Supported-PID sweep aborted — connection lost: \(error)") + obdError("Supported-PID sweep aborted — connection lost: \(error)", category: .connection) obdDelegate?.logMessage("Supported-PID sweep aborted — connection lost") break } // A "no data" refusal just means the car doesn't support this PID range / // Mode — expected, so keep it at debug. Anything else is a real error. if case BLEManagerError.noData = error { - logger.debug("\(pidGetter.properties.command): not supported (\(error))") + obdDebug("\(pidGetter.properties.command): not supported (\(error))", category: .protocol) } else { - logger.error("\(pidGetter.properties.command): \(error)") + obdError("\(pidGetter.properties.command): \(error)", category: .protocol) } } } From 57e75b4df934aa3440399326260fcb92856234a7 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Thu, 17 Sep 2026 21:36:25 +0100 Subject: [PATCH 67/68] =?UTF-8?q?Fix=09File=20isWorthRetrying(=5F:)=20spli?= =?UTF-8?q?ts=20transport=20drops=20(retry)=20from=20vehicle-answered-defi?= =?UTF-8?q?nitively=20(noProtocolFound,=20ignitionOff,=20=E2=80=A6)=20whic?= =?UTF-8?q?h=20now=20propagate=20=E2=80=94=20no=20more=20double=20protocol?= =?UTF-8?q?=20sweep=20under=20the=20app's=20own=20attempts:=202=09obd2serv?= =?UTF-8?q?ice.swift=20ATDPN=20"A"=20stripped=20only=20when=20something=20?= =?UTF-8?q?follows,=20so=20manual-mode=20J1939=20is=20detected=09elm327.sw?= =?UTF-8?q?ift=20Supported-PID=20matching=20scoped=20to=20the=20getter's?= =?UTF-8?q?=20own=20mode,=20so=20Mode=206=20MID=20bitmaps=20stop=20vouchin?= =?UTF-8?q?g=20for=20Mode=201=20PIDs=09elm327.swift=20try=20await=20Task.s?= =?UTF-8?q?leep=20in=20the=20retry=20backoff,=20so=20a=20disconnect=20mid-?= =?UTF-8?q?backoff=20stops=20the=20loop=09bleManager.swift=20Retry=20loop?= =?UTF-8?q?=20bails=20on=20a=20.cancelled/.failed=20socket=20instead=20of?= =?UTF-8?q?=20burning=20attempts=20against=20a=20dead=20NWConnection=09wif?= =?UTF-8?q?iManager.swift=20public=20init=20on=20Status=20/=20StatusTest?= =?UTF-8?q?=09decoders.swift=20strerror=5Fr=20instead=20of=20strerror=09Ma?= =?UTF-8?q?cSerialManager.swift=20Verification:=20swift=20build=20clean=20?= =?UTF-8?q?with=20no=20new=20warnings=20(the=20two=20MacSerialManager=20Se?= =?UTF-8?q?ndable=20warnings=20are=20pre-existing,=20at=20lines=20150=20an?= =?UTF-8?q?d=20210=20=E2=80=94=20nowhere=20near=20my=20edit),=20swift=20te?= =?UTF-8?q?st=2037/37=20passing,=20and=20build=5Fsim=20on=20the=20App=20sc?= =?UTF-8?q?heme=20succeeded=20with=20zero=20warnings.=20I=20used=20swift?= =?UTF-8?q?=20build=20for=20the=20package=20because=20XcodeBuildMCP's=20Sw?= =?UTF-8?q?iftPM=20tools=20aren't=20enabled=20here=20and=20it=20isn't=20am?= =?UTF-8?q?ong=20the=20commands=20CLAUDE.md=20rules=20out;=20the=20app=20i?= =?UTF-8?q?tself=20went=20through=20build=5Fsim=20as=20normal.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Left alone: the ELM327.connectionState cross-isolation read. The sink is .receive(on: .main) while the new guards read from an arbitrary executor, so the sweep's abort-on-disconnect is a hop late and the read races. Fixing it means deciding ELM327's isolation — bigger than a review follow-up, and documented as such. Fix doc at docs/fix/2026-09-17-bugfix-obd-logging-toggle-and-swiftobd2-pr6-review.md, with a matching ### Fixed bullet in CHANGELOG.md. --- .../Communication/BLE/bleManager.swift | 5 +++- .../Communication/MacSerialManager.swift | 7 ++++- .../SwiftOBD2/Communication/wifiManager.swift | 13 ++++++++++ Sources/SwiftOBD2/decoders.swift | 7 ++++- Sources/SwiftOBD2/elm327.swift | 21 ++++++++++++--- Sources/SwiftOBD2/obd2service.swift | 26 ++++++++++++++++++- 6 files changed, 71 insertions(+), 8 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index 6d77f2c..dfe5442 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -398,7 +398,10 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { } obdDebug("Retrying after error (attempt \(attempt)/\(attempts)): \(command) - \(error.localizedDescription)", category: .communication) - try? await Task.sleep(nanoseconds: UInt64(BLEConstants.retryDelay * 1_000_000_000)) + // `try`, not `try?`: swallowing the CancellationError here meant a user + // disconnect landing during the backoff still issued the next attempt + // against a link that is on its way down. + try await Task.sleep(nanoseconds: UInt64(BLEConstants.retryDelay * 1_000_000_000)) } } // Unreachable — the loop above always returns or throws on its last iteration. diff --git a/Sources/SwiftOBD2/Communication/MacSerialManager.swift b/Sources/SwiftOBD2/Communication/MacSerialManager.swift index 7a74a07..005c531 100644 --- a/Sources/SwiftOBD2/Communication/MacSerialManager.swift +++ b/Sources/SwiftOBD2/Communication/MacSerialManager.swift @@ -315,7 +315,12 @@ final class MacSerialManager: CommProtocol { @MainActor private func handleError(errno err: Int32) { - let reason = String(cString: strerror(err)) + // strerror_r, not strerror: the latter returns a pointer into a shared static + // buffer that another thread's call can overwrite mid-read. + var buffer = [CChar](repeating: 0, count: 256) + let reason = strerror_r(err, &buffer, buffer.count) == 0 + ? String(cString: buffer) + : "unknown error" obdError("Serial read error (errno \(err): \(reason)), disconnecting", category: .connection) obdDelegate?.logMessage("Serial: read error — errno \(err) (\(reason)) — disconnecting") disconnectPeripheral() diff --git a/Sources/SwiftOBD2/Communication/wifiManager.swift b/Sources/SwiftOBD2/Communication/wifiManager.swift index 3d404ff..326c588 100644 --- a/Sources/SwiftOBD2/Communication/wifiManager.swift +++ b/Sources/SwiftOBD2/Communication/wifiManager.swift @@ -257,6 +257,19 @@ class WifiManager: CommProtocol { if attempt == attempts { throw error } + // A fatal socket error cancels the connection (see sendAndReceiveData), and + // a cancelled or failed NWConnection never recovers — every remaining + // attempt would fail instantly against a dead socket, burning the retry + // budget and the sleeps between them for nothing. + if let state = tcp?.state { + switch state { + case .cancelled, .failed: + obdDebug("Socket is \(state) — abandoning remaining attempts", category: .communication) + throw error + default: + break + } + } obdDebug("Attempt \(attempt) failed, retrying: \(error.localizedDescription)", category: .communication) } } diff --git a/Sources/SwiftOBD2/decoders.swift b/Sources/SwiftOBD2/decoders.swift index e4d78cc..930a275 100644 --- a/Sources/SwiftOBD2/decoders.swift +++ b/Sources/SwiftOBD2/decoders.swift @@ -43,6 +43,11 @@ public struct Status: Codable, Hashable { public var oxygenSensorMonitoring = StatusTest() public var oxygenSensorHeaterMonitoring = StatusTest() public var egrOrVvtMonitoring = StatusTest() + + // The fields above are public but the synthesized memberwise initializer is not, so + // without this a consumer could read a decoded `Status` and never build one — no + // previews, no test fixtures, no placeholder while a read is in flight. + public init() {} } public struct StatusTest: Codable, Hashable { @@ -50,7 +55,7 @@ public struct StatusTest: Codable, Hashable { public var supported: Bool = false public var ready: Bool = false - init(_ name: String = "", _ supported: Bool = false, _ ready: Bool = false) { + public init(_ name: String = "", _ supported: Bool = false, _ ready: Bool = false) { self.name = name self.supported = supported self.ready = ready diff --git a/Sources/SwiftOBD2/elm327.swift b/Sources/SwiftOBD2/elm327.swift index 89e6dd8..395fb66 100644 --- a/Sources/SwiftOBD2/elm327.swift +++ b/Sources/SwiftOBD2/elm327.swift @@ -207,7 +207,12 @@ class ELM327 { // return here, so let the manual sweep take over instead of surfacing a // misleading "invalid ATDPN" message. (PROTOCOL.NONE's raw value is "NONE", // not "0", so "0" correctly maps to nil.) - let token = first.hasPrefix("A") ? String(first.dropFirst()) : first + // + // Only strip the marker when something follows it: `PROTOCOL.protocolA`'s own raw + // value is "A", so a bare "A" is protocol A (SAE J1939) reported in manual mode, + // not an empty auto-mode token. Stripping unconditionally turned that into "" and + // sent a correctly-detected J1939 bus down the manual sweep. + let token = (first.hasPrefix("A") && first.count > 1) ? String(first.dropFirst()) : first guard let obdProtocol = PROTOCOL(rawValue: token) else { let msg = "Protocol detect: auto-search found no protocol (ATDPN \(obdProtocolNumber.joined(separator: " ")))" obdDelegate?.logMessage(msg) @@ -623,9 +628,17 @@ extension ELM327 { continue } - let supportedCommands = OBDCommand.allCommands - .filter { supportedPidsByECU.contains(String($0.properties.command.dropFirst(2))) } - .map { $0 } + // Match within the getter's own mode. `dropFirst(2)` strips the mode, so an + // unqualified match let one mode's bitmap vouch for another's same-numbered + // command: the sweep includes the Mode 6 MID getters (0600, 0620, … 06A0), + // whose bitmaps enumerate Mode 6 MIDs, and MID 0x01 being supported marked + // Mode 1 PID 01 supported (and vice versa). A bitmap only ever describes its + // own mode, so the mode has to be part of the match. + let mode = String(pidGetter.properties.command.prefix(2)) + let supportedCommands = OBDCommand.allCommands.filter { + $0.properties.command.hasPrefix(mode) + && supportedPidsByECU.contains(String($0.properties.command.dropFirst(2))) + } supportedPIDs.append(contentsOf: supportedCommands) } catch { diff --git a/Sources/SwiftOBD2/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index 8c37792..834ba23 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -167,7 +167,7 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab public func startConnection(preferedProtocol: PROTOCOL? = nil, timeout: TimeInterval = 7, peripheral: CBPeripheral? = nil) async throws -> OBDInfo { do { return try await attemptConnection(preferedProtocol: preferedProtocol, timeout: timeout, peripheral: peripheral) - } catch OBDServiceError.adapterConnectionFailed { + } catch OBDServiceError.adapterConnectionFailed(let underlying) where Self.isWorthRetrying(underlying) { // A transient link drop mid-handshake (the adapter/port still settling // right after open) is common on the very first connect and otherwise // forces the user to manually retry — one clean retry here covers it. @@ -179,6 +179,30 @@ public class OBDService: ObservableObject, OBDServiceDelegate, @unchecked Sendab // immediately would just double that wait for no benefit, so it propagates as-is. } + /// Which handshake failures are worth one immediate retry. + /// + /// A transport that dropped while the adapter or port was still settling usually + /// succeeds on the second try and costs a second or two to find out. A vehicle-level + /// failure does not: `noProtocolFound` has already paid for the ELM327's own ATSP0 + /// auto-search *plus* a full 12-protocol manual sweep, each miss costing `ATSPn` + + /// `0100` + a full command timeout. Repeating that doubles a wait the user is already + /// sitting through and ends with the same answer — and because a consuming app may + /// well retry on top of this one, a retry here is never as cheap as it looks. + private static func isWorthRetrying(_ error: Error) -> Bool { + guard let elmError = error as? ELM327Error else { + // Transport-level errors (BLE/WiFi/serial) are the transient case this exists for. + return true + } + switch elmError { + case .noProtocolFound, .invalidProtocol, .ignitionOff, .invalidResponse: + // The vehicle answered (or definitively didn't) — asking again changes nothing. + return false + case .adapterInitializationFailed, .connectionFailed, .timeout, .unknownError: + // The link itself faltered; this is the drop a second attempt recovers from. + return true + } + } + private func attemptConnection(preferedProtocol: PROTOCOL?, timeout: TimeInterval, peripheral: CBPeripheral?) async throws -> OBDInfo { let startTime = CFAbsoluteTimeGetCurrent() obdInfo("Starting connection with timeout: \(timeout)s", category: .connection) From ff69b919875ba19f901f2f8db05f55bb214b4d24 Mon Sep 17 00:00:00 2001 From: Vlad Alexa Date: Thu, 17 Sep 2026 22:40:36 +0100 Subject: [PATCH 68/68] Restore Bluetooth state so the app wakes for a known adapter Without a restore identifier iOS never relaunched a suspended or terminated app when the dongle powered up, so a drive that started before the app was opened was never recorded. Creating the central manager last: with restoration the system delivers willRestoreState as the first delegate callback, and that handler reaches into peripheralManager, which the old ordering had not built yet. Co-Authored-By: Claude Opus 5 --- .../Communication/BLE/bleManager.swift | 86 +++++++++++++++++-- 1 file changed, 78 insertions(+), 8 deletions(-) diff --git a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift index dfe5442..9a3cdf2 100644 --- a/Sources/SwiftOBD2/Communication/BLE/bleManager.swift +++ b/Sources/SwiftOBD2/Communication/BLE/bleManager.swift @@ -54,6 +54,20 @@ enum BLEConstants { static let maxBufferSize = 1024 static let bluetoothPowerOnTimeout: TimeInterval = 30.0 static let pollingInterval: UInt64 = 100_000_000 // 100ms in nanoseconds + + /// Identifies this central to CoreBluetooth across process launches. + /// + /// Supplying it is what opts the host app into state preservation and restoration: + /// iOS remembers the central's connections and relaunches the app in the background + /// when a previously connected peripheral reappears, delivering + /// `centralManager(_:willRestoreState:)` before any other delegate callback. Without + /// it, an app the system has suspended or terminated simply never wakes for the + /// dongle, and a drive that starts before the app is opened is not recorded at all. + /// + /// Must stay stable: changing it orphans whatever the system has already preserved. + /// The host app also needs the `bluetooth-central` background mode, which + /// EvmetricsOBD already declares. + static let centralRestoreIdentifier = "com.swiftobd2.central.restore" } class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { @@ -98,15 +112,12 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { super.init() // Use background queue for better performance, but dispatch UI updates to main queue let bleQueue = DispatchQueue(label: "com.swiftobd2.ble", qos: .userInitiated) - - centralManager = CBCentralManager( - delegate: self, - queue: bleQueue, - options: [ - CBCentralManagerOptionShowPowerAlertKey: true, - ] - ) + // Components first, central manager second. With a restore identifier the system + // delivers `willRestoreState` as the very first delegate callback, right after the + // central is created, and that handler reaches straight into `peripheralManager`. + // These are implicitly-unwrapped, so creating the central ahead of them (as this + // did) would crash on a restore launch. messageProcessor = BLEMessageProcessor() characteristicHandler = BLECharacteristicHandler(messageProcessor: messageProcessor) peripheralManager = BLEPeripheralManager(characteristicHandler: characteristicHandler) @@ -117,6 +128,15 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { self?.obdDelegate?.adapterInfoUpdated(info) } } + + centralManager = CBCentralManager( + delegate: self, + queue: bleQueue, + options: [ + CBCentralManagerOptionShowPowerAlertKey: true, + CBCentralManagerOptionRestoreIdentifierKey: BLEConstants.centralRestoreIdentifier, + ] + ) } // MARK: - Central Manager Control Methods @@ -246,6 +266,49 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { obdError("Unexpected connection event: \(event.rawValue)", category: .bluetooth) } + /// Reattach to whatever CoreBluetooth was holding for us in a previous process. + /// + /// Called when iOS relaunches the app in the background because a preserved + /// connection came back, and also on an ordinary launch when the system still holds + /// state for this central. It arrives before `centralManagerDidUpdateState`, so the + /// central is not necessarily powered on yet; all this does is re-adopt the objects, + /// and the normal state machine takes over from there. + /// + /// Restored peripherals are the same `CBPeripheral` instances the system had, but + /// their delegates are not restored, so anything already connected has to be handed + /// back to `peripheralManager` to re-attach the delegate and rediscover services. + /// A peripheral still mid-connect is tracked as pending instead, so a later + /// disconnect has something to cancel. + func didRestoreState(_: CBCentralManager, restored: [CBPeripheral]) { + guard !restored.isEmpty else { + obdDebug("Bluetooth restore: nothing preserved", category: .bluetooth) + return + } + + if let connected = restored.first(where: { $0.state == .connected }) { + obdInfo("Bluetooth restore: resuming \(connected.name ?? "Unnamed")", category: .bluetooth) + pendingConnectPeripheral = nil + // Re-attaches the delegate and rediscovers services, exactly as didConnect + // does, so characteristics set up and the ELM327 session resumes through the + // existing path rather than a parallel one. + peripheralManager.setPeripheral(connected) + return + } + + if let connecting = restored.first(where: { $0.state == .connecting }) { + obdInfo("Bluetooth restore: connect still in flight to \(connecting.name ?? "Unnamed")", + category: .bluetooth) + pendingConnectPeripheral = connecting + let oldState = connectionState + connectionState = .connecting + OBDLogger.shared.logConnectionChange(from: oldState, to: connectionState) + return + } + + obdDebug("Bluetooth restore: \(restored.count) peripheral(s), none connected", + category: .bluetooth) + } + // MARK: - Async Methods func connectAsync(timeout: TimeInterval, peripheral: CBPeripheral? = nil) async throws { @@ -489,6 +552,13 @@ extension BLEManager: CBCentralManagerDelegate { didUpdateState(central) } + /// Must be implemented for state restoration to work at all: CoreBluetooth only + /// preserves a central's state if its delegate responds to this. + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { + let restored = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? [] + didRestoreState(central, restored: restored) + } + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { didFailToConnect(central, peripheral: peripheral, error: error) }