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).

ComponentRoot typeInstantiated by
AlbumArtCarouselItemMediaRoom.qml (and, through it, the Now Playing Studio preview)
CoverflowCardItemAlbumArtCarousel (three instances)
ThemedIconItemsettings tiles and cards, hub widgets
TerminalFeedbackRectanglesettings/MediaSettingsPage.qml (library-scan output); OBDDiagnostics.qml has its own inline terminal instead
CornerBracketsItemTerminalFeedback, SettingsCard, SettingsTile, SettingsDashboardCard, SettingsHubCard, SettingsHubGridCard (always hidden, see below)
SensorCard, GForceDisplayRectangleSensorMenu.qml
HomeOBDViewItemMainMenu.qml
ErrorToastItemMain.qml (once, last child of the window)
OBDAdapterSheetPopupOBDConnectButton.qml, settings/OBDSettingsPage.qml (Adapters card)
OBDConnectButtonRectangleOBDHome.qml and OBDMenu.qml (the "No vehicle connected" prompt)
OBDParameterModelQtObject singletonevery OBD view, the gauges, the dashboard editor, Main.qml's IMU gating
JeepVehicle, JeepHinge, JeepLamp, JeepLinkQuick3D NodeCarMenu.qml

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

StyleFlavourConstants (top of file)Notes
"Crossfade" (default and fallback)2DkCrossfadeDuration 250, kCrossfadeScale 0.97Old art fades out while the new art scales up from 0.97
"Slide"2DkSlideDuration 280, kSlideDistance 80 (passed through dp())Direction-aware horizontal slide
"Vinyl Lift"2DkLiftDuration 180, kLiftPause 60, kLiftScale 0.85Sequential: lift out, pause, drop in with OutBack overshoot 1.2 — 420 ms in total
"Dissolve"2DkDissolveDuration 900, kDissolveGridSize 20, kDissolveSmoothness 0.1GPU shader, see below
"Flip"2DkFlipDuration 350 (half per side)Y-axis flip: exit snapshot turns away, new art turns in
"Coverflow", "Conveyor", "Stack", "Depth", "Swing"3Dk3dDuration 350, k3dSideOffsetFactor 0.42, k3dSideScale 0.72, k3dFarScale 0.5Side 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 with layer.enabled: true: artRoundedMask (radius = artRadius when roundedArt) and vinylMaskRect (a circle with a black centre hole at 12 % of the width). They are handed to every card and to the dissolve effect as OpacityMask.maskSource so the GPU holds one mask texture, not one per card.
  • Side cards — prevCard / nextCard, visible only when previewEnabled and a source is set, at artSize * 0.9, with image.layer.live: !animBusy.
  • Exit snapshot — an Item holding a ShaderEffectSource of centerCard.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, emits artLoadFailed() on Image.Error (MediaRoom then substitutes assets/missing_art.png) and hosts the full-size MouseArea that emits artClicked().
  • Vinyl rotation — a Binding active only in vinylMode maps playbackPosition / playbackDuration to 0–360° (one revolution per track), smoothed by a 200 ms linear Behavior that is disabled while animating; the angle resets to 0 whenever artSource changes.
  • Animation types — Crossfade and the animated stages of Vinyl Lift use render-thread Animator types. Slide, Flip, Dissolve and the coverflow-style transitions also use NumberAnimation; 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.

Why the masks are passed in
The comment on 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 if maxG > 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 and gScale = circleSize / 2 / maxG pixels per g.
  • Trail — while connected, a 50 ms Timer appends {gx, gy} to a ListModel capped at 30 entries; a Repeater draws 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 OutQuad Behaviors and snap to the centre when disconnected.
  • Readout — magnitude.toFixed(decimalPlaces) + " g" in dp(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.

MemberDescription
qrSizeSide of the code box. Callers size it from the screen (55 % of the window height), not dp, so it scans from arm's length
showCancelShow 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 from App.OBDParameterModel.parameterInfo (short titles such as CLT) and values from liveValue(id).
  • Layout — a GridLayout of 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 a backgroundColor rectangle with a 1 px accent border, an upper-case title (Spacing.mainMenuOBDTextSize), the value with OBDParameterModel.decimalsFor(id) decimals + unit (Spacing.mainMenuOBDDataSize), or "-- unit" in secondaryTextColor until 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 width Behavior; hidden until a value arrives). displayValue has the same 50 ms linear Behavior as the OBDMenu cards ("fast rolling effect").
  • Shift light on the RPM card — a circular lamp anchored right-middle, visible when param === "RPM" and shiftLightManager.enabled and showOnHomeCard. It only draws: the active flag and the flash phase come from shiftLightManager (activeFlag.color while lit, a dark #1a1a1a silhouette otherwise). Size, glow (glowSize / glowIntensity), the colour Behavior (colorTransitionSpeed ms) and an 800 ms pulse (scale 1.0 → 1.15 → 1.0, when pulseEnabled) are manager properties too, so edits apply live.
  • Rebuild on change — onHomeOBDParametersChanged sets the Repeater model to [] and re-assigns it via Qt.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.

frontend/OBDConnectButton.qml (objectName: "obdConnectButton") is the outlined "Connect Bluetooth OBD2" button of the OBD pages' "No vehicle connected" prompt (OBDHome, OBDMenu). It reads "Connecting…" while obdManager.connectionState is Connecting and opens its own OBDAdapterSheet with offerSettings on; the host passes mainWindow (for the sheet's "OBD Settings" → navigateToSettings("obdSettings")) and fontFamily.

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)CountExamples
"Original 18" (historical group listed in originalParameters)18SPEED, 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)23RUN_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 O24O2_B1S3, O2_B1S4, O2_B2S3, O2_B2S4
Wide-range O2 voltage / current8 + 8O2_S1..S8_WR_VOLTAGE, O2_S1..S8_WR_CURRENT
Bank-2 catalyst2CATALYST_TEMP_B2S1, B2S2
Throttle / accelerator4THROTTLE_POS_C, ACCELERATOR_POS_E/F, THROTTLE_ACTUATOR
Fuel system4EVAPORATIVE_PURGE, FUEL_RAIL_PRESSURE_ABS, FUEL_INJECT_TIMING, FUEL_RATE
Time-based2RUN_TIME_MIL, TIME_SINCE_DTC_CLEARED
Other11MAX_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)37Torque, boost, air, temperature, throttle, EGR, fuel, drivetrain, distance/time and hybrid/EV values; delivered through obdParameterChanged(paramId, value)
BerryIMU8PITCH, 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

FileContent
assets/jeep_tj/*.glbSixteen 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.glbThe 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.hdr128 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). hasRearGlass is true only when Rig.parts has a rear_glass; on the soft top glassHinge is an empty, closed node with no file, rearGlassOpen stays 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 reports opened, the gate stays open while the glass is not closed, setting tailgateOpen mirrors into rearGlassOpen and 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 / passengerWheel are hinges with animate: false whose angle comes from steeringPose.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 while ready && visible && (wheelSpeedKph !== 0 || engineRpm > 0), integrates wheelDegreesPerSecond (speed in m/s over a 0.3937 m tyre radius) into wheelSpinAngle, which all four spin hinges read. The same timer drives the engine and driveline.
  • Engine and driveline — four spinner hinges, crankPulley, engineFan, frontDriveshaft and rearDriveshaft, turn about their own tilted axes. driveshaftAngle advances at wheelDegreesPerSecond × axleRatio and crankAngle at engineRpm × 6°/s slowed by engineMotionScale (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 Node of JeepLinks (pitman arm, drag link, three tie-rod segments with objectNames, two clamp sleeves, and a two-piece telescoping steering damper: "The steering damper telescopes; the steering rods remain rigid").
  • Lamps — a Repeater3D over [-1, 1] places a fog, brake and reverse JeepLamp on each side; a second Repeater3D places the two headlights as an emissive #Sphere (headlightGlow) plus a SpotLight (headlightBeam, 48° cone, brightness 8). All body-mounted lamps, and the third brake light, rise by bodyLift (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.

IMU drives the whole body only
CarMenu applies the BerryIMU quaternion to the parent 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.