Overview

OBDManager provides real-time vehicle data over the OBD-II port through an ELM327-compatible adapter. It lives in src/managers/obdmanager.{h,cpp}, talks ELM327 directly, and is registered as the QML context property obdManager. On desktop an OBDConnectionWorker owns a serial port or direct RFCOMM transport on its own thread; on Android the same class switches (behind #ifdef Q_OS_ANDROID) to a BLE GATT link provided by the Java helper android/src/org/octave/app/OctaveOBDBridge.java.

The QML (OBDMenu, OBDHome, OBDDiagnostics, HomeOBDView, the OBD settings page and the gauge/dashboard system) sees the same signals, slots and properties on every platform. Where the desktop and Android paths differ, this page says so explicitly.

All Platforms Android: Java BLE bridge

Architecture and Files

FilePlatformDescription
src/managers/obdmanager.{h,cpp}AllOBDManager (main thread: timers, settings, signal dispatch table) and OBDConnectionWorker (worker thread: QSerialPort, init, polling, DTC, scan). The Android BLE state machine lives at the bottom of the .cpp under #ifdef Q_OS_ANDROID.
src/managers/elm327protocol.{h,cpp}AllStatic ELM327Protocol class (init commands, PID table with a commandName column, parsers, elmVoltageRequest() / parseElmVoltage(), supportedCommandNames(), defaultPids()), ResponseBuffer, and the octave.elm327 logging category.
android/src/org/octave/app/OctaveOBDBridge.javaAndroidStatic Java class wrapping BluetoothGatt. Called from C++ via QJniObject: connect, disconnect, write, stateCode, pollResponse, pollLog, lastError, lastEvent, bondedDevices (paired adapters as "MAC|name").
src/managers/obdadapterfinder.{h,cpp}, src/platform/bluezpairer.{h,cpp}All; full flow on LinuxOBDAdapterFinder (QML obdAdapterFinder): Bluetooth discovery, PIN pairing through BlueZ, trust, SPP channel lookup, then hand-off to connect_direct(). See Adapter Finder.
frontend/OBDAdapterSheet.qmlQMLThe "Connect OBD2 Adapter" sheet the finder drives.
frontend/OBDParameterModel.qmlQMLSingleton every gauge binds to: catalog metadata, liveValue(id) over the C++ paramStore, demo mode and the vehicle gate (see below).
src/sim/simelm327.{h,cpp}, src/sim/carsim.{h,cpp}SimulatorSimulated ELM327 QIODevice and car physics for octave --sim (and --dev, or the simulated adapter), plugged in below the real manager through OBDConnectionWorker::setCustomTransport() (addCustomTransport() registers one beside it) + OBDManager::setPortOverride("sim://elm327") by Simulator (the sim object). Built in every configuration, inert unless started.
src/platform/wasm/webbluetooth.{h,cpp}, src/platform/wasm/obdadapterfinder_wasm.cppBrowserThe browser demo's real adapter: a Bluetooth LE ELM327 over Web Bluetooth (Chrome / Edge). The browser's chooser picks it, a JS helper carries the GATT link (same vendor profiles as OctaveOBDBridge.java), and a QIODevice on port webbt://<id> (addCustomTransport()) feeds the unchanged worker. See Building > WebAssembly.
Desktop                                   Android
+---------------------+                   +-------------------+
| OBDManager          |                   | OBDManager        |
| (main thread)       |                   | (main thread,     |
+----------+----------+                   |  Q_OS_ANDROID)    |
           | QThread "obd-worker"         +---------+---------+
+----------+----------+                             | QJniObject
| OBDConnectionWorker |                   +---------+---------+
|  QSerialPort 38400  |                   | OctaveOBDBridge   |
|  or RfcommSocket    |                   |  BluetoothGatt    |
|  ELM327Protocol     |                   |  ISSC/FFF0/FFE0   |
+----------+----------+                   +---------+---------+
           |                                        | BLE notify
  /dev/rfcomm* (bound by the manager on             |
  Linux), ttyUSB*, COM*, /dev/tty.*,                |
  rfcomm://MAC                                      |
           |                                        |
+----------+----------------------------------------+---------+
|        ELM327 adapter  ->  OBD-II port  ->  vehicle ECU      |
+-------------------------------------------------------------+
        

QML Consumers

QML fileWhat it uses
frontend/OBDParameterModel.qmlServes the C++ paramStore (fed from parameterSampled in main.cpp) as liveValue(id) and the ParameterCatalog metadata. Gauges, the parameter-card grid and home cards read this shared model; the global RPM overlays also listen directly to rpmChanged.
frontend/OBDMenu.qmlParameter-card grid (one card per parameter the connected vehicle supports; registers the "cards" poll demand), the dashboards chooser/renderer, the RPM/shift-light settings popup. While no adapter is connected no dashboard renders at all (obdConnected gates the renderer, the cards grid, swipe and the page dots): the page shows "No vehicle connected" and a "Connect Bluetooth OBD2" button (obdConnectButton) that opens OBDAdapterSheet, whichever dashboard is active.
frontend/OBDAdapterSheet.qmlEverything on obdAdapterFinder; in fallback mode availableAdapters, scan() and connect_to_adapter(); connected closes the sheet 1.5 s after the adapter comes up.
frontend/OBDHome.qmlStatus row bound to connectionStatusChanged / connectionStatusDetailChanged, the connection log (connectionLog), and the sub-page buttons; while connected is false, only "No vehicle connected" and the "Connect Bluetooth OBD2" button (OBDConnectButton).
frontend/OBDDiagnostics.qmlenter_diagnostic_mode() on creation and on every visible transition, exit_diagnostic_mode() when hidden or destroyed, plus read_dtc / read_current_dtc / read_status / read_freeze_frame / clear_dtc.
frontend/HomeOBDView.qmlHome-screen cards for the parameters in homeOBDParameters, plus the RPM card's shift-light disc.
frontend/Main.qmlConnects obdManager.rpmChanged by hand in Component.onCompleted to drive the global full-screen flash and aura overlays.
frontend/settings/OBDSettingsPage.qmlConnection card (a status string seeded from get_connection_status() and refreshed by onConnectionStatusChanged, connectionDetail, connectionProgress, reset_connection()), a 60-line Connection Log, the adapter list bound to availableAdapters, connect_to_adapter() (the "Go" button), connect_direct() (the Linux-only "Direct" button beside it), a "Find Bluetooth Adapters" button that opens OBDAdapterSheet, platform_hint(), fast mode, auto-reconnect attempts and the parameter toggles. The status dot's colour map is keyed on the full connectionStatusChanged vocabulary (including Reconnecting, Connection Failed, Stale Connection and the Android BLE states).

Simulated Adapter

Open Connect OBD2 Adapter and choose Simulated car. This entry is always available, including with Bluetooth off, on desktop, mobile and WebAssembly. It starts an automatic demo drive through the real ELM327 parser and poller, plus simulated IMU motion; no developer flags are required. obdManager.simulated identifies demo data, and the OBD screens display a Simulated label. Choosing hardware or closing the connection stops the demo motion. The saved hardware adapter is preserved, so a restart returns to the normal hardware configuration.

Service frontends can select it with obdManager.connect_to_adapter("sim://elm327"). Both adapter lists include it. Android sends this custom transport through the worker instead of the Java Bluetooth bridge.

Connection Flow

The main-thread lifecycle is driven by four QTimers created in the constructor:

TimerIntervalPurpose
m_startupTimer50 ms single-shotDefers the first scan + connection attempt out of the constructor so app startup is not blocked.
m_deviceScannerTimer10 sPassive port discovery while disconnected; stopped once connected.
m_portChangeTimer1 s single-shotDebounces obdBluetoothPortChanged from settings into one reconnect().
m_dataWatchdogTimer5 sStale-connection detection (30 s without data).
  1. Startup: the 50 ms timer fires onStartupTimer(): scan for ports, start the 10 s scanner, call startConnection().
  2. Guard: a mutex-protected m_isConnecting flag drops re-entrant attempts ("Already connecting -- skipping"). cleanupConnection() clears the flag, because the attempt it abandons has its initComplete disconnected and would never clear it. Before 2026-10-03 a reconnect() during an attempt left OBD stuck in "Connecting" with no worker. The abandoned worker logs connect to X abandoned (superseded by a newer connect) on octave.elm327. The attempt counter increments and the UI gets connectionStatusChanged("Connecting"), connectionStatusDetailChanged("Attempt N..."), connectionProgressChanged(10).
  3. Port check: checkPortExists(). Unix: the device file exists; Windows: the port is listed by QSerialPortInfo::availablePorts(); Android: the configured string matches the MAC regex ^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$. A missing port emits "Device Not Found", devicePresenceChanged(false), and schedules auto-reconnect.
  4. Worker: progress 15, "Found <port>, connecting...", then a fresh OBDConnectionWorker is moved to a new QThread and its started signal invokes the blocking connect (next section). On Android the manager skips this and calls startAndroidConnection() instead.
  5. Result (delivered to the main thread by queued signals):
    • Success: m_connected = true, attempts reset, "Connected" + progress 100, polling starts, the data watchdog starts, the device scanner stops, and scan_vehicle() is queued 2 s later.
    • Adapter but no vehicle: "No Vehicle" / "Connected to adapter, waiting for vehicle...", progress 50, then auto-reconnect scheduling. (The init worked but the RPM probe returned nothing.)
    • Failure: "Connection Failed" / "Could not connect to OBD adapter", progress 0, auto-reconnect scheduling.
  6. Post-diagnostic retries: when m_reconnectingAfterDiagnostic is set, a failed attempt retries every 2 s up to 3 times even if auto-reconnect is disabled in settings.

Threaded Connection Worker

OBDConnectionWorker owns the serial port for the whole session

The worker is a long-lived object: it creates the QSerialPort (or an RfcommSocket for an rfcomm:// port) on its own thread (38400 baud, 8N1, no flow control, 4 KB read buffer), runs the init sequence, then keeps polling, answering DTC requests and running scans until doDisconnect(). The thread is named obd-worker so it is attributable in top -H. Every request from the main thread is a QMetaObject::invokeMethod(..., Qt::QueuedConnection), every result comes back as a queued signal:

Worker signalHandled byEffect on the main thread
initComplete(bool, message)onWorkerInitCompleteSuccess/"adapter"/failure branch of the connection flow above (the word adapter in the message selects "No Vehicle").
dataReceived(signalName, value)onWorkerDataReceivedStamps m_lastDataReceived and dispatches to the matching parameter signal via m_signalDispatch (a QHash<QString, std::function> built once in buildSignalDispatch()).
connectionLost(reason)onWorkerConnectionLost"Disconnected", watchdog stopped, scanner restarted, auto-reconnect scheduled.
dtcResult, dtcCleared, milStatus, freezeFrameonWorkerDtc*Cached and re-emitted as the public diagnostic signals (codes become {code, description: ""} maps).
scanProgress, scanComplete, scanOutputonWorkerScan*Forwarded; scanComplete also persists the list through save_supported_obd_parameters().

ELM327 Init Sequence and Vehicle Probe

The init table is ELM327Protocol::initCommands() (timeouts in ms):

ATZ    1500 ms   # reset (needs the long timeout)
ATE0    500 ms   # echo off
ATL0    300 ms   # linefeeds off
ATS0    300 ms   # spaces off
ATH0    300 ms   # headers off
ATAT2   300 ms   # aggressive adaptive timing
ATSP0   500 ms   # auto protocol

Desktop (sendInitSequence()): each command is sent with sendCommand() and its own timeout, except that ATZ gets at least 5 s: a bound /dev/rfcommN opens instantly, but the kernel only brings the Bluetooth link up on the first write, so the reset's answer also includes the link-up (about 2.2 s for a NEXAS in the Jeep). An ATZ reply without ELM only logs a warning (clones omit the banner), and the function always returns true. The real test is the vehicle probe that follows: one 010C (RPM) request that waits up to the connection timeout (m_timeout, 5 s unless set_connection_timeout() changed it). An empty reply, NO DATA or UNABLE means "adapter but no vehicle": the worker releases the port and emits initComplete(false, "Connected to adapter, no vehicle response"). Note the header comment claims ELM327Protocol::defaultPids() lists "the 16 most common"; that list is not used by the worker -- the poll list is built from consumer demand by buildPidsToWatch() (see Which PIDs Get Polled).

Android: the same table is walked by a single-shot QTimer (sendNextAndroidInitCommand() / onAndroidInitStep()): each command is written, the timer armed with that command's timeout, and the next command sent when the timer fires regardless of whether a reply arrived. After ATSP0 the code sends 0100; while still connecting, each supported-PID bitmap whose bit 32 is set chains the next range (0120, 0140, 0160, up to 01C0) so the supported set covers the extended PIDs too. 2 s after 0100 it calls finalizeAndroidConnection().

Poll Loop and Pacing

startPolling() starts m_pollTimer (constructed with the worker as parent, so moveToThread() carries it along; before 2026-10-03 it stayed on the GUI thread and Qt refused to start it with “Timers cannot be started from another thread”) on the worker thread with interval 0 ms when obdFastMode is on (the default -- "poll as fast as possible") or 50 ms otherwise. Each tick (onPollTimer()) sends the next PID in round-robin order with a 500 ms timeout, parses it with ELM327Protocol::parseResponse() / decodePid(), and emits dataReceived. The ELM_VOLTAGE pseudo-PID (kElmVoltageKey) is special-cased: the tick sends ATRV instead and parses the "12.6V" text reply with parseElmVoltage() into elmVoltageChanged. Because sendCommand() blocks inside the tick, the effective rate is one PID per adapter round-trip. In fast mode each single-value poll carries the ELM327 response count (010C1, formatPidRequest(mode, pid, 1)): the adapter answers at the first ECU's reply instead of waiting out its timeout for more ECUs, which is most of the round trip on a Bluetooth LE adapter. Multi-value PIDs (multi-frame on CAN) go without it, and an adapter that answers ? loses it for the rest of the connection. On CAN (the connect asks ATDPN; ELM327Protocol::isCanProtocol()) fast mode also puts up to six consecutive single-value Mode 01 PIDs in one request (010C0D05, formatMultiPidRequest()), split by parseMultiPidResponse() with the table's byte counts (0x50 stays out: 4 bytes on the wire, 1 in the table); a PID that can't join (ATRV, a multi-value PID) waits in m_deferred and is polled on its own next, while the batch fills from the PIDs after it; a repeat ends the batch. An ECU that answers one PID per request three times running ends batching for the connection. The simulator answers multi-PID requests too. On CAN every module answers a functional request, and some refuse (7F0112): the supported-PID scan joins every ECU's bitmap (supportedPidsFromLines(), logged as vehicle reports N supported PIDs), and single polls and 0101 pick their own line with parseResponseLines(). Reading only the last line found no PIDs on a 2022 Hyundai. The scan asks 0100 up to three times before calling a car empty. sendCommand() drops bytes waiting before it writes (a timed-out reply's tail would otherwise answer the next command, and every answer after it would be one behind); an init command with no answer is sent once more, ATZ is followed by 200 ms for the adapter to finish, and a vehicle check cut short (STOPPED) is asked once more. The vehicle check passes only on an RPM reply (41 0C): after a reconnect with the ignition off, STOPPED passed for an answer and left a "Connected" link with no data until the 30 s watchdog. 10 s into a connection and every 30 s after, the worker logs OBD polling: N requests in 30 s (x/s), M values (y/s), round trip mean ... ms, max ... ms; ... NO DATA, ... unanswered at info level, so a user's log shows the poll rate; the data watchdog's reconnect (30 s without a value) and connect / reconnect requests are logged there too.

sendCommand() clears the ResponseBuffer, logs TX, writes + flushes, then loops until timeoutMs:

  • waitForReadyRead(100) returned bytes with a complete >-terminated response: log RX with the round-trip time and return. Bytes without the prompt yet: loop again immediately ("productive read: the prompt is probably next, do not pace") -- pacing here cost 100 ms per PID because the ELM327 often sends the data and the prompt as separate chunks (ee5a23e).
  • Readable but zero bytes 50 times in a row: the port is dead (see next section) -- stopPolling(), releasePort(), connectionLost("Adapter stopped responding").
  • The port's error is cleared before every command (QSerialPort errors are sticky; before 2026-10-03, one transient error during rfcomm link-up made every later init command fail in the same millisecond). Only a fatal error (ResourceError, DeviceNotFoundError, PermissionError, NotOpenError) breaks out; anything else is cleared and the wait continues. onSerialError reports ResourceError / DeviceNotFoundError as connectionLost, and logs every error except TimeoutError on octave.elm327 at WARNING, alongside the port open result (INFO / WARNING), so a failed connect is visible without debug logging.
  • Every iteration that delivered no data sleeps out the remainder of 100 ms, so the loop never spins faster than ~10 Hz while waiting.

On timeout a WARNING is logged (octave.elm327) and whatever partial response the buffer holds is returned. refresh_values() simply queues one extra onPollTimer() tick.

Port Lifecycle: Never Leave a Dead Port Open

On Linux an rfcomm node whose remote is gone (adapter unpaired, engine off) keeps POLLIN set while read() returns 0 bytes. QSerialPort has no "readable but empty means disconnected" handling, so an open port leaves a read notifier armed in the worker thread's event loop that spins at 100 % of a core between commands -- with no OCTAVE frame on the stack, which is why guards inside sendCommand() alone cannot fix it. Measured on the Orange Pi with strace: ~20,000 ppoll+read pairs per second. Three commits on 2026-09-09 closed this in layers:

  • 953f85f: sleep out the remainder of the 100 ms wait instead of re-polling immediately, stop on a real port error, name the worker threads.
  • 5d76a03: apply the 100 ms floor to readable-but-empty polls too (the first guard only covered waitForReadyRead() returning false), and abandon a command after 50 consecutive empty reads.
  • 0404f7b: releasePort() (close + deleteLater + clear buffer) on every init-failure path -- open failure, init failure, and "Connected to adapter, no vehicle response" -- so nothing holds the port open while OCTAVE is not actively talking to a live adapter.

If obd-worker ever shows up hot in top -H again, check whether an rfcomm fd is still held (ls -l /proc/<pid>/fd | grep rfcomm) before reading any loop.

Speed Optimizations

ConcernBehaviour
Command delayPoll timer interval 0 ms (fast mode) / 50 ms. The worker's setTimeout() value (m_timeout, from set_connection_timeout()) bounds only the vehicle-verify RPM probe; the other per-command timeouts are hard-coded (init table, 500 ms poll, 3-5 s diagnostics).
Protocol cachingNone. ATSP0 (auto) is sent on every init; the ELM327 does its own search.
Adaptive timingATAT2 in the init table.
Watchdog stampEvery dataReceived updates m_lastDataReceived (cheap: one qint64 store).

Device Discovery

scanForDevices() runs at startup, every 10 s while disconnected, and on demand (scan(), refresh_ports(), get_available_ports()):

PlatformScanned
WindowsAll COM ports via QSerialPortInfo; a port is included if its description contains bluetooth, obd, elm, serial or usb, or its name starts with COM (in practice: every COM port).
macOS/dev/tty.OBD*, /dev/tty.Bluetooth*, /dev/cu.OBD*, /dev/cu.Bluetooth*, /dev/tty.usbserial*, /dev/cu.usbserial*, /dev/tty.*ELM*, /dev/cu.*ELM*
Linux / Pi/dev/rfcomm*, /dev/ttyUSB*, /dev/ttyACM* (/dev/ttyS* deliberately excluded: hardware UARTs are almost never OBD adapters and produced many false positives)
AndroidlistAndroidPairedDevices(): the phone's bonded (paired) devices from OctaveOBDBridge.bondedDevices() (BluetoothAdapter.getBondedDevices() over JNI, entries "MAC|name"); the names are kept in m_androidDeviceNames so the adapter rows read name · MAC. The list is empty while Bluetooth is off or the runtime BLUETOOTH_CONNECT grant is missing -- the bridge requests it on the first Connect tap, and the 10 s scanner fills the rows in afterwards. No active discovery -- QBluetoothDeviceDiscoveryAgent historically crashed on Android, and the bonded list is enough for "pair once, reconnect by MAC".

Results are de-duplicated and sorted. Only when the list changes does the manager emit availablePortsChanged(list) and availableAdaptersChanged(list of maps), and -- if not connected and the configured port is now present -- reset the attempt counter and start a connection; if the configured port is absent it emits "Device at X not found. Available: ..." as the detail text.

Android: pair first, then tap Connect once
Until the app holds BLUETOOTH_CONNECT the bonded list comes back empty, so on a fresh install the adapter rows are blank; the first Connect tap makes the Java bridge request the permission, after which the paired adapters appear as name · MAC rows and can be tapped like any other adapter. The manual MAC entry field remains as a fallback (the earlier requestAndroidBluetoothPermission() path, which gated the list behind Qt's permission cache, has been removed).

Unified Adapter API

The OBD settings page renders identically on every OS. All platform variation (BLE on Android, rfcomm bind on Linux/Pi, COM enumeration on Windows, /dev/tty.* on macOS) lives inside the manager. QML also uses platform_hint() for platform-specific hints and visibility, including the Linux Direct button. The adapter-facing API:

Slot / PropertySignatureDescription
availableAdaptersproperty: QVariantListList of {name, identifier, kind} maps built from the discovered ports. kind is classified by regex/prefix: ble (MAC), bt-direct (rfcomm://), serial-rfcomm (/dev/rfcomm), serial-usb (/dev/ttyUSB, /dev/ttyACM), tty (/dev/tty., /dev/cu.), com (COM), else serial. Names: Bluetooth · MAC (on Android <device name> · MAC when the bonded-device listing supplied a name), Bluetooth (bound) · /dev/rfcommN, Bluetooth (direct) · MAC (plus ch N when the channel isn't 1), USB ELM327 · /dev/ttyUSBn, Serial · COMn.
availableAdaptersChangedsignal(QVariantList)Emitted alongside availablePortsChanged with the rich shape.
scan()Trigger a discovery sweep. Alias of refresh_ports().
connect_to_adapter(QString identifier)For sim://elm327, starts the shared demo without saving the port. For hardware: trims, classifies, logs connect → <name> to the connection log, saves the identifier with save_obd_bluetooth_port() (and add_obd_saved_adapter() for MACs and rfcomm:// URLs), runs the rfcomm bind on Linux when given a MAC (asynchronously; force_connect() follows once the bind succeeds), otherwise calls force_connect() directly. The QML calls this for adapter-row taps, saved-adapter chips and the manual-entry "Go" button.
connect_direct(QString mac, int channel = 1)Linux: turns a MAC into rfcomm://<MAC>[/<channel>] (an rfcomm:// URL is passed through) and hands it to connect_to_adapter(), which saves it and calls force_connect() with no bind. Anything else logs Direct connect needs a Bluetooth MAC. The settings page's "Direct" button (Linux only) calls it with the entry field's text. See Direct Bluetooth.
platform_hint() -> QString"windows" / "macos" / "linux" / "android". The QML uses it for entry hints and to show the Direct button on Linux. (The QML also has an "ios" branch; the manager does not return it today.)
connectionLogLineAppendedsignal(QString)One-line-at-a-time live log (added in 542b446). The manager keeps the last kConnectionLogLines (60) in the connectionLog property (notify connectionLogChanged, clearConnectionLog() empties it), which OBDHome and the settings page both bind, so a page built after a failed connect still shows it; until open-backend #10 each page kept its own buffer (40 and 60 lines) from the lines emitted while it existed. Sources: connect_to_adapter, the rfcomm bind steps, the direct Bluetooth connect and every worker connect attempt (OBDConnectionWorker::logLine: "Opened <port>", with a custom transport's objectName in brackets when set, e.g. the GATT characteristics Web Bluetooth picked; "ELM327: N of 7 setup commands answered (ATZ: …)"; "Vehicle check (010C): <answer>" (that first request waits at least 20 s, as it is the adapter's protocol search, and a timeout shows the partial reply), so "No Vehicle" can be told apart from a silent adapter), and every Android BLE state transition via logAndroid(), which also mirrors the line into connectionStatusDetailChanged.
Linux/Pi: automatic rfcomm bind

startRfcommBind(): if any /dev/rfcomm0..7 already exists it is used as-is ("using existing", no check that it is bound to the same MAC -- surfacing the real open() failure was judged better than guessing). Otherwise it runs rfcomm bind 0 <mac> 1 (channel 1 is the hard-coded fallback; the adapter finder can discover a different channel), waits up to 1 s for the node to appear, and saves /dev/rfcomm0 as the active port so the worker opens the node, not the MAC. A missing binary, non-zero exit or missing node is reported to the connection log with a fix ("add user to bluetooth group, or run sudo rfcomm bind 0 <mac> once and enter /dev/rfcomm0"), and the status becomes "Error" / rfcomm bind failed -- see Connection Log. A missing binary is detected through QProcess::FailedToStart.

The bind never blocks the GUI thread: it is an async QProcess killed by a 5 s timer, after which the manager polls for the node every 100 ms. A timeout or crash counts as failure, and a newer connect_to_adapter() call supersedes an in-flight bind (its result is ignored). Once bound, force_connect() is called from the completion handler.

Direct Bluetooth (rfcomm://, Linux)

A port of the form rfcomm://<MAC> or rfcomm://<MAC>/<channel> (channel 1 by default) skips the bound /dev/rfcommN node and opens a Bluetooth Classic RFCOMM socket straight to the adapter. It needs no rfcomm bind (root), connects explicitly with a timeout and a real error (Host is down, Connection refused), and nothing else on the system (blueman) can release it underneath us. It came out of 2026-10-03 on the Pi: the NEXAS (classic SPP, channel 1) never connected through /dev/rfcomm0, but a raw socket reached it in about 1.1 s and the Jeep's ECU answered.

  • src/managers/rfcommsocket.{h,cpp}, class RfcommSocket (a QIODevice over an AF_BLUETOOTH socket; the three constants and sockaddr_rc are spelled out so the build needs no libbluetooth-dev). The worker picks it in doConnect() when RfcommSocket::isUrl(port) and otherwise calls openSerialPort(). Everything after that talks through m_io, whichever device is open. connectTo() waits up to max(10, timeout) s in 100 ms slices, so a reconnect can abandon it. An EBUSY (one link per channel, still held by the previous worker or by a process with /dev/rfcommN open) is retried every 250 ms for up to 3 s, since switching transports releases the old link on its own thread. A hang-up or failed read sets isBroken(), which sendCommand() turns into connectionLost. On other platforms connectTo() fails with only supported on Linux.
  • checkPortExists only validates the URL, since there is no node to look for. The passive device scan (every 10 s) retries a configured rfcomm:// adapter whenever we're not connected, connecting, closed or in diagnostic mode. The dongle is usually only powered with the ignition on, and while it's off the connect fails within a few seconds (Host is down).

Adapter Finder: Scan, Pair, Connect

OBDAdapterFinder (QML obdAdapterFinder, src/managers/obdadapterfinder.{h,cpp}, with BlueZ pairing in src/platform/bluezpairer.{h,cpp}) lets someone connect an adapter without knowing its MAC or pairing it in the OS first. The "Connect Bluetooth OBD2" button on the empty Parameter Cards page and "Find Bluetooth Adapters" in Settings → OBD → Adapters both open frontend/OBDAdapterSheet.qml, which starts a scan. Phase 1 of TODO/obd-adapter-discovery.md: the full flow runs on desktop Linux (Pi / Orange Pi included). On every other platform supported is false and the sheet lists obdManager.availableAdapters instead (the ports and paired devices the OS already knows), with an "OBD Settings" shortcut.

Startup never waits on BlueZ. The QtBluetooth objects (QBluetoothLocalDevice, the discovery agent, BluezPairer) are created only once org.bluez is owned on the system bus, a check the bus daemon answers at once (isServiceRegistered, which never activates a service). A QDBusServiceWatcher creates them when BlueZ appears later. Creating them eagerly, as 0.9.5-rc1 did, made QBluetoothLocalDevice's constructor D-Bus-activate an org.bluez that never came up (BlueZ installed, no adapter or bluetoothd stopped), which blocked the GUI thread for about 45 s before the window appeared. Without BlueZ, bluetoothOn is false and startScan() reports "Bluetooth is not available: the Bluetooth service is not running."

  1. Scan (startScan()): QBluetoothDeviceDiscoveryAgent, Classic + LE, 15 s. Unnamed devices (BlueZ names them after their address) are dropped. likelyObd is set when the name matches the common ELM327 adapters (OBDII / OBD2 / V-LINK / OBDLink / Vgate iCar / Veepeak / BAFX / Konnwei / NEXAS / LELink / Viecar / ...) or a BLE device advertises one of the ELM327 BLE services (FFF0, FFE0/FFE1, ISSC). The sheet shows likely adapters only, sorted by signal, with "Show All Devices (N)" for the rest. A dual-mode adapter keeps every mode seen during the scan (the NEXAS reports Classic once and LE-only afterwards).
  2. Pair (connectAdapter(address), Classic only): the scan stops, then BluezPairer registers its own org.bluez.Agent1 on the system bus and calls Device1.Pair(). BlueZ sends the PIN request to the agent of the client that called Pair(), so OCTAVE answers it and the desktop's own PIN dialog never appears. On AuthenticationFailed it retries with the next PIN: 1234, 0000, 6789, 1111. Already-paired devices skip this step.
  3. Trust: Device1.Trusted = true, so the adapter reconnects later without being asked again. If pairing failed but the adapter answered (refused PIN, timeout), it is still trusted and the connection is tried anyway, because some adapters (the NEXAS) accept RFCOMM from a trusted host without pairing. If that connection also fails, the pairing error is shown.
  4. Channel: an SDP lookup for the Serial Port service (QBluetoothServiceDiscoveryAgent, 8 s); channel 1 if it finds nothing.
  5. Hand-off: obdManager.connect_direct(mac, channel) (see Direct Bluetooth), and add_obd_saved_adapter(name, url) so the saved chip shows the adapter's name. The finder then follows connectionStatusChanged. It ignores the Disconnected that force_connect() emits first, then reports Connected to <name>, ... isn't answering, turn the ignition on (No Vehicle), or the failure.

BLE-only devices are listed but greyed out on desktop ("Bluetooth LE adapters aren't supported on this device yet"): the desktop OBD worker has no GATT transport. BLE ELM327 adapters connect on Android through OctaveOBDBridge. Signals and properties are listed in the signals & slots reference.

Auto-Reconnect and Passive Scanning

scheduleAutoReconnect() reads obdAutoReconnectAttempts from settings (0 = disabled, which is the default; 1-10 = max attempts). The backoff is linear and capped:

int delay = qMin(10, 2 + m_connectionAttempts * 2);   // 4 s, 6 s, 8 s, 10 s, 10 s ... after attempt 1, 2, 3, 4, ...
QTimer::singleShot(delay * 1000, this, &OBDManager::startConnection);

When auto-reconnect is disabled, or the attempt count reaches the maximum, the manager emits "Auto-reconnect disabled" / "Max retries reached. Scanning..." and falls back to the 10 s device scanner. For serial device nodes, passive scanning re-attempts a connection when the discovered port list changes and the configured port is in it. Direct rfcomm:// connections are an exception: they retry on every 10 s scan while disconnected, even when the configured attempt limit is zero. For a serial-node connection, an adapter that is plugged in but has no vehicle response is tried once at startup and then left alone until the user taps Connect, changes the port, or the port disappears and reappears. A m_forceStopReconnect flag set by close() blocks all scheduling during shutdown.

While connected, a lost link is detected by the worker's serial errors and the empty-read detector (Poll Loop), which report connectionLost.

Data Watchdog

On desktop, every 5 s while connected and at least one PID is being watched, the watchdog compares the last-data timestamp with now. More than 30 s without a timestamp refresh triggers status "Stale Connection" / "No data received, reconnecting...", the connection is cleaned up, the attempt counter reset, and a fresh attempt starts 100 ms later.

m_dataWatchdogTimer.setInterval(5000);
static constexpr int DATA_WATCHDOG_TIMEOUT_MS = 30000;

The timestamp is refreshed whenever decoded base or extended values arrive. This detects missing callbacks or values, not every kind of vehicle failure. The watchdog is paused on entering diagnostic mode so DTC work cannot trigger a false reconnect. On Android it is never started; the BLE path has its own per-PID watchdog (below).

Supported OBD Parameters

The numbers differ by layer, so here they are exactly:

LayerCountWhere
Parameter signals on OBDManager84 float signals83 base command names + ignitionTimingChanged, which is emitted together with timingAdvanceChanged from the same PID (one entry of the signal dispatch table).
Base PID-table entries83ELM327Protocol::pidTable() has 82 Mode-01 entries from OCTAVE's base command set, at their standard SAE J1979 PID numbers, plus ELM_VOLTAGE under the pseudo-key kElmVoltageKey {0, 0} (the adapter's own ATRV). Each PidEntry carries a commandName (the established OBD command name, e.g. COOLANT_TEMP), so the settings names, scan vocabulary and poll list all come from the one table.
Legacy parameter-setting names83get_all_parameter_names() returns the same 83 base names, derived from pidTable() and sorted. This list excludes the 37 extended values.
Extended PID table23 PIDs / 37 valuesMulti-value entries in extendedPidTable(); values use obdParameterChanged(paramId, value).
ParameterCatalog (= OBDParameterModel.allParameters)12984 base OBD ids (83 commands plus the alias IGNITION_TIMING, aliasOf: "TIMING_ADVANCE") + 37 extended values + 8 BerryIMU ids. Its 120 polled OBD ids equal the decoder ids (tst_parametercatalog). The alias is polled as its target and published in vehicleSupportedParameters whenever the target is (OBDManager::withAliases).

Original 17 commands (enabled by default) -- 18 signals

These are the legacy obdParameters defaults. They no longer determine the live poll set, which follows consumer demand and vehicle support (see Which PIDs Get Polled).

ParameterCommand / PIDSignalDecoder (protocol table)
Coolant TemperatureCOOLANT_TEMP / 01 05coolantTempChangedA - 40
Control Module VoltageCONTROL_MODULE_VOLTAGE / 01 42voltageChanged(256A + B) / 1000
Engine LoadENGINE_LOAD / 01 04engineLoadChangedA * 100 / 255
Throttle PositionTHROTTLE_POS / 01 11throttlePositionChangedA * 100 / 255
Intake Air TemperatureINTAKE_TEMP / 01 0FintakeAirTempChangedA - 40
Timing AdvanceTIMING_ADVANCE / 01 0EtimingAdvanceChanged + ignitionTimingChangedA / 2 - 64
Mass Air FlowMAF / 01 10massAirFlowChanged(256A + B) / 100
Vehicle SpeedSPEED / 01 0DspeedMPHChangedA * 0.621371 (km/h to mph)
Engine RPMRPM / 01 0CrpmChanged(256A + B) / 4
Air/Fuel RatioCOMMANDED_EQUIV_RATIO / 01 44airFuelRatioChanged(256A + B) / 32768 × 14.7 -- the commanded lambda scaled to a gasoline AFR (decAirFuelRatio). The AFR gauge spans 10-18.
Fuel LevelFUEL_LEVEL / 01 2FfuelLevelChangedA * 100 / 255
Intake Manifold PressureINTAKE_PRESSURE / 01 0BintakeManifoldPressureChangedA (kPa)
Short Term Fuel Trim 1SHORT_FUEL_TRIM_1 / 01 06shortTermFuelTrimChanged(A - 128) * 100 / 128
Long Term Fuel Trim 1LONG_FUEL_TRIM_1 / 01 07longTermFuelTrimChanged(A - 128) * 100 / 128
O2 Sensor B1S1O2_B1S1 / 01 14oxygenSensorVoltageChangedA / 200
Fuel PressureFUEL_PRESSURE / 01 0AfuelPressureChangedA * 3
Oil TemperatureOIL_TEMP / 01 5CengineOilTempChangedA - 40

Other base commands (66, legacy default disabled)

PID numbers are the standard Mode 01 assignments: narrow-band O2 0x14-0x1B (B1S1..B1S4, B2S1..B2S4), wide-range O2 voltage 0x24-0x2B and current 0x34-0x3B (bytes C/D: (256C + D) × 8 / 65535 V, (256C + D) / 256 - 128 mA), catalyst temperatures 0x3C B1S1 / 0x3D B2S1 / 0x3E B1S2 / 0x3F B2S2, throttle C 0x48, accelerator E/F 0x4A/0x4B, throttle actuator 0x4C, evap absolute/alternate 0x53/0x54, secondary O2 trims 0x55-0x58, fuel-rail absolute pressure 0x59.

CategoryParameters
Engine / TimingRUN_TIME, ABSOLUTE_LOAD, COMMANDED_EGR, EGR_ERROR, ETHANOL_PERCENT
Fuel SystemFUEL_RAIL_PRESSURE_VAC, FUEL_RAIL_PRESSURE_DIRECT, FUEL_RAIL_PRESSURE_ABS, FUEL_RATE, FUEL_INJECT_TIMING, EVAPORATIVE_PURGE, FUEL_TYPE
Throttle / AcceleratorRELATIVE_THROTTLE_POS, THROTTLE_POS_B, THROTTLE_POS_C, ACCELERATOR_POS_D/E/F, THROTTLE_ACTUATOR, RELATIVE_ACCEL_POS
O2 Sensors (narrow-band)O2_B1S2, O2_B1S3, O2_B1S4, O2_B2S1, O2_B2S2, O2_B2S3, O2_B2S4
O2 Sensors (wide-range)O2_S1_WR_VOLTAGE .. O2_S8_WR_VOLTAGE, O2_S1_WR_CURRENT .. O2_S8_WR_CURRENT
O2 / Fuel TrimsSHORT_O2_TRIM_B1, LONG_O2_TRIM_B1, SHORT_O2_TRIM_B2, LONG_O2_TRIM_B2, SHORT_FUEL_TRIM_2, LONG_FUEL_TRIM_2
Catalyst TemperaturesCATALYST_TEMP_B1S1, B1S2, B2S1, B2S2
EnvironmentalBAROMETRIC_PRESSURE, AMBIANT_AIR_TEMP (the established command name's spelling), EVAP_VAPOR_PRESSURE, EVAP_VAPOR_PRESSURE_ABS, EVAP_VAPOR_PRESSURE_ALT
Distance / TimeDISTANCE_W_MIL, DISTANCE_SINCE_DTC_CLEAR, WARMUPS_SINCE_DTC_CLEAR, RUN_TIME_MIL, TIME_SINCE_DTC_CLEARED
OtherMAX_MAF, HYBRID_BATTERY_REMAINING, ELM_VOLTAGE
All 83 names poll

Earlier builds carried three hand-copied pidToCommand maps (desktop connect, scan, Android) that covered only 60 of the names, mis-numbered several PIDs (O2 B1S3/B1S4 and B2S1-S4, the catalyst temperatures, throttle/accelerator C-F, the evap pressures) and mapped 0x59 to LONG_O2_TRIM_B2, so 22 parameter signals never fired. The maps are gone: the single ELM327Protocol::buildPidTable() now names every entry, OBDManager::buildPidsToWatch() iterates it, and the narrow-band S3/S4 sensors, the 16 wide-range O2 PIDs, the secondary O2 trims, FUEL_RAIL_PRESSURE_ABS and ELM_VOLTAGE all reach QML.

Extended PIDs 0x61 and up (37 values from 23 PIDs)

The extended table adds values from PIDs 0x61–0xB2 beyond OCTAVE's 83 base commands. Several replies contain multiple values and support bits. They are decoded from one table, ELM327Protocol::extendedPidTable(): each value names its data bytes, any required support bit in byte A, and the minimum reply length. Values with a required support bit are skipped when that bit is clear. The mappings below describe the checked-in decoders, covered by decodeExtendedPid and everyExtendedParameterIsWired in tests/cpp/tst_elm327protocol.cpp; they are not a claim that every vehicle implements them. EGT 0x78/0x79, turbo RPM/temperature 0x74–0x76, exhaust pressure 0x73, DPF 0x7A–0x7C and the gear number in 0xA4 are not included.

Values reach QML through one signal, obdParameterChanged(paramId, value), instead of a signal per parameter; parameterSampled carries it into paramStore like any other id. Replies longer than one CAN frame are joined by parseResponseLines(). The vehicle scan reads the 0160-01C0 support bitmaps too, so it can report these PIDs. Boost pressure is absolute; subtract BAROMETRIC_PRESSURE for gauge boost.

GroupParameters (mode/PID)
TorqueDEMAND_ENGINE_TORQUE (01 61), ACTUAL_ENGINE_TORQUE (01 62), ENGINE_FRICTION_TORQUE (01 8E), REFERENCE_TORQUE (01 63)
BoostBOOST_PRESSURE_A (01 70), BOOST_PRESSURE_A_COMMANDED (01 70), WASTEGATE_A (01 72), WASTEGATE_A_COMMANDED (01 72)
AirMAF_SENSOR_A (01 66), MAF_SENSOR_B (01 66), INTAKE_TEMP_B1S1 (01 68), INTAKE_TEMP_B1S2 (01 68), INTAKE_TEMP_B2S1 (01 68), MANIFOLD_SURFACE_TEMP (01 84)
TemperatureCOOLANT_TEMP_SENSOR_1 (01 67), COOLANT_TEMP_SENSOR_2 (01 67)
ThrottleTHROTTLE_POS_G (01 8D), THROTTLE_ACTUATOR_A_COMMANDED (01 6C), RELATIVE_THROTTLE_A (01 6C)
EGREGR_A_COMMANDED (01 69), EGR_A_ACTUAL (01 69), EGR_A_ERROR (01 69)
FuelFUEL_RAIL_PRESSURE_A (01 6D), FUEL_RAIL_PRESSURE_A_COMMANDED (01 6D), FUEL_RAIL_TEMP_A (01 6D), ENGINE_FUEL_RATE_GS (01 9D), VEHICLE_FUEL_RATE_GS (01 9D), CYLINDER_FUEL_RATE (01 A2), EXHAUST_FLOW_RATE (01 9E)
DrivetrainTRANSMISSION_GEAR_RATIO (01 A4), RECOMMENDED_GEAR (01 65)
Distance and timeODOMETER (01 A6), ENGINE_RUN_TIME_TOTAL (01 7F), ENGINE_IDLE_TIME_TOTAL (01 7F)
Hybrid / EVHYBRID_BATTERY_VOLTAGE (01 9A), HYBRID_BATTERY_CURRENT (01 9A), EV_BATTERY_HEALTH (01 B2)

Vehicle identity (VIN)

After a successful connect the manager attempts to read Mode 09 PID 02 once (desktop: OBDConnectionWorker::doReadVin() before polling starts; Android: the first poll slot) and expose vin, vehicleMake and vehicleModelYear with vinChanged; read_vin() reads it again. parseVin() accepts the CAN multi-frame reply and the five numbered pre-CAN messages. decodeVin() takes the make from the world manufacturer identifier (first 3 characters, about 120 entries, then a 2-character fallback) and the model year from position 10 (30-year cycle; an alphabetic position 7 selects 2010+). An unsupported or invalid VIN response leaves vin and vehicleMake empty and vehicleModelYear at 0. The OBD settings Connection card shows a Vehicle row when a VIN is known.

Which PIDs Get Polled

Polling is demand-driven: OCTAVE polls what is on screen, not a list of parameters switched on in settings. Every consumer registers the parameter ids it shows with obdManager.setParameterDemand(consumer, ids) (an empty list removes the consumer). The QML consumers are "dashboard" (the active dashboard's PIDs), "cards" (every supported PID while Parameter Cards is open), "home" (the Home screen OBD tiles), "editor" (the dashboard editor canvas), "settings" (the OBD settings parameter list while it is shown) and "carMenu" (RPM and SPEED for the 3D Jeep's engine and driveline while CarMenu is on screen).

The poll set is built by buildPidsToWatch():

  • Union of every consumer's ids. BerryIMU ids (PITCH, ROLL, HEADING, ...) and unknown ids are dropped.
  • Filtered to vehicleSupportedParameters once this session's scan produced a result (an unknown or empty result filters nothing).
  • A multi-value PID (0x61+) is polled when any of its signals is wanted.
  • Heartbeat: RPM is always polled (ELM_VOLTAGE when the car has no RPM), which keeps the data watchdog fed and the global RPM shift light working on every screen.

A second, low-priority list comes from setBackgroundDemand(consumer, ids): the same rules (OBDManager::backgroundPidsToWatch()) minus the heartbeat and minus anything already in the foreground. The worker polls one background PID after each full pass of the foreground list (plain round-robin over the background list when the foreground is empty), so a 40-PID sweep costs the live gauges one request per cycle instead of slowing every gauge to a crawl. The only consumer is the black box, which asks for every PID the vehicle scan found. Android interleaves the same way in pollNextAndroidPid().

Changes are debounced for 250 ms (one re-watch per page switch) and applied live, without a reconnect. A scan completing also re-applies the set, since the supported filter changed.

buildPidsToWatch() / applyPollList()

The rules themselves are the public static OBDManager::pidsToWatch(demand, vehicleSupported), which buildPidsToWatch() calls with the current state; tst_obdmanager pins them.

startConnection() hands the current QList<PidKey> (sorted by PID so the round-robin order is deterministic) to the worker before the thread starts. The debounce timer calls applyPollList(): while connected it rebuilds the list and queues OBDConnectionWorker::updatePidsToWatch(), which swaps it in place. On Android it calls rebuildAndroidPollList() (additionally filtered by the 0100 bitmap, falling back to ATRV) and restarts the response-driven chain if it had died. While disconnected the change applies to the next connection; in diagnostic mode it is deferred to the reconnect exit_diagnostic_mode() performs anyway.

The per-parameter obdParameters enable settings no longer drive polling. The keys are still read and written (enable_all_supported() / enable_scanned_parameters() keep working) so existing settings files stay compatible.

Black Box: Trip Recorder

TripRecorder (src/managers/triprecorder.{h,cpp}, QML context property tripRecorder) records every decoded OBD value of every drive, with its time, into a SQLite database (SQLite compiled in from third_party/sqlite/, no Qt Sql driver plugin involved), and writes each drive out as a CSV + JSON pair when it ends. It is the vehicle's black box: the pages on screen decide what is polled fast, the recorder makes sure everything the car supports is recorded at least slowly, and a power cut loses at most the last two seconds.

Data path

  • OBDManager::parameterSampled(paramId, value) fires once for every decoded reading (base PIDs, multi-value PIDs and ELM_VOLTAGE), keyed by the OBDParameterModel id. The recorder is its only consumer today; it never touches the 84 per-parameter signals.
  • On top of the on-screen demand, the recorder registers a background demand (setBackgroundDemand("blackbox", vehicleSupportedParameters), see Which PIDs Get Polled) so every PID the scan found is swept, one per pass of the foreground list. Off with the blackBoxSweepAll setting.
  • Readings are coalesced on the GUI thread (a value that has not changed is stored again only every 5 s, so a held value still shows as held) and handed to TripStore (src/managers/tripstore.{h,cpp}) on its own thread every 2 s or 1000 samples, one transaction per batch.
  • Trip boundaries: a trip starts on the first RPM > 0 reading (any reading when the scan says the car has no RPM PID) and ends after 2 min of RPM 0, 2 min without the adapter (a shorter dropout is logged as an event and does not split the trip), when recording is switched off, on "End trip" in the viewer, or at shutdown (shutdown() blocks until the files are written, before obdManager.close()). Within a trip, sample times are start + monotonic elapsed, so an NTP step on the head unit cannot fold the trace.
  • Events (disconnected / reconnected / connection_lost, mil, dtc, dtc_clear, trip_end with its reason) go into the same trip.

On disk

Path (under <config>/history/)What
blackbox.sqlite (+ -wal)Tables trips (id = start time in ms, start/end, VIN, make, year, sample count, summary JSON, file base), params (interned ids), samples(trip_id, ts_ms, param_id, value) indexed by trip / parameter / time, events, meta (schema 1). WAL, synchronous=FULL (each 2 s batch is fsynced, so a hard power cut loses at most one batch), incremental auto-vacuum.
trips/<yyyy-MM-dd_HH-mm-ss>.csvEvery sample of the drive in time order: timestamp_ms,time,elapsed_s,parameter,value,unit (the unit from ParameterCatalog, last so readers that go by column position keep working). Written with QSaveFile when the trip ends.
trips/<stamp>.jsonThe summary: start/end, duration, sample and parameter counts, distance (trapezoid over the speed trace, gaps over 10 s skipped), max speed / RPM / coolant / oil temp, min and max voltage, fuel level first / last / used, per-parameter {id, title, unit, count, min, max, avg, first, last} (title and unit from ParameterCatalog), the events, clean (false when recovered after a power cut), and health / healthVersion (below).

On start the store finalises any trip left open (recoverOpenTrips(): end = last sample, empty shells dropped), then prunes the oldest closed trips until the database and files fit the blackBoxMaxMB cap (default 500 MB; about 3 MB per hour of driving with the full sweep). If the database cannot be opened (read-only or full file system, a broken config directory) storeReady stays false, lastError carries the reason, the OBD Home tile and the Black Box settings card say "unavailable", and nothing is recorded: the recorder never pretends. "Export" copies a drive's pair into Downloads/OCTAVE-trips/ (the way to get a file off a phone); on desktop "Open folder" opens trips/.

The viewer is TripHistory.qml (OBD Home → Trip History); the controls are the Black Box card in the OBD settings. Settings: blackBoxEnabled, blackBoxSweepAll, blackBoxMaxMB. QML API: Signals & Slots → TripRecorder. Tests: tst_triprecorder. To look at the page without driving, tools/trips/make_mock_trip.py writes a realistic mock drive into the database (see Development).

When a trip is summarised, TripStore::computeHealth() works out a few numbers from its samples in one pass (each reading judged against the latest value of the others) and keeps them in the summary as health, so a trend over months is a read of the summaries, never a scan of the samples. Trips summarised before a number existed (an older healthVersion, kHealthVersion in tripstore.cpp) get it worked out and stored on the first trend query that reaches them.

IdWhatUsual rangeFlagged when
warmupMinMinutes from a cold start (first coolant reading under 40 °C) to 70 °C—2 min longer than before
settledCoolantAverage coolant after the first 10 minutes, on drives of 15 minutes or more80–105 °Coutside the range, or 5 °C moved
idleVoltageCONTROL_MODULE_VOLTAGE while stopped at 400–1100 RPM13.0–14.8 Voutside the range, or 0.3 V lower
chargingVoltageThe same above 1300 RPM13.4–14.8 Voutside the range, or 0.3 V lower
idleRpmRPM while stopped once warm (coolant 70 °C or more)—100 RPM moved
ltft1, ltft2Long-term fuel trim, bank 1 / 2, once warm (cold readings are open loop)±10 %outside the range, or 4 points moved

tripRecorder.requestHealthTrends(days) returns each number per trip over a window with a verdict: the average of the last three drives (recent) against the usual range and against the average of the drives before them (baseline, once there are three), as status ok / watch / none plus a note saying what usually causes it. requestParamTrend(paramId, days) gives any recorded parameter's min / average / max per trip. Shown on Trip History's Trends view. To try it without months of driving, tools/trips/make_mock_trip.py --drives 40 --days 60 --drift thermostat (or alternator, lean) writes weeks of drives with a part wearing out over the second half.

Vehicle Scanning

On desktop, scan_vehicle() runs automatically 2 s after a successful connection. The API is also callable directly, but the current QML has no manual scan button. It emits terminal-style lines through scanOutputChanged ([SCAN], [INFO], [OK] NAME, a ==== summary), progress through scanProgressChanged(pct, "Checking NAME..."), and finishes with supportedCommandsChanged(list) + scanCompleteChanged(list) + scanProgressChanged(100, "Found N supported parameters"). The list is persisted under the supportedOBDParameters settings key. The same list (base command names plus multi-value signal ids, in OBDParameterModel vocabulary) becomes this session's vehicleSupportedParameters and sets vehicleScanComplete (true even for an empty result); both reset on disconnect, connection loss and close(), and are never loaded from the persisted key, so they always describe the car connected right now. They drive the QML "supported by this vehicle" gate and filter the poll set.

  • Desktop: doScanVehicle() on the worker pauses polling, sends 0100, 0120, 0140, ... 01E0 (stopping at the first range whose bitmap does not set bit 32 = "next range supported"), collects the supported PID numbers with parseSupportedPids(), and builds the final result through supportedCommandNames(): base command names and extended value IDs whose PIDs are supported, plus ELM_VOLTAGE. An entirely empty bitmap result returns an empty list. Polling resumes afterwards.
  • Android: no worker, so scan_vehicle() answers from the supported-PID bitmaps read during connect (ELM327Protocol::supportedCommandNames(m_androidSupportedPids)), emitting the same [SCAN] / [INFO] / [OK] / [DONE] lines and completing through onWorkerScanComplete() (which clears m_isScanning and persists the list), answering from what connect already learned. An empty bitmap set reports No supported PIDs found. The automatic post-connect scan is still desktop-only (it hangs off onWorkerInitComplete). Android has no automatic scan report or QML scan button; the live poll list still uses the initializer's supported-PID bitmaps.

Diagnostics (DTC)

The Diagnostics page owns a diagnostic mode: it calls enter_diagnostic_mode() when created and whenever it becomes visible again (a StackView push does not destroy the page), and exit_diagnostic_mode() when hidden or destroyed. On desktop:

Behaviour
EnterStops the watchdog and queues stopPolling on the worker; DTC work uses the same port and worker.
QueriesEach read_* queues doRead* on the worker, which pauses polling, sends raw 03 / 07 / 020200 (5 s), 04 (5 s), 0101 (3 s, byte A: bit 7 = MIL, bits 0-6 = count), parses every line of the reply with parseDtcResponse() / parseFreezeFrameDtc(), and resumes polling. Descriptions are empty strings.
Thread safetyResults come back as queued worker signals.
ExitStatus "Reconnecting", m_isConnecting cleared, full reconnect after 1 s via delayedReconnectAfterDiagnostic() with m_reconnectingAfterDiagnostic set (up to 3 retries regardless of the auto-reconnect setting).
Result shapedtcCodesChanged carries a list of {code, description} maps.

Transition guard: enter_diagnostic_mode sets m_diagnosticModeTransitioning under m_diagnosticModeLock, stops the watchdog and polling, then sets m_diagnosticMode and clears the flag. A second enter while a transition is in progress is ignored, and an exit_diagnostic_mode that finds an entry still transitioning cancels it ("Cancelling diagnostic mode entry") instead of reconnecting, so rapid menu switches cannot race each other. The entry path does no blocking I/O, so the window is short.

Android: BLE via OctaveOBDBridge

On Android the desktop worker is never created. startAndroidConnection() validates the MAC (missing/invalid gives "No MAC Address" / Enter your ELM327 MAC in OBD Settings, then tap Connect), resets the BLE state, and calls OctaveOBDBridge.connect(context, mac) through QJniObject. From then on a 50 ms QTimer (onAndroidBlePoll()) drains the bridge:

  1. pollLog() up to 50 lines per tick: W| lines become qCWarning(lcElm327) "[BLE] ...", I| lines qCInfo -- so a BLE drop in the car lands in octave-cpp.log and the Diagnostics export instead of only logcat (3858c5c).
  2. pollResponse() until empty: each notification chunk is fed to the same ResponseBuffer, and every complete >-terminated response goes to processAndroidResponse().
  3. stateCode(): only on a transition. 0 DISCONNECTED (log lastEvent, status Disconnected, reconnect if we were connected), 1 CONNECTING, 2 DISCOVERING (progress 50, Discovering services...), 3 READY (progress 70, Initializing ELM327..., start the init state machine after 500 ms), 4 ERROR (log lastError, BLE error, reconnect).

Inside the Java bridge: BluetoothAdapter.getRemoteDevice(mac) then connectGatt(ctx, autoConnect=false, callback, TRANSPORT_LE); on connect it runs service discovery and tries three vendor profiles in priority order -- ISSC Transparent UART (49535343-FE7D-..., separate notify/write characteristics; NEXAS, Vgate iCar Pro, OBDLink CX), FFF0 (FFF1 notify + FFF2 write, or a single read/notify/write FFF1; Konnwei, some BAFX), and FFE0/FFE1 (one characteristic both ways; HM-10 style clones). The first one with both roles wins; notifications are enabled through the CCCD descriptor and the state flips to READY. Writes use WRITE_TYPE_NO_RESPONSE when the characteristic allows it. Incoming bytes are queued in a ConcurrentLinkedQueue (GATT callbacks run on a Binder thread; the manager polls from the Qt main thread). Verified end-to-end on a NEXAS adapter over the FFF0 path (f1901d9).

Permissions (08e8245): on API 31+ connect() checks BLUETOOTH_CONNECT / BLUETOOTH_SCAN; if either is missing it casts the context to Activity, calls requestPermissions() (request code 7301) so the system dialog appears, sets the error "Requesting Bluetooth permission -- tap Allow, then tap Connect again", goes to state ERROR and returns false. Fresh installs (CI sideloads, Play builds) therefore get a working second tap instead of a silent SecurityException from connectGatt(). The same grant unlocks bondedDevices(), so the adapter list populates right after it. The older requestAndroidBluetoothPermission() (which wrestled Qt's QBluetoothPermission cache) has been removed.

Writes: writeAndroidBytes() drops data unless the last seen state is READY and chunks every write to 20 bytes (the default 23-byte ATT MTU minus 3). Init: the timer-driven sequence described above, then 0100 and a 2 s wait. Finalize: m_connected = true, Connected, progress 100, and rebuildAndroidPollList() builds the poll list: buildPidsToWatch() (consumer demand plus heartbeat, extended values included) intersected with the supported-PID bitmaps collected through PID C0 if the vehicle answered them (ELM_VOLTAGE always passes); an empty result falls back to ELM_VOLTAGE (ATRV) alone. Because connect chains 0120/0140/0160, requested PIDs at or above 0x21 (FUEL_LEVEL, CONTROL_MODULE_VOLTAGE, OIL_TEMP) survive the intersection and are polled. Polling is response-driven: pollNextAndroidPid() writes one request and arms a 500 ms single-shot watchdog; the next request goes out when a response is decoded or the watchdog fires. Ten consecutive watchdog fires (5 s stale) flush the response buffer. Reconnect after an unexpected DISCONNECTED/ERROR: up to 3 attempts, delay min(2000 * n, 10000) ms, counter reset on success.

What the Android path does not do

read_dtc, read_current_dtc, read_status, clear_dtc and read_freeze_frame go through invokeWorker(), which logs "no worker thread on this transport, ignoring" once per call and returns; refresh_values is likewise a no-op without a worker. enter_diagnostic_mode only stops the watchdog. The data watchdog does not apply (the BLE path has its own per-PID watchdog); the 10 s device scanner runs and is what refreshes the bonded-device rows.

ELM327 Protocol Module

src/managers/elm327protocol.{h,cpp} is pure protocol code (no I/O): the 83 PID entries (82 mode-01 commands plus ELM_VOLTAGE), the extended table, decoders, error tokens and public parsing helpers. The desktop worker, the Android path and the dev-mode simulator all use it; tests/cpp/tst_elm327protocol.cpp covers it without a vehicle.

PID table

Maps (mode, pid) to (name, signalName, commandName, decoder, expectedBytes); commandName is the established OBD command name (python-obd's vocabulary, e.g. COOLANT_TEMP, AMBIANT_AIR_TEMP), i.e. the settings key and the scan-result vocabulary. The decoder takes the data bytes after the 41 XX header:

static double decRpm(const QVector<uint8_t> &a) {
    return std::round((a[0] * 256.0 + a[1]) / 4.0 * 10.0) / 10.0;
}
t[{1, 0x0C}] = {"RPM", "rpmChanged", "RPM", decRpm, 2};
t[kElmVoltageKey] = {"ELM Voltage", "elmVoltageChanged", "ELM_VOLTAGE", nullptr, 0};   // {0, 0}: "ATRV", text reply

The 16 wide-range O2 entries are generated in a loop (O2_S1_WR_VOLTAGE .. O2_S8_WR_VOLTAGE at 0x24-0x2B, O2_S1_WR_CURRENT .. O2_S8_WR_CURRENT at 0x34-0x3B, four data bytes each; the lambda ratio in bytes A/B is ignored and the voltage / current is decoded from C/D as (256C + D) × 8 / 65535 V and (256C + D) / 256 - 128 mA).

Functions

FunctionDescription
formatPidRequest(mode, pid)"%02X%02X\r", e.g. 010C\r.
parseResponse(raw)Rejects any line containing NO DATA, UNABLE TO CONNECT, ERROR, ?, STOPPED, BUS INIT: ...ERROR (logged at debug), strips spaces, hex-decodes, and returns (mode - 0x40, pid, data bytes); fewer than two bytes is "unparseable".
decodePid(mode, pid, bytes)Looks up the table; a short response logs a WARNING with the byte counts; decoder exceptions log a WARNING (with what()) and return nothing. The ELM_VOLTAGE entry has no decoder and returns nothing here.
elmVoltageRequest(), parseElmVoltage(raw)"ATRV\r" and its reply: lower-case, strip the trailing v, convert to a number; "12.6V" → 12.6, anything else logs at debug and returns nothing.
supportedCommandNames(set)Sorted base command names and extended parameter IDs whose mode-01 PID is in the vehicle's supported set; ELM_VOLTAGE is always included.
parseSupportedPids(bytes)32-bit bitmap, MSB first, into 1-based PID offsets relative to the queried base.
parseDtcResponse(lines, mode)Takes every line of the reply (ResponseBuffer::lastLines()). Joins CAN multi-frame replies (00A, 0:…, 1:…), keeps one message per ECU, checks the response mode (43 / 47 / 4A), drops the CAN DTC-count byte (CAN data is an odd byte count; pre-CAN is always 6 bytes), reads 2-byte chunks, skips 0000, builds P/C/B/U + 4 digits and de-duplicates across ECUs. Covered by parseDtcResponse in tests/cpp/tst_elm327protocol.cpp.
parseFreezeFrameDtc(lines)Reads the DTC from a Mode 02 PID 02 frame 0 reply (42 02 00 hi lo).
parseResponseLines(lines, mode, pid)Like parseResponse, from every line of a reply: joins a CAN multi-frame answer and returns the first message answering mode/pid. Used for the multi-value PIDs.
decodeExtendedPid(mode, pid, bytes)[(paramId, value), ...] from extendedPidTable(), skipping values whose support bit is clear or whose bytes are missing. Covered by decodeExtendedPid in tests/cpp/tst_elm327protocol.cpp.
parseVin(lines), decodeVin(vin)Mode 09 PID 02 reply to the 17-character VIN ("" for NO DATA), and VIN to make + model year. See Vehicle identity.
defaultPids()16 common PIDs. Not used by the runtime poll list.

ResponseBuffer

Accumulates bytes until a > prompt. It trims to the last 1 KB if it grows past 4 KB without a prompt (corrupt stream guard), splits the response on \r, and returns the last non-empty line that is not an AT echo and not OK. That is why echo-on adapters and the SEARCHING... line preceding a first reply do not break parsing.

void ResponseBuffer::feed(const QByteArray &data) {
    m_buffer.append(data);
    if (m_buffer.size() > 4096)          // no '>' prompt for too long: trim
        m_buffer = m_buffer.right(1024);
}

std::optional<QString> ResponseBuffer::getResponse() {
    int idx = m_buffer.indexOf('>');
    if (idx == -1)
        return std::nullopt;
    QString response = QString::fromLatin1(m_buffer.left(idx)).trimmed();
    m_buffer = m_buffer.mid(idx + 1);
    QStringList lines = /* split on '\r', non-empty trimmed lines (also kept for lastLines()) */;
    for (int i = lines.size() - 1; i >= 0; --i)
        if (!lines[i].startsWith("AT") && lines[i] != "OK")
            return lines[i];
    return lines.isEmpty() ? QString() : lines.last();
}

Protocol Logging

A failed OBD session can be reconstructed from the logs (commit 86c8c3c). The octave.elm327 category logs TX/RX lines (response truncated to 200 chars, with the round-trip time) from OBDConnectionWorker::sendCommand, a WARNING when a command gets no response within its timeout, and in ELM327Protocol adapter error responses and unparseable responses at debug, short responses and decoder failures at warning. On Android the Java bridge's queued event/error lines are forwarded as [BLE] ... INFO/WARNING lines by the 50 ms poll. Debug-level lines need --debug (see the logging section of Architecture). The live in-app connection log is a separate, user-facing channel.

OBDParameterModel and IMU Parameters

Values flow through the C++ paramStore (src/managers/parameterstore.{h,cpp}, a QQmlPropertyMap with one notifying key per id). src/appcore.cpp feeds it from parameterSampled(id, value), which OBDManager emits for every decoded value, single- and multi-value, on the desktop worker path and on Android alike, and from the IMU's pitchChanged .. baroTempChanged signals for PITCH, ROLL, HEADING, ALTITUDE, ACCEL_MAG, LATERAL_G, LONGITUDINAL_G and BARO_TEMP (see Hardware Managers). The IMU values are ordinary parameters from that point on: any gauge can bind them, the PID picker lists them, demo mode sweeps them. An alias (IGNITION_TIMING) is written whenever its target (TIMING_ADVANCE) arrives. frontend/OBDParameterModel.qml serves it to every reader through liveValue(id) (paramStore[id], or the demo sweep); it has no per-signal handlers and no copy of the values.

Coalesced writes (486a809, moved into the store): ingest() only records the value and arms a one-shot 33 ms timer; the fold writes the pending values and emits changed(ids) once. The IMU alone fires about 420 orientation/gauge signals per second (7 signals at 60 Hz), plus barometer updates -- copying a whole dictionary on each one pegged the QML thread at 100 % on an idle Orange Pi (V4 string allocation + GC churn, found with py-spy). Values now land at most ~30 times a second and only while data arrives, and a binding on paramStore[id] re-evaluates only for its own id. hasParameter(id), getParamInfo(id), parameterInfo (with short titles for the home cards) and allParameters (the C++ ParameterCatalog: id, title, short title, unit, min, max, kind, decimals) are documented on the Gauges & Dashboards page.

Shift Light, Full-Screen Flash and Aura

The rules live in ShiftLightManager (src/managers/shiftlightmanager.{h,cpp}), fed by rpmChanged in main.cpp; the QML below only draws its activeFlag and lit. Its configuration is a list of flags stored as a JSON string under the rpm_flags setting and edited in the RPM settings popup inside frontend/OBDMenu.qml. Each flag is {rpmLow, rpmHigh, color, flash, flashSpeed, fullScreenFlash, fullScreenFlashOpacity, auraFlash, auraFlashOpacity, auraSpread} (new flags default to flash: false, flashSpeed: 100, fullScreenFlashOpacity: 0.5, auraFlashOpacity: 0.6, auraSpread: 0.18); the popup keeps the list sorted by rpmLow, keeps rpmLow <= rpmHigh and clamps ranges to rpm_max_rpm (default 8000). The manager migrates the old single-rpm format once on load to rpmLow = rpm, rpmHigh = rpm_max_rpm (the redline, not "no limit") and writes it back.

  • Active flag: the last flag (highest priority) whose [rpmLow, rpmHigh] range contains the current RPM, only while rpm_shift_light_enabled is on. activeFlag has every field filled in (defaults above; fullScreenFlashOpacity falls back to rpm_fullscreen_flash_opacity).
  • Flash: one C++ timer at the flag's flashSpeed toggles lit, so the card disc and both overlays flash in step; a newly active flag always starts lit.
  • Card indicator (HomeOBDView.qml): a disc on the RPM home card (rpm_show_on_home_card) coloured by the active flag, with glow (rpm_glow_size, rpm_glow_intensity), size (rpm_shift_light_size), colour transition (rpm_color_transition_speed), optional 800 ms pulse (rpm_pulse_enabled), dark while not lit.
  • Global full-screen flash (Main.qml, globalFullScreenFlash): a z: 999999 rectangle over the whole window in the flag colour at the flag's fullScreenFlashOpacity (fallback rpm_fullscreen_flash_opacity), visible while lit and the flag has fullScreenFlash; enabled: false so touches pass through.
  • Aura overlay (globalAuraFlash): the same trigger with auraFlash, but rendered as four edge gradients (top/bottom/left/right) that fade from the flag colour to transparent over auraSpread of the window (default 18 %) at auraFlashOpacity -- a perimeter glow that leaves the centre readable.

The manager re-reads every rpm_* setting whenever settingsManager.genericSettingChanged reports one, so editor changes apply live. Tests: tests/cpp/tst_shiftlightmanager.cpp.

Demo and Simulated Data

MechanismScopeHow it works
OBDParameterModel.simulationActiveDashboard editor "Demo data" buttonPure QML: every parameter sweeps a sine between its min and max with a per-parameter period (4-16 s) and phase, 10 Hz. While active, liveValue() serves the sweep instead of paramStore; on deactivation the real readings are back at once, so fake ones never linger. The editor forces it off in Component.onDestruction.
SimElm327Device + CarSim (src/dev/)Dev mode (octave --dev, Debug builds)Keeps the real manager and swaps the adapter underneath it: a QIODevice answering the AT init, every Mode 01 PID of the protocol tables (encoded with the inverse of the shipped decoders, all reported supported), DTC modes 03/04/07/0A, the freeze-frame DTC and a CAN multi-frame VIN. Bluetooth-like 35–70 ms replies with 2 % NO DATA in realistic mode; dropouts close the device so the real lost-link and reconnect paths run. Driven by the keyboard and the sim_* command-server actions (Development).

Settings Keys

KeyDefaultUsed by
obdBluetoothPortempty -> per-platform default /dev/rfcomm0, COM3, /dev/tty.OBD, Android: none (MAC required)Configured port/MAC; obdBluetoothPortChanged triggers the 1 s debounced reconnect, which is skipped when the new port is already the one the live or in-flight connection uses (m_activePort). connect_to_adapter() saves the port and connects at once, so without the skip the debounce killed its fresh attempt mid-handshake.
obdFastModetruePoll timer interval: 0 ms when on, 50 ms when off. On, polls also ask for a single reply (010C1) and, on CAN, carry up to six PIDs each.
blackBoxEnabledtrueTrip recorder on / off; turning it off ends the trip being recorded.
blackBoxSweepAlltrueRegister every scanned PID as background demand so the record covers PIDs not on screen.
blackBoxMaxMB500Cap (50–5000 MB) for the database plus trip files; oldest closed trips are pruned first.
obdAutoReconnectAttempts0Backoff attempts, clamped 0-10.
obdParametersmap, filled lazily with defaultsget_obd_parameter_enabled / save_obd_parameter_enabled (800 ms debounce, then obdParametersChanged).
supportedOBDParameters[]Last scan result.
homeOBDParameterslistWhich parameters the home screen shows.
obdSavedAdapters"[]"MACs remembered by connect_to_adapter as quick-connect chips.
rpm_flags, rpm_max_rpm, rpm_shift_light_enabled, rpm_show_on_home_card, rpm_shift_light_size, rpm_glow_size, rpm_glow_intensity, rpm_color_transition_speed, rpm_pulse_enabled, rpm_fullscreen_flash_opacity"[]", 8000, true, true, 0.5, 0.6, 0.6, 100, false, 0.5Shift light (generic save_setting / get_setting_with_default keys).
obdCardStyleCircularfalseParameter-card grid: circular gauges instead of square cards.
activeDashboard, dashboardEditorDraft"grid", ""See Gauges & Dashboards.
lastOBDPage""Last visited OBD sub-page indicator on OBDHome.

Full schema: Settings Reference.

Signals and Properties

Connection signals

SignalTypeDescription
connectionStatusChangedQStringCoarse status. Values emitted by the code: Connecting, Connected, No Vehicle, Connection Failed, Device Not Found, Disconnected, Stale Connection, Reconnecting, Error; Android also No MAC Address, Connecting BLE..., Discovering services..., Initializing ELM327..., Querying PIDs..., BLE connect failed, BLE error. OBDManager emits all three status signals through setConnectionStatus() / setConnectionDetail() / setConnectionProgress(), which store the value before emitting: Qt notifies QML bindings before connected slots, so (until 2026-10-29) a cache filled by a slot left connectionStatus / connectionState one change behind in QML (a connected car read "Connecting").
connectionStatusDetailChangedQStringHuman-readable detail line.
connectionLostUnexpectedlyQString reasonA live link dropped on its own: the worker's connectionLost while connected (port error, adapter stopped responding, a custom transport closing), or the Android BLE bridge reporting DISCONNECTED while connected. Not emitted for disconnect, port changes, diagnostic-mode switches or shutdown, which tear the worker down (or clear m_connected) first. Main.qml turns it into the ErrorToast notice. The data watchdog's "Stale Connection" restart (no parsed data for DATA_WATCHDOG_TIMEOUT_MS) doesn't emit it: that also fires with the key on and the engine off, so an adapter that keeps answering without data reconnects silently.
connectionProgressChangedint0-100 (10 attempt, 15 port found, 20 BLE connect started, 50 no-vehicle / BLE discovering, 70 BLE ready, 80 ELM327 init done over BLE, 100 connected).
devicePresenceChangedboolWhether the configured port/device exists.
availablePortsChangedQVariantListDiscovered port identifiers (strings).
availableAdaptersChangedQVariantListSame discovery as {name, identifier, kind} maps.
connectionLogLineAppendedQStringOne connection log line; connectionLog keeps the last 60.
connectionLogChanged—connectionLog changed (a line added, the oldest dropped, or cleared).
supportedCommandsChangedQVariantListVehicle-supported command names after a scan.
scanProgressChangedint, QStringScan progress and message.
scanCompleteChangedQVariantListScan results (same list as above).
scanOutputChangedQStringTerminal-style scan output line.

Diagnostic signals

SignalTypeDescription
dtcCodesChangedQVariantList[{code, description}, ...] (description is an empty string).
dtcCountChangedintNumber of DTCs.
milStatusChangedboolCheck Engine Light status.
dtcClearResultbool, QStringSuccess flag and message.
freezeFrameChangedQVariantListFreeze-frame DTCs, the same {code, description} maps plus values: what freeze frame 0 recorded, {paramId: value} in native units (Mode 02 PIDs 04-20 that the frame's bitmap lists and OCTAVE decodes). description (here and in dtcCodesChanged) comes from src/managers/dtcdescriptions.{h,cpp}: the meaning of common generic SAE codes, otherwise only what the code's structure says (system, generic or manufacturer-specific, powertrain area).
vinChangedQStringVIN read (or cleared); vin, vehicleMake, vehicleModelYear notify on it.
obdParameterChangedQString, floatA value from a multi-value PID: parameter id and value.
parameterSampledQString, floatEvery decoded reading (base, multi-value and ELM_VOLTAGE) by parameter id, one emission per reading. Consumed by the black box.

Base parameter signals (84) and extended values

Each is a float signal named {parameter}Changed, except the two legacy names distanceSinceCodesCleared and warmupsSinceCodesCleared (no Changed suffix) -- the model handlers are spelled to match. Extended values use obdParameterChanged(QString, float), vehicle identity uses vinChanged(QString), and scan-state changes emit vehicleSupportedParametersChanged(). See Supported OBD Parameters.

Properties

PropertyTypeDescription
connectionDetailQStringLast detail message (cached from the signal so the settings card can bind declaratively; added in 78ab72c).
connectionProgressintLast progress percent.
availableAdaptersQVariantListRich adapter list.
connectionStatusQStringLast connectionStatusChanged value, initially "Disconnected" (cached from the signal like connectionDetail; the cache connection is made first so handlers of the same signal already see the new value).
vin, vehicleMake, vehicleModelYearQString / QString / intDecoded vehicle identity; empty strings and year 0 when unknown. Notify: vinChanged.
vehicleSupportedParameters, vehicleScanCompleteQStringList / boolSupport results for the current connection; reset on disconnect. Notify: vehicleSupportedParametersChanged.
connectedboolQ_PROPERTY convenience over isConnected(). No QML uses it.

Slots

SlotSignatureDescription
reconnect()Clean up, reset the attempt counter, start a fresh connection.
force_connect()Same, but also clears a wedged m_isConnecting flag first (added so a BLE attempt that never reported back cannot block every later Connect tap). The Connect button and connect_to_adapter end here.
reset_connection()Alias of force_connect; used by the settings page's Connect button.
close()Sets the force-stop flag, stops timers, cleans up, and (unless already there) emits connectionStatusChanged("Disconnected"), so connected, connectionState and connectionStatus notify. No auto-reconnect follows; reset_connection() connects again. Called from aboutToQuit.
is_connected() -> boolConnection flag. Also exposed as camelCase isConnected().
get_connection_status() -> QStringThe last connectionStatusChanged value ("Disconnected" before any attempt, then "Connecting", "Connected", "No Vehicle", ...), the label the pages show; connectionState classifies it.
connectionStateproperty ConnectionState (notify connectionStatusChanged)Q_ENUM ConnectionState { Disconnected, Connecting, Connected, NoVehicle, Failed } from the status text (stateFor(): every BLE step, Reconnecting and Stale Connection are Connecting; Device Not Found, Connection Failed, Error, No MAC Address and the BLE failures are Failed; unknown text is Failed). QML imports OCTAVE.OBD 1.0 (registered in main.cpp) for OBDManager.Connecting etc.; the settings page, its widget, OBDHome and the OBD menu's connect button colour and animate by it (tst_obdmanager lists every emitted string).
scan, refresh_ports, get_available_ports() / () -> QVariantListDiscovery sweep; the last also returns the port list.
connect_to_adapter, platform_hint(QString) / () -> QStringSee Unified Adapter API.
set_target_address(QString)Persists the identifier via save_obd_bluetooth_port; the port-change debounce then reconnects. Fallback path in the QML when connect_to_adapter is absent.
check_device_presence() -> boolRe-checks the configured port and emits devicePresenceChanged.
set_connection_timeout(int)5-60 s (default 5): the wait for the vehicle-verify RPM probe. Runtime only -- there is no obdConnectionTimeout settings key.
set_auto_reconnect(bool)Deprecated no-op; use obdAutoReconnectAttempts.
scan_vehicle, is_scanning, get_all_parameter_names, get_supported_commands, enable_scanned_parameters, enable_all_supportedvariousSee Vehicle Scanning.
enter_diagnostic_mode, exit_diagnostic_mode, is_diagnostic_mode, read_dtc, read_current_dtc, read_status, clear_dtc, read_freeze_frame, get_dtc_codes, get_dtc_count, get_mil_status, get_freeze_framevariousSee Diagnostics.
setParameterDemand(QString consumer, QStringList ids)Register a consumer's visible parameters; an empty list removes its demand. Applied after 250 ms. Over the service, a consumer is released when the connection that registered it closes.
setBackgroundDemand(QString consumer, QStringList ids)Low-priority demand: polled one PID per pass of the foreground list (the black box sweep). Same consumer / empty-list rules.
read_vin()Request vehicle identity again.
connect_direct(QString mac, int channel = 1)Connect by RFCOMM URL on Linux.
refresh_values()Re-emit current values by queueing one extra poll tick (desktop, while connected).
coolantTemp, rpm, speedMPH, ... (18)() -> floatGetters for the original 18 values.
connect_obd, open_bluetooth_settings()connect_obd is an alias of startConnection(); open_bluetooth_settings is a desktop no-op. Not called from QML.

Design Decisions

  • Two transports, one class. OCTAVE keeps the Android BLE path inside OBDManager behind #ifdef Q_OS_ANDROID rather than a subclass, so main.cpp, the signal dispatch table and the QML surface are identical on every platform (2d05676).
  • Bypass Qt Bluetooth on Android. Qt 6.7's QBluetoothPermission aggregator reports Denied on Android 16 even with every runtime grant present, and QLowEnergyController reads that cache before initialising the adapter. BluetoothAdapter.getRemoteDevice + connectGatt do not consult it, so a 400-line Java helper polled over JNI was cheaper and more reliable than fighting the framework (f1901d9). Polling at 50 ms instead of registering native JNI callbacks keeps the Java side free of Qt headers.
  • Three BLE profiles, priority ordered. ELM327 BLE clones ship ISSC, FFF0 or FFE0 firmware; trying all three at service-discovery time made one APK work with NEXAS, Vgate, Veepeak, Konnwei and HM-10 boards without a per-adapter setting.
  • The worker owns the port. QSerialPort must live on the thread that reads it, and blocking waitForReadyRead inside the worker is simpler than an async state machine; the price is that every request from the main thread is a queued invokeMethod. The 2026-09-09 spin fixes (above) are all consequences of that choice: a port left open is a live notifier in that thread's event loop.
  • Release the port on every failure. Rather than trying to detect a dead rfcomm remote, the rule is "nothing may hold the port open while we are not actively talking to a live adapter" (0404f7b). It is a policy, not a heuristic, so it holds for USB and Bluetooth alike.
  • Live poll-list swaps. The worker copies the poll list before the thread starts and updatePidsToWatch() swaps it live when demand changes, so a page switch changes polling without a reconnect.
  • One PID table, standard numbering. The decoders used to sit beside three hand-copied name maps that drifted from each other and from the standard PID numbers (mis-numbered PIDs, a 0x59 mix-up, 23 names that could never be polled). Giving PidEntry a commandName column and iterating the table everywhere (buildPidsToWatch(), the scans, the Android list, the dev simulator) made the one table the single reference, and tst_elm327protocol checks it without a vehicle.
  • Diagnostics pause polling. ELM327 adapters are half-duplex request/response devices; interleaving Mode 03/04 traffic with a running PID loop corrupts both. OCTAVE therefore stops live data during DTC work and does a full reconnect afterwards to restore a clean adapter state.
  • Auto-reconnect defaults to off. The scanner-driven passive mode reconnects when a port appears, which is what happens when a Pi boots before the adapter pairs; unbounded retries against an adapter with no vehicle just spin the port (see the spin bug). Users who want scheduled retries set 1-10 attempts. Direct RFCOMM connections also retry through the 10 s scanner.
  • Unified adapter API. The settings page used to branch on isAndroid; moving MAC/COM/tty classification and the rfcomm bind into the managers made the page render identically everywhere and gave the manager a single connect entry point to test.
  • Observability over logcat. The in-app connection log (542b446), the ELM327 transcript category (86c8c3c) and the BLE log forwarding (3858c5c) all exist because the head unit is in a vehicle where neither a terminal nor adb logcat is reachable -- failures must land in the files the Diagnostics card exports.
  • Coalesced model writes. OBDParameterModel buffers into a plain object and flushes at 30 Hz because the alternative -- one dictionary copy per signal -- was measured at 100 % of a core on the Orange Pi with the adapter unplugged (486a809). Needle animations only need ~30 updates a second anyway.