Components
Reusable top-level QML components: AlbumArtCarousel, CoverflowCard, ThemedIcon, TerminalFeedback, CornerBrackets, SensorCard, GForceDisplay, HomeOBDView, ErrorToast, OBDAdapterSheet, the OBDParameterModel singleton and the rigged Jeep TJ
Scope and Conventions
This page covers the reusable QML types that live at the top of frontend/. The settings component library (settings/: cards, tiles, toggles, sliders, chips, hub widgets) is documented in Settings UI; the gauge primitives (gauges/) and the dashboard widgets (dashboards/widgets/) in Gauges & Dashboards; the design-token singletons (Style, Spacing, EnvironmentTheme) in Theme System. Full views are on the Pages page.
Because frontend/ is on the QML import path, every file here is usable by its type name without an import. Files that import "." as App reach the singletons as App.Style / App.Spacing / App.EnvironmentTheme. Almost every component carries the same two-line local dp() / dpMin() wrapper (see GUI Scale for why).
| Component | Root type | Instantiated by |
|---|---|---|
AlbumArtCarousel | Item | MediaRoom.qml (and, through it, the Now Playing Studio preview) |
CoverflowCard | Item | AlbumArtCarousel (three instances) |
ThemedIcon | Item | settings tiles and cards, hub widgets |
TerminalFeedback | Rectangle | settings/MediaSettingsPage.qml (library-scan output); OBDDiagnostics.qml has its own inline terminal instead |
CornerBrackets | Item | TerminalFeedback, SettingsCard, SettingsTile, SettingsDashboardCard, SettingsHubCard, SettingsHubGridCard (always hidden, see below) |
SensorCard, GForceDisplay | Rectangle | SensorMenu.qml |
HomeOBDView | Item | MainMenu.qml |
ErrorToast | Item | Main.qml (once, last child of the window) |
OBDAdapterSheet | Popup | OBDConnectButton.qml, settings/OBDSettingsPage.qml (Adapters card) |
OBDConnectButton | Rectangle | OBDHome.qml and OBDMenu.qml (the "No vehicle connected" prompt) |
OBDParameterModel | QtObject singleton | every OBD view, the gauges, the dashboard editor, Main.qml's IMU gating |
JeepVehicle, JeepHinge, JeepLamp, JeepLink | Quick3D Node | CarMenu.qml |
AlbumArtCarousel
frontend/AlbumArtCarousel.qml is the album-art display at the centre of MediaRoom. It owns three CoverflowCards (previous, centre, next), a frozen "exit snapshot" of the outgoing art, the dissolve shader, and ten selectable track-change animations. The header comment states the design: one unified transition list — transitionStyle picks any of the ten names, and each animation reads previewEnabled to decide whether the side cards take part (3D-flavoured animations animate them when the preview is on and no-op that part when it is off; 2D-flavoured animations never touch them).
Public API
// Inputs
property int direction: 1 // +1 next, -1 previous
property url artSource: ""
property url prevArtSource: ""
property url nextArtSource: ""
property string transitionStyle: "Crossfade"
property bool previewEnabled: false // show 3D side cards
property bool roundedArt: false
property real artRadius: 0
property bool vinylMode: false
property real sideCardAngle: 30
property real sideCardOpacity: 0.4
property bool showShadow: false
property real playbackPosition: 0 // vinyl rotation
property real playbackDuration: 0
// Outputs
property bool animBusy: false
readonly property bool dissolveActive
readonly property real dissolveProgress
readonly property url dissolveNewArt
property real artSize // min(w,h) * (previewEnabled ? 0.75 : 0.85)
signal animationFinished()
signal settled() // 50 ms after animationFinished, animBusy is false again
signal artClicked()
signal artLoadFailed()
function triggerSlide() // dispatch on transitionStyle
function showSideCards() // re-bind side-card opacity after a 3D animation
MediaRoom binds the inputs straight to settings: show3DAlbumPreview, roundedAlbumArt, albumArtCornerRadius (through dp()), vinylRecordMode, sideCardAngle, sideCardOpacity, showAlbumArtShadow and albumArtTransition. There is no separate "3D style" setting; the same albumArtTransition string selects a 2D or 3D animation.
Transition styles and constants
| Style | Flavour | Constants (top of file) | Notes |
|---|---|---|---|
"Crossfade" (default and fallback) | 2D | kCrossfadeDuration 250, kCrossfadeScale 0.97 | Old art fades out while the new art scales up from 0.97 |
"Slide" | 2D | kSlideDuration 280, kSlideDistance 80 (passed through dp()) | Direction-aware horizontal slide |
"Vinyl Lift" | 2D | kLiftDuration 180, kLiftPause 60, kLiftScale 0.85 | Sequential: lift out, pause, drop in with OutBack overshoot 1.2 — 420 ms in total |
"Dissolve" | 2D | kDissolveDuration 900, kDissolveGridSize 20, kDissolveSmoothness 0.1 | GPU shader, see below |
"Flip" | 2D | kFlipDuration 350 (half per side) | Y-axis flip: exit snapshot turns away, new art turns in |
"Coverflow", "Conveyor", "Stack", "Depth", "Swing" | 3D | k3dDuration 350, k3dSideOffsetFactor 0.42, k3dSideScale 0.72, k3dFarScale 0.5 | Side cards move into the centre slot; a few per-style numbers are inline (Coverflow fast-fade 220 ms, Swing ±70° exit / ±60° side, Depth 0.5 exit / 0.4 far scale) |
Every animation sets animBusy = true on start and calls animationFinished() on finish; a 50 ms settleTimer then clears animBusy and emits settled(). MediaRoom does its heavy work (FFT analysis of the new file) only on settled, so it never competes with animation frames. A _anyAnimRunning property ORs the ten animation running flags and gates the live-ness of the exit-snapshot texture and the drop-shadow layer.
Structure
- Shared masks — two hidden
Rectangles withlayer.enabled: true:artRoundedMask(radius =artRadiuswhenroundedArt) andvinylMaskRect(a circle with a black centre hole at 12 % of the width). They are handed to every card and to the dissolve effect asOpacityMask.maskSourceso the GPU holds one mask texture, not one per card. - Side cards —
prevCard/nextCard, visible only whenpreviewEnabledand a source is set, atartSize * 0.9, withimage.layer.live: !animBusy. - Exit snapshot — an
Itemholding aShaderEffectSourceofcenterCard.image(live: !_anyAnimRunning,hideSource: false) with its own translate/rotation. Animations move this frozen copy of the outgoing art while the centre card already shows the new one. - Centre card — carries
showShadow/shadowLive, the vinyl angle, emitsartLoadFailed()onImage.Error(MediaRoom then substitutes assets/missing_art.png) and hosts the full-sizeMouseAreathat emitsartClicked(). - Vinyl rotation — a
Bindingactive only invinylModemapsplaybackPosition / playbackDurationto 0–360° (one revolution per track), smoothed by a 200 ms linearBehaviorthat is disabled while animating; the angle resets to 0 wheneverartSourcechanges. - Animation types — Crossfade and the animated stages of Vinyl Lift use render-thread
Animatortypes. Slide, Flip, Dissolve and the coverflow-style transitions also useNumberAnimation; the render-thread claim does not apply to every transition.
The Dissolve shader
The file comment reads: "GPU shader replaces the old 400-item grid. A single ShaderEffect with one draw call dissolves the new art in over the old art (visible via centerCard underneath)." The ShaderEffect takes the hidden dissolveNewArtImage as source plus progress, gridSize and smoothness uniforms, is clipped by the same shared mask through OpacityMask, and points at a pre-compiled bundle:
// On Android, QML is bundled under assets:/ which Qt RHI's ShaderEffect
// rejects (only qrc:/ or local files). Embed the .qsb via qrc for Android.
fragmentShader: Qt.platform.os === "android"
? "qrc:/shaders/dissolve_squares.frag.qsb"
: "shaders/dissolve_squares.frag.qsb"
What the shader does and how the .qsb is produced is covered in Frontend Overview → Shaders. The three dissolve* readonly properties let MediaRoom keep its blurred background in step with the dissolve.
CoverflowCard
frontend/CoverflowCard.qml is "one album art image with 3D perspective transform, rounded-corner or vinyl masking, and animated coverflow properties". The carousel animates the four cf* properties; the card itself contains no animation.
// Animated by the carousel
property real cfOffset: 0 // Translate.x
property real cfAngle: 0 // Rotation about the Y axis, origin = centre
property real cfScale: 1.0 // bound to scale
property real cfOpacity: 1.0 // bound to opacity
// Image
property url artSource: ""
property real artSize: width // sourceSize = 2 * artSize (supersampled)
property bool asynchronous: true
property bool animating: false // smooth: !animating
// Masking (shared textures passed in by the carousel)
property bool roundedArt: false
property bool vinylMode: false
property real artRadius: 0
property Item roundedMask: null
property Item vinylMask: null
property real vinylAngle: 0 // image.rotation in vinyl mode
// Shadow (centre card only)
property bool showShadow: false
property bool shadowLive: true
readonly property alias image: artImage
readonly property alias imageStatus: artImage.status
transform: [
Translate { x: root.cfOffset },
Rotation { axis { x: 0; y: 1; z: 0 }; angle: root.cfAngle
origin.x: root.width / 2; origin.y: root.height / 2 }
]
Two layers are involved. The Image's own layer is enabled only when roundedArt || vinylMode and applies OpacityMask with whichever shared mask applies. The card's root layer is enabled only when showShadow, with layer.live: shadowLive, and its effect is a DropShadow { transparentBorder: true; horizontalOffset: 8; verticalOffset: 8; radius: 16; samples: 33; color: "#E0000000" } — the single user-toggleable drop shadow in the app.
roundedMask / vinylMask says it directly: "External mask items — shared across all cards to avoid duplicating GPU textures." Three cards plus the dissolve effect would otherwise allocate four identical mask textures per resize.
ThemedIcon
frontend/ThemedIcon.qml is a theme-tinted SVG icon. The header comment explains its purpose: it "wraps the Image + ColorOverlay pair that the rest of the app hand-rolls at every call site (BottomBar.qml alone repeats it ~20 times)", and the assets in frontend/assets/ "are authored as flat white fills precisely so they can be recoloured this way". (BottomBar.qml has since folded its copies into one inline TintedIcon.)
property url source: "" // relative to the *importing* file; subdirectories pass "../assets/…"
property color color: "#FFFFFF" // tint for every non-transparent pixel
property bool shadow: true
property color shadowColor: Qt.rgba(0, 0, 0, 0.40)
property real shadowOffsetX: 1
property real shadowOffsetY: 2
property real fillRatio: 1.0 // 0.7 matches the bottom-bar buttons
implicitWidth: 24; implicitHeight: 24
Internally: a hidden Image (sourceSize = 2× the rendered size — "2x supersample keeps SVG edges crisp when the tile scales up" — smooth, antialiasing, mipmap) feeds two ColorOverlays from Qt5Compat.GraphicalEffects: one offset by the shadow vector and tinted shadowColor, one tinted color. The comment stresses that the shadow "is a second ColorOverlay, not a blur, so there is no per-frame cost"; it reproduces the offset-glyph trick the settings tiles used when their icons were Unicode characters. See Theme System → ThemedIcon for how the tint follows the active theme.
Music Device Components
MusicDevicePreference is the singleton behind the Now Playing Music Player Object picker. It persists musicDeviceModel through the generic settings API. Choices are Album art (default), Record player, MP3 player, CD player, Cassette player and iPod. It probes MusicDeviceScene.qml on demand and caches whether Quick3D can load; Album art does not require that module.
MusicDeviceScene renders the selected device and accepts device, playing, artSource, trackTitle, artist, playbackPosition, playbackDuration and hasTrack. Position and duration are milliseconds. Its loading animation exchanges physical media on track changes, while playback progress drives the displays, record-player arm and cassette tape packs. MediaRoom hosts the scene in a Loader and keeps it loaded between visits while that mode remains selected.
MusicDeviceCover loads artwork asynchronously with a 512×512 decode target and falls back to assets/missing_art.png for an empty or failed source. MusicDeviceArtSurface places that cover on a Quick3D rectangle, with optional static alpha masks for record and CD labels. Models, masks and the studio probe live in assets/music_devices/; assetStore resolves assets that need real filesystem paths.
TerminalFeedback
frontend/TerminalFeedback.qml is a "reusable terminal-style feedback component for displaying backend progress" — a dark Rectangle with a header bar, a touch-friendly Flickable log and a custom scroll indicator. Its one consumer is "Library Scan Terminal Output" in settings/MediaSettingsPage.qml, fed by mediaManager.scanProgress. OBDDiagnostics does not use it — that page carries an older inline QtObject terminal with the same appendLine() / clear() API and prefix colouring, plus a blinking cursor and a scanline canvas.
property int maxLines: 50
property bool autoScroll: true
property string title: "Output"
property bool showHeader: true
property color terminalBackground: "#1a1a1a"
property color terminalBorder: "#333333"
property color textColor: "#00ff00" // classic terminal green
property color errorColor: "#ff4444"
property color infoColor: "#4a9eff"
property color successColor: "#44ff44"
property color warningColor: "#ffaa00"
property real fontSize: App.Spacing.overallText * 0.85
implicitWidth: dp(300); implicitHeight: dp(150)
function appendLine(text) // colour from [PREFIX], timestamp prepended
function appendLineWithColor(text, color)
function clear()
appendLine() picks the colour from the line's prefix — [ERROR] → error; [DONE], [SUCCESS], [FOUND], [OK] → success; [INFO], [SCAN], [CLEAR] → info; [WARN], [WARNING] → warning; [PATH] → grey — prepends a hh:mm:ss timestamp, and replaces the lines array (trimmed to maxLines) so the Repeater rebinds. Lines render in "Consolas, Monaco, monospace" with lineHeight: 1.3 and wrap anywhere. When autoScroll is on, a 150 ms OutQuad animation on contentY follows new content. The header holds the title and a "Clear" button; the Flickable uses pressDelay: 0, flickDeceleration: 1500 and maximumFlickVelocity: 4000 for touch, and a 4 dp scroll indicator appears only when the content overflows (opacity 0.8 while moving, 0.3 at rest).
Dormant environment styling
The component reads several App.EnvironmentTheme.active.* flags — terminalCornerBrackets (a CornerBrackets child), terminalScanlines (a Canvas that strokes a white line every 3 px at 4 % opacity), terminalAccentBorder (accent border at 40 % and a 1 px top highlight at 25 %), terminalHeaderAccent ("// TITLE" header, a pulsing status dot, "CLR" button) and terminalAccentScroll (a 2 dp accent scrollbar). Since commit 484bc76 EnvironmentTheme exposes one fixed active object in which every one of these flags is false and terminalRadius is 4, so the terminal always renders in its plain form: #333333 border, #2d2d2d header, grey "Clear" button, grey scrollbar. The branches remain in the file so the bindings keep working if environments are ever revived (see Theme System → EnvironmentTheme).
App.TerminalFeedback {
id: scanTerminal
title: "Library Scan Terminal Output"
Layout.fillWidth: true
}
scanTerminal.appendLine("[SCAN] Scanning ~/Music ...")
scanTerminal.appendLine("[DONE] 412 files indexed")
CornerBrackets
frontend/CornerBrackets.qml draws eight 1 px Rectangles — a horizontal and a vertical arm of length bracketLength = dp(12) at each corner, offset by −1 so they sit on the parent's border — tinted with App.Style.accent at an alpha that pulses 0.5 → 0.8 → 0.3 in two 2000 ms InOutSine legs, looping forever. It anchors itself to fill the parent; the caller controls visible, and the SequentialAnimation has running: brackets.visible, so a hidden instance costs nothing per frame.
It is instantiated in six places (TerminalFeedback, SettingsCard, SettingsTile, SettingsDashboardCard, SettingsHubCard, SettingsHubGridCard), every one bound to App.EnvironmentTheme.active.cornerBrackets or .terminalCornerBrackets. Both flags are permanently false in the single remaining environment (the file's own comment still calls it the "spacecraft theme" overlay), so the brackets are never shown in a shipped build. The component is retained because the ~40 EnvironmentTheme.active.X bindings were deliberately left intact when the presets were removed.
SensorCard
frontend/SensorCard.qml is the generic reading card in SensorMenu: a headerBackgroundColor Rectangle (radius: dp(10), Layout.minimumHeight: dp(100)) with a ColumnLayout of a centred label row (accent-coloured icon glyph + upper-case label in secondary text with 1.5 dp letter spacing), a spacer, the value in dp(36) bold, the unit in dp(14), and an optional 4 dp accent progress bar.
property string label: ""
property string value: "--"
property string unit: ""
property string icon: "" // Unicode glyph
property real barValue: -1 // 0..1 fills the bar, -1 hides it
property string globalFont: ""
property bool connected: false // shows "--" when false
The value Text has Behavior on text { enabled: false } so rapid IMU updates never queue text animations; the bar width has a 100 ms OutQuad Behavior. SensorMenu feeds pitch/roll as (value + 90) / 180 and heading as value / 360, and hides the bar for altitude and temperature.
GForceDisplay
frontend/GForceDisplay.qml is the G-force card in SensorMenu. Same shell as SensorCard; the body is a 2-D "friction circle" plot.
property real lateralG: 0 // X axis
property real longitudinalG: 0 // Y axis (up = positive)
property real magnitude: 0 // printed below the plot
property bool connected: false
property string globalFont: ""
property int decimalPlaces: 2
property real maxG: 1.5 // radius of the outer ring
- Rings — the outer ring at
maxG, a 1 g accent ring (only ifmaxG > 1) and a 0.5 g ring, plus a crosshair, all 1 px at low alpha; the circle is 92 % of the smaller card dimension andgScale = circleSize / 2 / maxGpixels per g. - Trail — while
connected, a 50 msTimerappends{gx, gy}to aListModelcapped at 30 entries; aRepeaterdraws them as 3 dp accent dots whose opacity ramps to 0.3 with age. - Marker — a 22 dp accent glow at 20 % alpha behind a 10 dp dot; the position properties have 50 ms
OutQuadBehaviors and snap to the centre when disconnected. - Readout —
magnitude.toFixed(decimalPlaces) + " g"indp(20)bold, or"--".
PhoneQrPairingPanel
frontend/PhoneQrPairingPanel.qml connects a phone over Wi-Fi by QR code, the only wireless
route (see Mirroring over Wi-Fi). A RowLayout: on the left the
code, drawn on a Canvas from phoneMirrorManager.qrPairingRows (one '0'/'1'
string per row) in whole pixels per module, black on white with a 4-module quiet zone whatever the theme; while no code is
shown, a same-size placeholder holds a "Show QR code" button. On the right "Connect your phone", the three steps (same Wi-Fi
network → Developer options → Wireless debugging → Pair device with QR code → scan), the
wirelessResult message (green / red), "The code expires after two minutes." and an optional Cancel.
| Member | Description |
|---|---|
qrSize | Side of the code box. Callers size it from the screen (55 % of the window height), not dp, so it scans from arm's length |
showCancel | Show the Cancel button while a code is up (default true) |
start() / cancel() | startQrPairing() unless one is running / cancelQrPairing() |
cancelled() | Emitted after Cancel was tapped |
Used twice: by PhoneMirrorView as its no-phone screen (it calls start() when
that screen appears and cancel() when it goes away), and inside a centred modal Popup on the Phone Dock
settings card ("Connect phone").
HomeOBDView
frontend/HomeOBDView.qml is the OBD panel embedded in the right two-thirds of MainMenu. It has no objectName and no required properties; it is a plain child, and the tap that opens OBDMenu.qml is a MouseArea in MainMenu, not in this file.
- Which parameters —
settingsManager.get_home_obd_parameters(), falling back to["SPEED", "RPM", "COOLANT_TEMP", "CONTROL_MODULE_VOLTAGE"], cut to at most 8 ("Limit to maximum 8 cards"). Any of the 129 model ids can be displayed, IMU ones included, because titles/units come fromApp.OBDParameterModel.parameterInfo(short titles such asCLT) and values fromliveValue(id). - Layout — a
GridLayoutof stacked cards: "1 column for up to 5 cards, 2 columns for 6-8 cards"; with an odd count in two-column mode the first card spans both columns. Each card is abackgroundColorrectangle with a 1 px accent border, an upper-case title (Spacing.mainMenuOBDTextSize), the value withOBDParameterModel.decimalsFor(id)decimals + unit (Spacing.mainMenuOBDDataSize), or "-- unit" insecondaryTextColoruntil the PID has reported, and a 4 dp progress strip along the bottom, inset by the border width so it doesn't paint over the border (200 ms widthBehavior; hidden until a value arrives).displayValuehas the same 50 ms linearBehavioras the OBDMenu cards ("fast rolling effect"). - Shift light on the RPM card — a circular lamp anchored right-middle, visible when
param === "RPM"andshiftLightManager.enabledandshowOnHomeCard. It only draws: the active flag and the flash phase come fromshiftLightManager(activeFlag.colorwhilelit, a dark#1a1a1asilhouette otherwise). Size, glow (glowSize/glowIntensity), the colourBehavior(colorTransitionSpeedms) and an 800 ms pulse (scale 1.0 → 1.15 → 1.0, whenpulseEnabled) are manager properties too, so edits apply live. - Rebuild on change —
onHomeOBDParametersChangedsets theRepeatermodel to[]and re-assigns it viaQt.callLater"to force re-creation of all delegates".
The window-wide full-screen and aura flashes for the same flags live in Main.qml (see Shift Light Overlays); the flags are edited in the RPM popup on OBDMenu.
ErrorToast
frontend/ErrorToast.qml (objectName: "errorToast") is the one app-wide error notice, for the few errors the driver should hear about while on another page. Main.qml instantiates it once as the window's last child (it draws over every page; popups draw above it). It sits top centre, since the bottom belongs to the BottomBar, in Style.statusDanger with bold white text, fades in and out (no slide), dismisses on a tap or after durationMs (8 s). show(message) shows a message or queues it; a message equal to the one showing, or to the last one queued, is dropped. currentMessage and queuedCount are read by tests/cpp/tst_errortoast.cpp and the dev command server.
Wired in Main.qml:
obdManager.connectionLostUnexpectedly(reason)→ "OBD connection lost: <reason>", unless the current page already shows OBD status (OBDMenu, OBDHome, OBDDiagnostics, or Settings on the OBD section).
Everything else stays on its own page and in the logs (no chime, one severity). Phone-mirror errors off-page are still open in TODO/error-notification-ui.md.
OBDAdapterSheet
frontend/OBDAdapterSheet.qml is the "Connect OBD2 Adapter" Popup (parented to Overlay.overlay, centred, at most 720 × 620 dp, styled like the dashboard chooser). Opening it starts obdAdapterFinder.startScan(); closing it stops the scan but leaves a pairing that's under way running. From the top: a title with a busy spinner and ✕, the finder's status line (red when statusIsError, or "Bluetooth is off." with a "Turn Bluetooth On" button), "Saved:" chips from settingsManager.obdSavedAdapters (a tap calls obdManager.connect_to_adapter()), the device list (signal bars, name, address · Classic / Bluetooth LE / Classic + LE · Paired, and "Connect" / "Working…"; rows the finder marks not connectable are dimmed with the reason), and a footer with Scan Again, Show All Devices (N) / OBD Adapters Only, and Cancel while busy. It closes itself 1.5 s after obdManager.connected turns true. When obdAdapterFinder.supported is false it lists obdManager.availableAdapters instead and, when the host sets offerSettings, shows an "OBD Settings" button that emits settingsRequested(). See OBD Manager → Adapter Finder.
OBDParameterModel
frontend/OBDParameterModel.qml is a pragma Singleton QtObject registered in qmldir. It serves every displayable parameter's metadata and is the single live-value store that all OBD views, gauges and dashboards read. The metadata itself comes from the C++ parameterCatalog (src/managers/parametercatalog.cpp), the one table for every frontend; the singleton keeps the API below so its consumers did not change.
Parameter definitions
{ id: "SPEED", title: "Speed", shortTitle: "SPD", unit: "MPH", min: 0, max: 160,
kind: "numeric", decimals: 0, source: "obd", aliasOf: "" }
allParameters (= parameterCatalog.parameters) holds 129 entries: 120 polled OBD ids, the alias IGNITION_TIMING (it carries TIMING_ADVANCE, aliasOf) and 8 BerryIMU readings. The kind field ("percentage", "temperature", "voltage", "pressure", "bidirectional", "numeric") is what gauge widgets match against their octaveSupportedKinds array, and what the dashboard editor's PID picker filters on.
| Group (catalog order) | Count | Examples |
|---|---|---|
"Original 18" (historical group listed in originalParameters) | 18 | SPEED, RPM, COOLANT_TEMP, OIL_TEMP, COMMANDED_EQUIV_RATIO, ENGINE_LOAD, THROTTLE_POS, FUEL_LEVEL, SHORT/LONG_FUEL_TRIM_1, INTAKE_TEMP, INTAKE_PRESSURE, MAF, TIMING_ADVANCE, CONTROL_MODULE_VOLTAGE, O2_B1S1, FUEL_PRESSURE, IGNITION_TIMING |
| Additional (batch 1) | 23 | RUN_TIME, DISTANCE_W_MIL, FUEL_RAIL_PRESSURE_VAC/DIRECT, BAROMETRIC_PRESSURE, AMBIANT_AIR_TEMP, RELATIVE_THROTTLE_POS, THROTTLE_POS_B, ACCELERATOR_POS_D, CATALYST_TEMP_B1S1/B1S2, EVAP_VAPOR_PRESSURE, SHORT/LONG_FUEL_TRIM_2, O2_B1S2/B2S1/B2S2, DISTANCE_SINCE_DTC_CLEAR, WARMUPS_SINCE_DTC_CLEAR, ABSOLUTE_LOAD, COMMANDED_EGR, EGR_ERROR, ETHANOL_PERCENT |
| Extra narrow-band O2 | 4 | O2_B1S3, O2_B1S4, O2_B2S3, O2_B2S4 |
| Wide-range O2 voltage / current | 8 + 8 | O2_S1..S8_WR_VOLTAGE, O2_S1..S8_WR_CURRENT |
| Bank-2 catalyst | 2 | CATALYST_TEMP_B2S1, B2S2 |
| Throttle / accelerator | 4 | THROTTLE_POS_C, ACCELERATOR_POS_E/F, THROTTLE_ACTUATOR |
| Fuel system | 4 | EVAPORATIVE_PURGE, FUEL_RAIL_PRESSURE_ABS, FUEL_INJECT_TIMING, FUEL_RATE |
| Time-based | 2 | RUN_TIME_MIL, TIME_SINCE_DTC_CLEARED |
| Other | 11 | MAX_MAF, FUEL_TYPE, EVAP_VAPOR_PRESSURE_ABS/ALT, SHORT/LONG_O2_TRIM_B1/B2, RELATIVE_ACCEL_POS, HYBRID_BATTERY_REMAINING, ELM_VOLTAGE |
| Extended PIDs (0x61 and above) | 37 | Torque, boost, air, temperature, throttle, EGR, fuel, drivetrain, distance/time and hybrid/EV values; delivered through obdParameterChanged(paramId, value) |
| BerryIMU | 8 | PITCH, ROLL, HEADING, ALTITUDE, ACCEL_MAG, LATERAL_G, LONGITUDINAL_G, BARO_TEMP |
Derived lookups: parameterInfo (a map keyed by id with title = the catalog shortTitle when there is one, e.g. COOLANT_TEMP → "CLT", unit, minValue, maxValue, kept for HomeOBDView compatibility), getParamInfo(id) (permissive — unknown ids get {title: id, unit: "", 0..100}), hasParameter(id) (strict; used by DashboardRenderer and the editor for validation), isOriginalParameter(id), decimalsFor(id) (the catalog decimals: 0 for speed, RPM, run times, distances and counters, else 1; used by the Parameter Cards and Home tiles) and sensorParameters / isSensorParameter(id) (the catalog's source: "imu" entries).
liveValue(id) is what every reader binds (the gauge primitives, home cards, Parameter Cards, CarMenu, the OBD settings preview): paramStore[id], or the demo sweep while demo mode is on, and undefined before the first reading or at shutdown. A binding depends only on the branch that ran, so outside demo mode it re-evaluates only when its own id changes. getParamValue(id) is the same with 0 for no value. Covered by tests/cpp/tst_vehiclegate.cpp and tst_parameterstore.cpp.
Live values: the C++ ParameterStore
The values live in C++: paramStore (src/managers/parameterstore.{h,cpp}, a QQmlPropertyMap) holds the latest value of every id, one notifying key per id. src/main.cpp feeds it from obdManager.parameterSampled(id, value) (every decoded PID, single- and multi-value, on desktop and Android) and the eight berryIMU signals; an alias (IGNITION_TIMING) is written whenever its target arrives. Writes are coalesced in the store: ingest() only records the value and arms a one-shot 33 ms timer, and the fold inserts every pending value and emits changed(ids). The reason is the Orange Pi lesson: the IMU alone sends ~440 values/s, and copying a whole dictionary on each one "pegged the QML thread at 100% CPU while idle".
A binding on paramStore[id] re-evaluates only when that id changes, and an id with no reading yet is undefined (the "--" readouts rely on it). Nothing copies the values into a QML map any more (the old whole-map paramValues, replaced on every fold, re-evaluated every reader whichever value changed; it is gone), and there are no per-signal handlers; OBDManager's typed signals still exist for other consumers.
Demo mode
simulationActive switches the singleton to "pure-QML fake data so dashboards can be built/tested without a live OBD connection": every parameter sweeps a sine between its min and max with a 4–16 s period and a per-parameter phase (so the screen "doesn't pulse in lockstep"), driven by a 100 ms timer; big-range values are rounded to integers. While active, liveValue() serves the sweep instead of the store; on deactivation it reads the store again, so the fake readings go and the real ones come back at once. The dashboard editor toggles this (see Gauges & Dashboards → Editor).
The Jeep TJ Rig
The 3D vehicle shown on CarMenu is not one mesh but a small rig of Quick3D components at the top of frontend/, assembled from part-local GLB files in frontend/assets/jeep_tj/. Every file uses unversioned Qt 6 imports (import QtQuick, import QtQuick3D), so they only load when the optional QtQuick3D module is installed — SensorHome probes CarMenu.qml for exactly this reason.
Assets and generated metadata
| File | Content |
|---|---|
| assets/jeep_tj/*.glb | Sixteen part meshes: body, driver_door, passenger_door, tailgate, hood, driver_wheel / passenger_wheel (front steering knuckles), driver_front_spin / passenger_front_spin / driver_rear_spin / passenger_rear_spin (rolling parts), crank_pulley / engine_fan / front_driveshaft / rear_driveshaft (engine and driveline spinners), steering_links (exported but not loaded — the linkage is drawn procedurally, see below). Geometry is batched by material within each part. The installed set is the lifted soft-top twin, so there is no rear_glass part (the hardtop build from build_jeep_tj.py adds one). |
| assets/jeep_tj/Rig.js, rig.json | "Generated by tools/vehicle/rig_export.py": for every part its file, pivot (metres, vehicle space), hinge axis ("X" or "Y"; "Z" for the spinners, which also carry an orient quaternion [w, x, y, z] that tilts their local +Z onto the real shaft axis) and openAngle (driver door −68°, passenger door 68°, tailgate 95°, hood −65°, rear glass 100° on the hardtop build, 0 for the rest); bounds (closedRadius 2.13 m, sweptRadius 2.82 m); bodyLift (0.0889 m, twin export only; the pitman pivot and end are raised by it too); and the steering geometry (knuckle pivots and ends, pitman pivot/end, pickupFraction 0.15, pitmanTravel 22°, tieBend 0.08, damper anchor). rig.json additionally lists the Blender object names batched into each part. |
| assets/jeep_tj_2003.glb | The complete 22 MB model as one file. It is the output of tools/vehicle/export_jeep_twin.py (or build_jeep_tj.py for the hardtop) and is used by the checker scripts and previews; the app does not load it. |
| assets/vehicle_studio.hdr | 128 KiB studio light probe generated by tools/vehicle/build_studio_probe.py; CarMenu uses it as SceneEnvironment.lightProbe (probeExposure: 0.8) so the paint and chrome pick up reflections. |
| tools/vehicle/, docs/vehicle/ | The Blender builder (written for 4.2, also builds with 5.2), the rig exporter, checker scripts, a README.md, and the photographic reference guide (references.md, reference-manifest.json, in-app and preview PNGs). |
tools/vehicle/README.md describes the model as "an original model reconstructed manually from photographs of one 2003 TJ Sahara", with the 2.372 m wheelbase anchored to published dimensions and everything else interpreted from the 164 reference photos of a single vehicle catalogued in docs/vehicle/references.md. No third-party meshes or photographic textures are bundled; the reference photography lives in the git-ignored dev/jeep-tj-reference/. The in-app trim (silver metallic paint, black Sport-style flares, KO2-style tyres on 16-inch satin-black rims) differs from the Shale Green reference car by design.
The installed assets are the lifted soft-top digital twin: build_jeep_twin.py takes the hardtop build, raises the sprung body 3.5 inches, replaces the hardtop with a framed black soft top, and rebuilds the suspension, seats, shifters, dash and steering wheel. tools/vehicle/export_jeep_twin.py poses that .blend at rest, bakes every object to world space, assigns each to the part of its nearest named ancestor (else body), takes the pivots from the twin's own joints and writes the same file set through rig_export.py. In the app the suspension, soft top and cabin are static body geometry, and the driveshafts keep the twin's rigid lift rather than being re-aimed at the axles. Export a new twin candidate with blender -b --python tools/vehicle/export_jeep_twin.py -- --blend dev/jeep-twin/<candidate>/jeep-twin.blend --output <dir>/jeep_tj_2003.glb, check it with check_jeep_asset.py <dir>, then copy it into frontend/assets/. The app itself never needs Blender.
JeepHinge
JeepHinge.qml is a Node that loads one part with RuntimeLoader from QtQuick3D.AssetUtils (source: "./assets/jeep_tj/" + part.file) and positions it at the part's pivot. Its rotation is orient (identity unless the part carries one) times animatedAngle about the part's axis. open: true drives animatedAngle to part.openAngle, through a 450 ms InOutCubic Behavior that can be disabled with animate: false (wheels are driven directly). It exposes moving, closed, opened (within 0.1°), ready (RuntimeLoader.Success) and error (errorString on failure, also logged with console.warn).
JeepVehicle
JeepVehicle.qml (pragma ComponentBehavior: Bound, objectName: "jeepVehicle") is the assembly. Its public state is plain booleans and reals that CarMenu's buttons and sliders toggle:
property bool driverDoorOpen, passengerDoorOpen, rearGlassOpen, tailgateOpen, hoodOpen
property bool headlightsOn, fogLightsOn, brakeLightsOn, reverseLightsOn
property real steeringInput: 0 // -1 .. 1, smoothed by a 180 ms Behavior
property real wheelSpeedKph: 0 // clamped to ±30
property real engineRpm: 0 // crank + fan, shown slowed 60x
property real axleRatio: 3.73 // driveshaft turns per wheel turn
readonly property bool ready // every RuntimeLoader succeeded
readonly property string error // first loader error, or ""
readonly property real radius // closedRadius, or sweptRadius while anything is open
function openAll(); function closeAll()
- Hinged panels — six
JeepHinges (body,driverHinge,passengerHinge,glassHinge,gateHinge,hoodHinge).hasRearGlassis true only whenRig.partshas arear_glass; on the soft topglassHingeis an empty, closed node with no file,rearGlassOpenstays false and CarMenu hides the Rear glass button. With a hardtop the tailgate and rear glass are sequenced: the glass opens only once the gate reportsopened, the gate stays open while the glass is notclosed, settingtailgateOpenmirrors intorearGlassOpenand raising the glass forces the gate open ("Open the swing gate fully before raising the glass; reverse this on closing"). The third brake light is a child of the gate hinge so it swings with the spare carrier. - Steering —
driverWheel/passengerWheelare hinges withanimate: falsewhose angle comes fromsteeringPose.driverAngle/passengerAngle; the front spin parts are nested inside them (relativeWheelPart()re-bases the pivot) "so their spin axis turns with the steering knuckle". - Wheel spin — a 16 ms
Timer, running whileready && visible && (wheelSpeedKph !== 0 || engineRpm > 0), integrateswheelDegreesPerSecond(speed in m/s over a 0.3937 m tyre radius) intowheelSpinAngle, which all four spin hinges read. The same timer drives the engine and driveline. - Engine and driveline — four spinner hinges,
crankPulley,engineFan,frontDriveshaftandrearDriveshaft, turn about their own tilted axes.driveshaftAngleadvances atwheelDegreesPerSecond × axleRatioandcrankAngleatengineRpm× 6°/s slowed byengineMotionScale(1/60) so the fan reads as motion instead of strobing at the frame rate. Both turn clockwise seen from the front (negative about their forward axis); the engine does not turn with the wheels. The model is a 4.0 L inline six (block, head, valve cover, coil rail, driver-side intake and exhaust manifolds, accessory drive and serpentine belt, fan and shroud), an NV3550 five-speed with bellhousing and shifter tower, and an NP231 chain case with offset front output. Placement follows the engine-bay reference photos. - Linkage — a
NodeofJeepLinks (pitman arm, drag link, three tie-rod segments withobjectNames, two clamp sleeves, and a two-piece telescoping steering damper: "The steering damper telescopes; the steering rods remain rigid"). - Lamps — a
Repeater3Dover[-1, 1]places a fog, brake and reverseJeepLampon each side; a secondRepeater3Dplaces the two headlights as an emissive#Sphere(headlightGlow) plus aSpotLight(headlightBeam, 48° cone, brightness 8). All body-mounted lamps, and the third brake light, rise bybodyLift(Rig.bodyLift, else 0).
JeepSteering.js
JeepSteering.js (.pragma library) is a small kinematics solver. solve(input, g) rotates the pitman arm by input × pitmanTravel in twelve steps and, at each step, Newton-iterates the two knuckle angles so that the drag link (pitman end → passenger knuckle) and the tie rod (driver knuckle → a pickup point pickupFraction along the drag link) keep their neutral lengths — "two fixed-length constraints, following the neutral assembly branch". It returns both wheel angles in degrees, the world points of every joint, and the residual errors. tiePoints() bends the tie rod by tieBend so it clears the differential. All coordinates are the metre-scale rig space the meshes use.
JeepLink and JeepLamp
JeepLink.qml draws a steel rod between two points: a #Cylinder scaled to the segment length and radius, rotated into place by a quaternion computed from the direction vector, with optional #Sphere joints at both ends (joints: true) and a PrincipledMaterial (metalness 0.65, roughness 0.42, tint #424b52 by default). JeepLamp.qml is an emissive lens (#Cube or #Sphere when round, lensSize in metres, NoLighting with an emission factor) plus a SpotLight (beamBrightness, beamAngle, beamInnerAngle, no shadows), both shown only while on. Each names its children <objectName>Glow / <objectName>Beam for scripts.
carModel node (scaled 30×, "metre-scale TJ, +Z forward / +Y up, pivot at chassis centre"); doors, hood, tailgate, lights and steering are manual controls. CarMenu feeds RPM and speed into the rig while OBD is connected or model simulation is active; otherwise wheel and engine motion use the manual sliders. The rig itself reads no backend manager.
Design Decisions
Each item is taken from a comment in the file or from the commit that introduced it.
One carousel, ten animations, one setting
The carousel header explains the shape of the API: rather than separate 2D and 3D pickers, every animation reads previewEnabled and adapts, so the settings UI exposes a single albumArtTransition list and the Now Playing Studio can preview any of them with previewTrackTransition(). The exit-snapshot / centre-card split means the incoming art is always the real Image and only the outgoing art is a frozen texture.
GPU work over scene-graph work
The dissolve moved from a 400-item grid of rectangles to one ShaderEffect draw call; masks are shared textures; the album-art shadow is a layer whose live flag is dropped during animation; Crossfade and the animated stages of Vinyl Lift use render-thread Animators. All of these are documented in-line as ways to keep a busy QML thread (IMU signals, backend calls) from stuttering the visuals on the single-board head unit.
No blur where a shape will do
ThemedIcon's shadow is a second ColorOverlay and MediaPlayer's row shadow is a translucent Rectangle; both comments say so explicitly, because a DropShadow blur costs a full-screen pass per frame on Pi-class GPUs. CornerBrackets and the environment features in TerminalFeedback stop their animations when hidden for the same reason.
A singleton as the data bus for gauges
OBDParameterModel centralises definitions and values so that a gauge only needs a parameter id. Feeding the IMU through the same store (rather than a second binding API) is what lets the compass, G-force and pitch/roll gauges, the PID picker and the demo mode work with no special cases; the store's 33 ms coalescing keeps that single feed cheap (the IMU alone sends ~440 values a second; one dictionary copy per value once cost a full Pi core), and its per-key notify means a value only re-evaluates the readers of its own id.
A rigged vehicle instead of one GLB
Splitting the model into per-part GLBs with generated pivot metadata lets QML own the articulation with public Qt APIs (RuntimeLoader + Node transforms) instead of reaching into an imported scene's internals, as tools/vehicle/README.md notes. It also keeps the steering solver, the lamp lights and the wheel spin as plain QML/JS that a script can drive by objectName, which is how the README captures are produced.