Utility Modules
Every supporting module that is not a feature manager: Clock, logging and crash handlers, the UI watchdog, DiagnosticsManager, VolumeController, version, the performance monitor, the command server, AssetStore, the src/util / src/platform trees, and the media/ / temp/ directories.
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.
| Module | Source | QML context property | Notes |
|---|---|---|---|
| AssetStore | src/util/assetstore.{h,cpp} | assetStore | Resolve bundled assets to local file URLs; extract from the APK on Android |
| Clock | src/managers/clock.{h,cpp} | clock | |
| Logging | src/util/logger.{h,cpp} (OctaveLog namespace) | – | Not a QObject; installed first thing in main() |
| Crash handlers | inside src/util/logger.cpp | – | Write to the error log |
| UI watchdog | src/util/uiwatchdog.{h,cpp} | – (not registered) | QObject, but internal only |
| DiagnosticsManager | src/managers/diagnosticsmanager.{h,cpp} | diagnosticsManager | Settings → Device → Diagnostics card |
| VolumeController | src/managers/volumecontroller.{h,cpp} | volumeController | Quadratic curve + dispatch |
| Version | project(OCTAVE VERSION …) in CMakeLists.txt → OCTAVE_VERSION define | – (surfaced as diagnosticsManager.appVersion) | |
| Performance monitor | src/dev/perfmonitor.{h,cpp}, src/util/perfscope.h | perfMonitor (only with --profile) | OCTAVE_DEV_TOOLS builds only |
| Command server | src/service/commandserver.{h,cpp}, coreactions.cpp, uidriver.{h,cpp} | – | OCTAVE_DEV_TOOLS builds only, started by --profile |
| Android JNI bridge | src/platform/androidmediabridge.h | – | Android builds only |
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
| Member | Kind | Declaration | Description |
|---|---|---|---|
time | property (read-only) | QString time NOTIFY timeChanged | The 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) | signal | void timeChanged(const QString &) | Formatted time, or "" when showClock is off |
update_time() | slot | public slots | Timer 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
| Setting | Without seconds | With seconds |
|---|---|---|
clockFormat24Hour = true | 14:30 | 14:30:05 |
clockFormat24Hour = false | 02:30 PM | 02: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 —
clockTimeTextbindsclock.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.clockShowSecondsoff, 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).
| File | Level | Max size | Backups | When |
|---|---|---|---|---|
octave-cpp.log | INFO and above (everything except QtDebugMsg) | 5 MB | 3 | Always |
octave-cpp-error.log | WARNING, ERROR (QtCriticalMsg) and FATAL, plus crash blocks | 2 MB | 5 | Always |
octave-cpp-debug.log | DEBUG and above (everything) | 10 MB | 2 | Only 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.*):
| Category | Declared in |
|---|---|
octave.androidauto | src/managers/androidautomanager.cpp |
octave.androidauto.connection | src/android_auto/aapconnection.cpp |
octave.androidauto.session | src/android_auto/aapsession.cpp |
octave.assetstore | src/util/assetstore.cpp |
octave.audioanalyzer | src/managers/audioanalyzer.cpp |
octave.berryimu | src/managers/berryimumanager.cpp |
octave.dashboards | src/managers/dashboardmanager.cpp |
octave.dev | src/dev/devruntime.cpp (OCTAVE_DEV_TOOLS builds only) |
octave.diagnostics | src/managers/diagnosticsmanager.cpp |
octave.elm327 | src/managers/elm327protocol.cpp (also carries the Android [BLE] bridge lines, see below) |
octave.embeddeddhu | src/items/embeddeddhuitem.cpp |
octave.esp32volume | src/managers/esp32volumemanager.cpp |
octave.fonts | src/util/fontcatalog.cpp |
octave.frontends | src/managers/frontendmanager.cpp (installable frontends) |
octave.frontends.supervisor | src/managers/frontendsupervisor.cpp (an exec frontend: start, restart, fall back to QML) |
octave.gesture | src/managers/gesturemanager.cpp |
octave.media | src/managers/mediamanager.cpp |
octave.network | src/managers/networkmanager.cpp |
octave.phonemirror, octave.phonemirror.client | src/managers/phonemirrormanager.cpp, src/phone_mirror/scrcpyclient.cpp |
octave.service | src/service/commandserver.cpp (the service socket; every build) |
octave.service.pairing | src/service/servicepairing.cpp (codes, tokens, refusals by address; never the code) |
octave.settings | src/managers/settingsmanager.cpp |
octave.shiftlight | src/managers/shiftlightmanager.cpp |
octave.sim, octave.dev.carsim | src/sim/simulator.cpp, src/sim/carsim.cpp (every build; --sim, --dev, the browser demo) |
octave.themes | src/managers/themecatalog.cpp |
octave.triprecorder | src/managers/triprecorder.cpp |
octave.tripstore | src/managers/tripstore.cpp |
octave.ui_watchdog | src/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()opensoctave-cpp-error.loga second time with rawopen(O_WRONLY|O_APPEND|O_CREAT)and installscrashHandlerfor SIGSEGV, SIGABRT, SIGFPE, SIGILL and SIGBUS. The handler uses low-level writes and backtrace functions (no Qt — not evenOctaveLog::flush(), which takes aQMutex; the log files need no flush there becauseRotatingFile::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 restoresSIG_DFLand re-raises so the OS still produces a core dump / tombstone. Backtraces exist only whereexecinfo.his 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 62CaptureStackBackTraceframes via_write, then returnsEXCEPTION_CONTINUE_SEARCH. qFatalis not a signal: it goes through the message handler asFATAL, after which the handler callsOctaveLog::flush(), before Qt aborts (SIGABRT →crashHandlerappends 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.
| Member | Behaviour |
|---|---|
| constructor | UiWatchdog(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 mechanism | QMetaObject::invokeMethod(this, lambda, Qt::QueuedConnection) writing an std::atomic<qint64> (monotonic ms since the watchdog started) |
| teardown safety | Worker 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
| Property | Type | Notify | Value |
|---|---|---|---|
logDir | str | CONSTANT | SettingsManager::getAppDataDir() + "/logs" |
appVersion | str | CONSTANT | OCTAVE_VERSION compile definition ("dev" if undefined) |
backendName | str | CONSTANT | "C++" |
deviceInfo | str | CONSTANT | <prettyProductName> <cpuArch> (<hostname>, <kernelVersion>) via QSysInfo |
lastExportPath | str | lastExportPathChanged | Folder written by the last exportLogs(); "" until it has run |
canOpenFolder | bool | CONSTANT | false on Q_OS_ANDROID / Q_OS_IOS, else true |
Invokables / slots
| Method | Signature | Behaviour |
|---|---|---|
recentLogLines(maxLines) | Q_INVOKABLE QString (int maxLines = 200) const | Reads 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 &) const | Full 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.
| Percent | Linear gain |
|---|---|
| 0 | 0.0 |
| 25 | 0.0625 |
| 50 | 0.25 |
| 75 | 0.5625 |
| 100 | 1.0 |
Public API
| Member | Declaration | Description |
|---|---|---|
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) | signal | Emitted after every applyVolume(); main.cpp connects the outputs (media, phone mirror, ESP32) to it |
toLinearStatic(float) | static float | Curve without an instance (tests, one-off math) |
| constructor | VolumeController(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) callsapplyVolume(settingsManager.currentVolume())once after the outputs are constructed, so the configured startup volume reaches every output at startup
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 passesOCTAVE_VERSION="${PROJECT_VERSION}"as a compile definition and reusesPROJECT_VERSIONforMACOSX_BUNDLE_BUNDLE_VERSION/MACOSX_BUNDLE_SHORT_VERSION_STRING. diagnosticsmanager.cpp falls back to"dev"if the define is missing.- The APK's
versionName/versionCodecome from it as well (QT_ANDROID_VERSION_NAME/QT_ANDROID_VERSION_CODE, code = major×10000 + minor×100 + patch). Only the Windows installer (MyAppVersionin 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
| Component | What it measures |
|---|---|
RingBuffer | Fixed-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 probe | A 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 probe | A 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 sample | CPU % (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 labels | Fed 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)
- Loop latency avg / p50 / p95 / p99 / max from the ring buffer; jitter avg / p99; CPU, memory and thread count.
- 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. - Plain-text line in the perf
.logtaggedOK/WARN/STUTTERwith the same thresholds, plusSLOWlines. - 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. - Loop buffer cleared,
metricsUpdatedemitted.
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.
| Tool | Purpose |
|---|---|
| dev/perf/profile_performance.py | External 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.py | Drives 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.py | Cranks 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.json | Registers 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
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
| Category | Actions |
|---|---|
| App | ping → "pong"; list_actions (in registration order) |
| Perf | get_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) |
| Navigation | navigate (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 / recording | screenshot (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 driving | find_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) |
| Media | next_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) |
| OBD | obd_status (connected, status string), obd_reconnect |
| Settings | get_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 collage | collage_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 thesave_*slot and the property to read back (toggle_settingreturnsold/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): launchesbuild/octave --dev --profileheadless (or sweeps an app already started with--profile,--no-launch), then walks every page withnavigate/open_settings, fixes the size withset_window_size, hides the overlay withset_dev_chromeand captures withscreenshot. - dev/screenshots/stories.py and the README clips use
record_start/record_stopplus thesim_*actions andfind_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,dragandcall; dev/tests/drive_dashboard_features.py drives the other dashboard features the same way. python -m dev.collage(thecollage_*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, andmain.cppsets 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 asisAndroidandassetStoreare 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.
kProtocolVersionin 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;opsnames every op this server answers). A property is{name, type, readable, writable, constant, notify?, enum?}(enumlists the keys of aQ_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. OnparamStore(aQQmlPropertyMap, keyed by parameter id)propertyis a key (nullbefore 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 theirsave_*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 (nullforvoid). 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_servicebridgesubscribeReplyComesBeforeItsEvents), interleaved with replies (a reply hasok, an eventevent):{"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}onparamStore: its 33 ms batches of changed readings, filtered toproperties(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).
throttleMscoalesces: at most one event per name (one mergedparamsevent) per interval, the latest value winning.tis 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 anunsubscribe {sub}, with its object, or with the connection.- List models (a property holding a
QAbstractItemModel; no object has one at present, protocol 3).getgives{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?}withchangeone ofreset(all rows; also the first event),insertandupdate(with the affected rows) andremove, which a client replays in order to keep its copy. List events ignorethrottleMs.
get,setandcallrun on the object's (GUI) thread, so a slow method holds the UI just as it would from QML; the client waits up totimeoutMs(default 30 s). Enum values travel as their key names ("Connected"; an enum property reads as its key); on the way in aQ_ENUMparameter 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
--profilerun 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.
| Scenario | Load | CPU % | OBD polls/s | Longest poll gap, ms | GUI round trip p99, ms | GUI round trip max, ms | Load clients got (headless) |
|---|---|---|---|---|---|---|---|
idle | no load clients | 2.4 / 2.8 | 18.7 / 18.4 | 125 / 131 | 0.8 / 0.3 | 0.9 / 0.4 | – |
fanout20 | 20 clients, each with a demand on and a subscription to all 129 PIDs, unthrottled | 3.2 / 3.1 | 18.6 / 18.7 | 127 / 197 | 0.5 / 0.4 | 1.8 / 0.5 | 19.6 events/s to each, 0 dropped |
nonreader | 1 client subscribed to paramStore, nowPlaying and the audio levels that never reads | 1.6 / 4.0 | 18.8 / 19.0 | 165 / 120 | 0.3 / 0.7 | 0.3 / 0.8 | non-reader still connected at the end |
callflood | 1 client pipelining call volumeController.toLinear, 64 in flight | 105.7 / 100.1 | 18.8 / 19.0 | 127 / 132 | 0.2 / 0.7 | 0.6 / 1.1 | 111,904 calls/s answered |
churn | 2 clients connecting, subscribing, reading one event and closing, in a loop | 171.5 / 163.0 | 19.0 / 18.5 | 153 / 171 | 0.3 / 0.4 | 0.8 / 0.5 | 9,008 cycles/s |
wsmix | 8 WebSocket clients on paramStore (40 ms) + nowPlaying, the phone video to 2 WebSocket and 1 TCP client | 2.9 / 5.2 | 18.7 / 18.9 | 128 / 131 | 0.3 / 0.6 | 0.3 / 0.8 | video 30 records/s to each |
all | every row above at once | 208.6 / 213.7 | 18.7 / 18.6 | 129 / 132 | 0.4 / 0.3 | 1.5 / 0.3 | 19.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 |
media | the 2026-10-22 baseline: paramStore (40 ms), nowPlaying, levels, video over WebSocket and TCP | 1.7 / 2.3 | 18.5 / 18.6 | 146 / 134 | 0.3 / 0.3 | 0.4 / 0.3 | video 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
--devrun left the IMU feed off; with--simboth 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
callkeeps 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 inhandleLinebefore trust and before any work; past it the op is answered{"ok": false, "code": "rateLimited", "retryAfterMs": N}. Devactions 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 fromgetpeername), 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):
| Scenario | CPU % before | CPU % with limits | OBD polls/s | Longest poll gap, ms | GUI round trip p99, ms | What the load got (headless) |
|---|---|---|---|---|---|---|
idle | 2.4 / 2.8 | 1.6 / 6.6 | 18.5 / 18.6 | 130 / 127 | 0.3 / 0.7 | – |
fanout20 | 3.2 / 3.1 | 2.8 / 3.2 | 18.7 / 18.4 | 134 / 132 | 0.7 / 0.3 | subscribers 19.3 events/s each, 0 dropped |
nonreader | 1.6 / 4.0 | 1.7 / 2.3 | 18.9 / 18.4 | 128 / 116 | 0.3 / 0.3 | non-reader still connected |
callflood | 105.7 / 100.1 | 1.7 / 2.0 | 18.9 / 18.5 | 129 / 151 | 0.4 / 0.3 | flooder closed (ignored rateLimited) |
churn | 171.5 / 163.0 | 32.9 / 34.4 | 18.8 / 18.6 | 109 / 121 | 0.2 / 0.4 | churn 100 cycles/s, 52,077 connections/s refused |
wsmix | 2.9 / 5.2 | 2.9 / 3.7 | 19.3 / 19.0 | 115 / 120 | 0.3 / 0.4 | video 30 records/s each |
all | 208.6 / 213.7 | 38.3 / 40.2 | 18.8 / 18.9 | 116 / 168 | 0.2 / 0.5 | subscribers 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 |
media | 1.7 / 2.3 | 2.0 / 2.6 | 18.9 / 18.9 | 123 / 125 | 0.5 / 0.3 | video 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/
| File | Contents |
|---|---|
assetstore.h / assetstore.cpp | AssetStore.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.cpp | The OctaveLog namespace: rotating file logging, the Qt message handler and the crash handlers. See Logging and Crash handlers. |
| uiwatchdog.h / uiwatchdog.cpp | UiWatchdog. See UI watchdog. |
| perfscope.h | OCTAVE_PERF_SCOPE(label). See Performance monitor. |
| buildinfo.h.in | Template 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.
| File | Guard | Contents |
|---|---|---|
bluezpairer.h / bluezpairer.cpp | Desktop Linux build sources | BluezPairer 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):
| Directory | Purpose | Location |
|---|---|---|
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.