OBD Manager
OBD-II vehicle diagnostics: a threaded ELM327 connection worker, 83 base commands and 37 extended values, DTC reading, an Android BLE bridge, and the parameter model that feeds every gauge
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.
Architecture and Files
| File | Platform | Description |
|---|---|---|
| src/managers/obdmanager.{h,cpp} | All | OBDManager (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} | All | Static 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.java | Android | Static 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 Linux | OBDAdapterFinder (QML obdAdapterFinder): Bluetooth discovery, PIN pairing through BlueZ, trust, SPP channel lookup, then hand-off to connect_direct(). See Adapter Finder. |
| frontend/OBDAdapterSheet.qml | QML | The "Connect OBD2 Adapter" sheet the finder drives. |
| frontend/OBDParameterModel.qml | QML | Singleton 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} | Simulator | Simulated 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.cpp | Browser | The 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 file | What it uses |
|---|---|
| frontend/OBDParameterModel.qml | Serves 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.qml | Parameter-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.qml | Everything 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.qml | Status 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.qml | enter_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.qml | Home-screen cards for the parameters in homeOBDParameters, plus the RPM card's shift-light disc. |
| frontend/Main.qml | Connects obdManager.rpmChanged by hand in Component.onCompleted to drive the global full-screen flash and aura overlays. |
| frontend/settings/OBDSettingsPage.qml | Connection 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:
| Timer | Interval | Purpose |
|---|---|---|
m_startupTimer | 50 ms single-shot | Defers the first scan + connection attempt out of the constructor so app startup is not blocked. |
m_deviceScannerTimer | 10 s | Passive port discovery while disconnected; stopped once connected. |
m_portChangeTimer | 1 s single-shot | Debounces obdBluetoothPortChanged from settings into one reconnect(). |
m_dataWatchdogTimer | 5 s | Stale-connection detection (30 s without data). |
- Startup: the 50 ms timer fires
onStartupTimer(): scan for ports, start the 10 s scanner, callstartConnection(). - Guard: a mutex-protected
m_isConnectingflag drops re-entrant attempts ("Already connecting -- skipping").cleanupConnection()clears the flag, because the attempt it abandons has itsinitCompletedisconnected and would never clear it. Before 2026-10-03 areconnect()during an attempt left OBD stuck in "Connecting" with no worker. The abandoned worker logs connect to X abandoned (superseded by a newer connect) onoctave.elm327. The attempt counter increments and the UI getsconnectionStatusChanged("Connecting"),connectionStatusDetailChanged("Attempt N..."),connectionProgressChanged(10). - Port check:
checkPortExists(). Unix: the device file exists; Windows: the port is listed byQSerialPortInfo::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. - Worker: progress 15,
"Found <port>, connecting...", then a freshOBDConnectionWorkeris moved to a newQThreadand itsstartedsignal invokes the blocking connect (next section). On Android the manager skips this and callsstartAndroidConnection()instead. - 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, andscan_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.
- Success:
- Post-diagnostic retries: when
m_reconnectingAfterDiagnosticis 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 signal | Handled by | Effect on the main thread |
|---|---|---|
initComplete(bool, message) | onWorkerInitComplete | Success/"adapter"/failure branch of the connection flow above (the word adapter in the message selects "No Vehicle"). |
dataReceived(signalName, value) | onWorkerDataReceived | Stamps 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, freezeFrame | onWorkerDtc* | Cached and re-emitted as the public diagnostic signals (codes become {code, description: ""} maps). |
scanProgress, scanComplete, scanOutput | onWorkerScan* | 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: logRXwith 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 (
QSerialPorterrors 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.onSerialErrorreportsResourceError/DeviceNotFoundErrorasconnectionLost, and logs every error exceptTimeoutErroronoctave.elm327at 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 coveredwaitForReadyRead()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
| Concern | Behaviour |
|---|---|
| Command delay | Poll 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 caching | None. ATSP0 (auto) is sent on every init; the ELM327 does its own search. |
| Adaptive timing | ATAT2 in the init table. |
| Watchdog stamp | Every 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()):
| Platform | Scanned |
|---|---|
| Windows | All 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) |
| Android | listAndroidPairedDevices(): 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.
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 / Property | Signature | Description |
|---|---|---|
availableAdapters | property: QVariantList | List 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. |
availableAdaptersChanged | signal(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.) |
connectionLogLineAppended | signal(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. |
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(aQIODeviceover anAF_BLUETOOTHsocket; the three constants andsockaddr_rcare spelled out so the build needs no libbluetooth-dev). The worker picks it indoConnect()whenRfcommSocket::isUrl(port)and otherwise callsopenSerialPort(). Everything after that talks throughm_io, whichever device is open.connectTo()waits up tomax(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/rfcommNopen) 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 setsisBroken(), whichsendCommand()turns intoconnectionLost. On other platformsconnectTo()fails with only supported on Linux. checkPortExistsonly validates the URL, since there is no node to look for. The passive device scan (every 10 s) retries a configuredrfcomm://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."
- Scan (
startScan()):QBluetoothDeviceDiscoveryAgent, Classic + LE, 15 s. Unnamed devices (BlueZ names them after their address) are dropped.likelyObdis 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). - Pair (
connectAdapter(address), Classic only): the scan stops, thenBluezPairerregisters its ownorg.bluez.Agent1on the system bus and callsDevice1.Pair(). BlueZ sends the PIN request to the agent of the client that calledPair(), 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. - 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. - Channel: an SDP lookup for the Serial Port service (
QBluetoothServiceDiscoveryAgent, 8 s); channel 1 if it finds nothing. - Hand-off:
obdManager.connect_direct(mac, channel)(see Direct Bluetooth), andadd_obd_saved_adapter(name, url)so the saved chip shows the adapter's name. The finder then followsconnectionStatusChanged. It ignores the Disconnected thatforce_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:
| Layer | Count | Where |
|---|---|---|
Parameter signals on OBDManager | 84 float signals | 83 base command names + ignitionTimingChanged, which is emitted together with timingAdvanceChanged from the same PID (one entry of the signal dispatch table). |
| Base PID-table entries | 83 | ELM327Protocol::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 names | 83 | get_all_parameter_names() returns the same 83 base names, derived from pidTable() and sorted. This list excludes the 37 extended values. |
| Extended PID table | 23 PIDs / 37 values | Multi-value entries in extendedPidTable(); values use obdParameterChanged(paramId, value). |
ParameterCatalog (= OBDParameterModel.allParameters) | 129 | 84 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).
| Parameter | Command / PID | Signal | Decoder (protocol table) |
|---|---|---|---|
| Coolant Temperature | COOLANT_TEMP / 01 05 | coolantTempChanged | A - 40 |
| Control Module Voltage | CONTROL_MODULE_VOLTAGE / 01 42 | voltageChanged | (256A + B) / 1000 |
| Engine Load | ENGINE_LOAD / 01 04 | engineLoadChanged | A * 100 / 255 |
| Throttle Position | THROTTLE_POS / 01 11 | throttlePositionChanged | A * 100 / 255 |
| Intake Air Temperature | INTAKE_TEMP / 01 0F | intakeAirTempChanged | A - 40 |
| Timing Advance | TIMING_ADVANCE / 01 0E | timingAdvanceChanged + ignitionTimingChanged | A / 2 - 64 |
| Mass Air Flow | MAF / 01 10 | massAirFlowChanged | (256A + B) / 100 |
| Vehicle Speed | SPEED / 01 0D | speedMPHChanged | A * 0.621371 (km/h to mph) |
| Engine RPM | RPM / 01 0C | rpmChanged | (256A + B) / 4 |
| Air/Fuel Ratio | COMMANDED_EQUIV_RATIO / 01 44 | airFuelRatioChanged | (256A + B) / 32768 × 14.7 -- the commanded lambda scaled to a gasoline AFR (decAirFuelRatio). The AFR gauge spans 10-18. |
| Fuel Level | FUEL_LEVEL / 01 2F | fuelLevelChanged | A * 100 / 255 |
| Intake Manifold Pressure | INTAKE_PRESSURE / 01 0B | intakeManifoldPressureChanged | A (kPa) |
| Short Term Fuel Trim 1 | SHORT_FUEL_TRIM_1 / 01 06 | shortTermFuelTrimChanged | (A - 128) * 100 / 128 |
| Long Term Fuel Trim 1 | LONG_FUEL_TRIM_1 / 01 07 | longTermFuelTrimChanged | (A - 128) * 100 / 128 |
| O2 Sensor B1S1 | O2_B1S1 / 01 14 | oxygenSensorVoltageChanged | A / 200 |
| Fuel Pressure | FUEL_PRESSURE / 01 0A | fuelPressureChanged | A * 3 |
| Oil Temperature | OIL_TEMP / 01 5C | engineOilTempChanged | A - 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.
| Category | Parameters |
|---|---|
| Engine / Timing | RUN_TIME, ABSOLUTE_LOAD, COMMANDED_EGR, EGR_ERROR, ETHANOL_PERCENT |
| Fuel System | FUEL_RAIL_PRESSURE_VAC, FUEL_RAIL_PRESSURE_DIRECT, FUEL_RAIL_PRESSURE_ABS, FUEL_RATE, FUEL_INJECT_TIMING, EVAPORATIVE_PURGE, FUEL_TYPE |
| Throttle / Accelerator | RELATIVE_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 Trims | SHORT_O2_TRIM_B1, LONG_O2_TRIM_B1, SHORT_O2_TRIM_B2, LONG_O2_TRIM_B2, SHORT_FUEL_TRIM_2, LONG_FUEL_TRIM_2 |
| Catalyst Temperatures | CATALYST_TEMP_B1S1, B1S2, B2S1, B2S2 |
| Environmental | BAROMETRIC_PRESSURE, AMBIANT_AIR_TEMP (the established command name's spelling), EVAP_VAPOR_PRESSURE, EVAP_VAPOR_PRESSURE_ABS, EVAP_VAPOR_PRESSURE_ALT |
| Distance / Time | DISTANCE_W_MIL, DISTANCE_SINCE_DTC_CLEAR, WARMUPS_SINCE_DTC_CLEAR, RUN_TIME_MIL, TIME_SINCE_DTC_CLEARED |
| Other | MAX_MAF, HYBRID_BATTERY_REMAINING, ELM_VOLTAGE |
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.
| Group | Parameters (mode/PID) |
|---|---|
| Torque | DEMAND_ENGINE_TORQUE (01 61), ACTUAL_ENGINE_TORQUE (01 62), ENGINE_FRICTION_TORQUE (01 8E), REFERENCE_TORQUE (01 63) |
| Boost | BOOST_PRESSURE_A (01 70), BOOST_PRESSURE_A_COMMANDED (01 70), WASTEGATE_A (01 72), WASTEGATE_A_COMMANDED (01 72) |
| Air | MAF_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) |
| Temperature | COOLANT_TEMP_SENSOR_1 (01 67), COOLANT_TEMP_SENSOR_2 (01 67) |
| Throttle | THROTTLE_POS_G (01 8D), THROTTLE_ACTUATOR_A_COMMANDED (01 6C), RELATIVE_THROTTLE_A (01 6C) |
| EGR | EGR_A_COMMANDED (01 69), EGR_A_ACTUAL (01 69), EGR_A_ERROR (01 69) |
| Fuel | FUEL_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) |
| Drivetrain | TRANSMISSION_GEAR_RATIO (01 A4), RECOMMENDED_GEAR (01 65) |
| Distance and time | ODOMETER (01 A6), ENGINE_RUN_TIME_TOTAL (01 7F), ENGINE_IDLE_TIME_TOTAL (01 7F) |
| Hybrid / EV | HYBRID_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
vehicleSupportedParametersonce 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:
RPMis always polled (ELM_VOLTAGEwhen 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 andELM_VOLTAGE), keyed by theOBDParameterModelid. 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 theblackBoxSweepAllsetting. - 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 > 0reading (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, beforeobdManager.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_endwith 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>.csv | Every 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>.json | The 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).
Health across drives
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.
| Id | What | Usual range | Flagged when |
|---|---|---|---|
warmupMin | Minutes from a cold start (first coolant reading under 40 °C) to 70 °C | — | 2 min longer than before |
settledCoolant | Average coolant after the first 10 minutes, on drives of 15 minutes or more | 80–105 °C | outside the range, or 5 °C moved |
idleVoltage | CONTROL_MODULE_VOLTAGE while stopped at 400–1100 RPM | 13.0–14.8 V | outside the range, or 0.3 V lower |
chargingVoltage | The same above 1300 RPM | 13.4–14.8 V | outside the range, or 0.3 V lower |
idleRpm | RPM while stopped once warm (coolant 70 °C or more) | — | 100 RPM moved |
ltft1, ltft2 | Long-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, sends0100,0120,0140, ...01E0(stopping at the first range whose bitmap does not set bit 32 = "next range supported"), collects the supported PID numbers withparseSupportedPids(), and builds the final result throughsupportedCommandNames(): base command names and extended value IDs whose PIDs are supported, plusELM_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 throughonWorkerScanComplete()(which clearsm_isScanningand 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 offonWorkerInitComplete). 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 | |
|---|---|
| Enter | Stops the watchdog and queues stopPolling on the worker; DTC work uses the same port and worker. |
| Queries | Each 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 safety | Results come back as queued worker signals. |
| Exit | Status "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 shape | dtcCodesChanged 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:
pollLog()up to 50 lines per tick:W|lines becomeqCWarning(lcElm327) "[BLE] ...",I|linesqCInfo-- so a BLE drop in the car lands inoctave-cpp.logand the Diagnostics export instead of only logcat (3858c5c).pollResponse()until empty: each notification chunk is fed to the sameResponseBuffer, and every complete>-terminated response goes toprocessAndroidResponse().stateCode(): only on a transition.0 DISCONNECTED(loglastEvent, 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(loglastError, 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.
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
| Function | Description |
|---|---|
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 whilerpm_shift_light_enabledis on.activeFlaghas every field filled in (defaults above;fullScreenFlashOpacityfalls back torpm_fullscreen_flash_opacity). - Flash: one C++ timer at the flag's
flashSpeedtoggleslit, 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 notlit. - Global full-screen flash (Main.qml,
globalFullScreenFlash): az: 999999rectangle over the whole window in the flag colour at the flag'sfullScreenFlashOpacity(fallbackrpm_fullscreen_flash_opacity), visible whilelitand the flag hasfullScreenFlash;enabled: falseso touches pass through. - Aura overlay (
globalAuraFlash): the same trigger withauraFlash, but rendered as four edge gradients (top/bottom/left/right) that fade from the flag colour to transparent overauraSpreadof the window (default 18 %) atauraFlashOpacity-- 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
| Mechanism | Scope | How it works |
|---|---|---|
OBDParameterModel.simulationActive | Dashboard editor "Demo data" button | Pure 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
| Key | Default | Used by |
|---|---|---|
obdBluetoothPort | empty -> 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. |
obdFastMode | true | Poll 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. |
blackBoxEnabled | true | Trip recorder on / off; turning it off ends the trip being recorded. |
blackBoxSweepAll | true | Register every scanned PID as background demand so the record covers PIDs not on screen. |
blackBoxMaxMB | 500 | Cap (50–5000 MB) for the database plus trip files; oldest closed trips are pruned first. |
obdAutoReconnectAttempts | 0 | Backoff attempts, clamped 0-10. |
obdParameters | map, filled lazily with defaults | get_obd_parameter_enabled / save_obd_parameter_enabled (800 ms debounce, then obdParametersChanged). |
supportedOBDParameters | [] | Last scan result. |
homeOBDParameters | list | Which 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.5 | Shift light (generic save_setting / get_setting_with_default keys). |
obdCardStyleCircular | false | Parameter-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
| Signal | Type | Description |
|---|---|---|
connectionStatusChanged | QString | Coarse 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"). |
connectionStatusDetailChanged | QString | Human-readable detail line. |
connectionLostUnexpectedly | QString reason | A 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. |
connectionProgressChanged | int | 0-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). |
devicePresenceChanged | bool | Whether the configured port/device exists. |
availablePortsChanged | QVariantList | Discovered port identifiers (strings). |
availableAdaptersChanged | QVariantList | Same discovery as {name, identifier, kind} maps. |
connectionLogLineAppended | QString | One connection log line; connectionLog keeps the last 60. |
connectionLogChanged | — | connectionLog changed (a line added, the oldest dropped, or cleared). |
supportedCommandsChanged | QVariantList | Vehicle-supported command names after a scan. |
scanProgressChanged | int, QString | Scan progress and message. |
scanCompleteChanged | QVariantList | Scan results (same list as above). |
scanOutputChanged | QString | Terminal-style scan output line. |
Diagnostic signals
| Signal | Type | Description |
|---|---|---|
dtcCodesChanged | QVariantList | [{code, description}, ...] (description is an empty string). |
dtcCountChanged | int | Number of DTCs. |
milStatusChanged | bool | Check Engine Light status. |
dtcClearResult | bool, QString | Success flag and message. |
freezeFrameChanged | QVariantList | Freeze-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). |
vinChanged | QString | VIN read (or cleared); vin, vehicleMake, vehicleModelYear notify on it. |
obdParameterChanged | QString, float | A value from a multi-value PID: parameter id and value. |
parameterSampled | QString, float | Every 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
| Property | Type | Description |
|---|---|---|
connectionDetail | QString | Last detail message (cached from the signal so the settings card can bind declaratively; added in 78ab72c). |
connectionProgress | int | Last progress percent. |
availableAdapters | QVariantList | Rich adapter list. |
connectionStatus | QString | Last 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, vehicleModelYear | QString / QString / int | Decoded vehicle identity; empty strings and year 0 when unknown. Notify: vinChanged. |
vehicleSupportedParameters, vehicleScanComplete | QStringList / bool | Support results for the current connection; reset on disconnect. Notify: vehicleSupportedParametersChanged. |
connected | bool | Q_PROPERTY convenience over isConnected(). No QML uses it. |
Slots
| Slot | Signature | Description |
|---|---|---|
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 | () -> bool | Connection flag. Also exposed as camelCase isConnected(). |
get_connection_status | () -> QString | The last connectionStatusChanged value ("Disconnected" before any attempt, then "Connecting", "Connected", "No Vehicle", ...), the label the pages show; connectionState classifies it. |
connectionState | property 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 | () / () -> QVariantList | Discovery sweep; the last also returns the port list. |
connect_to_adapter, platform_hint | (QString) / () -> QString | See 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 | () -> bool | Re-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_supported | various | See 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_frame | various | See 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) | () -> float | Getters 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
OBDManagerbehind#ifdef Q_OS_ANDROIDrather than a subclass, somain.cpp, the signal dispatch table and the QML surface are identical on every platform (2d05676). - Bypass Qt Bluetooth on Android. Qt 6.7's
QBluetoothPermissionaggregator reports Denied on Android 16 even with every runtime grant present, andQLowEnergyControllerreads that cache before initialising the adapter.BluetoothAdapter.getRemoteDevice+connectGattdo 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.
QSerialPortmust live on the thread that reads it, and blockingwaitForReadyReadinside the worker is simpler than an async state machine; the price is that every request from the main thread is a queuedinvokeMethod. 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
0x59mix-up, 23 names that could never be polled). GivingPidEntryacommandNamecolumn and iterating the table everywhere (buildPidsToWatch(), the scans, the Android list, the dev simulator) made the one table the single reference, andtst_elm327protocolchecks 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 noradb logcatis reachable -- failures must land in the files the Diagnostics card exports. - Coalesced model writes.
OBDParameterModelbuffers 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.