Overview & file map

The feature managers (media, OBD, sensors, phone mirror, dashboards) each have their own wiki page. This page covers everything else that supports them. The table shows where each piece lives, whether it is a QObject that QML can reach, and the context-property name registered in src/main.cpp.

ModuleSourceQML context propertyNotes
AssetStoresrc/util/assetstore.{h,cpp}assetStoreResolve bundled assets to local file URLs; extract from the APK on Android
Clocksrc/managers/clock.{h,cpp}clock
Loggingsrc/util/logger.{h,cpp} (OctaveLog namespace)–Not a QObject; installed first thing in main()
Crash handlersinside src/util/logger.cpp–Write to the error log
UI watchdogsrc/util/uiwatchdog.{h,cpp}– (not registered)QObject, but internal only
DiagnosticsManagersrc/managers/diagnosticsmanager.{h,cpp}diagnosticsManagerSettings → Device → Diagnostics card
VolumeControllersrc/managers/volumecontroller.{h,cpp}volumeControllerQuadratic curve + dispatch
Versionproject(OCTAVE VERSION …) in CMakeLists.txt → OCTAVE_VERSION define– (surfaced as diagnosticsManager.appVersion)
Performance monitorsrc/dev/perfmonitor.{h,cpp}, src/util/perfscope.hperfMonitor (only with --profile)OCTAVE_DEV_TOOLS builds only
Command serversrc/service/commandserver.{h,cpp}, coreactions.cpp, uidriver.{h,cpp}–OCTAVE_DEV_TOOLS builds only, started by --profile
Android JNI bridgesrc/platform/androidmediabridge.h–Android builds only
Dev tooling is compiled in only for Debug builds

Everything under src/dev/ is built only with the OCTAVE_DEV_TOOLS CMake option, which defaults to on for -DCMAKE_BUILD_TYPE=Debug desktop builds and off for everything else (Release, no build type, Android, iOS). Without it, --dev / --profile log --dev / --profile need a build with -DOCTAVE_DEV_TOOLS=ON (the default for Debug); ignoring and the app starts normally, and OCTAVE_PERF_SCOPE compiles to nothing. The client scripts that drive the command server live in dev/, which, like .mcp.json, is gitignored and exists only on developer machines.

Clock

src/managers/clock.{h,cpp} — QML name: clock

A QTimer-based clock that formats the current time once a second and emits it as a string. It reads three settings from SettingsManager on every tick — showClock, clockFormat24Hour and clockShowSeconds — so a settings change takes effect within a second without any signal wiring. Since commit 7777777 it emits timeChanged only when the text actually changed: without seconds the string changes once a minute, so the QML bindings that show the clock re-evaluate 60× less often, and hiding a previously visible clock emits the empty string once. If the clock starts hidden, nothing is emitted, because the cached text is already empty.

Public API

MemberKindDeclarationDescription
timeproperty (read-only)QString time NOTIFY timeChangedThe current text (what timeChanged last carried), set from construction: a service client subscribing to it gets it at once instead of waiting up to a minute
timeChanged(str)signalvoid timeChanged(const QString &)Formatted time, or "" when showClock is off
update_time()slotpublic slotsTimer callback (1000 ms); computes the text and emits on change
constructor–Clock(SettingsManager *, QObject *parent = nullptr)Starts the timer and computes the first text at once

Format

SettingWithout secondsWith seconds
clockFormat24Hour = true14:3014:30:05
clockFormat24Hour = false02:30 PM02:30:05 PM
showClock = false"" (emitted once)

The 12-hour form is built by hand (hour % 12, 0 → 12, zero-padded) with a literal AM/PM appended, so it does not depend on the system locale.

Emit-on-change

void Clock::update_time()
{
    QString text;
    if (m_settingsManager->showClock()) {
        // ... build "HH:mm[:ss]" or "hh:mm[:ss] AM/PM" ...
    }
    // Without seconds the text changes once a minute; re-evaluating every
    // binding that shows the clock 60 times for the same string is waste.
    if (text == m_lastText)
        return;
    m_lastText = text;
    emit timeChanged(text);
}

QML consumers

  • frontend/BottomBar.qml — clockTimeText binds clock.time, so the bar shows the current time from its first frame
  • frontend/ClockMenu.qml — same signal drives the digital readout, the date line and the analog hands (time.split(":") for hours / minutes / seconds; a missing seconds field, i.e. clockShowSeconds off, counts as 0)

The Clock does not log.

Logging

src/util/logger.{h,cpp} (commit 053f587)

OCTAVE writes rotating log files into <app data dir>/logs/, where the app data dir is the same one that holds settingsConfigure.json (Windows %APPDATA%\OCTAVE, macOS ~/Library/Application Support/OCTAVE, Linux $XDG_CONFIG_HOME/OCTAVE or ~/.config/OCTAVE).

FileLevelMax sizeBackupsWhen
octave-cpp.logINFO and above (everything except QtDebugMsg)5 MB3Always
octave-cpp-error.logWARNING, ERROR (QtCriticalMsg) and FATAL, plus crash blocks2 MB5Always
octave-cpp-debug.logDEBUG and above (everything)10 MB2Only with --debug

Worst-case disk use: ~32 MB without --debug, ~62 MB with it — sized for an SD-card head unit that must keep several sessions of history.

The --debug flag

main() scans argv for the literal --debug with strcmp before the QGuiApplication exists and calls OctaveLog::install(debugLogging). With debug the category filter is *.debug=true\nqt.*.debug=false (OCTAVE categories on, Qt’s own off) and the debug file is opened; without it the rule is *.debug=false, so qCDebug calls are filtered at the source and cost nothing.

OctaveLog

namespace OctaveLog {
void install(bool debug);   // message handler + crash handlers; call before QGuiApplication
QString logDir();          // <app data dir>/logs, created on install
void flush();              // flush all three files: aboutToQuit (main.cpp) and after a QtFatalMsg
}

install() creates the log directory via SettingsManager::getAppDataDir(), opens the files in append mode, sets the category filter rules, installs a qInstallMessageHandler and then the crash handlers. The handler formats every qDebug / qInfo / qWarning / qCritical / qFatal and every qC* category call — and, because QML console.* goes through the same Qt message system, every QML log line too — as:

2026-09-10 12:00:00.123 | INFO    | octave.media | mediamanager.cpp:123 | message

Levels are named DEBUG, INFO, WARNING, ERROR (for QtCriticalMsg) and FATAL; uncategorised messages show the category default; the file column is the basename only, or - when Qt supplies no location. On desktop the line is echoed to stderr first (deliberately not chained to Qt’s default handler, which would print it a second time); on Android and iOS, where stderr goes nowhere, the message is handed to the previous (Qt default) handler instead so it still reaches logcat / os_log. It is then written under a QMutex to the debug file (if enabled), to the main file (unless it is a debug message), and to the error file (warning and above). Each write is flushed immediately. flush() has two callers: the aboutToQuit handler in src/main.cpp calls it last, after the settings and metadata flushes, so everything the cleanup logged reaches disk; and the message handler calls it after a QtFatalMsg, right before Qt aborts. It is never called from the crash (signal) handlers, which must stay Qt-free. Rotation happens before a write that would push the file past maxBytes, by a rename chain (.3 deleted, .2→.3, .1→.2, current→.1).

Logging categories

The sources declare these Q_LOGGING_CATEGORY categories (all under octave.*):

CategoryDeclared in
octave.androidautosrc/managers/androidautomanager.cpp
octave.androidauto.connectionsrc/android_auto/aapconnection.cpp
octave.androidauto.sessionsrc/android_auto/aapsession.cpp
octave.assetstoresrc/util/assetstore.cpp
octave.audioanalyzersrc/managers/audioanalyzer.cpp
octave.berryimusrc/managers/berryimumanager.cpp
octave.dashboardssrc/managers/dashboardmanager.cpp
octave.devsrc/dev/devruntime.cpp (OCTAVE_DEV_TOOLS builds only)
octave.diagnosticssrc/managers/diagnosticsmanager.cpp
octave.elm327src/managers/elm327protocol.cpp (also carries the Android [BLE] bridge lines, see below)
octave.embeddeddhusrc/items/embeddeddhuitem.cpp
octave.esp32volumesrc/managers/esp32volumemanager.cpp
octave.fontssrc/util/fontcatalog.cpp
octave.frontendssrc/managers/frontendmanager.cpp (installable frontends)
octave.frontends.supervisorsrc/managers/frontendsupervisor.cpp (an exec frontend: start, restart, fall back to QML)
octave.gesturesrc/managers/gesturemanager.cpp
octave.mediasrc/managers/mediamanager.cpp
octave.networksrc/managers/networkmanager.cpp
octave.phonemirror, octave.phonemirror.clientsrc/managers/phonemirrormanager.cpp, src/phone_mirror/scrcpyclient.cpp
octave.servicesrc/service/commandserver.cpp (the service socket; every build)
octave.service.pairingsrc/service/servicepairing.cpp (codes, tokens, refusals by address; never the code)
octave.settingssrc/managers/settingsmanager.cpp
octave.shiftlightsrc/managers/shiftlightmanager.cpp
octave.sim, octave.dev.carsimsrc/sim/simulator.cpp, src/sim/carsim.cpp (every build; --sim, --dev, the browser demo)
octave.themessrc/managers/themecatalog.cpp
octave.triprecordersrc/managers/triprecorder.cpp
octave.tripstoresrc/managers/tripstore.cpp
octave.ui_watchdogsrc/util/uiwatchdog.cpp

Clock, VolumeController and the two Android platform headers have no category (the latter use plain qWarning, which lands under default).

Crash handlers & signal shutdown

Commit 053f587 (crash handlers) and 3810e03 (SIGTERM/SIGINT cleanup). The rule from src/util/logger.h: a crash in the vehicle must leave a trace in the error log.

Signal / exception handlers in logger.cpp

  • Unix: installCrashHandlers() opens octave-cpp-error.log a second time with raw open(O_WRONLY|O_APPEND|O_CREAT) and installs crashHandler for SIGSEGV, SIGABRT, SIGFPE, SIGILL and SIGBUS. The handler uses low-level writes and backtrace functions (no Qt — not even OctaveLog::flush(), which takes a QMutex; the log files need no flush there because RotatingFile::write() flushes every line as it lands): it writes ==== FATAL SIGSEGV - backtrace (addresses; symbolise with addr2line against this build) ====, up to 64 frames, and ==== end ==== to the error log and a shorter block to stderr, then restores SIG_DFL and re-raises so the OS still produces a core dump / tombstone. Backtraces exist only where execinfo.h is available (glibc or macOS); elsewhere — Android’s bionic libc — the block reads (no backtrace support on this platform).
  • Windows: SetUnhandledExceptionFilter(crashFilter) writes ==== FATAL exception 0x<code> at <address> ==== plus up to 62 CaptureStackBackTrace frames via _write, then returns EXCEPTION_CONTINUE_SEARCH.
  • qFatal is not a signal: it goes through the message handler as FATAL, after which the handler calls OctaveLog::flush(), before Qt aborts (SIGABRT → crashHandler appends the backtrace), so assertion text is on disk before the SIGABRT block.

SIGTERM / SIGINT run the normal cleanup

Launcher scripts, systemd and Ctrl-C all deliver a signal; the default action would kill the process without running aboutToQuit, leaving child-process and device cleanup incomplete (adb, ffmpeg, the DHU) and leaking the phone-mirror adb forward. src/main.cpp (#if !defined(Q_OS_WIN) && !defined(Q_OS_MOBILE)) therefore turns the signal into QCoreApplication::quit() with the self-pipe trick. A socketpair() is created; the std::signal handler only writes one byte into it, and a QSocketNotifier on the other end reads the byte on the event loop, logs Signal received, shutting down and calls QCoreApplication::quit(). Nothing Qt-side runs in signal context.

The aboutToQuit cleanup stops the dev runtime (on OCTAVE_DEV_TOOLS builds: command server, perf monitor and any recording) and the UI watchdog first, cleans up Android Auto, phone mirror, ESP32, IMU, gesture, OBD and network, then saves playback with _save_playback_state(), flushes the metadata store and the settings (flushPendingSave(), which writes immediately), and finishes with OctaveLog::flush().

UI watchdog

src/util/uiwatchdog.{h,cpp} — class UiWatchdog, commit f157b97. Not registered as a QML context property; it is started in src/main.cpp just before the managers are built (AppCore) and stopped from the quit cleanup.

A hung event loop used to vanish without a trace — the screen froze and the log simply stopped. The watchdog is a worker thread that posts a “ping” to the GUI thread every pingMs (2000) and records when the GUI thread answers it. If the answer is older than stallMs (5000) it logs once UI thread blocked for N s and counting as a WARNING on octave.ui_watchdog, and when the GUI thread catches up (silence back under one ping interval) it logs UI thread responsive again after N s. The warning also lands in octave-cpp-error.log (that file is WARNING+). Time is measured on a monotonic clock (QElapsedTimer), so a wall-clock jump (the Orange Pi has no RTC, and NTP corrects its clock after boot) cannot register as a stall.

worker thread ("ui-watchdog") GUI thread │ │ │─ sleep pingMs (2 s) ─┐ │ │◄─────────────────────┘ │ │── queued invoke: lastPong = now ───────────────►│ (runs when the event loop gets to it) │ │ │ silent = now - lastPong │ │ silent > stallMs (5 s) → WARNING "blocked for N s and counting" (once) │ silent ≤ pingMs after a stall → WARNING "responsive again after N s"
MemberBehaviour
constructorUiWatchdog(QObject *parent = nullptr, int pingMs = 2000, int stallMs = 5000)
start()Starts a QThread named ui-watchdog; the loop runs from its started signal
stop()Clears the atomic running flag, quit() + wait(2000)
ping mechanismQMetaObject::invokeMethod(this, lambda, Qt::QueuedConnection) writing an std::atomic<qint64> (monotonic ms since the watchdog started)
teardown safetyWorker owns nothing Qt-side; it only posts to this

A warning means the GUI failed to answer within the configured threshold; it does not identify the cause. Expected long operations can also trigger it, so use the surrounding logs to diagnose a stall.

DiagnosticsManager

src/managers/diagnosticsmanager.{h,cpp} — QML name: diagnosticsManager. Commits b5d47b8, 95dfb49, 7fd8405.

Field diagnostics: get the logs out of the head unit without a laptop. It backs the Diagnostics card on Settings → Device (frontend/settings/DeviceSettingsPage.qml), which shows the version / backend / device line and the log path, a scrollable tail of the log, and four buttons in a 2×2 grid (so they fit at dash scale on the Pi): Refresh, Copy, Export logs and — on desktop — Open folder. The controls and status text guard access to diagnosticsManager.

Properties

PropertyTypeNotifyValue
logDirstrCONSTANTSettingsManager::getAppDataDir() + "/logs"
appVersionstrCONSTANTOCTAVE_VERSION compile definition ("dev" if undefined)
backendNamestrCONSTANT"C++"
deviceInfostrCONSTANT<prettyProductName> <cpuArch> (<hostname>, <kernelVersion>) via QSysInfo
lastExportPathstrlastExportPathChangedFolder written by the last exportLogs(); "" until it has run
canOpenFolderboolCONSTANTfalse on Q_OS_ANDROID / Q_OS_IOS, else true

Invokables / slots

MethodSignatureBehaviour
recentLogLines(maxLines)Q_INVOKABLE QString (int maxLines = 200) constReads at most the last 256 KB of the main log (octave-cpp.log), keeps the last maxLines non-empty lines, then appends ---- errors ---- and the last maxLines / 4 lines of the error log if any. Returns (no log yet) when the main log is empty.
copyLogsToClipboard(maxLines)Q_INVOKABLE bool (int maxLines = 400)Puts OCTAVE <version> (C++ backend) on <device>, an ISO timestamp, and recentLogLines(maxLines) on the QGuiApplication clipboard. Logs Logs copied to clipboard. false if there is no clipboard.
exportLogs()Q_INVOKABLE QString ()Copies every octave*.log* file (current and rotated backups) into <Downloads>/OCTAVE-logs-<yyyyMMdd-HHmmss>/ (Documents if there is no Downloads location) plus a device-info.txt header, sets lastExportPath, emits lastExportPathChanged, returns the folder or "" on failure. On Android that is the public Download folder, attachable from any mail or files app — chosen over a FileProvider because androidx is deliberately not bundled.
openLogFolder()Q_INVOKABLE bool ()QDesktopServices::openUrl(QUrl::fromLocalFile(logDir)); returns false where canOpenFolder is false.
licenseText(name)Q_INVOKABLE QString (const QString &) constFull text of "LICENSE" (GPL-3.0) or "NOTICE", compiled in as :/licenses/ resources (qt_add_resources in CMakeLists.txt); "" for any other name. Shown by the About page's licenses card.

Signal: lastExportPathChanged(). Logging category octave.diagnostics. The QML calls are recentLogLines(200), copyLogsToClipboard(400), exportLogs() and openLogFolder().

VolumeController

src/managers/volumecontroller.{h,cpp} — QML name: volumeController. Introduced in commit d2b047b (“volume curve de-duplication”).

OCTAVE maps the UI volume percent (0–100) to a linear audio gain (0.0–1.0) through a quadratic curve, (percent / 100)2, which gives a more natural perceptual response at low volumes than a straight line. VolumeController owns the math and is the single entry point for routing a percent to every output; changing the curve means editing toLinearStatic() in one place.

PercentLinear gain
00.0
250.0625
500.25
750.5625
1001.0

Public API

MemberDeclarationDescription
applyVolume(percent)public slot void (int)Clamps to 0–100, stores the raw percent in memory via SettingsManager.setCurrentVolume(), computes the linear value and emits volumeApplied
toLinear(percent)public slot float (float)Pure curve math for QML; no dispatch
volumeApplied(int percent, float linear)signalEmitted after every applyVolume(); main.cpp connects the outputs (media, phone mirror, ESP32) to it
toLinearStatic(float)static floatCurve without an instance (tests, one-off math)
constructorVolumeController(SettingsManager *, QObject *parent = nullptr)Holds only the settings pointer; outputs are reached through the signal

Dispatch: who gets what

Settings store the percent, local media and the phone mirror take the linear gain, and the ESP32 LED takes the raw percent. The controller itself only emits; the fan-out is in main.cpp:

// src/main.cpp
QObject::connect(&volumeController, &VolumeController::volumeApplied,
                 [&mediaManager, &phoneMirrorManager, &esp32VolumeManager](int percent, float linear) {
    mediaManager.setVolume(linear);
    phoneMirrorManager.setVolume(linear);
#ifndef Q_OS_MOBILE
    esp32VolumeManager.send_volume_update(percent);   // the ESP32 manager rate-limits itself (50 ms)
#endif
});
volumeController.applyVolume(settingsManager.currentVolume());   // startup volume

The ESP32 leg is compiled out on mobile, where the manager is a stub.

Callers

  • frontend/BottomBar.qml (four slider / button sites) and frontend/MediaRoom.qml call volumeController.applyVolume(Math.round(value))
  • Gesture sensor volume_up / volume_down: applyVolume(currentVolume ± gestureVolumeStep)
  • ESP32 knob volumeChangeRequested(delta): fractional deltas are accumulated and applied once they reach a whole percent
  • AppCore::wireVolume() (src/appcore.cpp) calls applyVolume(settingsManager.currentVolume()) once after the outputs are constructed, so the configured startup volume reaches every output at startup
Rule

Never call an individual manager’s setVolume from QML or a hardware handler, and never re-derive the curve. Everything goes through volumeController.applyVolume(percent).

Version & update check

CMakeLists.txt — surfaced with the Diagnostics card (b5d47b8).

  • project(OCTAVE VERSION 0.9.5 LANGUAGES CXX) is the source of truth. CMake passes OCTAVE_VERSION="${PROJECT_VERSION}" as a compile definition and reuses PROJECT_VERSION for MACOSX_BUNDLE_BUNDLE_VERSION / MACOSX_BUNDLE_SHORT_VERSION_STRING. diagnosticsmanager.cpp falls back to "dev" if the define is missing.
  • The APK's versionName / versionCode come from it as well (QT_ANDROID_VERSION_NAME / QT_ANDROID_VERSION_CODE, code = major×10000 + minor×100 + patch). Only the Windows installer (MyAppVersion in build_scripts/installer_windows.iss) carries its own copy; bump it in the same commit (CI patches it from the tag on release builds).
  • The version is surfaced to users only through diagnosticsManager.appVersion (Device diagnostics card and the log export header).

The update check does not use the version string. NetworkManager.checkForUpdates() compares the local git HEAD against https://api.github.com/repos/<repo>/commits/main and reports up-to-date / update-available / ahead / diverged / error through updateStatus; the About page and the settings layouts show a badge when it is update-available. See Network Manager.

Performance monitor & perf tooling

src/dev/perfmonitor.{h,cpp}, src/util/perfscope.h; client tools in dev/perf/, dev/mcp_server.py, dev/mcp_benchmark.py and .mcp.json. Compiled in only with OCTAVE_DEV_TOOLS.

Everything here is switched on by one flag: build/octave --profile. OctaveDev::Runtime (src/dev/devruntime.cpp) creates a PerfMonitor writing dev/perf/logs/perf_<yyyyMMdd_HHmmss>.csv and .log under the project root, registers it as the QML context property perfMonitor before Main.qml loads (no shipped QML binds to it; it is there for a HUD), and after the load starts it together with the command server. Runtime::shutdown(), first in the aboutToQuit cleanup, stops both; the detailed report is appended to the .log.

PerfMonitor internals

ComponentWhat it measures
RingBufferFixed-size sample window with mean(), max() and linear-interpolated percentile(p). Capacities: 1000 for loop latency (5 s of 5 ms ticks, cleared every report), 300 for thread jitter, 200 per timing label.
Event-loop probeA 5 ms PreciseTimer; how late each tick fires beyond its 5 ms interval is one sample. A high value means the loop was blocked — UI stutter.
Jitter probeA std::thread that sleeps 10 ms and records how late it woke — scheduler / CPU contention. Reported under the gil_* keys and the gilJitterMs property so the client tools read one set of names.
Process sampleCPU % (delta of process CPU time over wall time, may exceed 100 on several cores), RSS MB and thread count: /proc/self/stat, statm and status on Linux / Android, getrusage on other Unix, GetProcessTimes / psapi on Windows.
Timing labelsFed by OCTAVE_PERF_SCOPE("label") at the top of a function, which records how long the rest of the scope took. Currently instrumented: settings.save_settings, audio._analyze_audio (FFT), media._update_position, media._extract_colors, media._kmeans_colors, media.next_track, media.previous_track, esp32._read_serial, obd._monitor_connection. With dev tools built but no --profile, a scope costs one relaxed atomic load; without OCTAVE_DEV_TOOLS it compiles to nothing.

Reporting cycle (every 1 s)

  1. Loop latency avg / p50 / p95 / p99 / max from the ring buffer; jitter avg / p99; CPU, memory and thread count.
  2. Terminal line ([PERF]), colour-coded by average loop latency: green < 5 ms, yellow < 16 ms, red otherwise. Labels whose p95 exceeds 2 ms are listed every fifth report.
  3. Plain-text line in the perf .log tagged OK / WARN / STUTTER with the same thresholds, plus SLOW lines.
  4. CSV row: timestamp, loop_avg_ms, loop_p50_ms, loop_p95_ms, loop_p99_ms, loop_max_ms, gil_avg_ms, gil_p99_ms, cpu_pct, mem_mb, threads.
  5. Loop buffer cleared, metricsUpdated emitted.

QML properties (all notify on metricsUpdated)

loopLatencyMs, loopP99Ms, gilJitterMs, cpuPercent, memMb (double), threadCount (int), summaryText (str, a one-line HUD string). Invokable: getDetailedReport() → str, listing loop / jitter / CPU and every label with p95 > 1 ms sorted by p95.

Instrumentation

#include "../util/perfscope.h"

void MediaManager::next_track()
{
    OCTAVE_PERF_SCOPE("media.next_track");   // times the rest of the function
    ...
}

dev/perf and the MCP servers

These are Python client scripts that drive the running app over the command server. The whole dev/ tree is gitignored, so they exist only on developer machines; dev/app_launch.py finds the binary (build*/octave, or OCTAVE_BIN) for the tools that launch the app themselves.

ToolPurpose
dev/perf/profile_performance.pyExternal sampler: finds the running octave process with psutil and records CPU / memory to CSV for a duration. Does not need --profile.
dev/perf/run_perf_loop.pyDrives the command socket (127.0.0.1:9876) through the full gamut of pages and settings in a loop and diffs the metrics between iterations.
dev/perf/brutalize.pyCranks every visual setting to maximum and hammers actions faster and faster until the loop latency degrades, then reports where it broke.
dev/perf/logs/Output: perf_*.csv / .log from PerfMonitor, benchmark_*.json and baseline.json from the benchmark server.
dev/mcp_server.py (octave-perf)MCP server wrapping the command socket as tools an AI agent can call: ping, list_actions, navigate, go_back, go_home, get_current_page, list_pages, the settings tools (get_all_settings, list_*_settings, toggle_setting, set_bool_setting, set_numeric_setting, set_string_setting), media tools (next_track, previous_track, toggle_play, pause, toggle_mute, toggle_shuffle, set_volume, skip_tracks, get_media_status, get_track_info), obd_status, get_metrics, get_detailed_report.
dev/mcp_benchmark.py (octave-benchmark)Systematic benchmarking and regression tracking (serves MCP only with --mcp; otherwise a CLI): benchmark_page, benchmark_setting, benchmark_transition, benchmark_media_ops, get_bottlenecks, run_benchmark_suite (five phases: baseline, settings matrix, stress, combined, bottlenecks), save_baseline, compare_to_baseline.
.mcp.jsonRegisters both servers for Claude Code, once per developer machine (octave-perf / octave-benchmark plus -rob and -zion variants pointing at that machine’s Python interpreter and checkout). Gitignored because the paths are absolute.

Command server

src/service/commandserver.{h,cpp} (server), src/dev/coreactions.cpp (built-in actions), src/dev/uidriver.{h,cpp} (navigation, capture and input), src/dev/collagebridge.{h,cpp} (UI collage actions). Started by OctaveDev::Runtime when --profile is passed on an OCTAVE_DEV_TOOLS build. Listens on 127.0.0.1:9876; --command-port N (or --command-port=N) moves it. If the port is taken it logs command server could not listen on 127.0.0.1:9876 and the app carries on without it.

Protocol

One JSON object per line in, one per line out. args is optional.

{"action": "navigate", "args": {"page": "obd"}}
{"ok": true, "result": {"navigated_to": "obd"}}

{"action": "bogus"}
{"ok": false, "error": "Unknown action: bogus"}

Invalid JSON gets {"ok": false, "error": "Invalid JSON: …"}; an exception thrown by a handler (CommandError or any std::exception) is logged on octave.service and returned as error. A client may keep the connection open and send many lines.

Architecture

CommandServer (lifecycle: start / stop / registerAction) │ ├── CommandTcpServer QTcpServer on its own QThread ("cmd-server"), │ │ bound to QHostAddress::LocalHost │ └── one QThread per client reads JSON lines → handleLine() → dispatch() │ ├── handlers{} action → std::function, registration order kept │ for list_actions │ └── GUI-thread access from a handler CommandServer::postGui(fn) queued, fire and forget (navigation, sim_*) CommandServer::onGui(fn) queued + semaphore wait for the result (30 s timeout → "GUI thread did not answer")

Handlers run on the connection thread, so a slow GUI call on one client never blocks get_metrics on another. Anything that touches Qt objects goes through onGui() (screenshots, item lookup, clicks, settings reads) or postGui() (navigation). Calls into QML functions go through invokeByName(), which passes each argument as a QVariant when the parameter is untyped (a QML JS function), and converts it to the parameter type otherwise.

Actions

CategoryActions
Appping → "pong"; list_actions (in registration order)
Perfget_metrics (loop_avg_ms, loop_p99_ms, gil_avg_ms, cpu_percent, mem_mb, thread_count, summary, backend: "cpp"), get_detailed_report, get_slot_timings (avg / p95 / max / count per label)
Navigationnavigate (page ∈ home, media_room, media_player, obd, obd_home, obd_diagnostics, sensor_home, settings, phone_mirror, android_auto, sensors, car, clock — mapped to the QML file by kPages; anything else returns Unknown page: … with the valid list), go_back, go_home, get_current_page (objectName, or class name, of the stackView current item), list_pages, open_settings (section ∈ displaySettings, mediaSettings, obdSettings, accessoriesSettings, deviceSettings, about — calls navigateToSettings(section)), list_settings_sections
Screenshots / recordingscreenshot (path; grabWindow() → PNG, so it works on Wayland, X11 and offscreen without any external tool), set_window_size (width, height; Main.qml reflows Spacing), set_dev_chrome (visible; toggles the devOverlay item, no-op without --dev), record_start (path, fps 1–60, default 30: a GUI-thread QTimer pipes raw RGBA grabWindow() frames into ffmpeg -c:v libx264 -preset veryfast -crf 18; needs ffmpeg on PATH), record_stop, record_status (frames, dropped, repeated, seconds, error; frames are paced by wall clock, repeating the last grab when rendering falls behind, and scaled to the logical window size)
UI drivingfind_item (name: objectName search through the window’s visual tree, popups included, preferring the visible match; returns scene geometry, centre, effective visibility and hiddenBy), list_items (prefix), list_interactive (no args: the reachable controls, each kind (button / toggle / slider / dropdown / text_field / scrollable), label (visible text inside it), icon (image file name), x / y / width / height / cx / cy (the visible part), plus name, checked, value or currentText when they apply; passive pointer handlers and plain Text don’t count as controls or as covering one), get_prop / set_prop (name, prop[, value]; "root" addresses the ApplicationWindow), call (name, method: no-arg invoke), find_text (text[, prop default text]: first visible item whose label matches, trimmed and case-insensitive), click (x, y: press + release injected with QTest, so pointer handlers keep their grab state as for a real finger), long_press (x, y, ms default 1000: press, hold with the event loop running, release), type_text (text[, field: objectName or placeholderText, else the focused item][, enter]: key events sent to the item directly, since headless windows and dev mode’s keyboard handler keep window-level keys from reaching fields), drag (x1, y1, x2, y2, steps default 12: press, interpolated moves, release), wheel (x, y, notches default 1: one 120-unit mouse-wheel event per notch, positive scrolls up; zooms the 3D Jeep and the trip chart)
Medianext_track, previous_track, toggle_play, toggle_shuffle (through nowPlaying, so they drive the phone while it has the media controls), pause (local player), toggle_mute (volumeController.toggleMute()), set_volume (volume 0.0–1.0 linear, default 0.5 — converted with toPercent and applied through volumeController.applyVolume, so every output, the sliders and the unmute rule see it), get_media_status, get_track_info, skip_tracks (count)
OBDobd_status (connected, status string), obd_reconnect
Settingsget_settings (legacy: volume, theme), get_all_settings, toggle_setting / set_bool_setting / list_toggle_settings, set_numeric_setting / list_numeric_settings, set_string_setting / list_string_settings
UI collagecollage_list_popups, collage_open_popup, collage_close_popups, collage_mount, collage_grab, collage_unmount — mount any .qml file or popup in isolation and grab it, driven by python -m dev.collage
Simulator (--dev only)sim_throttle, sim_brake, sim_rev, sim_engine, sim_gear, sim_upshift, sim_downshift, sim_speed, sim_rpm, sim_realistic, sim_scenario (cold, hot, low_fuel, else normal), sim_dropouts, sim_dropout_now, sim_failure_rate, sim_link, sim_imu (simulated IMU motion), sim_video, sim_pairing_code, sim_status — registered by Runtime::registerSimActions() as aliases for the sim object's methods, which every build serves under --sim

Settings actions in detail

  • Boolean toggles (12): show_clock, clock_24h, show_waveform, show_3d_tilt, show_3d_album, rounded_album_art, album_art_shadow, vinyl_mode, obd_fast_mode, auto_play, show_bottom_bar_controls, song_length_transition — each mapped to the save_* slot and the property to read back (toggle_setting returns old / new).
  • Numeric (10), clamped to a range: ui_scale (0.5–2.0), blur_radius (0–100), color_transition_ms (the server’s own clamp is 0–5000; the settings UI slider stops at 2000), album_art_corner_radius (2–32), bg_overlay_opacity (0–100), side_card_opacity (0.1–1.0), side_card_angle (5–60), button_tilt_duration (50–500), text_scroll_speed (1000–10000), clock_size (8–72).
  • String / enum (5): theme, font, mediaFolder (free), album_art_transition (Crossfade, Slide, Vinyl Lift, Dissolve, Flip), bottom_bar_orientation (bottom, side).

Lifecycle

// src/dev/devruntime.cpp — Runtime::afterLoad()
if (m_profile) {
    m_perf->start();
    m_ui = new UiDriver(m_app.engine, this);
    m_server = new CommandServer(m_port, this);
    registerCoreActions(*m_server, {m_ui, m_perf, m_app.settings, m_app.media, m_app.obd});
    m_collage = new CollageBridge(m_app.engine, m_app.projectRoot, this);
    m_collage->registerActions(*m_server);
    if (m_dev)
        registerSimActions();
    m_server->start();
}

registerAction() works before or after start(); registering a name again replaces its handler. Runtime::shutdown() stops the server, then the perf monitor, then any recording.

Who uses it

  • dev/screenshot_tool.py / python -m dev.screenshots (dev/screenshots/auto.py): launches build/octave --dev --profile headless (or sweeps an app already started with --profile, --no-launch), then walks every page with navigate / open_settings, fixes the size with set_window_size, hides the overlay with set_dev_chrome and captures with screenshot.
  • dev/screenshots/stories.py and the README clips use record_start / record_stop plus the sim_* actions and find_text / long_press / type_text.
  • dev/tests/drive_dashboard_editor.py: an end-to-end scenario that opens the dashboard chooser, creates and edits dashboards and checks the draft-rescue flow using find_item, get_prop, set_prop, click, drag and call; dev/tests/drive_dashboard_features.py drives the other dashboard features the same way.
  • python -m dev.collage (the collage_* actions), dev/perf/run_perf_loop.py, dev/perf/brutalize.py, and both MCP servers (above).

Service protocol

The way any frontend, in any language, reads and drives OCTAVE (TODO/open-backend.md, contract steps 1 and 4). It shares the command server's socket and line format, keyed on op instead of action: the arguments sit at the top level, and an optional id is echoed in the reply so a client can pipeline requests. The dev tooling's action form keeps working unchanged next to it.

Off unless asked for (src/service/serviceoptions.{h,cpp}; main.cpp owns the server). Everything on this device is trusted by default (anything else pairs first: "Pairing" below), so it starts only with --service [PORT] (or --service=PORT; 9876 by default, 0 for any free port), the serviceEnabled setting, --headless (no UI, so the service is the only way in; hello reports app.headless: true), or --profile in an OCTAVE_DEV_TOOLS build, and it listens on 127.0.0.1 unless --service-bind ADDRESS says otherwise (0.0.0.0 for every interface; the log then warns that it is reachable beyond this machine, and those clients must pair). --command-port N still picks the port. A bad port or address leaves it off with a warning. Plain --service serves the ops plus ping / list_actions; the UI-driving, screenshot, perf and sim_* actions are added only by the dev runtime (--profile, and --dev for sim_*) and are never built into a Release binary. The simulator itself is: --sim in any build, driven through the sim object (Development).

Browsers (src/service/websocket.{h,cpp}, RFC 6455 by hand: Qt WebSockets is in no build). The same port takes a WebSocket (ws://127.0.0.1:9876/): one request, reply or event per text frame, the same JSON as a line; ping gets pong, a client close ends the connection, a malformed or unmasked frame or a message over 1 MB closes it (1002). No extensions are negotiated. A plain GET / serves the reference client (sdk/web/index.html, embedded as :/web-client/index.html), so a browser reaches the service same-origin, and GET /dashboards the browser dashboard editor (sdk/web/dashboards.html, CommandServer::addPage; its policy also allows data: images, for thumbnails; see Dashboards); any other path is a 404. The first non-blank byte of a connection picks the protocol: only { is the line protocol; anything else is HTTP, which answers GET (the page, the routes, the WebSocket upgrade) and gives every other method a 405. So a browser's cross-site POST (a no-cors fetch needs no preflight) can never reach the line protocol, where its body would run as a trusted local op. A line connection that sends anything but a JSON object gets the error and is closed. Limits (CommandServer::Limits): 64 connections in all and 8 per network address (more are refused at once); a connection that sends nothing for 10 s is closed, and an untrusted one (protocol 2) after 60 s of silence; a JSON line over 1 MiB closes the connection (a WebSocket message is capped at 1 MB, an HTTP head at 16 KB); on Unix a connection's thread waits on its socket and a wake pipe together, so an event goes out the moment it is queued (elsewhere within the 20 ms read poll); while over 4 MiB waits unsent in the socket, events stay in the connection's bounded queue, which drops the oldest (streams fall back to a keyframe). Every HTTP request, with an Origin or not, needs an address in Host (an IP or localhost): a name is refused with 403, so a DNS-rebinding site can't read /art or /export. Origin rule: a request without Origin (scripts, the SDK) is allowed; a browser only from the service's own page: the Origin's host and port must equal the Host header's, and that host must be an IP address or localhost, because a name could be DNS-rebound to the car by any website. null (file:// pages, sandboxed iframes) is refused. Refusals get a 403 with the reason and a log warning. The page goes out with nosniff, no-store and a Content-Security-Policy that limits it to this service. Open it by address, not by a .local name.

Your own web frontend (src/service/staticfiles.{h,cpp}): --web-root DIR (or the serviceWebRoot setting, edited in the file; the flag wins) serves a directory at GET / instead of the reference client, so a built frontend is same-origin with the socket and passes the Origin rule. It does not turn the service on by itself, and a directory without an index.html is not used (a warning, and the reference client is served): / or a home directory is never published by mistake. Types by extension (html, js / mjs, css, json, svg, png, jpg, gif, webp, avif, ico, woff / woff2 / ttf / otf, wasm, webmanifest; anything else application/octet-stream, with nosniff); a directory serves its index.html; a path with no extension that names nothing gets the root index.html (single-page-app routes); a missing file with an extension is a 404. Refused: .. (also percent-encoded) 403, a backslash or NUL 400, any segment starting with . 404 (.git, .env), anything whose canonical path leaves the root through a symlink 403, files over 64 MB 413, methods other than GET 405. Its Content-Security-Policy allows the page's own scripts (no inline script; WebAssembly allowed), inline styles, data: / blob: images and fonts, and sockets only to this service ('self' plus ws:// / wss:// the request's Host when that is an IP or localhost), with frame-ancestors 'none'. Requests from the page carry its Origin and pass the same rule; a page on another site gets 403.

Demand follows the connection (ServiceBridge::releaseOnClose, wired in src/main.cpp): a call to obdManager.setParameterDemand / setBackgroundDemand or berryIMU.setDemand is remembered against its connection, and when the connection closes, cleanly or not, each consumer it still holds is released (called again with [] / false), so a killed client never leaves the car polled until a restart. A consumer the client released itself, or one a later connection registered under the same name, is left alone.

Album art and other routes (CommandServer::addRoute): GET /art (any ?query ignored, so /art?rev=N busts caches) answers with nowPlaying's current cover, read from its own artUrl file, never from anything in the request; typed by its signature (JPEG, PNG, GIF, WebP, BMP), 404 for no art, a non-image or a file over 16 MB. Clients key their cache on nowPlaying.artRevision. /art?song=<library file name> serves a library song's cover, resolved only through MediaManager's own lookup (libraryArtFile: the song must be in the scanned library, the file must canonically sit in the app's art cache), and /art?queue=<id> a phone queue item's art (PhoneMirrorManager::queueArtFile: the id must be in mediaQueue, the file in the queue-art folder); anything else is a 404. GET /export/trip (?format=json for its JSON sibling), /export/dashboard and /export/logs serve what the app itself last exported (tripRecorder.lastExportPath, dashboardManager.lastExportPath, diagnosticsManager.lastExportPath) as an attachment (Content-Disposition with a sanitised name, 64 MB cap, 404 before any export); the logs export is a folder, so /export/logs is a JSON list of its file names and ?file=<name> one of exactly those. Routes are answered ahead of the web root and the page. Cross-site loads: a plain request whose Sec-Fetch-Site is anything but same-origin or none (the address bar) is refused with 403, because an <img> or <script> on another site sends no Origin; non-browser clients send no such header and are not affected.

Media streams (src/service/streamhub.{h,cpp}; the full record layout is in docs/api/README.md, "Streams"): stream {object, name} → {stream, format, codec?, width?, height?}, then binary records on the same connection (one binary WebSocket frame each; on the line protocol 0xFF + u32 length + record): u8 version | u8 stream | u8 flags (1 key, 2 config, 4 JPEG, 8 discontinuity) | u8 0 | u64 pts µs | payload. phoneMirrorManager.video is the phone's H.264 as it arrives (tapped in ScrcpyClient's video loop before decoding, so the in-app picture is untouched; nothing is copied while nobody watches), Annex-B, every keyframe with SPS / PPS first; androidAutoManager.frames is JPEG from the DHU frame provider, at most 15 a second. streamConfig events announce parameter-set changes; a joiner waits for the next keyframe, and the phone is asked for one (RESET_VIDEO) at most once a second across all clients; a client that falls behind (2 MB or 90 records queued) loses its queue, gets streamDropped and resumes at a keyframe. unstream {stream}, or closing the connection, ends it. The connection queue keeps events and records in order (Connection::pushRecord). Under --sim (any build), sim.setVideo(true) loops tests/data/stream-test.h264 (compiled in) into the phone stream.

What an op can reach (src/service/memberscope.h): exactly what describe lists, the members the object's own classes declare below its first Qt base. call runs only public slots and Q_INVOKABLEs (never a signal, never QObject's deleteLater); get / set / subscribe see only the object's own properties and public signals (not objectName or destroyed). The dev-only actions keep the unrestricted lookup for QML functions.

Pairing (protocol 2) (src/service/servicepairing.{h,cpp}, ServiceBridge::trusted, CommandServer::setTrust): a connection is trusted if its peer is loopback and serviceLoopbackOpen is true (the default), or after pair {code, name} or hello {token}. The check runs in handleLine before every op and action except hello and pair ({"ok": false, "code": "unauthorised"}), and for the HTTP routes (/art, /export: 401 unless trusted, a valid ?ticket= from mediaTicket, or Authorization: Bearer). Codes: 6 digits from the system CSPRNG, 2 minutes, single use; guesses are limited per address (an IPv6 /64): one attempt a second, 10 wrong codes lock it out until restart, so other clients' guesses never void the owner's code or delay their attempt; at most 10 attempts a second across every address but loopback, which is never throttled (about 0.1 % of the codes can be tried while one shows); an untrusted hello learns only the protocol version and that it must pair; tickets are capped at 256; refusals logged with the address only. Tokens: 32 random bytes, kept as SHA-256 in serviceTokens (file-only), compared in constant time. Revoke (Settings) closes the device's connections (closeConnectionsOf: cleanups run, the connection thread drops the socket) and voids its tickets. Every HTTP reply carries Referrer-Policy: no-referrer; request query strings are never logged. The head unit's side is Settings > Device > Service (frontend/settings/DeviceSettingsPage.qml): the service switch with its warning (takes effect at the next start), "Show a code" (the 6 digits and a countdown), and the paired devices with Revoke.

{"op": "hello", "id": 1}
{"ok": true, "id": 1, "result": {"protocolVersion": 3, "app": {"version": "0.9.5", "build": "debug", "headless": false}, "objects": ["settingsManager", …]}}
  • Objects. src/service/serviceregistry.{h,cpp} holds the managers by the names QML knows them by. AppCore (src/appcore.{h,cpp}) enters each manager as it builds it, and main.cpp sets every entry as a QML context property (core.exposeTo(ctx)), so the QML UI and the protocol always see the same set (23 objects today; plain values such as isAndroid and assetStore are not exposed).
  • Spec. docs/api/protocol.json is the envelope as JSON Schema: every op's arguments and reply, the error form, the events, the stream record, the HTTP routes, the pairing flow (docs/api/README.md, "Protocol spec"). Hand-written; sdk/python/tests/test_protocol_spec.py (ctest sdk_python) checks it against a headless app, so an op, field or event added to the server without its line there fails the build. The SDK generators read it.
  • Version. kProtocolVersion in src/service/protocol.h (3). It is bumped on any breaking change: a removed or renamed op, object, property, method or signal, or a changed type or meaning. Additions don't bump it, and clients ignore fields they don't know.
  • Ops. src/service/servicebridge.{h,cpp} registers them on the server (CommandServer::registerOp). An unknown op is {"ok": false, "error": "Unknown op: …"}; an unknown object "Unknown object: …".
    • hello → {protocolVersion, app: {version, build, headless}, objects: [names]}.
    • describe {object?} → one object's {class, properties, methods, signals, enums?}, or every object's as {protocolVersion, objects: {name: …}, ops: […]} (about 110 KB for all 23; ops names every op this server answers). A property is {name, type, readable, writable, constant, notify?, enum?} (enum lists the keys of a Q_ENUM), a method {name, kind: "invokable" | "slot", params: [{name, type}], returns}, a signal {name, params}. Only what the app's own classes declare: members of Qt base classes (QObject, QAbstractItemModel, QQmlPropertyMap…) are left out, overloads are listed apart. src/service/metadescribe.{h,cpp}, which step 3's schema generator reuses.
    • get {object, property} → the value. On paramStore (a QQmlPropertyMap, keyed by parameter id) property is a key (null before its first reading), and without one the reply is every key that has a value.
    • set {object, property, value} → the value read back. Only writable properties: most manager state is read-only and changes through a method (settings through their save_* slots, e.g. call settingsManager save_gauge_temp_unit ["F"]), which keeps every rule in the manager.
    • call {object, method, args?: [...], timeoutMs?} → the return value (null for void). Overloads are told apart by argument count.
    • subscribe {object, signals?: [...], properties?: [...], throttleMs?} → {sub}, then events on the same connection (the reply always first: only the connection's own thread writes to the socket, and it writes a request's reply before the events queued meanwhile; tst_servicebridge subscribeReplyComesBeforeItsEvents), interleaved with replies (a reply has ok, an event event):
      • {"event": "property", sub, object, name, value, t}: the current value at once, then on every notify signal (read on the object's thread, so a notify with no argument still carries the value);
      • {"event": "signal", sub, object, name, args: [...], t};
      • {"event": "params", sub, object, values: {id: v}, t} on paramStore: its 33 ms batches of changed readings, filtered to properties (the ids; none = all), starting with what it holds;
      • {"event": "dropped", count} when a client stopped reading and more than 1000 events queued up (the oldest go).
      throttleMs coalesces: at most one event per name (one merged params event) per interval, the latest value winning. t is when the event was sent (a throttled one: the flush), in milliseconds on a monotonic clock that starts at the bridge's first event: for ordering and intervals, not wall time. A signal emitted on a worker thread is queued to the GUI thread first. A subscription ends with an unsubscribe {sub}, with its object, or with the connection.
    • List models (a property holding a QAbstractItemModel; no object has one at present, protocol 3). get gives {model: true, count, roles}; rows {object, property, from?, count?} the rows as {role: value} maps; subscribing to the property sends {"event": "model", …, name, change, first, last, rows?} with change one of reset (all rows; also the first event), insert and update (with the affected rows) and remove, which a client replays in order to keep its copy. List events ignore throttleMs.
    get, set and call run on the object's (GUI) thread, so a slow method holds the UI just as it would from QML; the client waits up to timeoutMs (default 30 s). Enum values travel as their key names ("Connected"; an enum property reads as its key); on the way in a Q_ENUM parameter takes the key or its number (convertTo() in commandserver.cpp), anything else is refused.
  • When it runs. In every build, when "Off unless asked for" above turns it on; main.cpp installs the bridge on the server (a --profile run shares it with the dev actions).

Tests: tests/cpp/tst_servicebridge.cpp (a real socket against a server on an ephemeral port).

Many clients

tools/stream/service_load.py starts one OCTAVE with the simulated car (--headless --sim --service PORT, or with --ui the QML UI offscreen; any build, release too) on its own config (--config), HOME and XDG dirs and runs each scenario for 30 s while a probe measures what everyone else loses: the OBD poll rate and its longest gap from the simulated adapter (sim.status() polls / poll_max_gap_ms, the Mode 01 replies it sent), a get every 100 ms (the round trip through the GUI thread, where QML waits too), a paramStore subscription (values a second and event latency), and the backend's CPU and RSS from /proc. It prints a Markdown table and writes results-<mode>.json.

python3 tools/stream/service_load.py build/octave PORT WORKDIR [MUSIC_DIR] [--ui] [--seconds 30] [--scenario idle fanout20 ...]
python3 tools/stream/service_load.py build/octave 0 WORKDIR --check     # the ctest service_load_check

--check runs idle and quick (20 subscribers and the call flood) for 4 s each and exits 1 unless no subscriber lost events, the flooder was limited and the poll rate stayed within 20 % of idle, so a regression in the limits fails CI (service_load_check, about 15 s).

Desktop results, 2026-10-23 (Debug build, AMD Ryzen 7 9700X, 16 threads, Arch Linux; the simulated car at 40 % throttle, a local track playing), before the limits below and before --sim: these rows ran --dev, the UI ones with --profile. Each cell is headless / UI. CPU is percent of one core.

ScenarioLoadCPU %OBD polls/sLongest poll gap, msGUI round trip p99, msGUI round trip max, msLoad clients got (headless)
idleno load clients2.4 / 2.818.7 / 18.4125 / 1310.8 / 0.30.9 / 0.4–
fanout2020 clients, each with a demand on and a subscription to all 129 PIDs, unthrottled3.2 / 3.118.6 / 18.7127 / 1970.5 / 0.41.8 / 0.519.6 events/s to each, 0 dropped
nonreader1 client subscribed to paramStore, nowPlaying and the audio levels that never reads1.6 / 4.018.8 / 19.0165 / 1200.3 / 0.70.3 / 0.8non-reader still connected at the end
callflood1 client pipelining call volumeController.toLinear, 64 in flight105.7 / 100.118.8 / 19.0127 / 1320.2 / 0.70.6 / 1.1111,904 calls/s answered
churn2 clients connecting, subscribing, reading one event and closing, in a loop171.5 / 163.019.0 / 18.5153 / 1710.3 / 0.40.8 / 0.59,008 cycles/s
wsmix8 WebSocket clients on paramStore (40 ms) + nowPlaying, the phone video to 2 WebSocket and 1 TCP client2.9 / 5.218.7 / 18.9128 / 1310.3 / 0.60.3 / 0.8video 30 records/s to each
allevery row above at once208.6 / 213.718.7 / 18.6129 / 1320.4 / 0.31.5 / 0.319.3 events/s to each, 0 dropped; non-reader still connected at the end; 33,327 calls/s answered; 6,867 cycles/s; video 30 records/s to each
mediathe 2026-10-22 baseline: paramStore (40 ms), nowPlaying, levels, video over WebSocket and TCP1.7 / 2.318.5 / 18.6146 / 1340.3 / 0.30.4 / 0.3video 30 records/s to each

What it shows, on a desktop:

  • The car and the screen lose nothing measurable in any scenario. The poll rate is set by the simulated adapter's latency (about 19 replies a second over 129 PIDs) and does not move; the GUI round trip stays under 2 ms; the probe's values a second (most of them the simulated IMU: in the first UI rows 21 instead of 148, because a GUI --dev run left the IMU feed off; with --sim both modes get it) and its event latency (p99 at most 1.4 ms) are the same in every row of a mode.
  • Subscribers are cheap. 20 clients on every PID add under 1 % CPU and no drops; RSS does not grow in any row.
  • Request rate is what costs. One client flooding call keeps a core busy (every call is a blocking hop to the GUI thread, which is why the others don't notice: they queue between its calls). Connection churn costs most: each connection gets a thread, and thousands a second take 1.6 to 1.7 cores. On a quad-core Pi that is a third of the machine (not measured yet).
  • A client that never reads costs nothing here, and stays connected. Its events fill the kernel's socket buffers and then 4 MB in the socket before they queue (1000 events, the oldest dropped), which at these rates takes minutes, so in a 30 s row it is never cut off.

Limits (CommandServer::Limits, src/service/commandserver.{h,cpp}; the client's view is in docs/api/README.md, "Limits per connection"). From what the table shows, the limits go on request and connection rates, not on subscribers:

  • Ops a second, a token bucket per connection (Connection::takeOp, 200 a second, 400 at once), checked in handleLine before trust and before any work; past it the op is answered {"ok": false, "code": "rateLimited", "retryAfterMs": N}. Dev actions are not counted. A connection refused more than 4000 times in one second ignores the answer and is closed.
  • New connections a second per address (admitConnect, before a thread exists: the peer from getpeername), 20 a second and 100 at once from the network, 100 and 400 from this device, which counts as one address.
  • A reader that stays behind: once 4 MB sit unsent in the socket, events wait in the bounded queue; staying there for 15 s closes the connection (keepingUp). At the event rates above that takes many minutes of not reading (a 240 s non-reader was still connected: its 6 KB a second had not filled the kernel's buffers and the 4 MB); it is the phone video that gets there in seconds.

The same rows with the limits (2026-10-23, the harness on --sim; each cell headless / UI):

ScenarioCPU % beforeCPU % with limitsOBD polls/sLongest poll gap, msGUI round trip p99, msWhat the load got (headless)
idle2.4 / 2.81.6 / 6.618.5 / 18.6130 / 1270.3 / 0.7–
fanout203.2 / 3.12.8 / 3.218.7 / 18.4134 / 1320.7 / 0.3subscribers 19.3 events/s each, 0 dropped
nonreader1.6 / 4.01.7 / 2.318.9 / 18.4128 / 1160.3 / 0.3non-reader still connected
callflood105.7 / 100.11.7 / 2.018.9 / 18.5129 / 1510.4 / 0.3flooder closed (ignored rateLimited)
churn171.5 / 163.032.9 / 34.418.8 / 18.6109 / 1210.2 / 0.4churn 100 cycles/s, 52,077 connections/s refused
wsmix2.9 / 5.22.9 / 3.719.3 / 19.0115 / 1200.3 / 0.4video 30 records/s each
all208.6 / 213.738.3 / 40.218.8 / 18.9116 / 1680.2 / 0.5subscribers 19.4 events/s each, 0 dropped; non-reader still connected; flooder closed (ignored rateLimited); churn 100 cycles/s, 49,501 connections/s refused; video 30 records/s each
media1.7 / 2.32.0 / 2.618.9 / 18.9123 / 1250.5 / 0.3video 30 records/s each

The flooder ignores retryAfterMs, so it is closed within its first second (before that rule, answering only its 200 a second and refusing the rest still took 59 % of a core). Churn costs a third of a core now: about 50,000 connections a second refused at the door, which a runaway local loop can still reach, and 100 cycles a second admitted.

Poll rate, longest poll gap and GUI round trip did not move in these rows either. Tests: tests/cpp/tst_servicebridge.cpp (aFloodIsRateLimitedAndNoOneElseIs, aClientThatIgnoresRateLimitedIsClosed, aConnectLoopIsRefusedAtTheDoor, aReaderThatStaysBehindIsClosed), the same over WebSocket in tests/cpp/tst_websocket.cpp, and service_load_check (above) for the whole of it against a real app.

Not covered: the Pi (the slowest head unit, still to measure); the test video is 300 kbit/s where a phone sends 8-16 Mbit/s; RSS is a Debug build's; the harness runs on the same machine (its own CPU is printed under the table, at most about half a core, in the churn rows).

src/util/ and src/platform/

Both directories are listed explicitly in qt_add_executable(octave …) in CMakeLists.txt.

src/util/

FileContents
assetstore.h / assetstore.cppAssetStore.localUrl(relativePath) resolves assets under frontend/assets/. Android extracts them to AppDataLocation/asset-cache/ when missing or changed in size, allowing Quick3D’s external model loader to read them. Desktop returns the source file URL. See the API reference.
logger.h / logger.cppThe OctaveLog namespace: rotating file logging, the Qt message handler and the crash handlers. See Logging and Crash handlers.
uiwatchdog.h / uiwatchdog.cppUiWatchdog. See UI watchdog.
perfscope.hOCTAVE_PERF_SCOPE(label). See Performance monitor.
buildinfo.h.inTemplate that CMake's configure_file turns into <build>/generated/buildinfo.h: OCTAVE_GIT_COMMIT and OCTAVE_GIT_DATE, the commit the binary was built from ("unknown" without git).

src/platform/

This directory contains Android glue and desktop Linux Bluetooth pairing support.

FileGuardContents
bluezpairer.h / bluezpairer.cppDesktop Linux build sourcesBluezPairer registers a BlueZ D-Bus PIN agent, tries the common ELM327 PINs, and trusts the adapter for reconnects. Used by OBDAdapterFinder. See OBD Manager.
androidmediabridge.h #ifdef Q_OS_ANDROID Namespace OctaveAndroid: inline QJniObject wrappers around org.octave.app.OctaveMediaBridge (android/src/org/octave/app/OctaveMediaBridge.java). Functions: readMetadata(path) (newline-delimited title / artist / album / duration from MediaMetadataRetriever), extractAlbumArt(path, out), getAppMusicDir() (the app’s own external-files Music dir: scoped-storage-safe, needs no permission; MediaManager scans it as extra playlists).

Android BLE bridge event logging (commit 3858c5c)

This does not live in src/platform. The OBD-over-BLE bridge is android/src/org/octave/app/OctaveOBDBridge.java (Java) and OBDManager::onAndroidBlePoll() in src/managers/obdmanager.cpp. Every setEvent / setError in the Java bridge is also pushed onto a bounded ConcurrentLinkedQueue (LOG_QUEUE_MAX = 200, so a disconnected adapter cannot grow it without bound) as "I|msg" or "W|msg", exposed via pollLog(). The C++ side drains up to 50 lines per tick of its existing 50 ms notification poll and emits them as qCInfo / qCWarning(lcElm327) << "[BLE]" << msg, so a BLE drop in the car lands in octave-cpp.log and in the Diagnostics export instead of only in logcat. No JNI native registration is needed.

media/ and temp/

MediaManager derives two directories from QCoreApplication::applicationDirPath() (src/managers/mediamanager.cpp):

DirectoryPurposeLocation
media/ MediaManager’s fallback library directory before settings are connected. During normal startup, SettingsManager supplies the effective library root: an empty stored mediaFolder resolves to QStandardPaths::MusicLocation. <app dir>/media (e.g. build/media in a checkout); <Music>/OCTAVE when the app directory is read-only (an installed .exe, a mounted AppImage, a signed .app); the standard Music location on Android.
temp/ Scratch cache: cover_<hash>.<ext> album covers extracted from tags (content-addressed, kept across runs so a boot does not re-read every tag; _prune_cover_dir() removes the least-recently-read files at startup until at most 500 remain). <app dir>/temp; AppLocalDataLocation/temp when the app directory is read-only; AppDataLocation/temp on Android.

Design decisions

Why the volume curve lives in one object

Commit d2b047b moved every copy of the curve, including those in BottomBar.qml and MediaRoom.qml, into VolumeController. The risk is plain: every output must use the same curve or they drift out of sync when the user moves the slider. The controller emits volumeApplied rather than holding the output managers so it can be constructed before the managers it feeds exist (“avoids tight coupling — VolumeController doesn’t need pointers to managers that may not exist yet”, volumecontroller.h).

Why the Clock emits on change

Commit 7777777: with seconds hidden the string changes once a minute, but the old code emitted it every second and every binding that shows the clock re-evaluated 60 times for the same value; a hidden clock emitted "" forever. Comparing against the last emitted text costs one string compare per second and removes the churn. Settings are still re-read on every tick rather than wired to change signals, which keeps the class free of any signal connections.

Why the crash handlers write where they do

The observability overhaul (053f587) exists because the head unit runs unattended in a vehicle: the only thing a user can do after a freeze or crash is export the logs from the Device settings page later. Crash traces are therefore written to the error log. Diagnostics exports both main and error logs, and its tail combines the main log with a shorter error-log tail. The code comments spell out the constraint: “a separate descriptor, opened once, so the signal path never touches Qt” and “async-signal-safe as far as practical” — the handler only calls write() and backtrace_symbols_fd() on that raw descriptor. It then restores SIG_DFL and re-raises so the OS still writes its core dump / tombstone; the file log is additive, not a replacement.

Why SIGTERM/SIGINT go through the event loop

Commit 3810e03: with the default action, a launcher or systemd stopping the app killed the process outright and the phone-mirror adb forward leaked. Calling QCoreApplication::quit() from inside a signal handler is not safe, so the handler only writes a byte into a socketpair and a QSocketNotifier does the real work on the event loop. It ends in the same aboutToQuit cleanup that a window close triggers.

Why a watchdog, and why it stops first

The header comment states the goal: “so a frozen head unit leaves a trace”. The watchdog (f157b97) is the smallest thing that turns a hang into evidence: one thread, one queued call, one warning per stall with the duration on recovery. The 2 s ping and 5 s stall threshold detect prolonged event-loop delays without logging on every normal tick. Commit 95dfb49 moved stop() to the head of the quit cleanup after a Pi run where the worker pinged a QObject Qt had already destroyed and the error was reported as CRITICAL — noise the whole effort exists to remove.

Why a TCP command server rather than an in-process test harness

The command server is what lets every external tool — the screenshot sweeper, the dashboard-editor scenario, the perf loops and the MCP servers — drive the real app process from outside without a display server: the app captures itself with grabWindow() and injects input from inside Qt (QTest mouse events, key events sent to the item), so the same scripts run on Wayland, X11, Xvfb and offscreen (an earlier xdotool + mss approach broke under Wayland). Newline-delimited JSON over localhost is trivially scriptable from any language and from an MCP tool. It is bound to 127.0.0.1. The socket itself has since become the service (src/service/, in every build, off unless asked for); what stays out of release binaries are the actions that drive the UI, which only the dev runtime (OCTAVE_DEV_TOOLS, --profile) registers. registerAction() lets dev-only code such as the UI driver and the collage bridge add actions without touching the core table.

Why perf instrumentation is a macro

OCTAVE_PERF_SCOPE keeps profiling out of shipped binaries entirely: without OCTAVE_DEV_TOOLS it expands to static_cast<void>(0), and with the tooling built but --profile off it is one relaxed atomic load. The labels are plain strings, so the list of instrumented functions can grow without touching the monitor.