diff --git a/Readme.md b/Readme.md index fff334f6..6781ffa0 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 diff --git a/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift b/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift index 086b0589..3c3be9a7 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLECharacteristicHandler.swift @@ -1,66 +1,97 @@ 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") + private var ecuWriteCharacteristic: CBCharacteristic? + private let messageProcessor: BLEMessageProcessor + + // 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) { + obdDebug("ISSC UART characteristic recognised (unused): \(uuid)", category: .bluetooth) + 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 + ecuReadCharacteristic = characteristic + + case "2AF1": // 18F0 service — write + ecuWriteCharacteristic = characteristic + + default: + obdInfo("Unknown characteristic: \(uuid) — properties: \(characteristic.properties.rawValue)", category: .bluetooth) + } + } + + obdInfo("Characteristics setup — Read: \(self.ecuReadCharacteristic != nil), Write: \(self.ecuWriteCharacteristic != nil)", category: .bluetooth) + } func discoverCharacteristics(for service: CBService, on peripheral: CBPeripheral) { switch service.uuid { @@ -71,6 +102,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 +112,31 @@ 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)") + // 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) { + 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) { + obdDebug("Unhandled notification from \(uuid): \(text)", category: .bluetooth) + } else { + obdDebug("Unhandled notification from \(uuid): \(data.map { String(format: "%02X", $0) }.joined(separator: " "))", category: .bluetooth) } return } @@ -99,5 +147,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/BLEConnection.swift b/Sources/SwiftOBD2/Communication/BLE/BLEConnection.swift index d9e0aa5e..50dbeef1 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 4c9b1bb0..1ae1baa6 100644 --- a/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift +++ b/Sources/SwiftOBD2/Communication/BLE/BLEDataProcessor.swift @@ -1,30 +1,91 @@ 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 + // never be resumed twice. + private let completionLock = NSLock() private var messageCompletion: (([String]?, Error?) -> Void)? - func processReceivedData(_ data: Data) { + /// 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() + defer { completionLock.unlock() } + guard messageCompletion == nil else { + obdError("Concurrent command detected — rejecting overlapping BLE command", category: .bluetooth) + return false + } + messageCompletion = completion + return true + } + + private func takeCompletion() -> (([String]?, Error?) -> Void)? { + completionLock.lock() + defer { completionLock.unlock() } + let completion = messageCompletion + 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 - 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() + func processReceivedData(_ data: Data) { + let snapshot = appendAndSnapshotBuffer(data) + + guard let string = String(data: snapshot, encoding: .utf8) else { + if snapshot.count > BLEConstants.maxBufferSize { + obdError("Buffer exceeded max size, clearing", category: .bluetooth) + clearBuffer() } 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) - buffer.removeAll() + clearBuffer() } } @@ -36,16 +97,15 @@ 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 } private func handleParsedResponse(_ lines: [String]) { - let completion = messageCompletion - messageCompletion = nil - - guard let completion = completion else { - logger.warning("Received response with no pending completion") + guard let completion = takeCompletion() else { + obdError("Received response with no pending completion", category: .bluetooth) return } @@ -60,34 +120,57 @@ class BLEMessageProcessor { func waitForResponse(timeout: TimeInterval) async throws -> [String] { - try await withTimeout(seconds: timeout, timeoutError: BLEMessageProcessorError.responseTimeout) { [self] in - 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) - } 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 + let claimed = setCompletion { 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) + } + } + // 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) + // 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 = takeBuffer() + _ = takeCompletion() + 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 } } + } func reset() { - buffer.removeAll() - let completion = messageCompletion - messageCompletion = nil - + clearBuffer() // Call completion with error if it exists - completion?(nil, BLEManagerError.peripheralNotConnected) + takeCompletion()?(nil, BLEManagerError.peripheralNotConnected) } } @@ -98,6 +181,7 @@ enum BLEMessageProcessorError: Error, LocalizedError { case writeOperationFailed case responseTimeout case invalidResponseData + case commandInFlight var errorDescription: String? { switch self { @@ -109,6 +193,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" } } } diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift b/Sources/SwiftOBD2/Communication/BLE/BLEPeripheralManager.swift index 60edf63b..04534d00 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,11 +12,13 @@ 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? - 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,33 +35,43 @@ 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()) } } } 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)") - connectionCompletion?(nil, error) + obdError("Error discovering characteristics: \(error.localizedDescription)", category: .bluetooth) + setupCompletion.take()?(nil, error) return } @@ -70,23 +81,32 @@ 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) } } 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 } guard let data = characteristic.value else { return } characteristicHandler.handleUpdatedValue(data, from: characteristic) } + + func reset() { + connectedPeripheral?.delegate = nil + connectedPeripheral = nil + setupCompletion.take()?(nil, BLEManagerError.peripheralNotConnected) + } } extension BLEPeripheralManager: CBPeripheralDelegate { diff --git a/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift b/Sources/SwiftOBD2/Communication/BLE/BLEScanner.swift index a324c741..09b0a7f0 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() @@ -31,7 +29,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 @@ -42,12 +43,11 @@ 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 - foundPeripheralCompletion?(peripheral, nil) - foundPeripheralCompletion = nil // Clear after calling + foundPeripheralCompletion.take()?(peripheral, nil) } func waitForFirstPeripheral(timeout: TimeInterval) async throws -> CBPeripheral { @@ -57,20 +57,37 @@ 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() + foundPeripheralCompletion.take()?(nil, BLEScannerError.scanTimeout) + } } // MARK: - CBPeripheralDelegate @@ -103,7 +120,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 00000000..e7d7053f --- /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 4e086990..9a3cdf2a 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 @@ -54,14 +54,26 @@ 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 { private let peripheralSubject = PassthroughSubject() // Replaced with centralized logging - see connectionStateDidChange for usage - static let RestoreIdentifierKey: String = "OBD2Adapter" - // MARK: Properties @Published var connectionState: ConnectionState = .disconnected @@ -79,7 +91,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() @@ -93,20 +112,31 @@ 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) - + + // 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) + peripheralScanner = BLEPeripheralScanner() + + characteristicHandler.onDeviceInfoUpdated = { [weak self] info in + DispatchQueue.main.async { + self?.obdDelegate?.adapterInfoUpdated(info) + } + } + centralManager = CBCentralManager( delegate: self, queue: bleQueue, options: [ CBCentralManagerOptionShowPowerAlertKey: true, - CBCentralManagerOptionRestoreIdentifierKey: BLEManager.RestoreIdentifierKey, + CBCentralManagerOptionRestoreIdentifierKey: BLEConstants.centralRestoreIdentifier, ] ) - - messageProcessor = BLEMessageProcessor() - characteristicHandler = BLECharacteristicHandler(messageProcessor: messageProcessor) - peripheralManager = BLEPeripheralManager(characteristicHandler: characteristicHandler) - peripheralScanner = BLEPeripheralScanner() } // MARK: - Central Manager Control Methods @@ -132,8 +162,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 @@ -144,10 +185,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: @@ -155,22 +196,24 @@ 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) } } func centralManagerDidPowerOn() { - guard let device = peripheralManager.connectedPeripheral else { - startScanning(BLEPeripheralScanner.supportedServices) - return - } - connect(to: device) + // Scanning is initiated explicitly by the caller (Dongle tab / scanForDevices). } 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(found) + } } func connect(to peripheral: CBPeripheral) { @@ -180,11 +223,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() @@ -193,6 +233,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 } @@ -201,14 +242,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. + 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?) { @@ -221,16 +262,51 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { resetConfigure() } - 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) + func connectionEventDidOccur(_: CBCentralManager, event: CBConnectionEvent, peripheral _: CBPeripheral) { + 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 } - } - func connectionEventDidOccur(_: CBCentralManager, event: CBConnectionEvent, peripheral _: CBPeripheral) { - obdError("Unexpected connection event: \(event.rawValue)", category: .bluetooth) + 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 @@ -238,34 +314,70 @@ class BLEManager: NSObject, CommProtocol, BLEPeripheralManagerDelegate { func connectAsync(timeout: TimeInterval, peripheral: CBPeripheral? = nil) async throws { try await waitForPoweredOn() - if connectionState.isConnected { + switch connectionState { + case .connectedToAdapter, .connectedToVehicle: obdInfo("Already connected to peripheral", category: .bluetooth) return + 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 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) - 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) 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 + let newState: ConnectionState = error is CancellationError ? .disconnected : .error + if oldState != .disconnected, oldState != newState { + connectionState = newState + OBDLogger.shared.logConnectionChange(from: oldState, to: newState) + } + throw error + } } func peripheralManager(_ manager: BLEPeripheralManager, didSetupCharacteristics peripheral: CBPeripheral) { 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) } @@ -311,23 +423,70 @@ 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 { - obdError("Command failed: \(command) - \(error.localizedDescription)", category: .communication) - throw error + // `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. The WiFi transport + // always honored retries, so the same vehicle behaved differently per transport: + // K-line detection (ISO 9141 / KWP 5-baud init takes 5-10 s inside the ELM327 + // while it prints "SEARCHING...") could never fit BLE's single 3 s window. + 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`, 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. + throw BLEManagerError.timeout + } + + 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, 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 } @@ -337,17 +496,40 @@ 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() + peripheralScanner.reset() + let oldState = connectionState connectionState = .disconnected if oldState != connectionState { OBDLogger.shared.logConnectionChange(from: oldState, to: connectionState) - - DispatchQueue.main.async { - self.obdDelegate?.connectionStateChanged(state: .disconnected) - } + obdDelegate?.peripheralsUpdated([]) + } + } + + /// 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() { + let target = peripheralManager.connectedPeripheral ?? pendingConnectPeripheral + stopScan() + resetConfigure() + if let target { + centralManager.cancelPeripheralConnection(target) } } } @@ -370,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) } @@ -377,13 +566,9 @@ 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 { +enum BLEManagerError: Error, CustomStringConvertible, LocalizedError { case missingPeripheralOrCharacteristic case unknownCharacteristic case scanTimeout @@ -398,6 +583,7 @@ enum BLEManagerError: Error, CustomStringConvertible { case unknownError case unsupported case unauthorized + case connectionInProgress public var description: String { switch self { @@ -429,6 +615,12 @@ 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." } } + + // 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/Communication/CommProtocol.swift b/Sources/SwiftOBD2/Communication/CommProtocol.swift new file mode 100644 index 00000000..31459859 --- /dev/null +++ b/Sources/SwiftOBD2/Communication/CommProtocol.swift @@ -0,0 +1,49 @@ +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 } + /// 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 + +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 + /// 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 { + 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." + case .connectionClosed: return "The connection closed before the adapter finished responding." + } + } +} diff --git a/Sources/SwiftOBD2/Communication/MacSerialManager.swift b/Sources/SwiftOBD2/Communication/MacSerialManager.swift new file mode 100644 index 00000000..005c5318 --- /dev/null +++ b/Sources/SwiftOBD2/Communication/MacSerialManager.swift @@ -0,0 +1,335 @@ +#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 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 + + + func scanForPeripherals() async throws {} + + func connectAsync(timeout: TimeInterval, peripheral: CBPeripheral?) async throws { + let path = UserDefaults.standard.string(forKey: "serialPath") ?? "" + guard !path.isEmpty else { + 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)) + obdError("Failed to open \(path): \(err.localizedDescription)", category: .connection) + throw CommunicationError.errorOccurred(err) + } + + // 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), + (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…") + obdInfo("Probing \(path) at \(rate) baud", category: .connection) + + if await probeRespondsValidASCII() { + 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 + // 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 + } 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 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) + + writeBytes("ATI\r") + + // 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 hasPrompt = bytes.contains(UInt8(ascii: ">")) + let valid = printable && hasPrompt + let preview = String(bytes: bytes, encoding: .ascii) ?? "" + obdInfo("Probe at fd=\(self.fileDescriptor): \(n) bytes, valid=\(valid), preview=\(preview)", category: .connection) + return valid + } + + 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] { + 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] { + // 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 + 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") + } + } + } + } + + func disconnectPeripheral() { + if fileDescriptor >= 0 { + close(fileDescriptor) + fileDescriptor = -1 + } + readTask?.cancel() + readTask = 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() { + disconnectPeripheral() + } + + private func sendRaw(_ command: String) async throws -> String { + guard fileDescriptor >= 0 else { + throw CommunicationError.invalidData + } + if ConfigurationService.shared.serialVerboseLogging { + obdInfo("→ \(command)", category: .connection) + obdDelegate?.logMessage("TX: \(command)") + } + + 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 + 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 + 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") + + // 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 } + 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 + self.needsResync = true + cont.resume(throwing: CommunicationError.invalidData) + } + } + } + } + + private func writeBytes(_ string: String) { + guard fileDescriptor >= 0 else { return } + let bytes = Array(string.utf8) + let written = bytes.withUnsafeBufferPointer { ptr in + write(fileDescriptor, ptr.baseAddress, bytes.count) + } + if written != bytes.count { + obdError("writeBytes: sent \(written)/\(bytes.count) bytes, errno=\(errno)", category: .connection) + obdError("writeBytes partial: \(written)/\(bytes.count) bytes", category: .connection) + } + } + + 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 { + // 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 ready > 0 { + let bytesRead = read(fd, buffer, bufferSize) + if bytesRead > 0 { + 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 { + let err = errno + await self.handleError(errno: err) + break + } + } + } + } + } + + @MainActor + private func handleReceivedData(_ chunk: String) { + let printable = chunk.replacingOccurrences(of: "\r", with: "↵").replacingOccurrences(of: "\n", with: "↵") + if ConfigurationService.shared.serialVerboseLogging { + obdInfo("← \(printable)", category: .connection) + obdDelegate?.logMessage("RX: \(printable)") + } + + if isMonitoring { + monitorFrames.append(contentsOf: parseLines(chunk)) + } else { + receiveBuffer += chunk + if receiveBuffer.contains(">") { + let raw = receiveBuffer + receiveBuffer = "" + responseContinuation?.resume(returning: raw) + responseContinuation = nil + responseToken = nil + } + } + } + + @MainActor + private func handleError(errno err: Int32) { + // 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() + } + + 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/Communication/SerialManager.swift b/Sources/SwiftOBD2/Communication/SerialManager.swift new file mode 100644 index 00000000..938d8072 --- /dev/null +++ b/Sources/SwiftOBD2/Communication/SerialManager.swift @@ -0,0 +1,219 @@ +#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? + private var responseToken: UUID? + + // 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] { + // 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 + 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 + } + } + } + } + + func disconnectPeripheral() { + inputStream?.remove(from: .main, forMode: .common) + outputStream?.remove(from: .main, forMode: .common) + inputStream?.close() + outputStream?.close() + inputStream = nil + outputStream = nil + session = 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() } + + // MARK: - Private + + private func sendRaw(_ command: String) async throws -> String { + guard let output = outputStream, output.streamStatus == .open else { + 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 + 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) + } + } + } + } + + 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 + responseToken = nil + monitorContinuation?.resume(returning: monitorFrames) + monitorContinuation = nil + monitorEndDate = 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 + responseToken = nil + } + } + } +} +#endif diff --git a/Sources/SwiftOBD2/Communication/mockManager.swift b/Sources/SwiftOBD2/Communication/mockManager.swift index 57461f80..e9eb6e74 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)) @@ -42,8 +40,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.. [String] { + return [] + } + func disconnectPeripheral() { connectionState = .disconnected obdDelegate?.connectionStateChanged(state: .disconnected) @@ -192,6 +201,10 @@ class MOCKComm: CommProtocol { func scanForPeripherals() async throws { } + + func reset() { + disconnectPeripheral() + } } extension OBDCommand { @@ -338,6 +351,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/Communication/wifiManager.swift b/Sources/SwiftOBD2/Communication/wifiManager.swift index e7b41f0c..326c5885 100644 --- a/Sources/SwiftOBD2/Communication/wifiManager.swift +++ b/Sources/SwiftOBD2/Communication/wifiManager.swift @@ -8,59 +8,158 @@ import CoreBluetooth import Foundation import Network -import OSLog - -protocol CommProtocol { - func sendCommand(_ command: String, retries: Int) async throws -> [String] - func disconnectPeripheral() - func connectAsync(timeout: TimeInterval, peripheral: CBPeripheral?) async throws - func scanForPeripherals() async throws - var connectionStatePublisher: Published.Publisher { get } - var obdDelegate: OBDServiceDelegate? { get set } + +// 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) + } } -enum CommunicationError: Error { - case invalidData - case errorOccurred(Error) +// 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 - let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "wifiManager") - var obdDelegate: OBDServiceDelegate? var connectionStatePublisher: Published.Publisher { $connectionState } var tcp: NWConnection? - func connectAsync(timeout _: TimeInterval, peripheral _: CBPeripheral? = nil) async throws { - let host = NWEndpoint.Host("192.168.0.10") - guard let port = NWEndpoint.Port("35000") else { + 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(hostString) + guard let port = NWEndpoint.Port(portString) else { throw CommunicationError.invalidData } - tcp = 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() 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)") + obdInfo("Connected to \(host.debugDescription):\(port.debugDescription)", category: .wifi) self.connectionState = .connectedToAdapter - continuation.resume(returning: ()) + gate.finishSuccess() case let .waiting(error): - self.logger.warning("Connection waiting: \(error.localizedDescription)") + // 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. + 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: + // 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 - continuation.resume(throwing: CommunicationError.errorOccurred(error)) + gate.finish(throwing: CommunicationError.cancelled) default: break } } - tcp?.start(queue: .main) + connection.start(queue: .main) } } @@ -68,25 +167,110 @@ 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 + // 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 + // 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"] + } + return try await sendCommandInternal(data: data, retries: retries) } + func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] { + 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 { + 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 == retries { + if attempt == attempts { throw error } - logger.warning("Attempt \(attempt) failed, retrying: \(error.localizedDescription)") + // 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) } } throw CommunicationError.invalidData @@ -94,50 +278,87 @@ 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 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)) + 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. + tcpConnection.cancel() 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 - } + // Accumulate TCP chunks until the ELM327 '>' prompt is received. + // A single receive() call may only return a partial response. + func readNext() { + tcpConnection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { chunk, _, isComplete, error in + if gate.isDone { return } + if let error = error { + obdError("Error receiving data: \(error.localizedDescription)", category: .communication) + gate.finish(throwing: gate.accumulated.isEmpty + ? CommunicationError.errorOccurred(error) + : CommunicationError.invalidData) + tcpConnection.cancel() + 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 - } + if let chunk, let str = String(data: chunk, encoding: .utf8) { + gate.append(str) + } - continuation.resume(returning: responseString) + 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() + } + } } + + readNext() }) } } private func processResponse(_ response: String) -> [String]? { - logger.info("Processing response: \(response)") - var lines = response.components(separatedBy: .newlines).filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + obdDebug("Processing response: \(response)", category: .communication) + // 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") + obdDebug("Empty response lines", category: .communication) return nil } - if lines.last?.contains(">") == true { - lines.removeLast() - } - if lines.first?.lowercased() == "no data" { return nil } @@ -150,4 +371,8 @@ class WifiManager: CommProtocol { } func scanForPeripherals() async throws {} + + func reset() { + disconnectPeripheral() + } } diff --git a/Sources/SwiftOBD2/Logging/OBDLogger.swift b/Sources/SwiftOBD2/Logging/OBDLogger.swift index 1fcbf3d5..21402a98 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/Utils.swift b/Sources/SwiftOBD2/Utils.swift index 6583f050..76d77859 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 7a3012ea..5661292f 100644 --- a/Sources/SwiftOBD2/codes.swift +++ b/Sources/SwiftOBD2/codes.swift @@ -7,13 +7,79 @@ import Foundation -public struct TroubleCode: Codable, Hashable, Comparable { +/// 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 } 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, 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 + } } let codes: [String: String] = [ diff --git a/Sources/SwiftOBD2/commands.swift b/Sources/SwiftOBD2/commands.swift index 0829c703..649da4f6 100644 --- a/Sources/SwiftOBD2/commands.swift +++ b/Sources/SwiftOBD2/commands.swift @@ -67,11 +67,11 @@ 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) } } -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 @@ -220,9 +220,123 @@ 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 { + public enum Mode3: CaseIterable, Codable, Comparable, Sendable { case GET_DTC var properties: CommandProperties { switch self { @@ -240,7 +354,29 @@ public enum OBDCommand: Codable, Hashable, Comparable, Identifiable { } } - public enum Mode6: CaseIterable, Codable, Comparable { + 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 case MONITOR_O2_B1S2 @@ -331,7 +467,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 @@ -437,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) @@ -453,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) @@ -470,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) @@ -482,22 +618,25 @@ 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) - 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) @@ -514,10 +653,136 @@ 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) + + 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, 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) + 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) + } + } + + /// 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 } } } diff --git a/Sources/SwiftOBD2/decoders.swift b/Sources/SwiftOBD2/decoders.swift index 42af8238..930a275e 100644 --- a/Sources/SwiftOBD2/decoders.swift +++ b/Sources/SwiftOBD2/decoders.swift @@ -17,21 +17,45 @@ 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 = "" - - var misfireMonitoring = StatusTest() - var fuelSystemMonitoring = StatusTest() - var componentMonitoring = StatusTest() + public var ignitionType: String = "" + + 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, so a consuming app should relabel + // them using `ignitionType`. + 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() + + // 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() {} } -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) { + public init(_ name: String = "", _ supported: Bool = false, _ ready: Bool = false) { self.name = name self.supported = supported self.ready = ready @@ -61,13 +85,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") @@ -90,12 +126,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 { @@ -141,50 +179,58 @@ 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] = { 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), @@ -213,11 +259,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 { @@ -323,6 +378,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 @@ -365,10 +422,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 @@ -443,27 +501,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 let value = FuelTypes[safe: Int(i)] else { return .failure(.invalidData) } - return .success(.stringResult((value))) + return .success(.stringResult(value)) } } 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)))) } } @@ -478,14 +533,18 @@ 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 value = ((Double(a) * 256.0) + Double(b)) / 4.0 + // ((A*256)+B)/4 is ONE signed 16-bit value, so the sign fold belongs to the pair, + // not to each byte: B is the unsigned low half. Sign-folding B on its own (harmless + // while twosComp was a no-op, real now that it isn't) subtracted a full 256 counts + // from every reading whose low byte happened to be >= 0x80. + let raw = twosComp((Int(bytes[0]) << 8) | Int(bytes[1]), length: 16) + + let value = Double(raw) / 4.0 return .success((.measurementResult(MeasurementResult(value: value, unit: UnitPressure.kilopascals)))) } } @@ -552,11 +611,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)]))) @@ -694,6 +754,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 @@ -701,6 +772,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)))) } } @@ -723,17 +797,29 @@ 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 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)) } @@ -750,6 +836,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 ba03f931..395fb66c 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() @@ -81,10 +79,12 @@ class ELM327 { private func setupConnectionStateSubscriber() { comm.connectionStatePublisher .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.hashValue)") + obdDebug("Connection state updated: \(state.description)", category: .connection) } .store(in: &cancellables) } @@ -126,6 +126,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) } @@ -137,24 +142,36 @@ 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 { - logger.info("Attempting preferred protocol: \(protocolToTest.description)") + let msg = "Protocol detect: testing preferred \(protocolToTest.description)…" + obdInfo(msg, category: .protocol) + obdDelegate?.logMessage(msg) if await testProtocol(protocolToTest) { + let found = "Protocol found: \(protocolToTest.description)" + obdInfo(found, category: .protocol) + 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" + obdInfo(fallback, category: .protocol) + 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…" + 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 } @@ -162,35 +179,84 @@ 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") - + // 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", retries: 2) + 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") + obdInfo("ATDPN response: \(obdProtocolNumber)", category: .protocol) + obdDelegate?.logMessage("ATDPN → \(obdProtocolNumber.joined(separator: " "))") - guard let obdProtocol = PROTOCOL(rawValue: String(obdProtocolNumber[0].dropFirst())) else { - throw ELM327Error.invalidResponse(message: "Invalid protocol number: \(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.) + // + // 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) + throw ELM327Error.noProtocolFound } - _ = await testProtocol(obdProtocol) + let valid = await testProtocol(obdProtocol) + let protocolMsg = "Detected protocol: \(obdProtocol.description) (valid=\(valid))" + obdInfo(protocolMsg, category: .protocol) + obdDelegate?.logMessage(protocolMsg) 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 { - self.logger.info("Testing protocol: \(protocolOption.description)") + // Single attempt per protocol: each miss costs a full command timeout and the + // sweep is already the fallback path. + for protocolOption in Self.manualSweepOrder { + obdInfo("Testing protocol: \(protocolOption.description)", category: .protocol) _ = try await okResponse(protocolOption.cmd) - if await testProtocol(protocolOption) { + 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 } @@ -199,17 +265,19 @@ 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 { - // 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.") + 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))" + obdInfo(msg, category: .protocol) + 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))" + obdInfo(msg, category: .protocol) + obdDelegate?.logMessage(msg) return false } } @@ -220,22 +288,62 @@ 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. func adapterInitialization() async throws { - // [.ATZ, .ATD, .ATL0, .ATE0, .ATH1, .ATAT1, .ATRV, .ATDPN] - logger.info("Initializing ELM327 adapter...") + obdInfo("Initializing ELM327 adapter...", category: .connection) + 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 - logger.info("ELM327 adapter initialized successfully.") + // 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) + 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 + // (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", retries: 3) + obdDelegate?.logMessage("ATE0 → OK") + + obdDelegate?.logMessage("Adapter init: ATL0 ATH1 ATS0…") + _ = try await okResponse("ATL0", retries: 3) + _ = try await okResponse("ATS0", retries: 3) + _ = try await okResponse("ATH1", retries: 3) + 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", retries: 3) + obdDelegate?.logMessage("ATSP0 → OK — adapter ready") + obdInfo("ELM327 adapter initialized successfully.", category: .connection) } catch { - logger.error("Adapter initialization failed: \(error.localizedDescription)") + let msg = "Adapter init FAILED: \(error.localizedDescription)" + obdError(msg, category: .connection) + obdDelegate?.logMessage(msg) throw ELM327Error.adapterInitializationFailed } } @@ -244,6 +352,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 @@ -252,56 +368,142 @@ 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: " | ") + obdInfo("CMD \(message) → \(response)", category: .communication) + obdDelegate?.logMessage("CMD \(message) → \(response)") + } + return result } - private func okResponse(_ message: String) async throws -> [String] { - let response = try await sendCommand(message) + func sendMonitorCommand(_ command: String, duration: TimeInterval) async throws -> [String] { + try await comm.sendMonitorCommand(command, duration: duration) + } + + 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 { - 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)") - guard let statusData = try canProtocol?.parse(statusResponse).first?.data else { + obdDebug("Status response: \(statusResponse)", category: .service) + guard let messages = try canProtocol?.parse(statusResponse), !messages.isEmpty else { return .failure(.noData) } - return statusCommand.properties.decode(data: statusData) + // MIL / DTC count / monitor readiness are a real per-ECU reading, not a bitmap to + // 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) + } + // 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]] { + obdInfo("Scanning for trouble codes", category: .service) var dtcs: [ECUID: [TroubleCode]] = [:] - logger.info("Scanning for trouble codes") - let dtcCommand = OBDCommand.Mode3.GET_DTC - let dtcResponse = try await sendCommand(dtcCommand.properties.command) - 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)") + obdError("Failed to decode DTC: \(error)", category: .parsing) } } + 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] { + _ = 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 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], 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 + } + return result } func clearTroubleCodes() async throws { @@ -319,7 +521,10 @@ class ELM327 { return nil } - guard let data = try? canProtocol?.parse(vinResponse).first?.data, + // 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 = preferredECUMessage(messages, pidEcho: 0x02)?.data, var vinString = String(bytes: data, encoding: .utf8) else { return nil @@ -372,7 +577,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 { @@ -404,23 +609,54 @@ extension ELM327 { for pidGetter in pidGetters { do { - logger.info("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. // || || // 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 } - 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 { - 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 { + 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 { + obdDebug("\(pidGetter.properties.command): not supported (\(error))", category: .protocol) + } else { + obdError("\(pidGetter.properties.command): \(error)", category: .protocol) + } } } // filter out pidGetters @@ -430,20 +666,34 @@ extension ELM327 { return Array(Set(supportedPIDs)) } - private func parseResponse(_ response: [String]) -> Set? { - guard let ecuData = try? canProtocol?.parse(response).first?.data else { + /// 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], baseOffset: UInt8 = 0) -> Set? { + 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, 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/obd2service.swift b/Sources/SwiftOBD2/obd2service.swift index 1a547039..834ba234 100644 --- a/Sources/SwiftOBD2/obd2service.swift +++ b/Sources/SwiftOBD2/obd2service.swift @@ -5,11 +5,20 @@ import Foundation public enum ConnectionType: String, CaseIterable { case bluetooth = "Bluetooth" case wifi = "Wi-Fi" - case demo = "Demo" + case serial = "USB Serial" } 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 { @@ -22,9 +31,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 @@ -33,6 +42,26 @@ 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") } + } + public var serialPath: String { + 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. @@ -42,10 +71,19 @@ 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? + @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)? + public var onLog: ((String) -> Void)? @Published public var connectionType: ConnectionType { didSet { switchConnectionType(connectionType) @@ -73,9 +111,14 @@ public class OBDService: ObservableObject, OBDServiceDelegate { let bleManager = BLEManager() elm327 = ELM327(comm: bleManager) case .wifi: - elm327 = ELM327(comm: WifiManager()) - case .demo: - elm327 = ELM327(comm: MOCKComm()) + let config = ConfigurationService.shared + elm327 = ELM327(comm: WifiManager(host: config.wifiHost, port: config.wifiPort)) + case .serial: + #if os(iOS) + elm327 = ELM327(comm: SerialManager()) + #else + elm327 = ELM327(comm: MacSerialManager()) + #endif } #endif elm327.obdDelegate = self @@ -86,10 +129,33 @@ public class OBDService: ObservableObject, OBDServiceDelegate { 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) + } + } + + 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) + } + } + + public func logMessage(_ message: String) { + DispatchQueue.main.async { + self.onLog?(message) } } @@ -98,17 +164,56 @@ 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 { + do { + return try await attemptConnection(preferedProtocol: preferedProtocol, timeout: timeout, peripheral: peripheral) + } 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. + 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. + } + + /// 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) - + 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() - + obdDebug("Initializing vehicle connection...", category: .connection) let vehicleInfo = try await initializeVehicle(preferedProtocol) @@ -121,6 +226,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 } } @@ -140,6 +256,20 @@ public class OBDService: ObservableObject, OBDServiceDelegate { 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) + } + /// Switches the active connection type (between Bluetooth and Wi-Fi). /// /// - Parameter connectionType: The new desired connection type. @@ -154,9 +284,14 @@ public class OBDService: ObservableObject, OBDServiceDelegate { let bleManager = BLEManager() elm327 = ELM327(comm: bleManager) case .wifi: - elm327 = ELM327(comm: WifiManager()) - case .demo: - elm327 = ELM327(comm: MOCKComm()) + let config = ConfigurationService.shared + elm327 = ELM327(comm: WifiManager(host: config.wifiHost, port: config.wifiPort)) + case .serial: + #if os(iOS) + elm327 = ELM327(comm: SerialManager()) + #else + elm327 = ELM327(comm: MacSerialManager()) + #endif } elm327.obdDelegate = self } @@ -227,7 +362,15 @@ public class OBDService: ObservableObject, OBDServiceDelegate { 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()) @@ -243,6 +386,37 @@ public class OBDService: ObservableObject, OBDServiceDelegate { 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 = 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 + // [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. @@ -254,6 +428,15 @@ public class OBDService: ObservableObject, OBDServiceDelegate { } } + /// 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. @@ -292,6 +475,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) @@ -303,9 +494,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) } } @@ -373,6 +568,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 @@ -416,4 +634,5 @@ public struct VINInfo: Codable, Hashable { public let Model: String public let ModelYear: String public let EngineCylinders: String + public let Trim: String? } diff --git a/Sources/SwiftOBD2/parser.swift b/Sources/SwiftOBD2/parser.swift index 602abd80..12b5a788 100644 --- a/Sources/SwiftOBD2/parser.swift +++ b/Sources/SwiftOBD2/parser.swift @@ -13,10 +13,15 @@ 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 + case becm = 0x04 public var description: String { switch self { @@ -26,6 +31,8 @@ public enum ECUID: UInt8, Codable { return "Transmission" case .unknown: return "Unknown" + case .becm: + return "BECM" } } } @@ -44,11 +51,29 @@ public struct CANParser { .map { $0.replacingOccurrences(of: " ", with: "") } .filter(\.isHex) - frames = try obdLines.compactMap { try Frame(raw: $0, idBits: idBits) } - - let framesByECU = Dictionary(grouping: frames) { $0.txID } - - messages = try framesByECU.values.compactMap { try Message(frames: $0) } + // 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) } + + // 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. + messages = framesByECU.values.compactMap { try? Message(frames: $0) } } } @@ -60,6 +85,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 { @@ -79,7 +108,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 { @@ -87,9 +124,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 @@ -104,8 +159,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] } @@ -117,6 +176,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 @@ -148,6 +211,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 @@ -162,6 +226,12 @@ struct Frame { } } -enum ParserError: Error { +enum ParserError: Error, LocalizedError { case error(String) + + var errorDescription: String? { + switch self { + case .error(let message): return message + } + } } diff --git a/Sources/SwiftOBD2/protocols/protocol_can.swift b/Sources/SwiftOBD2/protocols/protocol_can.swift index c108d5ba..f1107ade 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/Sources/SwiftOBD2/protocols/protocol_legacy.swift b/Sources/SwiftOBD2/protocols/protocol_legacy.swift index 6a85a33a..8e9e063f 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) } } } @@ -38,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 { @@ -102,12 +111,25 @@ 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] } - // 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 @@ -140,6 +162,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 { @@ -148,13 +173,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 } } @@ -162,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 { diff --git a/Tests/SwiftOBD2Tests/elm327Test.swift b/Tests/SwiftOBD2Tests/elm327Test.swift index b541a9f7..0bebd5e8 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() } } diff --git a/Tests/SwiftOBD2Tests/takeOnceCompletionTests.swift b/Tests/SwiftOBD2Tests/takeOnceCompletionTests.swift new file mode 100644 index 00000000..0af915bd --- /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 + } +} diff --git a/Tests/SwiftOBD2Tests/test_protocol_can.swift b/Tests/SwiftOBD2Tests/test_protocol_can.swift index ffcba4f1..4686471b 100644 --- a/Tests/SwiftOBD2Tests/test_protocol_can.swift +++ b/Tests/SwiftOBD2Tests/test_protocol_can.swift @@ -39,4 +39,50 @@ final class test_protocol_can: XCTestCase { // to long } } + + /// 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 + /// 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), 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]), + Data([0x00, 0x98, 0x3B, 0x20, 0x13]), + ]) + } + } } + +let CAN_29_PROTOCOLS: [CANProtocol] = [ + ISO_15765_4_29bit_500k(), + ISO_15765_4_29bit_250k(), +] diff --git a/Tests/SwiftOBD2Tests/wifiManagerTests.swift b/Tests/SwiftOBD2Tests/wifiManagerTests.swift new file mode 100644 index 00000000..4c63fd67 --- /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)") + } + } + } +}