Architecture Overview
System design, communication patterns, threading model, startup order,
observability, and the reasoning behind each of those choices — verified against
src/main.cpp and the manager sources.
High-Level Architecture
OCTAVE is a backend/frontend split joined by Qt's signals and slots. The
C++ / Qt 6 backend in src/ — built into every shipped binary
(Windows .exe, macOS .dmg, Linux x86_64 and aarch64 AppImages,
Android .apk, and the WebAssembly demo at robdegeorge.com/OCTAVE/) — owns all business logic, hardware I/O, network calls and data
processing; the QML frontend in frontend/ owns rendering, animation and interaction.
EMSCRIPTEN in CMake, OCTAVE_WASM + Q_OS_MOBILE in C++). A browser has no serial ports, Bluetooth, processes or persistent disk, so that build drops the OBD transports, TagLib and FFmpeg, swaps in inert stand-ins from src/platform/wasm/, keeps the whole frontend/ tree in an in-memory filesystem, and drives the real OBDManager from the simulated car through WebDemo's autopilot. Everything above the transports — managers, paramStore, dashboards, the black box (SQLite in memory), the 3D vehicle — is unchanged. Nothing persists across reloads and there is no service / command server. Details: Building: WebAssembly.
Backend / Frontend Split
| Layer | Technology | Location | Responsibility |
|---|---|---|---|
| Backend | C++17 / Qt 6.7.3 (CMake 3.21+) | src/managers/, src/util/, src/phone_mirror/, src/android_auto/, src/items/, src/platform/ |
Business logic, hardware I/O, API calls, data processing, state |
| Frontend | QML (Qt Quick, Qt Quick 3D for the vehicle view) | frontend/ |
UI rendering, animation, interaction, layout — loaded from disk at runtime (Android bundles it as assets:/frontend) |
| Bridge | src/main.cpp |
src/ |
Instantiates managers, registers context properties and custom types, wires cross-manager signals, owns shutdown |
Managers own hardware access and application state; QML consumes that state through the registered API. Some integrations explicitly cross the rendering boundary, such as frame providers, video sinks, and developer UI-driving tools. The only contract between them is the set of context-property names, signals, slots and properties (see Design Decisions).
#ifdef Q_OS_ANDROID /
Q_OS_MOBILE. Desktop-only managers keep their QML-facing API as stubs there, so QML needs
nothing beyond typeof fooManager !== "undefined" guards for features legitimately absent on
one platform.
The Manager Pattern
Every backend feature is a manager class inheriting QObject. It
exposes state through Q_PROPERTY, Q_INVOKABLE / public slots and signals:
// src/managers/examplemanager.h
class ExampleManager : public QObject {
Q_OBJECT
Q_PROPERTY(int value READ value NOTIFY valueChanged)
public:
int value() const { return m_value; }
public slots:
void setValue(int v) { m_value = v; emit valueChanged(v); }
signals:
void valueChanged(int);
private:
int m_value = 0;
};
Key principles:
- Signals notify the UI (and other managers) when backend state changes
- Slots are methods QML can call — many use snake_case names rather than the usual C++ camelCase (
next_track,scan_library,connect_settings_manager), and QML calls them as written - Properties expose values with automatic binding updates
- Managers are self-contained: each owns its state, timers and threads
- src/appcore.cpp (
AppCore, every manager as a member, no QML engine;octave --headlessruns it with no engine at all) wires cross-manager signals and passes dependencies where needed, for example settings references, theVolumeControllerfan-out to its outputs, andOBDAdapterFinderhanding a connection toOBDManager.
All Managers
Every row below is a member of AppCore (src/appcore.{h,cpp}), entered in its service registry under the listed name; src/main.cpp builds the core and sets each registry entry as a context property (core.exposeTo(ctx)), so the QML UI and the service protocol see the same objects. Mobile stubs are
header-level #else // Q_OS_MOBILE classes with the same API so QML bindings stay valid.
| Class | Source (src/managers/) | Context property | Purpose |
|---|---|---|---|
| SettingsManager | settingsmanager.{h,cpp} | settingsManager | Central settings store, JSON persistence, getAppDataDir() |
| DiagnosticsManager | diagnosticsmanager.{h,cpp} | diagnosticsManager | Read / copy / export the log files from the head unit (Settings → Device → Diagnostics) |
| Clock | clock.{h,cpp} | clock | Real-time clock, format from settings |
| NetworkManager | networkmanager.{h,cpp} | networkManager | WiFi status, GitHub update check, git-based self-update + restart |
| VolumeController | volumecontroller.{h,cpp} | volumeController | Single entry point for the quadratic volume curve |
| MediaManager | mediamanager.{h,cpp} | mediaManager | Local playback (QMediaPlayer), library scan, album art + colours, persistent metadata store |
| AudioAnalyzer | audioanalyzer.{h,cpp} | audioAnalyzer | 96-bar FFT waveform (QAudioDecoder + radix-2 FFT) |
| OBDManager (+ ELM327) | obdmanager.{h,cpp}, elm327protocol.{h,cpp} | obdManager | Vehicle diagnostics, standard and extended PID decoding, DTCs; Android BLE via a JNI bridge |
| OBDAdapterFinder | obdadapterfinder.{h,cpp} (+ platform/bluezpairer.{h,cpp}) | obdAdapterFinder | Bluetooth scan, PIN pairing via BlueZ, trust, hand-off to connect_direct() (desktop Linux) |
| ESP32VolumeManager | esp32volumemanager.{h,cpp} | esp32VolumeManager | Wireless rotary knob over USB serial (ESP-NOW pair); stub on mobile |
| BerryIMUManager | berryimumanager.{h,cpp} | berryIMU | Madgwick fusion (accel + gyro, 6DOF; magnetometer for heading only) over I²C; on Android re-implemented with QtSensors (accelerometer, compass, pressure) |
| GestureManager | gesturemanager.{h,cpp} | gestureSensor | PAJ7620U2 gesture sensor over I²C; stub on mobile |
| AndroidAutoManager | androidautomanager.{h,cpp}, androidautomanager_native.cpp + src/android_auto/ | androidAutoManager | Android Auto: the built-in head unit (native receiver, androidAutoBackend = "native") or Google DHU launch + window capture; stub on mobile |
| AirPlayManager | airplaymanager.{h,cpp} + src/airplay/, src/platform/bluezhidmouse, third_party/uxplay/ | airPlayManager | An iPhone over AirPlay (screen and audio, UxPlay's library), touch back through OCTAVE as a Bluetooth LE mouse; available false without libplist (Linux only so far) |
| PhoneMirrorManager | phonemirrormanager.{h,cpp} + src/phone_mirror/scrcpyclient.{h,cpp} | phoneMirrorManager | Built-in scrcpy-protocol client; stub on mobile |
| H264Decoder | src/util/h264decoder.{h,cpp} | none (ScrcpyClient, AapConnection, AirPlayReceiver) | libavcodec H.264 (Annex B) → YUV 4:2:0 QVideoFrame, shared by phone mirroring, built-in Android Auto and AirPlay; open() fails without OCTAVE_HAVE_FFMPEG |
| DashboardManager | dashboardmanager.{h,cpp} | dashboardManager | Built-in preset + user JSON dashboards, save / delete / duplicate, hot reload |
| ParameterCatalog | parametercatalog.{h,cpp} | parameterCatalog | Title / short title / unit / range / kind / decimals of every OBD PID and IMU reading, one table for C++ and every frontend; see Signals & Slots |
| NowPlaying | nowplaying.{h,cpp} | nowPlaying | What is playing and its controls, phone or local; the one phone-position clock; the phone/local hand-off |
| ParameterStore | parameterstore.{h,cpp} | paramStore | Latest value of every PID and IMU reading as a QQmlPropertyMap (one notifying key per id, 33 ms coalescing), fed from parameterSampled and the IMU in main.cpp; OBDParameterModel mirrors it |
| ShiftLightManager | shiftlightmanager.{h,cpp} | shiftLightManager | RPM shift light: flag migration, band match and flash phase, fed by OBDManager::rpmChanged; QML only draws it. See OBD Manager → Shift Light |
| TripRecorder | triprecorder.{h,cpp}, tripstore.{h,cpp} | tripRecorder | Black box: every OBD reading of every drive into SQLite (own thread) and one CSV + JSON per trip; see OBD Manager → Black Box |
| Simulator | src/sim/simulator.{h,cpp} (+ carsim, simelm327, imufeed, simconfig) | sim (experimental) | The simulated car behind the real OBD stack, IMU motion, a test video and pairing codes; every build, inert unless --sim / --dev / the browser demo starts it |
| FrontendManager (+ FrontendSupervisor) | frontendmanager.{h,cpp}, frontendmanifest.{h,cpp}, frontendsupervisor.{h,cpp} | frontendManager | Installable frontends: install from a folder or zip, remove, pick the active screen; the supervisor runs an exec frontend, restarts it with back-off and falls back to QML |
| ServicePairing | src/service/servicepairing.{h,cpp} | servicePairing (QML only, not a service object) | Pairing codes, paired devices and their scopes, revoking; Settings > Device > Service |
| UiWatchdog | src/util/uiwatchdog.{h,cpp} | none (internal) | Logs a blocked GUI thread |
| FontCatalog | src/util/fontcatalog.{h,cpp} | fontCatalog | Registers the bundled UI and icon fonts at startup; the font picker's list |
| ThemeCatalog | src/managers/themecatalog.{h,cpp} | themeCatalog | The built-in colour themes from frontend/themes.json (Style.qml binds them) and activeTheme, the palette in force for any frontend |
| PathUrl | src/util/pathurl.h | none (SettingsManager, DashboardManager) | A picked folder or file as a path to open (file URLs, Android content trees) |
| Units | src/util/units.{h,cpp} | units | Time formatting, compass points and unit conversions for QML and C++ |
| QmlModules | src/util/qmlmodules.{h,cpp} | none (feeds hasQuick3D / hasQuick3DAssetUtils) | Is an optional QML module installed, from its qmldir in the import paths, without loading it |
| DashboardGrid | src/util/dashboardgrid.{h,cpp} | none (through dashboardManager.canPlace / fitAt) | Dashboard grid placement: in the grid, no overlap, shrink-to-fit; shared by spec normalising and the editor |
| VariantListModel | src/util/variantlistmodel.{h,cpp} | none (no manager serves one at present; the service bridge still handles it) | Generic list model of QVariantMap rows, one role per key, count and get(row); a field change is one dataChanged on one row, so a ListView keeps its delegates and scroll position |
Other context properties include assetStore (local asset URLs), wikiViewerAvailable, wikiHomeUrl, hasQuick3D / hasQuick3DAssetUtils (optional QML modules, checked without loading them), and screenAutoScale (see
startup) and isAndroid (true only in the Android build).
On an OCTAVE_DEV_TOOLS build, --profile also
registers perfMonitor, and dev mode (octave --dev)
adds devMode, devKeyboardController and devToolsManager.
Backend-QML Communication
Context Properties
Managers are registered as QML context properties on the engine's root context. That makes them globally visible in every QML file without an import:
// src/main.cpp
QQmlContext *ctx = engine.rootContext();
ctx->setContextProperty("mediaManager", &mediaManager);
// QML
Button {
onClicked: mediaManager.next_track()
}
Text {
text: mediaManager.get_display_name(mediaManager.get_current_file())
}
Every context property must exist before engine.load(Main.qml): bindings are evaluated at
first load, and an unregistered name is an error, not a late binding. src/main.cpp therefore builds the
AppCore and exposes every manager (including dashboardManager, the last one) ahead of the load, and the dev
runtime's beforeLoad() hook runs before it too, so --dev can point the real
OBDManager at its simulated ELM327 before the QML is loaded.
Signals and Slots
Signals are the notification mechanism. QML reacts to them declaratively:
// QML — react to a backend signal
Connections {
target: mediaManager
function onCurrentMediaChanged(filename) {
albumArt.source = mediaManager.get_album_art(filename)
}
}
Slots are methods QML calls, declared under public slots: or marked
Q_INVOKABLE; a return value goes back to QML as a JS value:
Q_INVOKABLE QVariant loadDashboard(const QString &id);
Qt Properties
Properties combine a getter, an optional setter and a notify signal into one bindable value. QML
re-evaluates any binding that reads the property when its notify signal fires. CONSTANT
properties (e.g. diagnosticsManager.logDir) never notify and are read once.
Custom QML Types
One component is embedded as a QML item rather than a context property: the Android Auto
frame item, registered with qmlRegisterType under the OCTAVE.AndroidAuto module:
// src/main.cpp — src/items/embeddeddhuitem.h, a QQuickItem
qmlRegisterType<EmbeddedDhuItem>("OCTAVE.AndroidAuto", 1, 0, "EmbeddedDhuItem");
// QML
import OCTAVE.AndroidAuto 1.0
EmbeddedDhuItem { anchors.fill: parent }
Phone mirroring does not use a custom type: it binds a stock VideoOutput's
videoSink to phoneMirrorManager.videoSink (see video paths).
The frontend's own singletons (Style, Spacing, EnvironmentTheme,
OBDParameterModel, WidgetCatalog, MusicDevicePreference) are declared in frontend/qmldir.
Application Startup Sequence
src/main.cpp runs logging first, then the managers (AppCore), then the
engine-only setup, the service and Main.qml:
src/main.cpp
- Logging + crash handlers first —
argvis scanned for--debug,--smoke(exit 0 five seconds after start; CI launches packaged builds with it),--headlessand--dump-api DIR, andOctaveLog::install(debug)runs beforeQGuiApplicationexists, so anything a manager constructor logs (and any crash) reaches the files.--dump-apifirst points the config at a temp dir so its logs land there. - Other modes —
--dump-apibuilds anAppCore, writes docs/api/ and exits;--headless(desktop) skips WebEngine, builds anAppCoreon the offscreen platform and serves it (The Service). The rest of this list is the normal UI run. - QGuiApplication — organization/application name
OCTAVE;QQuickStyle::setStyle("Basic")so sliders are fully customisable.Unitsis created before the engine (bindings that format times run while the engine tears down). - Auto-scale and scalar context properties —
screenAutoScale = min(width, height) / 720, clamped to 0.4–3.0 (the shorter edge, because Android reports the natural portrait size even when locked landscape);isAndroid. frontend/—assets:/frontendon Android; on desktopfrontend/next to the binary, or one directory up (sobuild/octavefinds the checkout's).UiWatchdogstarted, thenAppCore(src/appcore.{h,cpp}): every manager as a member, constructed in dependency order (settings, diagnostics, clock, network, volume; media, analyzer; the PID catalog, OBD, shift light, adapter finder, trip recorder, ESP32, IMU, value store, gestures; Android Auto, phone mirror, now playing; fonts, dashboards) and wired by itswire*()helpers (Signal Wiring Graph). On Android the constructor also requests the runtime permissions; a granted audio permission triggers a library scan. Each manager is entered in the service registry;core.exposeTo(ctx)makes them all context properties.- Engine-only setup — the
dhuframeimage provider,qmlRegisterType<EmbeddedDhuItem>and theOCTAVE.OBDenum type, the QML import path,assetStore(src/util/assetstore.{h,cpp}:file://URLs for frontend/assets/ files that Quick3D's Assimp importer must open itself, extracted once from the APK on Android), thehasQuick3D/hasQuick3DAssetUtilsprobes, and the offline wiki (wikiViewerAvailable,wikiHomeUrl: the local wiki, elseqrc:/wiki/index.html, or the APK's assets on Android). - Developer tooling (
OCTAVE_DEV_TOOLSbuilds only) —OctaveDev::Runtime::beforeLoad()reads--dev/--profile; without them it does nothing. A build without the option warns and ignores the flags. - The service —
makeService()creates the command server and the service bridge when asked for (The Service); not started yet. - Load
Main.qml— returns-1if no root object was created; thenRuntime::afterLoad(server)(dev overlay, perf monitor, and with--profileits actions on the server), the server starts, andsetQuitOnLastWindowClosed(true). - Signals — on Unix desktop,
SIGTERM/SIGINTare turned intoQCoreApplication::quit()with the self-pipe trick (socketpair+QSocketNotifier) so theaboutToQuitcleanup still runs under systemd or Ctrl-C. app.exec().
Threading Model
The UI runs on the Qt main thread. Long-running work generally uses workers or asynchronous Qt APIs, with queued signals or timers returning results. There are exceptions: library scanning and parts of hardware initialization still execute on the GUI thread. The table describes the main paths:
| Manager | Threading |
|---|---|
| OBD | OBDConnectionWorker on a dedicated QThread owns a serial port or direct RFCOMM transport and a poll QTimer (interval 0 in fast mode, 50 ms otherwise). Android: the main thread owns the BLE link through the OctaveOBDBridge JNI class; no worker thread. |
| Network | QNetworkAccessManager + QProcess (git) with QTimers. |
| Media | Library scan runs synchronously on the GUI thread. Desktop neighbour pre-cache and album-colour extraction use QtConcurrent::run; Android pre-cache is synchronous. Timers handle position, saved state and cache scheduling. |
| AudioAnalyzer | QtConcurrent::run decodes with QAudioDecoder and runs an in-house radix-2 FFT; never touches GUI objects. |
| BerryIMU / Gesture | Worker QObject on its own QThread doing ioctl I²C reads (/dev/i2c-N). |
| ESP32 knob | Event-driven QSerialPort::readyRead on the main thread; reconnect / scan / keepalive / send-debounce QTimers. |
| Phone mirror | ScrcpyClient runs the video/audio socket loops on a std::thread, decodes with libavcodec (H264Decoder) and pushes QVideoFrames to the sink. |
| Android Auto (built-in) | adb calls (find the phone, check the head unit server, forward) through QtConcurrent::run + QFutureWatcher. AapConnection runs the socket, the AapSession and the H264Decoder on its own QThread; decoded frames go to the sink, PCM, state and errors come back to the GUI thread, where the QAudioSinks live. |
| UI watchdog | QThread pinging the GUI thread. |
OBD Worker Thread
Connecting to an ELM327 takes seconds (reset, protocol negotiation, supported-PID discovery), so it runs on a worker and reports progress by signal. The diagram is schematic (Android uses its JNI BLE bridge instead):
Every command and reply is logged on the octave.elm327 category
(TX ... / RX ... (N ms)), so a failed session leaves
a protocol transcript in the debug log (see logging).
Thread Pools and QtConcurrent
Short-lived work with a single result (an API call, a decode, a colour extraction) uses a pool rather
than a hand-rolled thread: QtConcurrent::run + QFutureWatcher, whose
finished signal arrives on the main thread.
// MediaManager colour extraction
auto *watcher = new QFutureWatcher<QVariantMap>(this);
watcher->setFuture(QtConcurrent::run([this, imagePath]() {
return _extract_album_colors(imagePath);
}));
Sensor Read Loops
The I²C sensors are read in a loop on their own thread (a
BerryIMUWorker / gesture worker on a QThread), with values delivered to the main thread by signal. QTimers are used for the surrounding
lifecycle — delayed start (1 s after construction), retry back-off, reconnect, the ESP32's
10 s port scan and 2 s keepalive — not for the sampling itself.
UI Watchdog
UiWatchdog (src/util/uiwatchdog.cpp) is started
right after SettingsManager and stopped first in cleanup (after the dev runtime, when present). A worker thread posts
a queued call to the GUI thread every 2 s and records when it is answered; if the GUI thread has been
silent for more than 5 s it logs UI thread blocked for N s and counting on
octave.ui_watchdog, then responsive again after N s when the loop drains. In the
browser build a hidden page (phone locked, tab in the background), which the browser all but stops, logs
page hidden; the browser paused it and page running again after N s at info level
instead (visibilitychange, read through embind). It is
purely observational — it never kills or restarts anything.
Volume System
Volume uses a quadratic curve: the 0–100 slider percent becomes a linear 0.0–1.0 gain through
linear = std::pow(percent / 100.0, 2.0); // src/managers/volumecontroller.cpp VolumeController::toLinearStatic()
The curve and its dispatch live in exactly one class, VolumeController, registered
as volumeController.
applyVolume(percent) clamps, stores the raw percent in SettingsManager
(setCurrentVolume, idempotent), computes the linear value and fans it out:
| Output | Connection to volumeApplied(percent, linear) in main.cpp |
|---|---|
| Local media | mediaManager.setVolume(linear) |
| Phone mirror audio | phoneMirrorManager.setVolume(linear) |
| Built-in Android Auto audio | androidAutoManager.setVolume(linear) (mute: mutedChanged → androidAutoManager.setMuted()), applied to every Android Auto QAudioSink |
| ESP32 LED ring | esp32VolumeManager.send_volume_update(percent) (compiled out under Q_OS_MOBILE) |
One call therefore drives every output. The ESP32 manager adds its own 50 ms debounce, so the ring never sees more than 20 updates a second.
Every source of a volume change routes through the same entry point:
| Source | Call |
|---|---|
UI sliders (BottomBar.qml, MediaRoom.qml) | volumeController.applyVolume(Math.round(value)) |
| ESP32 knob | Serial → volumeChangeRequested(delta) → accumulator in main → applyVolume(currentVolume + change) |
| Gesture sensor | I²C → actionTriggered("volume_up"/"volume_down") → applyVolume(currentVolume ± gestureVolumeStep) |
| Startup | main.cpp calls applyVolume(settingsManager.currentVolume()) once, after the outputs exist |
The knob sends fractional deltas; main.cpp accumulates them until a whole step is available:
// src/main.cpp
static float accumulator = 0.0f;
accumulator += delta;
if (std::abs(accumulator) >= 1.0f) {
int change = static_cast<int>(accumulator);
accumulator -= change;
volumeController.applyVolume(settingsManager.currentVolume() + change);
}
mediaManager.setVolume(),
phoneMirrorManager.setVolume() or esp32VolumeManager.send_volume_update() directly for a
user-driven change, and never re-implement the curve. Go through volumeController.applyVolume()
(or toLinear() for pure math in QML).
Video into QML: Image Provider vs QVideoSink
Two external video sources reach the scene graph by two different mechanisms, chosen by how the frames arrive:
Android Auto, DHU backend — image://dhuframe
Google's Desktop Head Unit is a separate process with its own window. OCTAVE launches it and
captures the window with QScreen::grabWindow(). findDhuWindow() returns zero, so automatic embedded capture is incomplete (on Linux and macOS the capture falls back to the whole screen). See Android Auto limitations. Captured frames are plain
QImages, so a QQuickImageProvider named dhuframe serves the latest
one and QML re-requests it:
// src/main.cpp
engine.addImageProvider(QStringLiteral("dhuframe"), androidAutoManager.frameProvider());
// QML
Image { source: "image://dhuframe/frame"; cache: false }
Phone mirror — QVideoSink
The phone streams H.264 over a TCP socket that OCTAVE decodes itself with libavcodec.
Decoded YUV420P planes are wrapped in a QVideoFrame without an RGB conversion and pushed to the
QVideoSink that QML's VideoOutput exposes, so the GPU does the colour conversion
and the frame never becomes a QImage:
// QML (PhoneMirrorView.qml) — inside the VideoOutput
Component.onCompleted: if (phoneMirrorManager) phoneMirrorManager.videoSink = videoSink
videoSink is a Q_PROPERTY(QObject*) on
PhoneMirrorManager. Audio comes over a second socket as PCM and is played
through a QAudioSink with a client-side gain (scrcpyAudioGain); sound detected in
that PCM drives duckingChanged → MediaManager.setDucking() so local music dips
under navigation prompts.
Phone mirror and built-in Android Auto decode with the same H264Decoder
(src/util/h264decoder.{h,cpp}), pulled out of ScrcpyClient so every projection
source decodes one way: it owns the libavcodec context (slice threading only), puts SPS/PPS sent apart in front of the next
packet, and copies a decoded YUV420P / YUVJ420P picture into a mapped QVideoFrame.
What is specific to a source stays with it (the phone mirror's black-frame hold and size tracking are in
ScrcpyClient's frame callback).
Android Auto, built-in — QVideoSink
With androidAutoBackend = "native", AndroidAutoView.qml gives its VideoOutput's sink to
androidAutoManager.videoSink the same way, and the receiver's worker thread decodes the phone's H.264 into it.
See The Built-in Android Auto Receiver.
The Built-in Android Auto Receiver
src/android_auto/ makes OCTAVE the head unit for the phone's Android Auto: it speaks the Android
Auto Protocol (AAP) to the phone's head unit server, reached through an adb forward to phone port 5277. It is compiled only with
OCTAVE_HAVE_NATIVE_AA (OpenSSL + FFmpeg, desktop; Building) and driven by
AndroidAutoManager's native mode (src/managers/androidautomanager_native.cpp), which does the
adb work, loads the head-unit certificate and retries. User-facing behaviour:
Built-in Android Auto; byte layouts:
docs/ANDROID_AUTO_PROTOCOL.md.
| File | Role |
|---|---|
aapproto.{h,cpp} | The handful of AAP messages OCTAVE sends and reads, hand-encoded in the protobuf wire format (ProtoWriter / ProtoReader, no libprotobuf); the head unit's configuration (video size, fps, density) and the channel numbering |
aapframer.{h,cpp} | Framing: FrameReader splits the byte stream into frames, MessageAssembler joins a channel's fragments into messages. Two steps because TLS is one stream across every channel: each encrypted frame is decrypted in arrival order before reassembly |
aaptls.{h,cpp} | TLS 1.2 client over an OpenSSL memory BIO pair, the bytes travelling inside AAP messages instead of a socket; loads the user's PEM certificate and key at run time (OCTAVE ships none) |
aapsession.{h,cpp} | One session with no I/O of its own (bytes in through receive(), out through transmit): version → TLS → auth → service discovery → channel opens, media acks, audio focus, sensors (night mode, driving status), touch input reports, pings every 1.5 s (no answer for 10 s fails it) and bye-bye. Transport-agnostic, so the same session can later run over a USB accessory link |
aapconnection.{h,cpp} | The session over a QTcpSocket on a worker thread with the H264Decoder; frames to the QVideoSink, audio (media, guidance, system) to one QAudioSink per stream on the GUI thread; 10 s from TCP connect to service discovery before it gives up on a head unit server that accepts but never answers |
The manager side: find the phone with adb devices and Phone Mirror's serial choice, check the phone's
/proc/net/tcp for a socket listening on port 5277, adb forward tcp:0 tcp:5277, then
AapConnection::start("127.0.0.1", port, options). Volume, mute, audio output and night mode reach it from
AppCore (VolumeController, the audioOutputDevice setting, themeCatalog: any theme
outside the light group is night). tst_androidauto runs the core against a fake phone with throwaway self-signed
certificates, no device needed (Development).
The Phone Server Fork
The Java program that runs on the phone during mirroring is OCTAVE's own fork of the scrcpy server
(Apache-2.0, forked at v3.3.4, package org.octave.phoneserver), source in
phone_server/. The prebuilt jar
tools/phone-server/octave-phone-server is committed alongside it with its
LICENSE and NOTICE; the build embeds all three as Qt resources (:/phone-server/) and writes
the jar to a temp file at runtime, so no packaging target has to carry a loose file. The client pushes the
jar over adb every session (the server's clean-up helper deletes it from
/data/local/tmp once it is running) and speak the scrcpy 3.x protocol to it.
- The version string must agree in two places or the server refuses to start:
SCRCPY_VERSION_NAMEinphone_server/build_without_gradle.shandkServerVersioninsrc/phone_mirror/scrcpyclient.h— both3.3.4-octave. - The
phone-serverCI job rebuilds the jar from source on every push and fails if the committed jar does not carry the expected package and version (a byte-level difference is reported as a warning because javac/d8 output differs across toolchains). adbitself is bundled:scripts/fetch_platform_tools.pydownloads Google's pinned platform-tools (SHA-256 verified) into the gitignoredtools/platform-tools/<os>/, and CI ships it beside the binary. Google publishes no aarch64 build, so ARM boards fall back to the distro'sadb.- Protocol byte layouts are documented in docs/PHONE_MIRROR_NATIVE_PLAN.md; re-verify them against
DesktopConnection,control/ControlMessageReaderanddevice/Streamerwhen pulling upstream changes.
Settings Persistence
All user settings live in one JSON file, settingsConfigure.json, under the directory returned by
SettingsManager::getAppDataDir(). The same directory holds
logs/, dashboards/ (user dashboards), metadata_cache.json (the media
manager's persistent tag/cover store).
| Platform | Path |
|---|---|
| Windows | %APPDATA%\OCTAVE\ |
| macOS | ~/Library/Application Support/OCTAVE/ |
| Linux / Raspberry Pi | $XDG_CONFIG_HOME/OCTAVE/, default ~/.config/OCTAVE/ |
| Android | Same code path as Linux: QDir::homePath() + "/.config/OCTAVE/", which resolves inside the app's private storage |
Reads happen once: after the constructor has loaded and validated the file, the in-memory object
is authoritative (loadSettings() returns m_settings
without touching disk). Writes are coalesced: every setter calls saveSettings(),
which updates memory, marks a pending flag and (re)starts a 500 ms single-shot timer; when it fires,
flushPendingSave() writes the whole object atomically
(QSaveFile: temp file, fsync, rename). Writes are not serialized across running application instances. OBD parameter selections have a separate 800 ms debounce, which flushPendingSave() does not drain. main.cpp explicitly flushes the main settings store on shutdown, but simultaneous instances can overwrite each other's settings. See
Settings Manager for the registry, validation and every key.
Dashboards: JSON Specs + DashboardManager
OBD dashboards are data, not code. A dashboard is a JSON object with id,
label, schema, a grid size and a list of cells, each naming a widget type from
frontend/dashboards/WidgetCatalog.qml, a PID id from OBDParameterModel, a grid
rectangle and per-widget props:
{
"id": "sport", "label": "Sport", "schema": 1,
"gridColumns": 12, "gridRows": 8,
"cells": [
{ "type": "DigitalReadout", "paramId": "SPEED",
"col": 0, "row": 0, "colSpan": 6, "rowSpan": 7,
"props": { "valueScale": 7.0, "padDigits": 3 } },
...
]
}
- Sources — built-in presets in
frontend/dashboards/presets/(fullgrid,minimal,performance,sport,tj-wrangler) and user files in<appdata>/dashboards/. Ids must be unique across both; a user file that collides with a built-in id is rejected at load. - API (
src/managers/dashboardmanager.h) —dashboardsproperty (list of{id, label, builtIn, path}, built-ins first),loadDashboard(id),isBuiltIn(id),saveDashboard(spec),deleteDashboard(id)(hides a built-in),restoreBuiltInDashboards(),hiddenBuiltInCount,duplicateDashboard(sourceId, newLabel),refresh(),dashboardsChanged. - Hot reload — a
QFileSystemWatcheron the user directory, debounced and compared against a (name, mtime, size) signature so OCTAVE's own atomic saves do not double-fire. - Rendering —
frontend/dashboards/DashboardRenderer.qmlinstantiates the cells from the spec; the chooser inOBDMenu.qmlshows every dashboard as a live miniature;frontend/dashboards/editor/DashboardEditor.qmlis the in-app drag-and-drop editor (pure QML on top of the manager's save/duplicate/delete; an abandoned draft is rescued into settings and offered on the next chooser open). - Primitives —
frontend/gauges/:CircularGauge,ArcGauge,BarGauge,LinearGauge,DigitalReadout,SparklineGauge,WarningLight, plus the self-bindingGForceGaugeandCompassGauge.
The full spec (binding API, every prop, parameter bindings, recipes) is docs/GAUGE_AUTHORING.md; the wiki page is Gauges & Dashboards.
The 3D Vehicle (CarMenu)
frontend/CarMenu.qml shows a rigged, lifted soft-top 2003 Jeep TJ (frontend/JeepVehicle.qml) in a
Qt Quick 3D scene, rolled and pitched live by berryIMU, with working doors, hood,
tailgate, lights, steering and spinning wheels. The model is an original reconstruction: the full
frontend/assets/jeep_tj_2003.glb plus per-part GLBs under frontend/assets/jeep_tj/
with generated Rig.js / rig.json pivot metadata, built by
tools/vehicle/build_jeep_tj.py and build_jeep_twin.py, then exported by
export_jeep_twin.py (Blender, not needed at runtime). The photo references and
the corrections they informed are catalogued in docs/vehicle/references.md.
Qt Quick 3D is optional at build time: CMakeLists.txt probes for it, and
frontend/SensorHome.qml probes by creating the CarMenu.qml component and greys out
the "3D View" card with "Requires the QtQuick3D module" when it fails. RuntimeLoader also needs
Qt's assimp asset-import plugin; on distro Qt that is often a separate package (see BUILD.md).
JeepVehicle.qml assembles the part GLBs with public Qt APIs rather than reaching into an
imported scene's internals, so a rebuild of the asset set does not break the QML.
Logging, Crash Handlers and Diagnostics
OCTAVE runs in a car with no console attached, so "it was in the terminal" is not observability. It therefore writes rotating log files, leave a trace on crash, notice a hung UI, and let the user get the files off the head unit from the screen.
Log files — src/util/logger.cpp
OctaveLog::install(debug) runs before QGuiApplication and installs a
qInstallMessageHandler that routes every qDebug/qCDebug/qInfo/
qWarning/qCritical/qFatal and every QML console.* call
into the same logs/ directory while still echoing to stderr on desktop (on Android and iOS,
where stderr goes nowhere, the line is handed to Qt's default handler instead so it reaches logcat /
os_log). Managers use
one Q_LOGGING_CATEGORY each (octave.media,
octave.elm327, octave.dashboards,
octave.ui_watchdog, …). Without --debug the filter rule
*.debug=false discards debug messages at the source, so they cost nothing.
| File | Level | Max size | Backups |
|---|---|---|---|
octave-cpp.log | INFO+ | 5 MB | 3 |
octave-cpp-error.log | WARNING+ and crash backtraces | 2 MB | 5 |
octave-cpp-debug.log | DEBUG+ (only with --debug) | 10 MB | 2 |
Maximum on disk: ~32 MB without debug, ~62 MB with. Format:
2026-09-10 12:00:00.123 | INFO | octave.media | mediamanager.cpp:123 | Library scan complete
Crash handlers
- Unix:
SIGSEGV/SIGABRT/SIGFPE/SIGILL/SIGBUShandlers write a==== FATAL SIGSEGV ====block and abacktrace_symbols_fdbacktrace tooctave-cpp-error.logover a raw descriptor opened at install time (no Qt in the signal path — not evenOctaveLog::flush(), which the log files do not need because every line is flushed as it is written), then restore the default action and re-raise so the OS still produces its core / tombstone. Windows usesSetUnhandledExceptionFilter+CaptureStackBackTrace. Symbolise withaddr2line -e octaveagainst the same build.qFatalpasses through the message handler first, which callsOctaveLog::flush()after a fatal message, so assertion text is on disk before the abort; theaboutToQuithandler calls the same flush last on a normal exit.
Getting logs out of the vehicle — DiagnosticsManager
Settings → Device → Diagnostics (frontend/settings/DeviceSettingsPage.qml) is backed by
diagnosticsManager: recentLogLines(200) shows the tail of the
main log followed by the error log; copyLogsToClipboard(400) prepends a
version/device header; exportLogs() copies every current and rotated log file plus a
device-info.txt into <Downloads>/OCTAVE-logs-<timestamp>/ (so on the Android
head unit they can be attached from any mail or files app) and updates lastExportPath;
openLogFolder() opens the directory in the OS file manager where canOpenFolder is
true. Constant properties logDir, appVersion (from OCTAVE_VERSION,
0.9.5), backendName and
deviceInfo feed the page header.
The Service: Other Frontends
The QML UI is the reference frontend, not the only possible one. Every manager is published over the
service protocol (src/service/): JSON
over TCP, or WebSocket frames on the same port, with hello, describe, get,
set, call, subscribe and rows. The objects are the same ones QML sees, because
AppCore's registry feeds both, and the published list (docs/api/,
Service API Reference) is generated from the meta-objects and checked by
tst_apidocs. tst_conformance checks that the QML uses nothing the service doesn't publish.
- Off unless asked for:
--service [PORT], theserviceEnabledsetting,--headless, or--profilein a developer build; loopback only unless--service-bind. - Who may use it (protocol 2): everything on this device is trusted by default; anything else pairs with a 6-digit code from Settings > Device > Service and gets a token whose scopes (
read,media,vehicle, …,full) decide what it may change. Each connection has limits (ops and new connections a second, a reader that stays behind), so one client cannot slow the car, the screen or the others (Many clients). - The contract:
protocolVersion3; tools/api/check_compat.py in CI fails a breaking change without a version bump and a docs/api/CHANGELOG.md entry; members marked experimental or deprecated say so indescribe; docs/api/protocol.json describes the envelope (ops, errors, events, stream records) as JSON Schema. --headless: the managers and the service with no QML engine and no window (about a fifth less memory on the desktop). Another program is the UI.- Streams: the
streamop sends the phone mirror's H.264 and Android Auto's frames as binary records on the same connection;GET /artserves covers. --sim(every build): a simulated car, IMU motion and test tracks on a throwaway config, driven through the experimentalsimobject, so a frontend can be written without a car.- Installable frontends (
FrontendManager, Settings > Device > Frontends): a folder or zip with anoctave-frontend.jsonmanifest, either web (served atGET /) or a program OCTAVE runs headless, restarts with back-off and falls back to QML from. - Clients: sdk/python (stdlib only), sdk/web (the reference page the app serves at
http://127.0.0.1:9876/), sdk/typescript and sdk/go (generated from docs/api/); starter projects in templates/ (JavaScript, Python, Go); tools/api/fake_server.py for testing a new SDK without the app. Writing a Frontend is the walkthrough.
Profiling and the Command Server
Developer tooling lives in src/dev/, compiled in only with the
OCTAVE_DEV_TOOLS CMake option (default on only for Debug desktop builds) and inert without its
flags. --profile creates a
PerfMonitor (event-loop latency, a thread-wake jitter probe, per-label timings, CPU / memory)
writing CSV and log files to dev/perf/logs/, registers it as the perfMonitor context
property, turns the service's command server on (127.0.0.1:9876, --command-port N
to move it; The Service) and adds the UI-driving actions to it (navigate, click, drag,
capture, record), which run on the GUI thread. Both are stopped first on aboutToQuit and the
report is written to the log. --sim (any build; implied by dev mode, --dev) adds a simulated car behind the real OBD stack, driven by the experimental sim object (src/sim/); dev builds also take the sim_* actions.
OctaveDev::Runtimeis created inmain.cppafter the managers. The simulated car is not part of it:main.cppstarts thesimobject (Simulator::start, every build) for--simor--dev, and it talks to the realOBDManagerthrough a fake ELM327QIODevice(OBDConnectionWorker::setCustomTransport(),OBDManager::setPortOverride()).- Hot paths are timed with
OCTAVE_PERF_SCOPE(src/util/perfscope.h), which compiles to nothing without the option.
See Development for the protocol, the action list and the clients (MCP servers, screenshot and perf scripts in dev/) that drive the app over it.
Signal Wiring Graph
Cross-manager signals are wired in src/appcore.cpp (the wire*() helpers of AppCore), mostly as lambdas. This is the central nervous
system that connects hardware inputs to software outputs (the ESP32 LED sync block and the volume fan-out to the
knob are compiled out under Q_OS_MOBILE, where those managers are stubs):
Shutdown & Cleanup
src/main.cpp connects a cleanup handler to aboutToQuit. The dev runtime (when compiled in) and the watchdog stop first; the managers clean up next; playback state, the metadata store and settings are flushed after manager cleanup, and the log is flushed last:
// src/main.cpp
QObject::connect(&app, &QGuiApplication::aboutToQuit, [&]() {
devRuntime.shutdown(); // OCTAVE_DEV_TOOLS builds only
uiWatchdog.stop();
androidAutoManager.cleanup(); phoneMirrorManager.cleanup();
esp32VolumeManager.cleanup(); berryIMU.cleanup(); gestureSensor.cleanup();
obdManager.close();
networkManager.cleanup();
mediaManager._save_playback_state(); // before the settings flush
mediaManager.flush_metadata_store();
settingsManager.flushPendingSave();
OctaveLog::flush(); // last: everything the cleanup logged reaches disk
});
On Unix desktop, SIGTERM/SIGINT reach this handler too (through the self-pipe),
otherwise adb forwards, the DHU and the phone server would be orphaned when a launcher script or
systemd stops the app. With --profile, OctaveDev::Runtime::shutdown() stops the
command server and the perf monitor and writes the report.
Design Decisions — Why It Is Built This Way
Each rationale below is taken from a code comment, CLAUDE.md, BUILD.md or a design doc in the repository, not inferred.
One C++ tree for every platform
Every shipped artefact, including the Android APK, is built from one CMake tree by one CI matrix, so there is one packaging story (CMake plus Qt's own deploy tools) and native performance on the Orange Pi / Raspberry Pi head unit. The QML frontend is a separate layer that talks to the managers only through their registered names, so the UI can change without touching the backend and vice versa.
A manager per feature, registered as a context property
One QObject per feature keeps ownership obvious: each manager owns its state, timers and
threads and cleans them up in cleanup(). Context properties (rather than QML singletons or
qmlRegisterSingletonInstance) were chosen so the objects are constructed in a controlled order
in one place, can be handed to each other before the QML exists (Clock(settingsManager),
connect_settings_manager()), can be reconfigured by the dev runtime before the load
(OBDManager::setPortOverride()), and are reachable in every QML file with no import. Custom QML
types are reserved for things that must be items in the scene graph (the DHU frame item).
Mobile stubs in the headers instead of typeof checks everywhere
On Android, desktop-only managers such as ESP32, gesture, phone mirror and Android Auto expose stub APIs under Q_OS_MOBILE. These preserve the QML-facing names, though calls may report unavailability. BerryIMU is different: Android uses a QtSensors implementation. Some signal wiring is also platform-guarded. CMake currently defines Q_OS_MOBILE for Android; iOS builds are not wired up.
Threading rule: I/O off the main thread, results only by queued signal
The OBD connect, the sensor read loops, decoding and HTTP all run elsewhere because a 30 ms stall is visible on a dashboard gauge. Managers never call into QML from a worker; they emit signals, and Qt delivers them queued on the GUI thread. The UI watchdog exists because a frozen head unit in a car has nobody watching a terminal.
Settings in memory, one coalesced atomic write
Re-reading the JSON per key "cost a parse + validate on every slider release and every track change, and
always returned what we had just written" (settingsmanager.cpp). The in-memory copy is now
authoritative and disk writes are debounced 500 ms and written temp-file-then-rename, which also
"spares the SD card on the Pi".
Volume curve in one place
A quadratic curve gives a more natural perceptual response than linear at low volume, but every output
must apply the same curve or they drift apart as the slider moves. Centralising both the math and
the dispatch in VolumeController means a curve change is a one-line edit and a new output is
one more connection. The controller uses a signal fan-out
(volumeApplied) rather than holding pointers so the controller could be constructed in
Phase 1 before the outputs existed.
Image provider for the DHU, QVideoSink for the phone
The DHU is a foreign window that can only be captured, producing QImages at a timer
rate; an image provider is the natural fit. The phone mirror is a stream OCTAVE decodes itself, so it hands
YUV frames straight to the video sink and skips both the RGB conversion and the disk. Qt's own FFmpeg
backend was rejected for the mirror because it refuses a raw Annex-B H.264 QIODevice
(docs/PHONE_MIRROR_NATIVE_PLAN.md, Phase 0); libavcodec decodes at 12–15 %
of one RK3588 core and paint the first frame in about a second. The built-in Android Auto receiver is a stream OCTAVE decodes
too, so it takes the phone mirror's route (and its decoder) rather than the DHU's.
Fork the phone server, bundle adb
The goal of the native mirror was "no user-installed dependencies": no scrcpy binary, no v4l2loopback, no
ffmpeg on PATH, no adb shell input for touch. Forking the server (package renamed, version
3.3.4-octave) lets OCTAVE change the phone side (the keeper that survives locks and cable
blips, the screen-off policy) while CI checks the committed jar's package and version (hash mismatches only warn). Bundling
platform-tools avoids a separate adb installation on the desktop targets that bundle it; Linux aarch64 needs a compatible adb installed separately.
Dashboards as JSON, primitives as QML
Hand-written dashboard QML files could not be created by the user in the car. Moving the layout into a
JSON spec rendered by DashboardRenderer made the in-app editor possible (pure QML on top of
saveDashboard/duplicateDashboard/deleteDashboard), keeps built-ins and
user files in the same format, and lets a file dropped into the user directory appear without a restart.
The gauge primitives stay as QML because that is where the drawing belongs; the spec only names them.
QML loaded from disk, not compiled in
frontend/ is added to the import path and Main.qml is loaded from the file system
(from assets:/frontend on Android) so a QML edit
does not require a rebuild. The one exception is the .qsb shader, which must be a Qt resource
because RHI's ShaderEffect rejects assets:/ URIs on Android.
Observability first
Logging is installed before the application object, crash handlers use raw descriptors so they work when
Qt cannot, the watchdog records stalls, and DiagnosticsManager exists so the driver can export
the logs from the screen. All of that follows from OCTAVE being daily-driven in a vehicle: in-car
reliability and after-the-fact debuggability are first-order requirements, not polish. Errors are logged and surfaced through page-specific status text; a global notification layer is parked in
TODO/error-notification-ui.md.