Development Guide
Dev mode, the simulated car, the command server and MCP servers, profiling, screenshots and video stories, how to add features, testing, CI, the deferred-work folder — and why the tooling is built this way.
Design Decisions
Why dev mode wraps production instead of duplicating it
Dev mode is a flag on the production binary: octave --dev builds every manager exactly as a normal start does, then points the real OBDManager at a simulated ELM327 adapter (a QIODevice registered through OBDConnectionWorker::setCustomTransport()) and reparents the dev overlay into the real window. Nothing in the OBD stack is replaced, so the AT init sequence, PID parsing and decoding, demand-driven polling, the vehicle scan and gate, VIN decode and the DTC paths all run the code that ships.
Why a TCP command server and MCP servers for performance work
The head unit runs in a Jeep; the problems that matter are stutter, stalls and regressions that only show up when the app is driven the way a driver drives it. A JSON-over-TCP command server (src/service/commandserver.cpp, only started by --profile) lets a script or an agent navigate pages, toggle every setting, skip tracks, drive the simulator and read the perf counters while the perf monitor measures the event loop, so a test is repeatable and comparable between commits (dev/docs/perf_test_plan.md, dev/perf/run_perf_loop.py). The two MCP servers expose the same channel to a coding agent so it can reproduce a report, bisect a regression or benchmark a setting without a human clicking. The README makes the same point: "The same command channel is exposed as MCP tools, so a coding agent can navigate the app, change settings, read performance counters and record clips."
Where the tooling lives, and why the in-app half is committed
The tooling has two halves. The in-app half (perf monitor, UI driver and actions, dev keyboard, overlay) is src/dev/. It is committed because CMake must list it, but it is only compiled when the OCTAVE_DEV_TOOLS option is on: the default only for CMAKE_BUILD_TYPE=Debug desktop builds, off for every other or missing build type, Android and iOS, so no shipped binary carries the perf monitor or the UI-driving actions. Even when compiled, nothing runs without --dev / --profile. The simulator (src/sim/) and the service socket (src/service/) are in every build, inert unless --sim / --service (or a setting) start them. Production code touches it in three small places: OCTAVE_PERF_SCOPE("label") timing scopes (src/util/perfscope.h, compiled to nothing without the option), OBDManager::setPortOverride() / OBDConnectionWorker::setCustomTransport() (generic hooks; nothing is registered in a normal run) and a friend declaration on BerryIMUManager for the simulator's IMU feed (SimImuAccess).
The client half (dev/: MCP servers, screenshot / story / collage / perf scripts, written in Python) talks to the app only over the command server's TCP socket. It stays in .gitignore and is synced between the developer machines through Dropbox, so a fresh clone has the in-app half but none of the clients.
Why screenshots are grabbed from inside Qt
The first screenshot tool used xdotool + mss (X11 input and capture) and broke under Wayland. The current one asks the running app to capture itself: screenshot and record_start run grabWindow() on the GUI thread, which works identically on Wayland, X11 and Xvfb, and hides the dev HUD automatically. It also gives deterministic window sizes for README assets (dev/docs/SCREENSHOTS.md).
Why errors do not surface in the UI
Errors are reported through logs and page-specific status text. The one exception is ErrorToast: a short top-centre notice when the OBD link drops while you're on another page. The rotating logs, crash handlers, UI watchdog, and Diagnostics card support troubleshooting after a session.
Repository Map for Developers
| Path | Tracked? | What it is |
|---|---|---|
| src/, CMakeLists.txt | yes | The C++ application: main.cpp, managers/, util/ (logger, UI watchdog, perfscope.h, the shared h264decoder), phone_mirror/, android_auto/ (the built-in Android Auto receiver, built with OCTAVE_HAVE_NATIVE_AA), items/, platform/ (Android bridges and Linux BlueZ pairing) |
| src/sim/ | yes | The simulator, built in every configuration and inert unless started (--sim, --dev, or the simulated adapter): simulator (the sim service object), carsim + simelm327 (simulated car and adapter), imufeed (IMU motion), simconfig (the throwaway config and its test tracks) |
| src/dev/ | yes | In-app dev tooling that drives the UI, built only with OCTAVE_DEV_TOOLS: devruntime (the --dev / --profile wiring), coreactions + uidriver, perfmonitor, devkeyboard (overlay keys into the simulator), devtoolsmanager, collagebridge, and qml/ (DevOverlay.qml, DevToolsPanel.qml) |
| frontend/ | yes | QML, gauges/, dashboards/presets/*.json, shaders/, assets/ (incl. assets/readme/ for the README media) |
| android/ | yes | Qt Android package source: manifest, build.gradle, res/, Java bridges in src/org/octave/app/, assets/frontend + assets/scripts + assets/wiki symlinks, assets/python-packages/ (bundle zip gitignored) |
| phone_server/, tools/phone-server/ | yes | Forked scrcpy server source and the committed jar (+ LICENSE / NOTICE) |
| scripts/ | yes | fetch_platform_tools.py (adb), gen_gauge_param_docs.py (regenerates the parameter table in docs/GAUGE_AUTHORING.md §6 from src/managers/parametercatalog.cpp; tst_parametercatalog fails while an id is missing there), octave-desktop.sh (desktop launcher), octave-update.sh (legacy Pi updater with hard-coded paths), octave-start.sh / octave-cpp-start.sh (stubs pointing to the Pi launcher in deployment/orange-pi-5-plus/) |
| packaging/linux/, packaging/windows/, build_scripts/ | yes | AppImage script (its tools/ download dir is gitignored), Windows DLL dependency resolver, and the Inno Setup .iss |
| tests/cpp/, tests/fixtures/ | yes | QtTest suite run by ctest, and its H.264 / QML fixtures (Testing) |
| docs/ | yes | GAUGE_AUTHORING.md (the gauge/dashboard spec — read it before building a gauge), PHONE_MIRROR_NATIVE_PLAN.md (protocol byte layouts, hardware results), ANDROID_AUTO_PROTOCOL.md (Android Auto Protocol framing, handshake and channel messages, as checked against a phone), vehicle/ (Jeep TJ photo references, references.md, reference-manifest.json, reference-board.html) |
| tools/trips/ | yes | make_mock_trip.py: writes a realistic 28-minute mock drive (cold start, city, highway, a 9 s adapter dropout, 23 PIDs, ~40k samples) into the black box database of a history dir (--history, default the app's own; --minutes, --start, --seed), or with --drives N --days D --drift thermostat|alternator|lean|none N drives of 10–35 minutes over the last D days with that part wearing out over the second half, for the health trends. Run with OCTAVE closed; on the next start TripStore::recoverOpenTrips() closes it with the real summariser (clean, because the script logs a trip_end event) and writes the CSV + JSON, so Trip History shows exactly what a recorded drive looks like. For designing the page without driving |
| tools/vehicle/ | yes | Blender build scripts for frontend/assets/jeep_tj_2003.glb (build_jeep_tj.py, build_jeep_twin.py, export_jeep_twin.py, rig_export.py, check scripts, steering_geometry.json) |
| TODO/ | yes | Deferred work, one plan per file (below) |
| wiki/ | yes | This wiki; rebuild the search index with python wiki/build_search_index.py after content changes |
| dev/ | no | Client tools for the command server: app_launch.py (which binary the tools start), screenshot/story/collage tools, MCP servers, perf/, tests/, docs/, jeep-tj-reference/ (164 reference photos) |
| .mcp.json | no | MCP server registrations |
| tools/platform-tools/, tools/scrcpy*/ | no | Fetched adb; leftover scrcpy binaries from the removed external-scrcpy path |
Dev Mode Setup & Usage
Dev mode is the simulator of --sim (below, in every build) plus keyboard controls, the overlay and the UI tooling, for testing without an OBD-II adapter.
# any build, release included
build/octave --headless --sim # simulated car, IMU motion, test tracks; the sim object on the service; throwaway config
build/octave --sim # the same car in the QML UI (no service unless --service)
build/octave --headless --sim --config /tmp/octave-sim # keep the config between runs (nothing seeded there)
# needs a build with OCTAVE_DEV_TOOLS=ON, the default for -DCMAKE_BUILD_TYPE=Debug
build/octave --dev # --sim on your usual config + overlay + keyboard driving
build/octave --dev --profile # + perf monitor, command server :9876 and the sim_* actions
build/octave --profile # production mode, real OBD, perf monitor + command server
build/octave --dev --profile --command-port 9877
build/octave --service # any build: the service protocol only (no UI / perf / sim actions)
build/octave --headless # the managers + the service, no QML engine, no window
build/octave --headless --web-root dist # serve your built web frontend at http://127.0.0.1:9876/
build/octave --headless --dev # --headless --sim on your usual config, plus the sim_* actions (no UI / perf actions)
python3 tools/stream/service_load.py build/octave 19818 /tmp/load/cfg ~/Music # service load: CPU, RSS, event latency with 5 clients (TODO/open-backend.md has the baseline)
build/octave --help # every flag (and --version); exits before any manager, config or log file
build/octave --dump-api docs/api # regenerate the published API (then: python wiki/build_api_reference.py)
A build without the option (Release, or no build type) ignores --dev / --profile with a warning; --sim and --service work in every build (service protocol). --sim on its own runs on a throwaway config (logged at start, deleted at exit); --dev keeps your usual one, so use --config DIR (or XDG_CONFIG_HOME=/tmp/octave-dev, as the story and collage tools do with their own profile directory) when a dev run should not touch your settings.
OctaveDev::Runtime
src/main.cpp builds every manager as usual, then hands them to src/dev/devruntime.cpp:
- Before it,
main.cppstarts the simulator (core.sim.start(), for--simor--dev):CarSim, the simulated adapter registered for portsim://elm327,obdManager.setPortOverride("sim://elm327")(the saved port setting is left alone) and the IMU feed. beforeLoad()— with--dev: registersdevKeyboardController(driving the simulator's car),devToolsManageranddevMode = true. With--profile: createsPerfMonitoras theperfMonitorcontext property.engine.load(Main.qml)— the production QML; there is no separate dev entry.afterLoad()— reparents src/dev/qml/DevOverlay.qml into the window (C++ object ownership plus a QObject parent so the JS GC cannot reclaim it); with--profile, starts the perf monitor and adds the core, collage and (in dev mode)sim_*actions to the command server, whichmain.cppcreated (--profileturns the service on in these builds) and starts right after.startHeadless()— instead of the two above under--headless, where there is no engine: with--devthesim_*actions on the service (the simulator itself, with the IMU feed on from the start, ismain.cpp's). No overlay, keyboard, perf monitor or UI actions;--profileis ignored with a warning.shutdown()— first thing inaboutToQuit: writes the perf report, finishes a recording;main.cppthen stops the command server before the managers clean up.
Keyboard controls
Handled by devKeyboardController.handle_key(key, pressed) (DevKeyboardController in src/dev/devkeyboard.h), fed from Keys.onPressed / onReleased in the overlay. The overlay's HUD re-reads obdManager.rpm(), speedMPH(), ... every 100 ms; those getters only change for PIDs that are being polled.
| Key | Action | Type |
|---|---|---|
W / Up | Throttle 100 % while held | Continuous |
S / Down | Brake 100 % while held | Continuous |
Space | Quick rev (throttle blip to 70 %) | Instant |
E | Toggle engine on/off | Toggle |
Q / R | Downshift / upshift | Instant |
1–6 | Set gear | Instant |
F1 / F2 / F3 / F4 | Cold start / overheating / low fuel / reset to normal | Scenario |
Tab | Toggle the dev overlay HUD | Toggle |
Dev mode architecture
build/octave --dev --profile
|
+-- every manager, every connection (src/main.cpp, unchanged)
+-- core.sim.start() CarSim (60 Hz physics), SimElm327Device for sim://elm327,
| obdManager.setPortOverride, the IMU feed (src/sim/)
+-- Runtime::beforeLoad devKeyboardController / devToolsManager / devMode,
| perfMonitor
+-- engine.load(Main.qml) production frontend
+-- Runtime::afterLoad src/dev/qml/DevOverlay.qml reparented into the window,
command server :9876 (core + collage + sim_* actions)
OBDManager --worker thread--> OBDConnectionWorker --QIODevice--> SimElm327Device
(real AT init, PID polling, | reads CarState
scan, VIN, DTC parsing) v (mutex, shared_ptr)
CarSim <-- keyboard, sim_*
Simulated Car and Adapter
Everything here is in src/sim/, built in every configuration (release builds too, so a frontend author can download a release instead of building OCTAVE) and inert until started with --sim, --dev, or by choosing Simulated car in the OBD adapter picker. The picker works in desktop, mobile and WebAssembly builds, including releases, without Bluetooth or launch flags. src/sim/demodrive.cpp drives the shared simulator automatically: idle, accelerate, cruise, coast and brake, with simulated IMU motion. Selecting hardware or closing the OBD connection stops the demo motion and restores the hardware sensor path. The simulated adapter does not replace the saved hardware adapter; the UI labels its data Simulated. Simulator (src/sim/simulator.h) owns the parts and is served as the experimental sim object (docs/api/README.md, "Simulator").
src/sim/carsim.cpp + src/sim/simelm327.cpp
CarSim is a simple physics model (throttle drives RPM and speed, brake slows it, the gear follows speed while the engine runs, load / MAF / MAP / timing / AFR / O2 from throttle and RPM, scenarios that hold temperatures or fuel) stepped by a 60 Hz GUI-thread timer into a mutex-guarded CarState. SimElm327Device is a sequential QIODevice on the OBD worker thread that answers what the worker sends:
- The AT init sequence (
ATZ→ELM327 v1.5,ATS0turns spaces off,ATRV→ battery voltage, otherATcommands →OK). - Mode 01: every PID in
ELM327Protocol::pidTable()andextendedPidTable(), encoded with the inverse of the shipped decoders. Modelled PIDs follow the simulation; the rest hold plausible fixed values. The supported-PID bitmaps (0100...01E0) report all of them, so no widget is gated out in a simulated run. - Mode 03 / 07 / 0A DTCs (P0300 + P0420 stored, P0300 pending; CAN count byte), Mode 04 clear (codes stay cleared until restart), Mode 02 freeze-frame DTC, PID 01 MIL + count, and the Mode 09 VIN
1J4FA49S03P123456(2003 Jeep) as a CAN multi-frame reply. - Link behaviour: in realistic mode each reply arrives after a Bluetooth-like 35–70 ms (700 ms for
ATZ) and 2 % of PID polls answerNO DATA; withsim.setRealistic(false)replies take 8 ms and never drop. A dropout (sim.dropoutNow(), or random ones every 90–180 s withsim.setDropouts(true)) closes the device for 2–4 s, the worker reports the link lost, and the manager's passive scan reconnects.
Because the poller walks the demanded PIDs one by one at adapter speed, a page that asks for ~120 PIDs (Parameter Cards) takes several seconds to fill, as it does in the car.
src/sim/imufeed.cpp is the simulated IMU (sim.setImu, below). Turning it on stops the BerryIMU I²C worker; turning it off restarts it. It is on from the start with --sim and --headless; a GUI --dev run leaves it off, so a Pi's real IMU is not masked.
src/sim/simconfig.cpp prepares the throwaway config of a --sim run without --config (HOME, XDG_*, APPDATA and the config folder pointed at a temporary directory, as --dump-api does): settingsConfigure.json with the ESP32 knob and gesture sensor off and mediaFolder set to a Music/ folder holding three generated 20 s WAV tracks (tones, with title, artist and album in a RIFF INFO chunk). sim.setVideo(true) loops tests/data/stream-test.h264, compiled in as :/sim/stream-test.h264 (not in the browser build), into the phone video stream.
The sim object and the sim_* actions
Every build serves the sim object (active, setThrottle, setBrake, rev, setEngineOn, toggleEngine, setGear, upshift, downshift, setSpeed, setRpm, setRealistic, scenario, setDropouts, dropoutNow, setFailureRate, setLinkUp, setImu, setVideo, pairingCode, status); it is marked experimental, and when the run is not simulated every control returns false and does nothing. In developer builds with --dev the command server also takes the older sim_* actions, which are aliases for those methods and answer {"ok": false} when there is no simulated car. Over the command server the controls are sim_throttle / sim_brake (value), sim_rev (intensity), sim_engine (on), sim_gear, sim_upshift, sim_downshift, sim_speed (mph), sim_rpm, sim_scenario (name: cold | hot | low_fuel | normal), sim_realistic (enabled), sim_imu (on), sim_status, sim_dropouts (on), sim_dropout_now, sim_failure_rate (value, default 0.02) and sim_link (up: false unplugs the simulated adapter until it is plugged back in, for testing the no-vehicle states), sim_video (on) and sim_pairing_code. All but sim_status and sim_pairing_code are queued on the GUI thread and answer {"queued": name}. The scenarios hold while the engine runs: the per-tick update adds noise around the scenario’s coolant and oil temperatures instead of resetting them to normal. sim_imu starts the IMU feed (src/sim/imufeed.cpp), which drives the real berryIMU manager’s signals with a synthetic drive tied to the simulated speed (winding road for heading and lateral G, longitudinal G from acceleration, body roll and pitch, hills for altitude), so the sensors page, G-force and compass widgets and the 3D vehicle move without hardware. It pauses the manager’s I²C start/retry path while on.
DevToolsPanel
src/dev/devtoolsmanager.cpp & src/dev/qml/DevToolsPanel.qml (embedded in src/dev/qml/DevOverlay.qml)
DevToolsManager is exposed to QML as devToolsManager. It captures the window with Qt's grabWindow() (Wayland- and X11-safe), saves PNGs to dev/screenshots/output/, keeps a manifest.json of every capture with its description, and can generate a GALLERY.md.
| Member | Kind | Description |
|---|---|---|
captureWindow(name, description) | Slot | Grab the window to <name>.png and record it in the manifest |
beginCapture() / endCapture() | Slot | Emit hideOverlaysRequested / showOverlaysRequested so the HUD is out of the picture |
clearScreenshots(), generateGallery(), openOutputFolder() | Slot | Housekeeping |
screenshotStatus (str), screenshotOutput (str), screenshotCount (int), isCapturing (bool) | Property + ...Changed signal | "idle" / "capturing" / "done" / "error", the accumulated terminal-style output, manifest count, in-progress flag |
Command Server (TCP)
src/service/commandserver.cpp, coreactions.cpp, uidriver.cpp
A TCP server on 127.0.0.1:9876 that accepts newline-delimited JSON commands. main.cpp owns it: it starts with --profile (dev-tools builds), --service or the serviceEnabled setting (when and where it listens) and stops on aboutToQuit.
The listener runs on its own thread and every client connection gets a thread, so a slow GUI call on one connection never blocks get_metrics on another. Handlers run on the connection thread and reach Qt objects through CommandServer::onGui(fn, timeoutMs) (blocking, 30 s default; a stuck GUI thread comes back as the error GUI thread did not answer within … ms instead of a hung client) or postGui(fn) (fire and forget: navigation and the sim_* controls). Extra actions are added with registerAction(name, handler); invokeByName() calls slots / invokables / QML functions by name with argument conversion. --command-port N picks another port.
Protocol
// Client sends JSON + newline:
{"action": "next_track"}
// Server responds with JSON + newline:
{"ok": true, "result": null}
// With arguments:
{"action": "navigate", "args": {"page": "media_player"}}
// Error response:
{"ok": false, "error": "Unknown action: foo"}
Actions (the list_actions response)
- App:
ping(returns"pong"),list_actions - Performance:
get_metrics,get_detailed_report,get_slot_timings - Navigation:
navigate(page id),go_back,go_home,get_current_page,list_pages,open_settings(section id),list_settings_sections - Screenshots / recording:
screenshot(path),set_dev_chrome(show/hide the HUD),set_window_size,record_start(path,fps),record_stop,record_status - UI driving:
find_item(geometry + effective visibility of any QML item byobjectName, popups included),list_items(prefix match),list_interactive(every control a finger could reach right now: not hidden, clipped, under a sheet or popup that takes the tap, or painted over by a page; each with its kind, the text and icon drawn inside it, its on-screen rectangle and its checked / value state; the view the AI usability testers indev/ux/get),get_prop/set_prop(thenameis"root"for the window, else an item’sobjectName, else a manager’s context-property name such asshiftLightManager),call(invoke a no-arg method such asforceActiveFocus),find_text(an item by its visible label, or another string property such asplaceholderText, for QML without objectNames),click(x, y),long_press(x, y, ms),type_text(into a field matched by objectName or placeholder, key events sent straight to it),drag(x1, y1, x2, y2, steps) andwheel(x, y, notches) — synthetic mouse events delivered through the window's normal event path, so MouseArea drag thresholds and Flickable behave as they would for a finger. Closed popups leave the item tree, and pages pushed vianavigateare not destroyed ongo_home, so tests should prefer the visible instance (the server does) and scroll targets into view before clicking. - Media:
next_track,previous_track,toggle_playandtoggle_shufflego throughnowPlaying(the phone while it has the controls),pause(local player),toggle_mute,set_volume,get_media_status,get_track_info,skip_tracks - OBD:
obd_status,obd_reconnect - Settings:
get_settings,get_all_settings,toggle_setting,set_bool_setting,set_numeric_setting,set_string_setting, and thelist_toggle_settings/list_numeric_settings/list_string_settingsdiscovery calls - Collage:
collage_list_popups,collage_open_popup,collage_close_popups,collage_mount,collage_grab,collage_unmount(python -m dev.collage; src/dev/collagebridge.cpp, registered whenever the command server runs, with or without--dev) - Simulator: in dev mode the
sim_*actions (above)
Navigation bridge and valid pages
The GUI-thread half is UiDriver: navigation is queued, captures and UI driving block so the result can be read back. Mouse input goes through QTest (the build links Qt Test when OCTAVE_DEV_TOOLS is on). Because QML function foo(arg) parameters are untyped and register as QVariant, UiDriver::navigateTo() calls the window's navigateTo(page) through invokeByName() with a QVariant argument instead of Q_ARG(str, ...), which would match nothing and fail silently. Page ids map to QML files:
home, media_room, media_player, obd, obd_home, obd_diagnostics, trip_history, sensor_home,
settings, phone_mirror, android_auto, airplay, sensors, car, clock
# settings sections for open_settings:
displaySettings, mediaSettings, obdSettings, accessoriesSettings, deviceSettings, about
Example consumer: dev/tests/drive_dashboard_editor.py drives the whole dashboard editor (place, bind, drag, resize, options, save, edit, duplicate, delete, draft rescue, hot reload; ~40 assertions, ~3 min) under Xvfb (xvfb | visible | offscreen modes), writes screenshots to dev/screenshots/output/editor/, cleans up its test-dash*.json dashboards, and exits 1 on any failed assertion. dev/tests/drive_dashboard_features.py reuses its helpers for per-widget overrides, colour, enum options, drag-to-resize, share / import, swipe between dashboards and the no-vehicle Parameter Cards state (screenshots in dev/screenshots/output/features/). test_dtc.py / clear_dtc.py in the same folder are stand-alone hardware checks that talk to an adapter on /dev/rfcomm0 directly, without the app.
MCP Servers
dev/mcp_server.py (octave-perf) and dev/mcp_benchmark.py (octave-benchmark) are MCP servers (MCPServer on mcp 2.x, falling back to FastMCP on 1.x) that let a coding agent such as Claude Code drive OCTAVE through the command server. Both need the mcp Python package (pip install mcp) and the app running with --profile; the benchmark script only serves MCP when given --mcp (without it, it runs the suite once as a CLI), so .mcp.json passes that flag; every tool just sends a JSON action to 127.0.0.1:9876.
Setup
pip install mcp # for the MCP client scripts
build/octave --dev --profile # simulator; drop --dev for real OBD
python dev/mcp_server.py # stdio MCP server; normally launched by the agent from .mcp.json
The local .mcp.json is gitignored. Configure its server commands with the Python interpreter and script paths for your checkout; machine-specific paths copied from another workstation will fail. Pass --mcp to the benchmark server.
AI agent (Claude Code)
| stdio (MCP)
v
dev/mcp_server.py / dev/mcp_benchmark.py
| TCP 127.0.0.1:9876, JSON + newline
v
CommandServer (a thread per client) --onGui / postGui--> UiDriver / managers (GUI thread)
|
v
PerfMonitor (metrics, slot timings) CarSim + SimElm327Device (sim_*) grabWindow() (screenshot / record)
octave-perf tools
| Group | Tools |
|---|---|
| App / navigation | ping, list_actions, navigate(page), go_back, go_home, get_current_page, list_pages |
| Settings | get_all_settings, list_toggle_settings, list_numeric_settings, list_string_settings, toggle_setting(key), set_bool_setting, set_numeric_setting, set_string_setting |
| Media / OBD | next_track, previous_track, toggle_play, pause, toggle_mute, toggle_shuffle, set_volume, skip_tracks(count), get_media_status, get_track_info, obd_status |
| Performance | get_metrics, get_detailed_report, get_slot_timings, read_perf_log, read_perf_csv |
| Scripted tests | run_track_skip_test(count, delay_seconds), run_idle_baseline(duration_seconds), run_rapid_skip_test(count), run_navigation_test(settle_seconds), run_settings_toggle_test, run_slider_sweep_test(key, steps), run_settings_optimizer, apply_performance_profile, run_spam_test(total_skips, burst_size, burst_delay), run_chaos_test(duration_seconds), run_full_test_suite |
| Media production | record_start(path, fps), record_stop, record_status, sim(action, value, name, on), run_story(names, visible, media_folder), optimize_assets(inputs, out_dir, width) |
dev/docs/perf_test_plan.md is the agent-facing script for a full regression pass (idle baseline, navigation, single / 10 / 20-track skips, settings toggles, slider sweeps) with pass criteria per section; dev/docs/perf_optimize_prompt.md and dev/docs/PERFORMANCE_AUDIT.md record earlier optimisation rounds.
octave-benchmark tools
benchmark_page(page, settle_seconds, samples), benchmark_setting(key) (sweeps a setting across its range), benchmark_transition(transition, skip_count, ...), benchmark_media_ops, get_bottlenecks(min_p95_ms), run_benchmark_suite(include_stress) (five phases: baseline, settings matrix, stress, combined, bottlenecks), save_baseline and compare_to_baseline, which diffs a run against a saved JSON baseline and flags regressions.
Stand-alone perf scripts
dev/perf/run_perf_loop.py talks to the socket directly and loops the full test gamut, comparing iterations; dev/perf/brutalize.py maxes every visual setting and hammers the app until it degrades; dev/perf/profile_performance.py is a system-level sampler for the Orange Pi (--duration, --interval). Perf CSV/log output lands in dev/perf/logs/.
Performance Profiling
src/dev/perfmonitor.cpp
build/octave --profile # OCTAVE_DEV_TOOLS build
--profile writes dev/perf/logs/perf_<YYYYMMDD_HHMMSS>.csv and .log, registers the monitor as the perfMonitor context property and starts the command server. get_metrics is what the MCP tools and perf scripts read; numbers depend on the machine and build type, so compare a run against a baseline taken on the same machine and build.
Hot paths are timed with OCTAVE_PERF_SCOPE("label") from src/util/perfscope.h: compiled to nothing without OCTAVE_DEV_TOOLS, one relaxed atomic load when built but not profiling, safe from any thread. The current labels: media.next_track, media.previous_track, media._update_position, media._extract_colors, media._kmeans_colors, audio._analyze_audio (FFT), obd._monitor_connection, esp32._read_serial, settings.save_settings. Add a scope at the top of any function worth watching.
What is measured
| Metric | Method | Threshold |
|---|---|---|
| Event-loop latency | How late a 5 ms precise QTimer fires (a zero-interval timer would keep the loop spinning, cost a whole core and skew the CPU figure). p50/p95/p99/max per one-second window | avg < 5 ms green, < 16 ms yellow (WARN), otherwise red (STUTTER) |
Thread jitter (gil_avg_ms / gil_p99_ms columns) | A thread sleeps 10 ms and records how late it wakes, which measures scheduler / CPU contention | Higher = threads starved |
| Per-slot timing | OCTAVE_PERF_SCOPE | Slots with p95 > 2 ms are reported as slow (detailed report lists p95 > 1 ms) |
| CPU / RSS / threads | /proc/self on Linux, getrusage on macOS, process times / working set on Windows | CPU is per core (can exceed 100 %) |
Outputs
- Terminal: a colour-coded
[PERF]line refreshed every second; slow slots every 5 s. - CSV: columns
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. - Perf log: plain text with OK / WARN / STUTTER tags and slow-slot details.
- QML HUD:
perfMonitor.summaryText. - Command server:
get_metrics,get_detailed_report,get_slot_timings.
Manual instrumentation
#include "../util/perfscope.h"
void Foo::bar()
{
OCTAVE_PERF_SCOPE("foo.bar");
...
}
Screenshots & Video Stories
dev/screenshots/ (auto.py, stories.py, optimize.py; core.py / scenarios.py / cli.py are the deprecated xdotool tool kept for reference). Full documentation: dev/docs/SCREENSHOTS.md.
Every launcher (auto.py, stories.py, python -m dev.collage, dev/tests/drive_dashboard_editor.py, dev/shots_gap/) starts the app through dev/app_launch.py, which picks the binary ($OCTAVE_BIN, else build-desktop-<user>/octave, build/octave, then the newest build*/octave) and starts it with --dev --profile (--profile alone for production mode).
Menu sweep
python -m dev.screenshots # launch dev mode headless (Xvfb if installed), shoot every page, quit
python dev/screenshot_tool.py # identical wrapper
python -m dev.screenshots --visible # real window (full GPU; a tiling WM may resize it)
python -m dev.screenshots --no-launch # sweep an app you started yourself with --profile
python -m dev.screenshots --prod # production mode (no --dev), real OBD
Options: --size WxH (1280x720), --settle SECS (1.2), --dev-chrome, --keep, --port N (9876), --out DIR, and the mode flags --xvfb / --offscreen. Mode is auto-picked --visible > --offscreen > Xvfb (if xvfb-run exists) > offscreen. Output: dev/screenshots/output/*.png, manifest.json, GALLERY.md for all 11 top-level pages and 6 settings sections. --out must be an absolute path inside the checkout (the manifest stores repo-relative paths). The offscreen platform has no GL, so the ColorOverlay icons and MultiEffect album art render blank there — Xvfb (xorg-server-xvfb, llvmpipe) is the faithful headless path.
Video stories (README / demo clips)
python -m dev.screenshots.stories --list
python -m dev.screenshots.stories # all stories
python -m dev.screenshots.stories drive media --visible # Wayland is fine
python -m dev.screenshots.stories tour --media-folder ~/Music --size 1280x720 --fps 30
python -m dev.screenshots.stories --no-launch drive # app already up with --profile
Launches the dev app with its own settings profile (dev/screenshots/profile/, so demos never touch your real config), drives it over the command server (navigation, media, sim_*) and records with record_start / record_stop: a GUI-thread QTimer calls root.grabWindow() per frame and pipes raw RGBA into ffmpeg -c:v libx264 -crf 18 (keep fps ≤ 30). Frames are paced by wall clock: when rendering misses ticks (the 3D vehicle under software GL) the last frame is repeated, so clips play at real speed (repeated in the stop line). Grabs are scaled to the logical window size, so a device pixel ratio above 1 (e.g. QT_FONT_DPI=144 under X11) does not crop the video. Each story yields <story>.mp4, an 800 px animated .webp at 15 fps (the README asset), a palettegen .gif and the requested stills under dev/screenshots/output/stories/, plus manifest.json. Stories are step lists in stories.py (("nav", page), ("settings", section), ("wait", s), ("still", name), ("rec", name) / ("stop",), ("media", action), ("sim", action, args), ("click", objectName) (waits up to 3 s for it), ("click_text", label), ("long_press_text", label), ("long_press_current",) (the playing track’s row), ("type", field, text[, enter]) (one character at a time so it reads on video), ("tap", x, y), ("wait_item", name), ("pid", search, id) (editor PID picker), ("py", fn)) registered in STORIES: tour, drive, media, dashboards, settings, jeep, shiftlight, editor (builds, previews and saves a “Trail Rig” dashboard, deleted again afterwards), diagnostics (warning lights, then read / freeze-frame / clear codes), library (song action sheet, sort, playlists, music search; search needs network), sensors (simulated IMU, 3D vehicle, clock still) and mirror (needs a phone, runs only when named). --out may point outside the repo (the manifest then uses absolute paths).
python -m dev.screenshots.optimize <png|mp4> --width N --out frontend/assets/readme rescales (lanczos) and recompresses with ffmpeg, then pngquant if installed. Keep animated assets under ~3 MB. The same operations are the run_story, record_*, sim and optimize_assets MCP tools.
AI Usability Testers
dev/ux/ has AI agents use the app the way a person does, to find where the UI confuses or slows people down. Each tester is a persona (first_timer, driver, enthusiast, non_techie) given a goal in its own words (“mute the sound”, “find out if the car has trouble codes”). It sees only a screenshot with a numbered tag on every control list_interactive reports, and can only tap, long-press, swipe, scroll and type. navigate, set_prop and the settings actions are kept for the orchestrator, which uses them for setup and to check, from the app’s own state, whether the goal was really reached. Every run gets its own instance (--dev --sim --profile --command-port N under Xvfb, with a temp HOME / XDG_* and the throwaway sim config), so runs can go in parallel.
venv/bin/python dev/ux/run.py [-t tasks] [-p personas] [--parallel N] [--repeat N] [-m model]: runs the testers through a local vision model in Ollama (defaultqwen2.5vl:7b). Tasks, personas and success checks are in dev/ux/tasks.json. Each run writes per-step screenshots, asteps.jsonlwith what the tester saw, thought, did and complained about, the result, and an in-character debrief todev/ux/runs/<stamp>/.venv/bin/python dev/ux/audit.py: a rule-based sweep with no model. It visits every page and every settings card and flags tap targets under 56 px, overlapping tap areas, controls with no text or icon, and screens with more than 35 controls.venv/bin/python dev/ux/backlog.py: combines the latest run and audit intoevidence.md(the task scoreboard, friction by screen, dead-tap hotspots, debrief issues and audit hits), which triage turns into the ranked backlog.
The cycle is: run, collate, triage, fix one item, then re-run the same tasks to confirm that success went up or the number of taps went down. See dev/ux/README.md.
How to Add New Features
Mobile-only code lives behind #ifdef Q_OS_ANDROID / Q_OS_IOS.
- Manager: add
src/managers/foo.h/foo.cpp(aQObjectwithQ_PROPERTY,Q_INVOKABLE, signals; log through aQLoggingCategory), list both files in CMakeLists.txt'sqt_add_executable, add it as a member ofAppCore(src/appcore.h, in construction order), wire it in src/appcore.cpp andexpose("fooManager", &fooManager)there: that makes it a QML context property and a service object at once. Write files that change while driving withQSaveFile. - Settings: add the key with its default to
SettingsManager(defaults table +Q_PROPERTY+save_xxx), and to the settings registry if it should appear in the Quick Panel. - QML: build the view in frontend/; settings pages go under frontend/settings/. Gauges and dashboards follow docs/GAUGE_AUTHORING.md.
- Dev mode / tooling: if the feature depends on hardware, add a simulator in src/dev/ (wired in
devruntime.cpp); add command-server actions (registerAction) and MCP tools if an agent should be able to drive it; put anOCTAVE_PERF_SCOPEon any new hot path. - Verify:
cmake --build build -jandctest --test-dir build, then run it (./build/octave --dev --profile, screenshots / stories / dashboard drivers for UI work). Add a test under tests/cpp/ for logic that can run headless (below). - Wiki: update the manager page and Signals & Slots Reference, the Settings Reference for new keys, and rebuild the search index. Stale docs are treated as worse than missing docs.
Testing & Linting
qmllint pass over frontend/ on every push and PR to main. The suite covers a broken start-up path, the OBD stack against the simulator, the regressions from in-car fixes and a few load-bearing invariants; UI behaviour is still tested manually, with the UI-driving scripts and on hardware. TODO/future-tests.md records test ideas; compare it with the existing suite before treating an item as missing.
Test suite (QtTest, ctest)
tests/cpp/ is the test suite. It is built when OCTAVE_BUILD_TESTS is on (the default for -DCMAKE_BUILD_TYPE=Debug desktop builds) and runs headless on the offscreen platform:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
ctest --test-dir build --output-on-failure # or run one: build/tests/cpp/tst_obdsim
| Test | Covers |
|---|---|
tst_elm327protocol | DTC replies (pre-CAN, CAN, multi-frame, several ECUs), freeze-frame DTC, PID 0x54 offset, multi-value PIDs, multi-frame join, VIN parse (CAN and pre-CAN) and decode; every multi-value id has a settings default and a ParameterCatalog entry |
tst_obdmanager | The demand-driven poll-list rules (OBDManager::pidsToWatch(): union of consumers, IMU / unknown ids ignored, scan filter, RPM / ELM_VOLTAGE heartbeat, multi-value PIDs, the IGNITION_TIMING alias polled as its target and supported with it) and the Linux rfcomm bind behind connect_to_adapter(MAC) (non-blocking, failure reported, a newer connect wins; OCTAVE_RFCOMM_PROGRAM points it at a fake) |
tst_obdsim | Integration, needs OCTAVE_DEV_TOOLS: the real OBDManager against the simulated ELM327 — connect, VIN, vehicle scan, demand-driven polling of single and multi-value PIDs, MIL / read / clear DTCs, a lost link that reconnects through the passive scan (~15 s) |
tst_obdadapterfinder | Needs OCTAVE_DEV_TOOLS. Adapter-name and BLE-service heuristics, list order, the hand-off to OBDManager (named saved chip, outcome read from the status, connecting to the simulated adapter), pairing-failure messages, no Bluetooth objects without BlueZ (runs where bluetoothd is down, e.g. CI) |
tst_servicebridge | The service protocol over a real socket: hello with the protocol version and the registry's objects, the id echo, an unknown op, the legacy action form unchanged, the registry forgetting a deleted object; describe over a fixture (own members only, enum keys, notify, constant, overloads, signal parameter names); enum arguments by name or number; get / set / call on the fixture (an enum property set by key, read-only and constant refused) and on a QQmlPropertyMap (by key, all keys, writes refused); subscribe: property values at once and on notify, signal arguments, unsubscribe, throttling to the latest value, paramStore-style batches filtered by id, a subscription ending with its object, a signal from a worker thread, pipelined requests all answered; list models (summary, rows, ordered insert / update / remove events not coalesced by a throttle); QVariant and Qt JSON returns, properties and signal arguments as their contents; a subscribe reply before any of its events (pipelined); stream over a real socket (reply, streamConfig, 0xFF-framed records with the right header and SPS first, ended by closing); a killed connection releasing the demand consumers it still holds (not one it released, not one another connection took over); the limits per connection (a flood answered rateLimited with retryAfterMs while another connection is not, closed when it ignores that, a connect loop refused at the door, a reader that stays behind closed) |
tst_serviceoptions | When the service runs and where it listens: off by default, --service [PORT] / =PORT, the setting, --headless, --profile only with the dev tools, --service-bind, bad ports and addresses keeping it off, --web-root / the setting (made absolute, the flag over the setting, a directory without index.html refused) |
tst_staticfiles | What --web-root, GET /art and /export/... serve (attachments with sanitised names, size caps; an export folder's own files only) (images typed by their bytes, a non-image never served): content types, query and fragment stripped, a directory's index.html, the single-page-app fallback, a symlink inside; and what it refuses: .. plain and percent-encoded, backslashes, NUL, absolute URLs, dotfiles, file and directory symlinks out of the root, a dangling link |
tst_apidocs | docs/api/ matches the built app's --dump-api output (stale or orphaned files fail, with the regeneration command in the message), and the dump builds its managers in a throwaway config, never the real one. Skipped outside the Linux desktop build |
tst_conformance | The QML UI uses nothing a second frontend can't: every setContextProperty name in src/ is a service object (or on its short, reasoned list of QML-only values: scale factor, build flags, the dev tools), every object.member in frontend/ and the dev overlay names a published property, method or signal, and every function onX() in a Connections { target: object } a published signal. Reads docs/api/, which tst_apidocs keeps equal to the build |
tst_websocket | The service's WebSocket side: the handshake (RFC 6455's example key), the Origin rule (own page, localhost, IPv6, LAN address allowed; another site, another port, null, a DNS-rebound name refused), split / fragmented / oversize / unmasked frames, server frame encoding, a live client round trip with ping / pong / close, the page at GET /, 404 and 403, and the line protocol unchanged on the same port; a web root served over a real socket (its CSP, nosniff / no-store, a same-origin module fetch passing and another site's refused, traversal, a hostile Host header kept out of the CSP, the upgrade still working); routes (/art?rev=N, exact paths only) and the route handler reading its own query; Sec-Fetch-Site cross-site / same-site loads refused, same-origin and the address bar served; the limits per connection over WebSocket frames (rateLimited, the connect bucket refusing upgrades, a WebSocket reader that stays behind closed while another keeps its replies) |
wiring | AppCore's cross-manager wiring on the real binary (tests/wiring/check_wiring.py, unittest over the Python SDK): octave --headless --sim on a free port with a throwaway config; volume reaches mediaManager on the quadratic curve and is saved, mute reaches it and a raise unmutes, RPM reaches shiftLightManager, readings reach paramStore and start a black-box trip, the gauge speed unit reaches parameterCatalog.displayParameters and toDisplay, a track change is saved as the last played song and a playing track lights audioAnalyzer.levels. The hardware inputs (gesture sensor, ESP32 knob) are not covered: see TODO/future-tests.md |
sdk_python | The Python SDK (sdk/python, unittest) against a real octave --headless on a free port with a throwaway config: hello, describe, get / call with the matching property event, paramStore and the catalog, list-model rows, errors as OctaveError, requests from eight threads. Also test_protocol_spec.py: docs/api/protocol.json against the same app (the spec's ops are the server's, replies / errors / events validate strictly). Registered when CMake finds Python 3 (stdlib only, no venv), absent otherwise |
sdk_go | The Go SDK (sdk/go, go test ./...): its generated protocol_gen.go / objects_gen.go match docs/api/, the Deprecated: / Experimental: paragraphs come through from a fixture API, and the client against a real octave --headless (hello and the version, the server's ops against the generated list, a typed object's call / get / set, errors, a subscription's events, a stream reply, a media ticket), and fake_test.go, the same client against tools/api/fake_server.py (pairing with scopes, forbidden, rate limiting, an untrusted start). Registered when CMake finds go |
template_js, template_python, template_go | The starter templates (templates/), each against a real octave --headless --sim on a free port: pairing with the media scope (the JS one), readings via setParameterDemand and paramStore, play / pause, the cover route. Registered when CMake finds node, Python 3, go respectively |
api_compat | tools/api/check_compat.py's rules (unittest, no app): removals, type, access, notify, enum, return and argument changes break; additions, appended signal arguments, a return value where there was none, experimental members and deprecations don't; the changelog section lookup; and tools/api/tests/test_fake_server.py, the Python client against the fake service (tools/api/fake_server.py). Registered with sdk_python |
service_load_check | tools/stream/service_load.py --check (about 15 s): a real octave --headless --sim on a free port, idle and then 20 subscribers on every PID with a call flood; fails if a subscriber drops events or its connection, the flooder is not limited, or the OBD poll rate moves more than 20 % from idle (Many clients). Registered with sdk_python |
tst_settingsmanager | apply_settings through the save slots (registry and derived names, strict types, int rounding, refusals with reasons, file-only keys), the four Now Playing presets fully applicable and applied, the service keys file-only and kept by a reset; A truncated settings file is restored from .bak (and kept as .corrupt); a failed write leaves the file intact; every value set through the setters survives a restart (the live volume deliberately doesn't); a file from an older build keeps its valid values, gets defaults for missing or wrongly typed keys, and keeps unknown keys |
tst_audioanalyzer | Bars run while any consumer (setDemand, the view's setViewActive) wants them and stop with the last; The waveform analyzer drives itself: live phone frames only while a view is shown and something plays, hiding flattens the bars, a local track is decoded only once a view is up and after the page's own track-change hold, local bars follow the position reports (a generated WAV), the phone source skips local analysis |
tst_dashboardmanager | The widget catalog as data (every field, palette order, each type with a QML url and the reverse, a missing file empty); a known widget's editable props held to their kind (bool, int rounded and clamped, real clamped, enum option, string), other props and unknown types untouched. One set of spec rules on import, save and load (keys, off-grid cells moved in, overlaps shrunk then dropped, unknown types and PIDs kept, file-safe ids, newer schema passed through), the shipped presets already normal, refusal reasons, export / import round trip (export redirected to a temp dir), import errors, grid placement (canPlace, fitAt) |
tst_triprecorder | The black box: TripStore summary maths (distance, peaks, per-parameter first / last), CSV + JSON files, list / detail / downsampled series, export, delete, recovery of a trip left open, size pruning oldest-first that spares the live trip; TripRecorder with a fake clock: trip starts on RPM > 0 and ends on engine-off / connection-lost timeouts, unchanged values coalesced to the heartbeat, a short dropout not splitting a trip, the live trip's elapsed seconds and its throttled liveTripChanged, shutdown writing the open trip, the enabled setting, and the background poll-list rules (OBDManager::backgroundPidsToWatch()); health across drives: the per-trip numbers from a synthetic cold-start drive (warm-up, running coolant, idle / charging voltage, warm idle, trims, cold readings left out), a thermostat drift over eight drives flagged against the usual range and the earlier drives, the day window, the per-parameter trend, filling in health for a summary written before it existed, and the recorder answering from its thread |
tst_mediamanager | A real MediaManager on a scanned temp library: a deleted track skips to the next playable one, all-missing stops cleanly, shuffle survives a re-select with the current song first, a zero-byte cached cover is never served; tag sanitizing (control characters, whitespace, NFD, emoji and joined emoji, lone surrogates, the length cap never splitting an emoji) |
tst_volumecontroller | applyVolume() clamps to 0–100, stores the percent, emits volumeApplied once with the quadratic-curve value; toLinear() / toLinearStatic() follow (percent/100)^2 |
tst_nowplaying | NowPlaying over the real MediaManager and PhoneMirrorManager: starts local, the phone taking and losing the controls, one phone-position clock only while playing, trackChanged once per track, local reports ignored while the phone is the source, shuffle local-only, a local track handing the controls back |
tst_parameterstore | The C++ value store: writes coalesced into one fold (last value wins), an alias written with its target, snapshot(), QML bindings on paramStore.RPM re-evaluating only for RPM (not for other ids), unknown ids undefined, QML writes rejected |
tst_parametercatalog | The C++ parameter catalog: 129 unique ids with valid kinds and ranges, its OBD ids exactly the ids ELM327Protocol decodes (both directions), the QML OBDParameterModel serving it (ids in order, short titles for home cards, decimals, sensor ids), and every id listed in the generated table in docs/GAUGE_AUTHORING.md §6 |
tst_sensorsettings | The IMU and gesture managers follow their settings with no page involved: saved imuEnabled / sensorEmitRate / gestureSensorEnabled / gestureCooldown are applied when the settings manager is attached and on every change, and setEnabled() no longer writes the setting (Linux) |
tst_esp32readbuffer | Knob lines split across reads, an overlong partial line dropped with one warning and resynced, a line at the limit kept (through ESP32VolumeManager::feedSerialData()) |
tst_esp32volumemanager | Port selection (Linux, over a pty): with no port saved only an ESP32-S3 is auto-selected; a hand-picked port that isn't an ESP32-S3 gets no writes and stays "Waiting" until a recognised knob command arrives, which counts as a tick and switches it to "Connected". |
tst_adaptivebitrate | Phone-mirror adaptive bit rate on simulated packet streams: climb on a clear link, warm-up, congestion cut and hold, floor, still screen, key-frame spike, clock jump |
tst_scrcpyclient | The phone-mirror client without a phone: device messages and the media command over a socket pair; stopping mid-start leaves no adb forward or server shell; a C++ fake scrcpy 3.x server (handshake, frames from tests/fixtures/h264/, mid-stream resize, corrupt packet, garbage header, early exits); reattaching to a persisted server (no push, no new server, follows its logcat) and giving up on a gone one within the 4 s deadline |
tst_airplay | Registered only when OCTAVE_HAVE_AIRPLAY is on (libplist + OpenSSL + libavcodec, Linux). The AirPlay receiver without an iPhone: DMAP track metadata (and truncated or foreign blocks), the AirPlay volume to a slider fraction, the pointer path (counts across each side from the aspect, homing into the corner, small steps that add up, the remainder carried over many tiny drag moves), the touch calibration recovering a known scale, the manager off until enabled, and a real receiver started in a temp config answering an RTSP GET /info on loopback with its name, then restarting on the stored key |
tst_bluezhidmouse | Linux with Qt DBus. The Bluetooth LE mouse: its HID report descriptor parsed item by item (Generic Desktop / Mouse, a 5-byte input report with wheel and AC Pan, no report ID), report encoding and clamping, and start() failing cleanly with no system bus (a child run). With OCTAVE_TEST_BT_LIVE=1 it also registers with the real bluetoothd, opens and closes the pairing window and checks the adapter is left as it was |
tst_androidauto | Registered only when OCTAVE_HAVE_NATIVE_AA is on (OpenSSL + FFmpeg). The built-in Android Auto receiver's protocol core with no device and no Google certificate: protobuf wire format (round trips, truncation, zigzag), framing (bulk frames, fragments reassembled across interleaved channels, a fragment without its first refused), service discovery announcing every channel Android Auto requires, an unsupported resolution and a bad certificate refused at start; then a whole session against a fake phone, an OpenSSL TLS server over memory BIOs with throwaway self-signed certificates generated at test start: version, TLS, auth, discovery, a video channel open, codec config and a fragmented frame with their acks, media audio, sensor values, audio focus, ping, a touch as an input report, and a polite bye-bye; also a certificate and key in one PEM, the phone ending the session, and a silent phone timing out |
tst_qmlmodules | Optional QML modules found by their qmldir in the import paths (main.cpp's hasQuick3D) |
tst_phonemirror | PhoneMirrorManager: every error message's lastErrorKind; sessionState and recovery over the fake adb (no phone waits and restarts when it appears, no probes while the page is hidden, a drop reconnects quietly then gives up after the grace, setup errors never retry); adb devices parsing, state summary, serial choice, server-log classification, software-encoder fallback, Wi-Fi serials / messages, QR pairing, phone media follow / hand-back, notices, and the async probe / start flow (never blocks, coalesces, stop wins, cable-pull reattach, another phone in the persist window starts fresh, no second session) |
tst_errortoast | ErrorToast.qml: one message at a time, a queue, repeats of the showing or last-queued message dropped, auto-dismiss in order, tap to dismiss (in a real window) |
tst_triphistoryview | TripHistory.qml's Trends view against a fake tripRecorder: opening it asks for the trends and picks the first flagged number, a metric card selects it, the window chips ask again (a late answer for another window is ignored), a parameter chip asks for that parameter (and again on a window change), a tap on a chart point opens that drive, and a window without drives shows no cards |
tst_phonemirrorview | PhoneMirrorView.qml against a fake manager: it reports setViewActive, shows the QR screen and hint for "noPhone", goes back to "Connecting" when the manager restarts, holds the last frame while "reconnecting" and shows the error page when the manager gives up, the two-phone picker, and touches that start in the letterbox never reaching the phone (in a real window with a portrait frame) |
tst_fontcatalog | The bundled fonts register in C++ (FontCatalog): the picker's two fonts by display name, the symbol font apart, a missing folder empty |
tst_folderbrowser | mediaManager.browse_music_folders's rules: the roots (missing dropped, duplicates merged); folders and audio files by name, no dotfiles, a parent up to the root (and from a root nested in another); refused outside the roots directly, through .., a symlink out (neither listed nor followed), /, /etc, a file |
tst_pathurl | Picker results as paths: desktop with escapes, a Windows drive letter, Android primary storage / subfolder / root / SD card / a tree with a document part, other providers and web URLs refused; file URLs for QFile |
tst_clock | clock.time: a read-only property notified by timeChanged (argument kept), set from construction, 24 h / 12 h and seconds formats, no notify for the same text, "" with the clock off |
tst_streamhub | Media streams: the H.264 helpers on tests/data/stream-test.h264 (120 access units, 4 keyframes, SPS sizes 640x360 then 1280x720, codec string); against real connection queues, a joiner starting at a keyframe with SPS / PPS first, streamConfig on a change, a client that falls behind dropped to the next keyframe, keyframe requests rate-limited across 20 joiners, a closed connection forgotten |
tst_themecatalog | The theme table from frontend/themes.json: 17 palettes with all ten keys and valid colours, the light / dark lists naming real themes; activeTheme following themeSetting, the Album Art placeholder then the extracted colours, a custom theme over a built-in, the fallback for an unknown name |
tst_units | Display rules: durations ("m:ss", "h:mm:ss" from an hour, bad input), the compass point at the 22.5° boundaries and 360, the unit conversions |
tst_vehiclegate | The real OBDParameterModel and DashboardRenderer against a fake obdManager: placeholders before / after a scan and after a disconnect; an alias in the scan result is supported but gets no Parameter Card; a value pushed through a real ParameterStore re-evaluates only the liveValue() bindings of its own PID, and leaving demo mode brings the real readings back |
tst_qmlfixtures | tests/fixtures/*.qml, each in its own qmlfixturehost process with the C++ managers: the 3D music device scenes and their saved preference, and the offline wiki viewer (with and without Qt WebEngine) |
tst_musicdeviceassets | Budgets for the generated music device GLBs (triangles, 16-bit indices, unused materials / textures / images, one decal atlas), the light probe and masks |
tst_boot | The built octave boots with --smoke and exits 0; with the dev tooling, --dev --profile boots and answers ping on its command server; --service answers hello and lists none of the dev-only actions; --headless --smoke answers hello with headless: true and a get, never loading Main.qml; with the dev tooling, --headless --dev lists the sim_* actions (and no UI ones), connects obdManager to the simulated adapter and takes sim_throttle; with none of them, nothing listens and the log says Service off; --help / -h / --version exit 0 within a second and create nothing in the config dir; every case picks its port from the OS |
Each test compiles the sources it needs (octave_add_test() in tests/cpp/CMakeLists.txt, with OCTAVE_SOURCE_DIR defined for repo files) and gets its own temp HOME / XDG_CONFIG_HOME when it touches settings or exports, so a run never writes the real config. Phone-mirror tests use tests/cpp/fake-adb.sh as adb (behaviour set through FAKE_ADB_* variables, documented in the script). A few tests reach private members through a friend class Tst… declaration in the manager's header. tst_obdsim and tst_obdadapterfinder link the simulator from src/sim/, in every build; tst_boot skips its --dev --profile and --headless --dev cases without it.
Manual testing workflow
- Dev mode:
./build/octave --devfor rapid iteration with the simulator and keyboard controls;--profileto script it. - Profile mode:
./build/octave --profileand watch loop latency / thread jitter; run dev/docs/perf_test_plan.md through the MCP server before and after a change that touches a hot path. - UI-driving scripts: dev/tests/drive_dashboard_editor.py for the editor; write new ones against the
find_item/click/dragactions. - Logs:
--debugfor verbose logs (octave-cpp-debug.log). - Hardware: the Orange Pi 5 Plus head unit in the Jeep is the reference for OBD, IMU, gesture, ESP32 and phone-mirror changes (the aarch64 AppImage exists for it); the Galaxy Tab S6 / S22 Ultra are the Android targets named in TODO/android-cpp-port.md.
- Before tagging:
cmake --build build -jandctest --test-dir build; CI then builds every platform on av*tag.
Continuous Integration
.github/workflows/build.yml has always-on checks and a tag-gated release matrix (full detail on the Building page):
lint—ruff check .over the Python tools in scripts/ and packaging/, andscripts/gen_gauge_param_docs.pyfollowed bygit diff --exit-code docs/GAUGE_AUTHORING.md, so the generated parameter table cannot fall behind the catalog; andtools/api/check_compat.py, which compares docs/api/ with the PR's base (or the commit before a push) and fails when a stable member, a parameter id or its unit is removed or changed without akProtocolVersionbump and a docs/api/CHANGELOG.md entry naming it (rules: docs/api/README.md, Versioning).cpp-compile-check— Debug CMake/Ninja build against Qt 6.7.3, thectestsuite (above), thenqmllintover frontend/ failing only oncriticalentries, so a C++ or QML break cannot sit onmainuntil the next tag.phone-server— rebuilds the phone-server jar from source (JDK 21) and checks the committed jar carries the same class and version string; hash mismatch is a warning.sdk—npm testfor sdk/typescript: its generated types must match docs/api/, the facade type-checks, and the client passes its tests against a fake service.cpp-build-windows/cpp-build-macos/cpp-build-linux/cpp-build-linux-arm64/cpp-build-android— Release builds and installers, only onv*tags or manual dispatch, and only afterlintandcpp-compile-checkpass.release— attaches the five installers to a single GitHub Release on a version tag, published as a pre-release until the published files pass clean-VM testing (see Building).
.github/workflows/wiki-pages.yml publishes the OCTAVE project site to GitHub Pages on every push to main that touches src/, frontend/, CMakeLists.txt, packaging/wasm/ or wiki/ (or by manual dispatch). The project site takes the custom domain of the RobDeGeorge.github.io user site, so the site root is robdegeorge.com/OCTAVE/: the app itself, built for the browser with Qt for WebAssembly by packaging/wasm/build.sh (Qt 6.12 wasm_multithread + emsdk 5.0.5, both cached between runs) and hosted by packaging/wasm/index.html; the build-wasm job uploads it and the deploy job assembles the site. The wiki lives under robdegeorge.com/OCTAVE/wiki/, which is the address linked from the README. The workflow copies wiki/ into _site/wiki/ without the index builder scripts (rsync --exclude '*.py') and generates a redirect stub /OCTAVE/<page>.html for every wiki page, so the old top-level wiki links still resolve. Internal wiki links are relative, so the wiki works under that subpath unchanged; the in-app reader files (embedded.js / embedded.css) are published too but never loaded by the website. The lowercase robdegeorge.com/octave/ and /octave/wiki/ redirect from the website repo. Run python wiki/build_search_index.py before pushing wiki edits so the published search is current.
To test the browser build locally, build it (Building) and serve the staged folder with any static server that sends the COOP/COEP headers threaded WebAssembly needs (a plain python -m http.server does not): packaging/wasm/README.md carries a one-line Python server (the headers are Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp). The published site gets the headers from packaging/wasm/coi-serviceworker.js instead.
Deferred Work: the TODO Folder
TODO/ holds one markdown file per intentionally deferred plan, written so a future session can pick it up cold: a Status line, a Last updated date, why it is parked, file paths and line numbers, prerequisites, a recommended order, and "delete this file when done". These are planning notes, not the source of truth for implemented behavior; verify them against the source before acting. TODO/README.md is the index: every plan with its one-line goal, effort (S / M / L / XL) and the device it needs, ordered from quick wins to new subsystems. It was last audited against the code on 2026-10-18, when finished and obsolete plans were deleted.