Overview

OCTAVE's OBD view has two presentations: the built-in Parameter Cards grid inside frontend/OBDMenu.qml, and dashboards -- JSON specs that place gauge primitives on a grid. Five presets ship in frontend/dashboards/presets/; users build their own in an in-app editor, or drop a JSON file into the user dashboards directory. DashboardManager (src/managers/dashboardmanager.{h,cpp}) enumerates both sources, watches the user directory, and exposes save / delete / duplicate plus export / import for sharing. frontend/dashboards/DashboardRenderer.qml turns a spec into live widgets; the chooser popup shows every dashboard as a scaled live miniature; the selection persists as the activeDashboard setting, and a horizontal swipe on the OBD page cycles through Parameter Cards and every dashboard. Parameter Cards lists every PID the connected vehicle supports, and a vehicle gate keeps dashboards from showing PIDs that vehicle doesn't report.

Every gauge binds to the OBDParameterModel singleton by paramId ("RPM", "SPEED", "LATERAL_G"...) -- title, unit, min and max are auto-populated from the model and the live value updates reactively. No gauge touches obdManager directly, so the same widgets work unchanged with the live OBD manager, the dev-mode simulated car, a test fake, or the editor's demo data.

The step-by-step authoring spec is docs/GAUGE_AUTHORING.md; this page documents how the pieces are built and why. Where the two disagree, the code wins and both should be updated.

Files at a Glance

frontend/
  OBDParameterModel.qml          singleton: allParameters (from parameterCatalog), liveValue(id) (from paramStore), demo mode, vehicle gate
  OBDMenu.qml                    Parameter Cards (vehicle PIDs), DashboardRenderer host, swipe, chooser (Import/Share)
  qmldir                         registers OBDParameterModel and WidgetCatalog as singletons
  gauges/
    CircularGauge.qml            arc + ticks + optional needle + centre readout
    ArcGauge.qml                 top half-dial with readout below the chord
    BarGauge.qml                 horizontal / vertical filled bar with warn threshold
    SegmentedGauge.qml           discrete level strip, optional high warning, missing-data state
    LinearGauge.qml              scale with bidirectional fill from zero
    DigitalReadout.qml           big number + title + unit
    SparklineGauge.qml           timer-sampled rolling history line (Canvas)
    WarningLight.qml             threshold tell-tale
    GForceGauge.qml              self-binding LATERAL_G / LONGITUDINAL_G dot + trail
    CompassGauge.qml             self-binding HEADING compass card
  dashboards/
    DashboardRenderer.qml        spec -> absolutely positioned Loaders
    widget-catalog.json          the catalog data: palette meta, spans, supportedKinds, label/range/color flags,
                                 editableProps, colorSwatches (dashboardManager.widgetCatalog)
    WidgetCatalog.qml            singleton: that data + type -> url
    presets/                     sport, minimal, fullgrid, performance, tj-wrangler (.json)
    widgets/
      NowPlayingWidget.qml       self-binding media card
      MediaControlsWidget.qml    self-binding transport buttons
      ClockWidget.qml            time + optional date, follows 12/24-hour preference
    editor/
      DashboardEditor.qml        full-screen page: owns the working spec; toolbar, choosers, sheet, density, undo, save / close / draft
      EditorCanvas.qml           dot grid, select-then-drag with live push preview, drop ghost, corner resize grip
      PalettePopup.qml           widget palette (reads WidgetCatalog)
      PIDPicker.qml              searchable, kind-filtered, vehicle-gated PID list
      PropertiesPanel.qml        PID / position & size / options / label & range / color inspector
src/managers/dashboardmanager.{h,cpp}   DashboardManager
src/util/dashboardgrid.{h,cpp}          grid placement (canPlace, fitAt, nearestFree, arrange: swap / push), density (rescale), sizes (minSpan, sizeOptions)
docs/GAUGE_AUTHORING.md                 authoring spec (source of truth for the API)
dev/tests/drive_dashboard_editor.py     end-to-end editor scenario (command server)
dev/tests/drive_dashboard_features.py   overrides, resize, swipe, sharing scenario (command server)
tests/cpp/tst_dashboardmanager.cpp      spec rules, import, export/import round trip, grid placement (QtTest)
tests/cpp/tst_vehiclegate.cpp           headless QML gate test with a fake obdManager (QtTest)
        

Data Flow: OBDParameterModel

obdManager.parameterSampled ─┐
berryIMU (8 signals) ─────────┴─> paramStore (C++, 33 ms fold) ─ paramStore[id] ─> OBDParameterModel.liveValue(id) ─> gauge bindings
  (main.cpp)                       one notifying key per id                          (or the demo sweep)                 _normalized
                                                                                                                         _animNorm (Behavior)
        

frontend/OBDParameterModel.qml (registered as a singleton in frontend/qmldir) declares the members below. The parameter metadata comes from the C++ parameterCatalog (src/managers/parametercatalog.cpp); the singleton has no table of its own.

MemberDescription
allParametersparameterCatalog.parameters: array of { id, title, shortTitle, unit, min, max, kind, decimals, source, aliasOf }, 129 entries: 121 OBD entries (including the IGNITION_TIMING alias, aliasOf: "TIMING_ADVANCE", and adapter voltage) and 8 BerryIMU readings. Of the OBD entries, 37 newer values arrive through obdParameterChanged(paramId, value).
kindOne of percentage (0-100 %), temperature (°C), voltage, pressure (kPa/Pa), bidirectional (signed, straddles zero: trims, timing, EGR error, WR currents, pitch/roll/G), numeric (everything else). Widgets declare which kinds they accept; the PID picker filters on it.
originalParameters, isOriginalParameter(id)The historical group of 18 ids, also used as the settings list fallback before a supported-PID list is available.
parameterInfo, getParamInfo(id), decimalsFor(id)Compact metadata (catalog shortTitle: SPD, CLT, AFR...) keyed by id for the home cards; decimalsFor is the catalog decimals (1 for an unknown id); getParamInfo returns a permissive fallback for unknown ids.
hasParameter(id)Strict membership test, added for renderer/editor validation precisely because getParamInfo never fails.
liveValue(id), getParamValue(id)The live value: paramStore[id], or the demo sweep while simulationActive; undefined before the first reading and at shutdown (getParamValue gives 0). A binding re-evaluates only when its own id changes. (Until #8 this was paramValues, a whole map replaced on every fold that re-evaluated every reader.) The values live in the C++ paramStore, which coalesces writes to one fold per 33 ms (introduced in QML in 486a809 after the IMU's ~440 signals/s pegged the QML thread at 100 % on an idle Orange Pi; now in C++).
simulationActiveDemo mode: a 100 ms timer sweeps every parameter along a sine between min and max (period 4-16 s and phase vary per index so the screen does not pulse in lockstep; big ranges are rounded to integers). While on, liveValue() serves the sweep (_simValues) instead of the store; switching off reads the store again (the real readings, no fake ones).
sensorParameters, isSensorParameter(id)The eight BerryIMU ids (catalog source: "imu"). They are not OBD PIDs, so the vehicle gate never applies to them.
vehicleKnown, vehicleSupportedParametersMirrors of obdManager.vehicleScanComplete / vehicleSupportedParameters: this session's supported-PID scan, cleared on disconnect, never loaded from the saved list. Guarded with typeof obdManager.
isSupported(id)True for sensor ids, and for OBD ids the scanned vehicle reported. False otherwise, including when no vehicle is known (callers wanting "anything goes offline", like the PID picker, check vehicleKnown first).
vehicleParametersThe supported OBD entries of allParameters, in allParameters order, without aliases (no second Timing Advance card); empty until a vehicle has been scanned. Feeds Parameter Cards.
(feeding)None in QML: the store is fed in main.cpp (the 84 per-signal handlers and the berryIMU block are gone).

The eight sensor parameters (fed by berryIMU, not obdManager):

idTitleUnitMinMaxKind
PITCHPitch°-9090bidirectional
ROLLRoll°-180180bidirectional
HEADINGHeading°0360numeric
ALTITUDEAltitudem03000numeric
ACCEL_MAGG Magnitudeg03numeric
LATERAL_GLateral Gg-22bidirectional
LONGITUDINAL_GLongitudinal Gg-22bidirectional
BARO_TEMPCabin Baro Temp°C-2060temperature

The full OBD metadata list is in docs/GAUGE_AUTHORING.md §6 and src/managers/parametercatalog.cpp. A model entry describes a displayable value; availability depends on the vehicle, adapter and decoding path. See OBD Manager for backend polling and supported-PID behavior.

Vehicle Gate and Polling Demand

Every connect runs a supported-PID scan; OBDManager publishes the result as obdManager.vehicleSupportedParameters + vehicleScanComplete, which OBDParameterModel mirrors (see the table above). The gate only ever describes the vehicle that is answering right now:

WhereNo vehicle scannedVehicle scanned
Any dashboard / Parameter Cards (OBDMenu)No dashboard renders while the adapter is disconnected: the page shows "No vehicle connected" and a connect button in place of the active dashboard (obdConnected gate). Once connected, Parameter Cards shows "Reading your vehicle's PIDs…" until the scan answers.One card per vehicleParameters entry; "No supported PIDs reported" when the scan came back empty.
DashboardRendererEvery cell rendersA cell bound to an unsupported PID shows a "Not supported by this vehicle" placeholder (objectName: dashUnsupported_<i>) instead of a gauge stuck at 0.
Editor PIDPickerEvery PID, with the note "No vehicle connected. Showing every PID."The vehicle's PIDs sort first in each kind group; the rest are greyed and labelled "Not supported by this vehicle" but stay assignable (a dashboard may be meant for another car, or shared).
Editor PropertiesPanel-Red note under the PID button when the bound PID isn't supported.

Polling is demand-driven. The backend polls only PIDs that some on-screen consumer asked for through obdManager.setParameterDemand(consumer, ids) (an empty list withdraws the consumer). QML consumers:

ConsumerRegistered byIds
"dashboard"OBDMenu while it is the active StackView page and a dashboard is showingDashboardRenderer.paramIds (distinct ids in the spec)
"cards"OBDMenu while Parameter Cards is showingevery id in vehicleParameters
"editor"DashboardEditor for its lifetimethe canvas cells' ids (live preview while editing)
"carMenu"CarMenu while visibleRPM and SPEED for the rig animation
"home"HomeOBDView while visiblethe Home tiles' ids
"settings"OBDSettingsPage while its list is visibleits visible parameters

How the union is filtered, the heartbeat PID, the debounce and live re-apply are backend matters: see OBD Manager. A new dashboard needs no wiring -- the renderer's paramIds is registered automatically. The gate is covered headlessly by tests/cpp/tst_vehiclegate.cpp.

Shared Binding API

Every PID-bound primitive pastes the same ~15-line preamble: a paramId string, a _pInfo lookup that scans allParameters, and properties defaulted from it. The self-binding widgets (below) skip paramId and read their ids directly.

property string paramId: ""
readonly property var _pInfo: {
    if (!paramId) return null
    var all = App.OBDParameterModel.allParameters
    for (var i = 0; i < all.length; i++)
        if (all[i].id === paramId) return all[i]
    return null
}
property string title: _pInfo ? _pInfo.title : ""
property string unit:  _pInfo ? _pInfo.unit  : ""
property real   min:   _pInfo ? _pInfo.min   : 0
property real   max:   _pInfo ? _pInfo.max   : 100
property real   value: paramId ? (App.OBDParameterModel.liveValue(paramId) || 0) : 0
PropertyTypeDefaultNotes
paramIdstring""Any id from allParameters. Drives title/unit/min/max/value.
title, unitstringfrom paramIdOverride the label / suffix (a preset can set "title" in props).
min, maxrealfrom paramId (0 / 100 when unbound)Range used for normalisation. Presets override them freely (the TJ preset rescales RPM to 0-6000, coolant to 60-115). DigitalReadout has no min/max.
valuerealliveOverride for previews and demos.
decimalsint0Digits after the point in numeric readouts; WarningLight has no decimals property.
labelColor, valueColorcolorApp.Style.obdLabelColor, App.Style.obdValueColorNumeric readout colors; WarningLight has its own label colors. Other default colors come from App.Style (obdBarColor, obdBoxBackground, statusDanger, accent).
octaveSupportedKindsvar (readonly)per widgetKinds the widget accepts, ["*"] = any, [] = self-binding. Must mirror WidgetCatalog.supportedKinds.
octaveEditablePropsvar (readonly)per widgetManifest of editable props declared in the gauge itself. Not read by the current editor -- the properties panel uses the curated editableProps list in WidgetCatalog instead (see WidgetCatalog).

Normalisation and animation are also shared: _normalized = clamp((value - min) / (max - min), 0, 1) and a _animNorm property with a Behavior { NumberAnimation { duration: 120-140; easing: OutCubic } }. The raw value is never animated (readouts show the true number; only the geometry eases).

No reading is not zero. Every PID-bound primitive binds value: App.OBDParameterModel.sample(paramId), which is NaN until there is a finite reading, and exposes hasValue. Without one the readout shows --, no fill, needle or marker is drawn, _normalized is 0 and no threshold (redline, warnAbove, triggerAbove / triggerBelow) is evaluated, so a missing fuel level never lights a low-fuel warning. A real 0 is a value. SparklineGauge records a missing sample as a gap in the trace; GForceGauge and CompassGauge hide the dot / keep the card still and show -- g / --°.

Alerts. The value gauges (Circular, Arc, Bar, Segmented, Linear, DigitalReadout, Sparkline) share one rule, frontend/gauges/AlertLevel.js: warnAbove / warnBelow (level 1, warnColor = the theme's statusWarning, amber) and critAbove / critBelow (level 2, critColor = statusDanger, red), inclusive, critical winning; alertLevel is 0 without a reading. At a level the value text and the fill take its colour, and alertFlash blinks the value while critical. Circular and Arc draw each band as a thin arc on the inner edge of the scale (Alerts.bands(); a warning band stops where a critical one starts); redlineStart stays a separate red zone. All four limits are stored in the PID's own unit (paramUnit). Until 2026-10-30 Bar and Segmented had only warnAbove, drawn red; the presets moved those limits to critAbove.

Text stays inside the cell. Header rows give the value its width and elide the title in what is left; ArcGauge and DigitalReadout scale their readout block by its measured height against the space they have; values use fontSizeMode: Text.HorizontalFit. BarGauge and LinearGauge lay out with explicit geometry per orientation, so switching orientation after creation lays out as creating it so, and a bar's fill is the track's own shape revealed by a clip (exactly proportional, never outside the track). tst_dashboardwidgets checks every widget's text bounds with a long title and a wide value at 160×50, 80×32, 320×100, 164×103 and 100×240. The renderer additionally clips every cell.

Gauge Primitives

Defaults below are read from the QML property declarations, not from the manifests.

CircularGauge

Arc-sweep dial built from three Shape { ShapePath { PathAngleArc } } layers (track, static redline zone, animated fill), a Repeater of tick rectangles, an optional needle + hub, and a centre readout. octaveSupportedKinds: ["*"]. Fills its own bounding box; the arc radius is min(width, height) / 2 - 4 dp, arc width max(6 dp, 12 % of radius).

PropertyTypeDefaultNotes
startAnglereal135PathAngleArc degrees, 0 = 3 o'clock, clockwise.
sweepAnglereal270Three-quarter dial; 180 gives a half-dial.
showNeedleboolfalseNeedle (3 dp wide, 92 % of radius, transformOrigin: Item.Bottom) + hub; the readout shifts up by 45 % of the radius to clear it.
showTicksbooltrue
majorTickCountint9Inclusive of both endpoints. Total ticks = (major - 1) * minorPerMajor + 1.
minorTicksPerMajorint5
redlineStartrealNaNWhen set and strictly inside (min, max): a red arc from that value to max, and the fill turns red once value >= redlineStart.
showCenterReadoutbooltrueTitle, value, unit stacked; font sizes capped at 22 % / 30-42 % / 18 % of the arc radius.
trackColor, fillColor, redlineColor, needleColorcolordarker box bg, obdBarColor, statusDanger, obdBarColor

ArcGauge

Same engine as CircularGauge but geometry for a wide "n" shape: the arc centre sits radius + 8 dp from the top, radius = max(10 dp, min(width / 2, height * 0.6) - 4 dp), and the readout is a column just below the centre line rather than in the middle. ["*"].

PropertyTypeDefaultNotes
startAngle / sweepAnglereal180 / 1809 o'clock -> 12 -> 3 o'clock.
showNeedle, showTicksboolfalse, true
majorTickCount / minorTicksPerMajorint5 / 4
redlineStartrealNaNAs CircularGauge.
showReadoutbooltrueTitle / value / unit under the chord (unit row hidden when empty).
trackColor, fillColor, redlineColor, needleColorcolortheme

BarGauge

Filled pill. octaveSupportedKinds: ["percentage", "temperature", "numeric", "pressure", "voltage"] -- deliberately not bidirectional, because the bar grows from one end and does not represent signed ranges well (use LinearGauge). Horizontal layout: compact "Title Value Unit" header row, bar below. Vertical: title top, bar (40 % of width or maxThickness) centred, value bottom. The fill is hidden below 1 % so the rounded-cap floor does not render as a blob at zero.

PropertyTypeDefaultNotes
orientationstring"horizontal"or "vertical".
warnAbove, critAbove, ...realNaNThe shared alerts (above): the fill (200 ms colour animation) and the value turn amber / red.
showLabel, showValuebooltrue, true
maxThicknessreal (dp)0 = fillCaps the pill thickness so a bar in a tall cell stays a bar (the Sport preset uses 40).
trackColor, fillColor, warnColor, critColorcolortheme

LinearGauge

Horizontal scale with an indicator dot above the track and ticks below. ["bidirectional"]. When centerOriginAtZero (auto when min < 0 < max) the fill starts at the zero mark (_zeroT = -min / (max - min)) and grows left or right; a 2 dp origin marker is drawn there.

PropertyTypeDefaultNotes
majorTickCount / minorTicksPerMajorint5 / 4
showTicksbooltrue
centerOriginAtZeroboolmin < 0 && max > 0Overridable.
trackColor, fillColorcolortheme

DigitalReadout

Big number, no chrome. ["*"]. No min/max. The value text uses fontSizeMode: Text.HorizontalFit so wide numbers shrink instead of clipping, and the whole stack scales by _vfit = min(1, height / naturalHeight).

PropertyTypeDefaultNotes
padDigitsint0Zero-pads the integer part (sign preserved).
valueScalereal3.5Multiplier on App.Spacing.overallText (Sport uses 7.0).
showTitle, showUnitbooltrue, true
alignmentintQt.AlignHCenteror AlignLeft / AlignRight.
unitColorcolorobdLabelColor

SegmentedGauge

A horizontal discrete level strip for any numeric PID. segmentCount defaults to 20 (2–40); the shared alert limits default off. Each lit segment past warnAbove / critAbove takes that level's colour, like a shift-light strip, the readout takes the value's level, and unlit segments past a limit keep a dim outline. showLabel and showValue default on. Decimals default to 0 and can be set to 0–4. Range and threshold overrides are stored in native units and follow display-unit preferences. Missing/non-finite samples show --, with no lit segments or warning; a real zero remains zero. Invalid ranges fill no segments. Values outside a valid range clamp the strip, while the numeric readout stays truthful. Default span: 6 × 2.

SparklineGauge

Rolling history drawn on a Canvas. ["*"]. A Timer (running only while visible, triggeredOnStart) pushes value into _samples every sampleIntervalMs and drops the oldest past maxSamples; the x step is width / (maxSamples - 1) and samples are right-aligned so the newest sits at the right edge. It samples on its own clock rather than on value changes so the x-axis means time regardless of the OBD poll rate (and is unaffected by the model's 33 ms coalescing).

PropertyTypeDefaultNotes
maxSamplesint60
sampleIntervalMsint50060 × 500 ms = 30 s window by default.
fillBelowbooltrueArea fill at 22 % alpha of obdBarColor.
showHeaderbooltrue"Title Value Unit" strip.
autoScaleboolfalseY-range from the visible window's min/max instead of the PID range -- for fuel trim, timing and other small-swing values.
lineColor, fillColor, backgroundColorcolorthemefillColor defaults to lineColor at 22 % alpha, so the editor's single Color pick (which writes lineColor) recolors line and area together.

WarningLight

Round tell-tale. ["*"]. Lit when value >= triggerAbove or value <= triggerBelow (either or both may be set; NaN disables). Colour and border animate over 160 ms; the optional pulse is an opacity loop (1.0 -> 0.45 -> 1.0) that only runs while lit, pulsing is enabled and the widget is visible.

PropertyTypeDefaultNotes
triggerAbove, triggerBelowrealNaN, NaN
labelstring""Short text inside the disc (38 % of the diameter).
activeColorcolorApp.Style.statusDangerPresets pass hex strings (e.g. "#F1C40F" for the fuel light).
inactiveColorcolordarker obdBoxBackground
activeLabelColorcolor"white"
pulseboolfalseReserve for critical alerts (overtemp, overrev).
maxSizereal (dp)0 = fillCaps the diameter so a light in a big cell stays an idiot-light (Performance uses 90).

Self-Binding Widgets

Five widgets have octaveSupportedKinds: [] and no paramId. The renderer skips the PID bind for them (it checks item.paramId !== undefined), the editor never opens the PID picker for them, and the properties panel hides its PID section.

WidgetSourceProps (default)Notes
GForceGaugeliveValue("LATERAL_G"), liveValue("LONGITUDINAL_G")maxG (1.5), showTrail (true), showValue (true)Rings at 100 % and 50 %, crosshair, "1.5g" scale label, dot animated over 100 ms, a 20-point trail sampled every 100 ms with opacity fading by age, and a total-G readout sqrt(lat² + lon²). Compact sibling of the full-screen GForceDisplay.qml.
CompassGaugeliveValue("HEADING")showDegrees (true)The card (12 ticks, N/E/S/W letters, N in statusDanger) rotates by -heading under a fixed accent-coloured lubber triangle (a Canvas repainted on accentChanged); letters counter-rotate to stay upright. RotationAnimation { direction: Shortest } handles the 359° -> 0° wrap. Centre readout shows degrees and the 8-point cardinal.
NowPlayingWidgetnowPlayingshowArt, showArtist, showProgress (all true)Binds nowPlaying (the phone while it has the media controls, else local media), so it follows source and track changes with no refresh; art falls back to assets/missing_art.png. Display-only.
MediaControlsWidgetnowPlayingnonePrev / play-pause / next calling nowPlaying.previous() / togglePlay() / next(), which route to the live source; the icon binds nowPlaying.playing. Interactive in rendered dashboards; in the editor the drag/select MouseArea sits on top so taps select the cell.
ClockWidgetLocal clock, app 12/24-hour preferenceshowDate (true), showSeconds (false)Time and optional abbreviated date, fitted to the cell. Updates once per second while visible, refreshes immediately when shown, and works with the bottom-bar clock hidden. No PID or OBD polling.

Angle and Needle Math

PathAngleArc measures degrees from 3 o'clock, clockwise. A QML Item with rotation: 0 points straight up, so an item rotated to sit on arc angle a needs rotation: a + 90. Both dials use:

// tick i of N (N = (majorTickCount - 1) * minorTicksPerMajor + 1)
_t        = i / (N - 1)
_angleDeg = startAngle + sweepAngle * _t
x         = cx + tickR * cos(_angleRad) - width / 2
y         = cy + tickR * sin(_angleRad) - height / 2
rotation  = _angleDeg + 90              // long axis radial

// needle (transformOrigin: Item.Bottom, drawn pointing up from the centre)
rotation  = (startAngle + sweepAngle * _animNorm) + 90

// redline arc
_t         = (redlineStart - min) / (max - min)
startAngle = startAngle + sweepAngle * _t
sweepAngle = sweepAngle * (1 - _t)

With the CircularGauge defaults (135° start, 270° sweep) the minimum sits at item rotation 225° (down-left) and the maximum at 135° (down-right); a redline equal to max has zero width and looks like a missing end, so keep redlineStart < max.

Dashboard JSON Schema (schema 1 and 2)

{
  "id":          "my-board",        // required, unique across presets + user files, file-safe slug
  "label":       "My Board",        // required, shown in the chooser and the OBD header
  "schema":      1,                 // 1, or 2 with "layouts" (every built-in)
  "gridColumns": 12,                // optional, default 12
  "gridRows":    8,                 // optional, default 6 (presets use 8; tj-wrangler 12)
  "margins":     20,                // optional dp, outer padding, default 20
  "spacing":     16,                // optional dp, gap between cells, default 16
  "cells": [
    {
      "type":    "CircularGauge",   // a WidgetCatalog type (== the .qml file name)
      "paramId": "RPM",             // an OBDParameterModel id; omit or "" for self-binding widgets
      "col": 6, "row": 0,             // 0-based grid position, default 0/0
      "colSpan": 6, "rowSpan": 7,     // default 1/1
      "props": { "showNeedle": true, "redlineStart": 5500,   // any QML property of the widget
                 "title": "Revs", "max": 6000,          // per-widget overrides (Label & Range)
                 "fillColor": "#2ECC71", "needleColor": "#2ECC71" }  // Color row
    }
  ],
  "layouts": {                       // schema 2, optional
    "portrait": {                    // a portrait screen: one placement per cell, same order
      "gridColumns": 8, "gridRows": 12,  // default: the grid turned
      "cells": [ { "col": 0, "row": 0, "colSpan": 8, "rowSpan": 6 } ]
    }
  }
}
FieldRequiredRead byRules
idyesDashboardManager, OBDMenuMissing id: file skipped (logged). Duplicate of a built-in: user file skipped. "grid" is reserved for the Parameter Cards entry (DashboardManager::kParameterCardsId): uniqueIdForLabel never returns it, saveDashboard refuses it, and a user file carrying it is skipped on scan (logged). saveDashboard writes <id>.json and refuses an id that isn't file-safe ([A-Za-z0-9_-], ≤ 64 chars).
labelyes (for save)DashboardManager, OBDMenuWhitespace collapsed, capped at 80 chars. saveDashboard rejects an empty label; a file with none loads named after its id.
schemaby conventionDashboardManagerWritten by the editor and every preset. Save and the import sanitizer refuse a spec newer than kSupportedSchema (2) with "This dashboard needs a newer version of OCTAVE"; a newer file dropped into the folder by hand is listed, and DashboardRenderer shows that message instead of its cells (and demands no PIDs). It reads the limit from dashboardManager.supportedSchema, or 1 with no manager; loadDashboard passes such a file through untouched and the editor won't open it. A missing schema is 1.
gridColumns, gridRowsnoRenderer, EditorNot a positive JSON number (0, missing, a string) = 12 / 6; capped at 48.
margins, spacingnoRenderer and editorApplied through App.Spacing.dp(); 0..200, null = absent. The editor keeps a loaded spec's values (specMargins / specSpacing), renders its canvas with them and writes them back into saves and drafts (_withGridGeometry()), but only when the spec had them — a dashboard without the keys keeps relying on the 20 / 16 defaults and its JSON stays minimal.
cells[].typeyesRenderer, Editor, WidgetCatalogKept in the file whatever it is (a newer build's widget survives a save). Unknown type: renderer logs and skips the cell (its area stays empty); editor shows it inert as "needs a newer OCTAVE", and Delete works.
cells[].paramIdnoRenderer, EditorKept in the file. Unknown id: renderer logs and leaves the widget unbound; the editor canvas doesn't bind it either. Ignored for widgets without a paramId property.
cells[].col/row/colSpan/rowSpannoRenderer, EditorNormalised on save and load (DashboardManager::normalizeSpec): a cell off the grid keeps its size and moves back in, shrinking only if wider than the grid; cells never overlap (the earlier cell in file order wins, a later one shrinks toward 1×1 and is dropped only if its corner is taken). The editor's tap / drag / resize use the same placement code (canPlace, fitAt, src/util/dashboardgrid.{h,cpp}).
cells[].propsnoRenderer, EditorScalars only (bool, number, string ≤ 200 chars); nested objects and arrays are dropped on save and load. Applied after paramId so overrides win over model defaults. This is also where the editor's per-widget overrides live: title, unit, min, max and the color keys from the catalog's colorProps; omit a key to follow the PID metadata / live theme. Keys the widget does not declare are silently skipped. The string "NaN" is coerced to NaN (JSON cannot encode it; used to un-set thresholds).
layouts.portraitno (schema 2)Renderer, EditorWhere each cell goes on a portrait screen, {gridColumns, gridRows, cells: [{col, row, colSpan, rowSpan}]}, one placement per cell in cells order (a cell the rules drop takes its placement with it). Normalised like the cells; a missing or colliding placement goes to the nearest free spot of its size, then 1×1, and if even that fails the layout is dropped. A spec with one is saved as schema 2, one without as 1. DashboardRenderer uses it when taller than wide (orientedSpec(spec, true)); without one it derives a layout by fixed rules (derivePortrait: the grid turned, every widget its on-screen shape on a 16:9 screen, reading order, made smaller in steps until all fit), the same on every run. Every built-in has one (TJ Wrangler's and Sport's hand-tuned). JSON Schema: docs/api/dashboard-spec.schema.json.

Built-in Presets

Read-only; "Copy" in the chooser forks any of them into an editable user dashboard.

id / labelGridLayout
sport / Sport12 × 8Digital speed readout (valueScale 7, 3-digit pad) and a needle RPM dial with 6500 redline side by side over rows 0-6; a bottom strip of four horizontal bars (coolant warn 105, oil warn 130, fuel, throttle) with maxThickness 40.
minimal / Minimal12 × 8Three full-height circular gauges: Speed, RPM (redline 6500), Fuel.
fullgrid / Full Grid12 × 84 × 2 compact circular gauges (7 majors, 4 minors): Speed, RPM (redline 6500), Coolant (105), Oil (130), Throttle, Engine Load, Fuel, System Voltage (1 decimal).
performance / Performance12 × 8Full-width needle ArcGauge for speed; a row of RPM digital, TEMP / REV / FUEL warning lights (pulse on the first two, yellow fuel light below 12 %) and a coolant digital; two sparklines (engine load, timing advance with autoScale).
tj-wrangler / TJ Wrangler 4.012 × 12The author's daily-driver layout, brought upstream verbatim from the Orange Pi rig (37b8acf): needle dials for speed (max 100), RPM (max 6000, redline 5000) and coolant (60-115, redline 105); ELM voltage digital; bars for MAP (max 105), IAT (warn 60), load and throttle; a timing LinearGauge (-10..35); sparklines and linear gauges for all four fuel trims; and 250 ms sparklines for the four narrow-band O2 sensors. It is also the reference for how far props can go: overriding min/max per cell, sampleIntervalMs, orientation.

DashboardManager

Context property dashboardManager, registered before the QML engine loads. main.cpp calls setPresetsDir(<frontend>/dashboards/presets) then setUserDir(<app data dir>/dashboards); the second call performs the initial scan and starts the directory watch. The user dir is ~/.config/OCTAVE/dashboards/ on Linux, ~/Library/Application Support/OCTAVE/dashboards/ on macOS, %APPDATA%/OCTAVE/dashboards/ on Windows (via SettingsManager::getAppDataDir()), and is created on demand.

MemberKindBehavior
dashboardsProperty (list), notify dashboardsChangedHeaders { id, label, builtIn, path } (plus showWhen when the dashboard has one): presets first in file-name order, then user files in file-name order.
loadDashboard(id)Invokable -> mapThe spec normalised by the spec rules (a newer-schema file untouched), or an empty map for an unknown id / unreadable file (logged).
isBuiltIn(id)Invokable -> bool
saveDashboard(spec)Slot -> id or ""A spec with no id gets uniqueIdForLabel(label) first (a new dashboard in one call). Normalises the spec (spec rules; refuses a newer schema, an empty label or an id that isn't file-safe) and needs a configured user dir; refuses an id that belongs to a built-in (hidden ones included) or is "grid"; otherwise writes <userDir>/<id>.json (overwriting an existing user file with that id) and rescans.
deleteDashboard(id)Slot -> boolRemoves the user file. A built-in is hidden instead: its id is added to <userDir>/.hidden-builtins (a JSON array) and it drops out of dashboards, but its id stays reserved. False for unknown ids.
hiddenBuiltInCountProperty (int), notify dashboardsChangedNumber of built-ins the user has deleted (hidden).
supportedSchemaProperty (int), constantkSupportedSchema (2): the highest dashboard schema this build reads; DashboardRenderer shows "needs a newer OCTAVE" above it.
restoreBuiltInDashboards()Slot -> boolClears .hidden-builtins and rescans; false if nothing was hidden.
uniqueIdForLabel(label)Invokable -> idThe id a new dashboard named label should save under: slug of the label, then -2, -3, ... until it is not a visible or hidden built-in, a user dashboard, or "grid". The editor, Duplicate and Import all use it.
duplicateDashboard(sourceId, newLabel)Slot -> id or ""Reads the source spec (built-in or user), sets label to newLabel or "<source label> (Copy)", generates a unique id, and calls saveDashboard.
refresh()SlotFull rescan of both directories.
exportDashboard(id)Slot -> path or ""Writes the spec to <Downloads>/OCTAVE-dashboards/<id>.json (Documents if there is no Downloads location), atomically.
copyDashboardToClipboard(id)Slot -> boolPuts the spec on the clipboard as compact JSON.
importDashboard(pathOrUrl)Slot -> new id or ""Accepts a plain path, a file:// URL, or an Android content:// URL (handed to QFile as is). Reads at most 512 KB, then as importDashboardFromText.
importDashboardFromClipboard()Slot -> new id or ""Clipboard text through importDashboardFromText; "The clipboard is empty" otherwise.
importDashboardFromText(json)Slot -> new id or ""Parse, sanitize, assign a fresh id from the label, saveDashboard.
lastShareErrorProperty (string), notify lastShareErrorChangedHuman-readable reason the last export/import failed; "" after a success. The chooser's status banner shows it.
lastImportReportProperty (map), notify lastImportReportChanged (experimental)What the last successful import kept and left out: {id, label, cells, droppedCells (types), droppedProps, movedCells, portraitDropped, unknownTypes, paramIds}; {} after a refused one. The chooser's report popup checks paramIds against the connected car.
thumbnailFromImage(image)Invokable -> string (experimental)A QImage (QML grabToImage's result.image) scaled into 320 × 180 as a data:image/jpeg;base64,... URL (PNG where there is no JPEG writer), at most 96 KB; "" for anything else (so over the service).
preview / previewDashboard(spec) / endPreview()Property (map), notify previewChanged; slots (experimental)A dashboard being edited on another device: {id, spec} (spec held to the load rules, no thumbnail; id "" for one not saved yet) or {}. previewDashboard returns false and ends the preview for an unusable spec; each call restarts a 60 s expiry. OBDMenu shows it in place of the active dashboard, with or without a car.
widgetPresetsProperty (list), notify widgetPresetsChanged (experimental)"My widgets": [{id, label, baseType, paramId, props}] from <config>/widgets/*.json, by label; props held to the spec rules for the base type.
saveWidgetPreset(preset) / deleteWidgetPreset(id)Slots (experimental)Save {label, baseType, paramId, props} (an existing id overwrites, else a slug of the label) -> id or "" with lastError; delete by id.
widgetPresetText(id), copyWidgetPresetToClipboard(id), importWidgetPresetFromText(json), importWidgetPresetFromClipboard()Invokable / slots (experimental)A preset as shared text ({"schema": 1, "kind": "widgetPreset", ...}), and back as a new one (never overwrites). The dashboard importer refuses a preset, and the preset importer a dashboard, each pointing at the other.
  • Scanning: every *.json is fully parsed (there is no lightweight header read; readHeader calls readFullSpec). Non-object roots, parse errors, missing ids, duplicate preset ids and user ids colliding with a built-in are logged (octave.dashboards category) and skipped.
  • Atomic writes: pretty-printed JSON is written through QSaveFile (a temporary file committed over the target). This prevents readers from seeing a partially written JSON file; it is not a guarantee against every power-loss failure.
  • Id generation: slugify lowercases, replaces runs of [^a-z0-9] with -, trims dashes, and falls back to "dashboard"; uniqueIdForLabel appends -2, -3, ... up to 999 (then a millisecond timestamp), skipping hidden built-ins and "grid". The editor calls it before saving a new dashboard, so there is one rule (tests/cpp/tst_dashboardmanager.cpp).
  • Hot reload (c754f3d): a QFileSystemWatcher on the user dir, a 300 ms single-shot debounce, and a signature name:mtime:size|... of the JSON files. A directory event only triggers a rescan when the signature differs from the one recorded at the last scan -- the manager's own saves already rescanned, so they do not double-fire. If the directory was recreated the watch is re-armed.
Synchronous rescans
Specs are parsed with QJsonDocument and the hot-reload signature uses QFileInfo::lastModified().toSecsSinceEpoch(). Headers carry the user file's absolute path, and dashboardsChanged is emitted synchronously at the end of every rescan -- which is why the chooser's delete button must not run code after deleteDashboard() in its own delegate (see below).

Sharing: Export and Import

In the chooser, every JSON dashboard card has a Share action (a menu: Save to Downloads -> exportDashboard, Copy as text -> copyDashboardToClipboard), and the header has an Import button (From file… opens a FileDialog filtered to *.json; Paste from clipboard). Results show in a status banner at the bottom of the chooser for 4.5 s (accent on success, statusDanger with lastShareError on failure).

Imports never overwrite. The incoming id is discarded and a new one is generated from the label with the same uniqueIdForLabel rule as Duplicate, so importing the same file twice gives name and name-2. The spec rules every save and load apply (DashboardManager::normalizeSpec, see the field table above) run first, plus the import-only refusals:

  • Root must be a JSON object with a cells array; schema (default 1) must be 1 or 2 -- newer is refused with "This dashboard needs a newer version of OCTAVE".
  • Only schema, label, gridColumns, gridRows, margins, spacing, cells survive; per cell only type, paramId, col, row, colSpan, rowSpan, props. Anything else -- an id, a source field, unknown keys -- is dropped.
  • props keeps scalar values only (bool, number, string ≤ 200 chars); nested objects and arrays are dropped.
  • Numbers must be JSON numbers (no bools, no numeric strings); geometry is moved into the grid and overlaps resolved as for any save, grid size 1..48, margins/spacing 0..200. Label is whitespace-collapsed, capped at 80 chars, default "Imported dashboard".
  • Caps: 512 KB of text, 256 cells. Cells with an empty or over-long type are skipped; unknown types are kept (the renderer skips them, so a newer catalog's widgets survive a round trip).

Shared dashboards are pure data -- no QML or script is ever loaded from an import. Covered by tests/cpp/tst_dashboardmanager.cpp (spec rules on save, load and import, the presets already satisfying them, rejections, export/import round trip, grid placement).

Reuse, Alerts, Auto-show and the Browser Editor

Phase 4 of docs/DASHBOARD_EDITOR_REVIEW.md. The optional spec keys stay schema 1 (an older build drops them):

KeyWhat it does
alertBannerDashboardRenderer.activeAlerts names every cell at its critical limit (critAbove / critBelow, in the PID's own unit) with its value in a red strip along the top (dashAlertBanner). false turns it off; absent = on.
showWhen{paramId, above, below}: frontend/dashboards/AutoShowRules.qml, checked twice a second by OBDMenu while its page is on screen with a car connected, brings the dashboard up when the rule starts to hold (first in chooser order wins), and the one before back after 5 s without it, unless the user picked another; once per crossing. The rules' PIDs are an autoShow polling demand. An auto-shown dashboard isn't saved as activeDashboard.
thumbnailA JPEG / PNG data URL ≤ 96 KB. The editor grabs its canvas (nothing selected) on Done; export carries it; the browser editor's list shows it.
  • Import report (frontend/dashboards/ImportReportPopup.qml): after every successful import, a live miniature and one line per thing that didn't come across, from lastImportReport: values the connected car doesn't report (shown as "--"), or "no car connected" when none has been scanned; values and widget types this build doesn't know; widgets dropped as overlapping; settings reset; widgets moved onto the grid; a portrait layout that didn't fit. Show it / Edit / Done.
  • Style (toolbar menu): copyStyle / pasteStyle / applyStyleToAll carry a widget's look -- its WidgetCatalog.styleKeys (editable props in the look and advanced sections, not a WarningLight's label or a PID-unit prop) and its first colorProps value, set on every colour prop of the target -- never its data, range or alerts. Pasting across types keeps the keys the target has; "Use this style on every ..." changes every other widget of the type in one undo step.
  • My widgets: Save as my widget... (Style menu) stores the selected widget's type, PID and props under a name (saveAsPreset); the gallery (PalettePopup) lists WidgetCatalog.presets first under Mine, each with Copy (as text) and ×; Paste a widget imports one; tapping places it (addPreset).
  • Alerts and auto-show... (Grid · Demo menu) edits alertBanner and showWhen (limits typed in the gauges' unit, stored in the PID's).
  • Browser editor: GET /dashboards on the service port serves sdk/web/dashboards.html (embedded at :/web-client/dashboards.html, CommandServer::addPage, a CSP that also allows data: images; not served when a --web-root frontend owns the port). It pairs like the reference client, lists the dashboards (thumbnail or a schematic, Edit / Customize / Copy / Download / Delete, New from a template filled from the car, Import by paste or file with the report), and edits one: drag to move, drag the corner to resize (placed by arrange), add by widget or by value (sizeOptions / minSpan / nearestFree), the catalog's options by section, colour swatches, Coarse / Normal / Fine (rescale; a portrait layout is derived again), the portrait layout (kept a placement per cell), undo / redo, and the dashboard's alert banner and auto-show rule. Show on the car sends each change (250 ms debounce, a keep-alive every 30 s) to previewDashboard; Save, leaving or closing the page ends it.
  • In a browser (chooser header, frontend/dashboards/RemoteEditPopup.qml): the editor's address from servicePairing.serviceUrls (set by main.cpp from the address the service listens on), a QR code of it (servicePairing.qrRows) and a pairing code when it is on the network; on loopback only (the default) the address for a browser on the same computer and a note that the network needs --service-bind; with the service off, where to turn it on.

DashboardRenderer: Spec to QML

frontend/dashboards/DashboardRenderer.qml is an Item with one input, spec. It positions cells absolutely instead of using GridLayout: GridLayout's fillHeight + rowSpan interaction does not give proportional sizes (a rowSpan: 4 cell came out the same height as a rowSpan: 1 peer), and deterministic cells are exactly the snap model the editor needs.

_cols    = spec.gridColumns || 12
_rows    = spec.gridRows    || 6
_margin  = dp(spec.margins  !== undefined ? spec.margins : 20)
_spacing = dp(spec.spacing  !== undefined ? spec.spacing : 16)
_cellW   = max(1, (width  - 2 * _margin - _spacing * (_cols - 1)) / _cols)
_cellH   = max(1, (height - 2 * _margin - _spacing * (_rows - 1)) / _rows)

cell.x      = _margin + col * (_cellW + _spacing)
cell.y      = _margin + row * (_cellH + _spacing)
cell.width  = colSpan * _cellW + (colSpan - 1) * _spacing
cell.height = rowSpan * _cellH + (rowSpan - 1) * _spacing

A Repeater over spec.cells creates, per cell, a clipped Item containing an asynchronous Loader:

  1. _knownType = WidgetCatalog.isKnownType(type) and _knownParam = !paramId || OBDParameterModel.hasParameter(paramId); Component.onCompleted emits one console.warn per failure, naming the dashboard id.
  2. The Loader is active only for known types; its source is WidgetCatalog.urlFor(type).
  3. onLoaded binds item.width/height to the Loader (Loader auto-sizing is unreliable when the loaded root has no implicit size), sets item.paramId first (only if the widget has that property and the id is known), then walks props applying every key the item already declares, through _coerce() for the "NaN" sentinel. Range props (WidgetCatalog.paramUnitProps(type): min / max where rangeProps, and editable props flagged "paramUnit": true such as warnAbove, redlineStart, triggerAbove / triggerBelow) are written in the PID's own unit and bound through OBDParameterModel.toDisplay(paramId, v), so they follow the gauge unit settings (gaugeTempUnit, gaugeSpeedUnit, sensorAltitudeUnit); the editor canvas does the same and the properties panel shows and stores them converted.
  4. Loader.Error is logged with the source URL; it never throws into the page.
  5. Vehicle gate: _unsupported is true when the cell has a known paramId, OBDParameterModel.vehicleKnown and !isSupported(paramId); the Loader is hidden and a placeholder (dashUnsupported_<index>) shows the title (or the title override) and "Not supported by this vehicle".

The renderer also exposes paramIds (distinct paramIds in the spec), which OBDMenu registers as the "dashboard" polling demand.

Cells without a paramId are still placed -- a WarningLight with an explicit value can be decorative. The renderer is instantiated in two places: full-size in OBDMenu for the active dashboard, and once per chooser card as a live miniature (same spec, scaled).

WidgetCatalog

The catalog's data lives in frontend/dashboards/widget-catalog.json ({schema, widgets, colorSwatches}), read by DashboardManager and served as dashboardManager.widgetCatalog, so every frontend gets the same widget list, and published as docs/api/widget-catalog.json. normalizeSpec uses it to hold a known widget's editableProps to their kind (a wrong type is dropped; numbers clamped to min / max, ints rounded). frontend/dashboards/WidgetCatalog.qml is a singleton that adds each type's QML url (its urls map: Qt.resolvedUrl("../gauges/X.qml") or "widgets/X.qml") and offers the ordered widgets array (a type needs both an entry and a url), colorSwatches, metaFor(type), urlFor(type) and isKnownType(type). It is the single whitelist the renderer, the palette, the PID picker and the properties panel all consult, so "what can render" and "what can be placed" cannot drift.

Each entry declares, besides type / url / displayName / glyph / default span (for a 12 × 6 grid; the editor scales it) / supportedKinds:

FieldMeaning
category, description, demoThe widget gallery's tab (one of the top-level categories), its one-line description, and {paramId, props} its live miniature runs on ({} for self-binding widgets).
minSize, aspectThe smallest size (dp, {w, h}) the widget reads at, and its natural width / height. dashboardManager.minSpan(type, ...) turns minSize into the smallest span on a given grid and sizeOptions(type, ...) offers S / M / L spans (about 1/12, 1/6 and 1/3 of the grid, nearest the aspect, never below the minimum) for the editor's size presets.
labelPropstrue if the widget has title + unit string props that default to the PID metadata: the panel offers Title / Unit overrides.
rangePropstrue if it has min + max real props: the panel offers Min / Max overrides and warns when min ≥ max.
colorPropsProps the single Color swatch row writes, all to the same value (e.g. ["fillColor", "needleColor"]). Unset = the live theme color.
editableProps{ key, label, kind, def[, min, max][, options] }; kind is bool (Switch), int / real (number field clamped to min/max), string (text field) or enum (segmented buttons over options). def is the gauge's real default (null = NaN-when-unset threshold, shown empty with placeholder "off").

The catalog also carries colorSwatches (the eight colors after the panel's "T" (theme) swatch), categories (the gallery's tabs, in order) and suggestions (byId then byKind: the widget data-first add proposes for a parameter; WidgetCatalog.suggestFor()). Every editableProps entry has a section (look / range / advanced) that places it in the settings sheet, and the value gauges list the shared alert props (warnAbove, warnBelow, critAbove, critBelow, alertFlash) besides those in the table.

typePalette name / glyphDefault spansupportedKindslabel / rangecolorPropseditableProps
CircularGaugeCircular ◯4 × 4*yes / yesfillColor, needleColorshowNeedle, showTicks, showCenterReadout, redlineStart, decimals (0-4), majorTickCount (2-20), minorTicksPerMajor (0-10), startAngle, sweepAngle
ArcGaugeArc ◠6 × 3*yes / yesfillColor, needleColorshowNeedle, showTicks, showReadout, redlineStart, decimals, majorTickCount, minorTicksPerMajor
BarGaugeBar ▮3 × 2percentage, temperature, numeric, pressure, voltageyes / yesfillColororientation (enum horizontal/vertical), showLabel, showValue, decimals, maxThickness, the alerts
SegmentedGaugeSegmented ▮6 × 2*yes / yesfillColorsegmentCount (2–40), showLabel, showValue, decimals (0–4), the alerts
LinearGaugeLinear ↔4 × 2bidirectionalyes / yesfillColorshowTicks, decimals, majorTickCount, minorTicksPerMajor
DigitalReadoutDigital 884 × 2*yes / novalueColorshowTitle, showUnit, decimals, padDigits (0-8), valueScale ("Digit size", 0.5-12)
SparklineGaugeSparkline ∿6 × 2*yes / yeslineColorautoScale, fillBelow, showHeader, decimals, maxSamples (10-300), sampleIntervalMs (100-5000)
WarningLightWarning ⚠2 × 2*no / noactiveColorlabel (string), triggerAbove, triggerBelow, pulse
GForceGaugeG-Force ◎4 × 4(none: self-binding)no / nodotColormaxG (0.2-5), showTrail, showValue
CompassGaugeCompass N4 × 4(none)no / nonorthColorshowDegrees
NowPlayingWidgetNow Playing ♪6 × 2(none)no / noprogressColorshowArt, showArtist, showProgress
MediaControlsWidgetMedia Keys ▶4 × 2(none)no / noaccentColor(none)
ClockWidgetClock 124 × 2(none)no / novalueColorshowDate, showSeconds

The catalog is what the panel reads, so the gauge never has to be instantiated to build the inspector. Older gauges also carry legacy octaveEditableProps manifests; new widgets only need the catalog entry and URL mapping. Anything not listed is still settable in the JSON by hand.

OBDMenu Integration: Active Dashboard and Chooser

  • Registry: dashboardRegistry = [ { id: "grid", label: "Parameter Cards", builtIn: true } ].concat(dashboardManager.dashboards), rebuilt in a Connections handler on dashboardsChanged (guarded with typeof dashboardManager so the page loads without the manager).
  • Active dashboard: activeDashboardId is read from the generic setting activeDashboard (default "grid") and written by setActiveDashboard(id). The header shows the registry label; the DashboardRenderer is visible and loads dashboardManager.loadDashboard(activeDashboardId) only when the id is not "grid", otherwise Parameter Cards is shown.
  • Parameter Cards: one card per OBDParameterModel.vehicleParameters entry -- the connected vehicle's full supported-PID list from this session's scan (no longer driven by the per-PID settings toggles). Square or circular per obdCardStyleCircular; 2 / 3 / 4 columns for up to 4 / 9 / 16 cards, 5 beyond that on wide screens. The grid sits in a vertical Flickable (parameterCardsScroll) and rows keep a 120 dp minimum height, so a long list scrolls instead of shrinking. Empty states (parameterCardsEmpty): "No vehicle connected" (with obdConnectButton, "Connect Bluetooth OBD2", which opens the OBDAdapterSheet scan / pair / connect sheet), "Reading your vehicle's PIDs…", "No supported PIDs reported". While visible it registers every card's id as the "cards" polling demand.
  • Swipe between dashboards: a horizontal-only DragHandler (dashboardSwipeHandler) on the page content cycles dashboardRegistry (Parameter Cards + every dashboard, wrapping). The content follows the finger via a Translate bound to _swipeOffset; on release it commits when the drag passed 20 % of the width, or for a fast flick (> 800 dp/s) that passed 8 % (so a tap that skids on a bumpy road never flips the page), else it springs back. A commit slides out (160 ms), switches with setActiveDashboard, then slides the new one in from the far side (220 ms) -- only the active dashboard is ever instantiated, so there is no cost for neighbours. Page dots under the header title (dashboardPageDots) show the position. Being xAxis-only, it leaves the cards' vertical scroll and widget buttons alone. swipeDashboard(dir) is callable directly. The handler, the dots and swipeDashboard() are all disabled while obdConnected is false.
  • Fallback: if the active id is no longer in the registry after a rescan (deleted in-app or externally), the handler calls setActiveDashboard("grid"). Delete goes through the page-level deleteUserDashboard(id) because dashboardsChanged is emitted synchronously inside deleteDashboard(), which destroys the card delegate that tapped Delete -- any code after the call in the delegate threw "obdPage is not defined" (c754f3d).
  • Chooser popup (square Dashboards button top-right, openDashboardChooser() / closeDashboardChooser() are also callable from the command server): a modal Popup up to 960 dp wide with a 2-column grid of cards. Each card renders a live miniature -- a second DashboardRenderer laid out at the page's full size and scaled down with transformOrigin: Item.Center. The miniature Loaders activate at aboutToShow (preloadMinis) so instantiation overlaps the popup's enter animation, and the stage fades in 180 ms after the spec lands so the gauges' 140 ms zero-to-value animation is never visible. The "grid" card uses a schematic 3 × 3 placeholder instead.
  • Card actions: always-visible buttons, no long-press ("discoverability-hostile in a glance-and-go in-car UI"). Copy on every JSON dashboard (calls duplicateDashboard(id, "")), Share on every JSON dashboard (see Sharing), Delete on every JSON dashboard (on a built-in it hides the preset until Restore defaults, a header button shown while hiddenBuiltInCount > 0), Edit only when builtIn === false; Delete is two-tap ("Delete" -> "Sure?" with a 3 s timer). The grid sentinel (Parameter Cards) has no actions and can never be deleted.
  • + New pushes dashboards/editor/DashboardEditor.qml onto the page's StackView with { stackView, callerPage }; Edit adds editingId. ▲ Draft appears when the dashboardEditorDraft setting is non-empty (re-read on every aboutToShow) and pushes the editor with draftJson. Import (importDashboardButton) opens a menu: From file… / Paste from clipboard.

Dashboard Editor Internals

The editor is QML over DashboardManager's save / placement API. It is a full-screen page: it declares hidesNavigation: true, and while it is the StackView's current item Main.qml (navigationHidden) hides the nav bar and gives the page the whole window, so it is left only through Done or Close. Interaction model (touch first, select then manipulate; design and rationale in docs/DASHBOARD_EDITOR_REVIEW.md §4.2):

  • Tap a widget to select it: it lifts (scale 1.02 and a shadow) and a floating toolbar appears above it, or below when there is no room (Data · Settings · S / M / L · Duplicate · Delete).
  • Drag the selected widget to move it (after 8 dp). An unselected widget never moves under a passing finger; press and hold selects one and drags it in the same gesture. While dragging the widget follows the finger (scale 1.05), a ghost shows the drop cell and the neighbours slide to where the drop would put them; the ghost turns red with the reason when the drop won't go.
  • Tap-tap: with a widget selected, tapping another one offers Swap / Select, and tapping free space offers Move here / Add here. With nothing selected, tapping free space offers Add here; + Add (bottom right) finds room by itself.
  • Resize with the size chips (S / M / L from dashboardManager.sizeOptions) or the corner grip, which snaps to the grid, shows "4 × 2" in the ghost, and never goes below the widget's readable minimum (minSpan) unless the widget is already smaller.
  • Settings opens the properties panel as a side sheet (a bottom sheet on a portrait screen). The canvas keeps its full-size layout and is zoomed to fit what the sheet leaves (canvasFrame.zoom), so nothing reflows while it is open; drags and resizes are off meanwhile, taps still select another widget.
  • Delete shows "<widget> removed · Undo" in the snackbar; every edit is undoable.
  • Keyboard: Ctrl+Z / Ctrl+Y (or Ctrl+Shift+Z), Delete, Ctrl+D (duplicate), arrow keys (nudge), Escape (close a chooser, then the sheet, then the selection).

Controls are at least touchSize: 9 mm where the screen reports its size (Screen.pixelDensity), never under 44 dp. While open, the editor registers its cells' PIDs as the "editor" polling demand so gauges preview live values from a connected car.

DashboardEditor.qml -- owner of the working spec

StateMeaning
cellsThe working cell array, never mutated in place. Every edit builds a new array and goes through _apply(next), which records it for undo, marks the work unsaved and calls _show(next).
cellModelA ListModel mirroring cells for the canvas, one row per cell (roles type, paramId, cellCol / cellRow / cellColSpan / cellRowSpan, propsJson). _show() inserts, removes or sets only the rows that differ, so a move repositions one widget and an option change reloads one widget; every other widget keeps its instance (sparkline history, needle state). Rebuilding the whole canvas on each edit was the old behaviour.
canUndo / canRedo, undo() / redo()Up to 50 snapshots of the grid size and cells; a new edit after an undo drops the redo branch. Top-bar Undo / Redo buttons and the platform shortcuts (Ctrl+Z, Ctrl+Y / Ctrl+Shift+Z).
gridColumns, gridRowsThe loaded spec's values. A new dashboard (autoDensity, on by default) starts at defaultDensity(): the finest of Fine / Normal / Coarse whose cells are at least 12 mm on this screen (Normal where the screen doesn't report its size).
density, setDensity(name)"coarse" 4 × 3, "normal" 6 × 4, "fine" 12 × 8 (turned on a portrait screen), or "custom" for any other grid. The Grid · Demo menu picks one; dashboardManager.rescale() lays the cells out again on the new grid (refused with a reason when they don't all fit), undoable like any edit.
sheetOpenThe settings sheet for the selected widget (openSheet() / closeSheet(); deselecting closes it).
layoutMode, setLayoutMode(mode)"landscape" or "portrait": the layout on the canvas (top bar Portrait, editorPortraitButton). cells / gridColumns / gridRows are that layout; the other waits in _otherLayout. Cells carry an editor-only _uid (dropped on save) so a switch can match them: the other layout takes each cell's data and options from the one on screen and keeps its positions, and a cell it lacks goes to the nearest free spot (_reconcile). The first switch to portrait derives it (derivePortrait). The editor opens on portrait on an upright screen (autoLayoutMode, picked once the dashboard or template is loaded), not marking the dashboard changed, and follows the screen if it turns until the first edit or a press of Portrait (_followScreen); the browser editor at /dashboards does the same. A portrait layout on a landscape screen (or the reverse) is edited in an upright box: the screen turned, fitted into the frame (canvasFrame.rotated / designW / designH). Undo covers switches.
specToSave()What Done saves (and a draft keeps): the landscape cells, plus layouts.portrait once there is a portrait layout (then schema 2).
polledCount, updatesPerValueThe status line (editorPollingStatus): how many values on the dashboard come from the car, and what each gets of the adapter's ~15 requests a second (elmRequestsPerSecond); amber under one a second.
drivingLocked, passengerOverride, editWhileDrivingDriving lock: above about 5 km/h (3 mph in the store's own unit, off again under 1.5 mph; read from paramStore.SPEED, never the demo data) the editor is read-only: no selection, drags, toolbar, + Add or undo, and _apply refuses edits; a banner (editorDrivingLock) says why, and Done / Close still work. "I'm a passenger" (editorPassengerButton) lifts it for the session; the Grid · Demo menu's Allow editing while driving (setting dashboardEditWhileDriving) for good.
editingIdNon-empty when editing an existing user dashboard (the id is kept on save).
selectedIndex / selectedCellDrives the toolbar, the settings sheet and the PID picker (select(index), clearSelection()).
dirtySet by every mutation and by renaming; gates the "Save your changes?" question on Close and the draft rescue.
draftJsonInjected by the chooser's Draft button; restored in Component.onCompleted before any editingId load.

Mutation API called by the canvas and panel:

FunctionBehaviour
addCellAt(type, col, row)Tries, in order, the catalog's default span (given for a 12 × 6 grid) scaled to this grid, the S size and the readable minimum (minSpan), none smaller: first over the tapped cell (shifted in from the edge), then fitAt()'s shrink if that stays readable, then the nearest free spot (dashboardManager.nearestFree()). col < 0 (+ Add) goes straight to the nearest free spot from the top left. No room: a reason in the snackbar, returns false. Selects the new cell and opens the PID picker unless the widget is self-binding.
moveCell(index, col, row)Clamps into the grid and calls dashboardManager.arrange(): the widgets the cell lands on swap with it (one widget, whose span fits where the moved one was) or move to the nearest free spot of their size; widgets it doesn't cover never move. Refused (no room): nothing changes and the reason shows in the error banner; the canvas has already put the dragged widget back.
nudgeCell, resizeCellStepper and arrow-key helpers (±1 column/row/span) built on the same checks.
resizeCellTo(index, colSpan, rowSpan)Absolute resize for the drag grip, through the same arrange() as a move: growing over a neighbour pushes it aside. Never below the readable minimum, or the current size if that is already smaller.
setCellSize(index, colSpan, rowSpan)A size chip: the new span from the same top-left, moved in from the edge when it would pass it.
swapCells(index, other)Tap-tap Swap: index moves to other's corner, and arrange() swaps or pushes other.
duplicateCell(index)A copy at the nearest free spot of its size, to the right first; selects it.
deleteCell(index)removeCell plus the "removed · Undo" snackbar (toolbar, sheet and Delete key use it).
removeCell, setCellParam, setCellProp, clearCellPropPatch helpers; clearing a prop removes the key so the gauge default applies again.
setCellProps(index, keys, value)Writes one value to several props in a single mutation (the Color row's colorProps); value === undefined removes them all (back to theme).
save()Label defaults to "Untitled"; id = editingId or dashboardManager.uniqueIdForLabel(label); builds { schema: 1, id, label, gridColumns, gridRows, cells } plus margins / spacing when the loaded spec carried them; on success clears dirty, clears the stored draft if this session resumed one, calls callerPage.setActiveDashboard(savedId) and pops. Failure shows the reason in the snackbar. Done calls it.
close(), discard()Close: straight out without changes; with changes a dialog (editorCloseDialog) asks Save / Discard / Keep editing. Discard clears dirty, clears a resumed draft and pops.
LoadingdashboardManager.loadDashboard() (or normalizedSpec() for a rescued draft) already returns the spec in the grid with no overlaps, so the editor takes the cells as they are. Unknown types stay as inert cells ("needs a newer OCTAVE" in the panel, Delete works) and unknown PIDs stay unbound, so saving keeps them. A spec with a newer schema is not opened.

Draft stash: popping the editor (for example with Home or Settings) destroys it. Opening another cached page above it can leave the editor alive underneath. Component.onDestruction first forces OBDParameterModel.simulationActive = false (demo data must never leak into the real OBD views), then, if dirty, writes { editingId, label, gridColumns, gridRows, cells } (plus margins / spacing when the loaded spec had them) as JSON to the dashboardEditorDraft setting; restoring a draft reads them back the same way. Only a session that resumed a draft may clear it, so opening and closing a fresh editor cannot wipe an earlier draft. Demo data is the checkable item in the top bar's Grid · Demo menu, bound to simulationActive.

Settings sheet: the properties panel was first docked beside the canvas (it resized the canvas on every selection change, rescaling the dashboard mid-edit) and then floated over it (it covered the widget being edited). The sheet takes its own strip and the canvas is scaled, not re-laid out, to fit the rest, with drags off while it is open, which avoids both.

EditorCanvas.qml

  • Geometry mirrors the renderer exactly: the canvas takes the spec's optional margins / spacing (undefined keeps the 20 dp / 16 dp defaults) and uses the same cell formulas, so the layout you edit is the layout that renders.
  • canPlace(col, row, colSpan, rowSpan, exceptIndex): inside the grid and no rectangle overlap with any other cell; it calls dashboardManager.canPlace(), the rule save and load use too. arrangement(index, col, row, colSpan, rowSpan) asks dashboardManager.arrange() where everything would go for a drop or resize (swapping or pushing), caching the last answer (canArrange() is its ok); it drives the ghost and the live preview.
  • The grid is a dot at every gutter crossing (one Canvas), brighter while a drag snaps to it. A background MouseArea turns a tap into emptyCellTapped(col, row) on a free cell (cellAt(), isFree() over the _occupied map) or backgroundTapped() outside the grid.
  • Placed cells: a Repeater over the editor's cellModel. Each delegate has a synchronous Loader with the same bind/props logic as the renderer, reloaded only when its own type / paramId / propsJson change; a "Choose data" badge on unbound PID widgets; a selection frame and shadow; and a MouseArea whose drag.target is set only for the selected cell (or one held down; drag.threshold 8 dp). onClicked emits cellTapped, onPressAndHold cellHeld; while dragging, _updateDragCandidate() rounds the item position to the nearest cell and sets the drag state (dragIndex, dragCol / dragRow, dragValid, dragReason); the other cells bind their position to _previewCells (the arrangement's cells) and animate there in 110 ms; onReleased re-binds the delegate's x / y to its cell and emits cellMoveRequested.
  • Drop ghost (editorDropGhost): accent when the drop or resize would go through, statusDanger with the reason (editorDropGhostText) when not; shows the span while resizing.
  • Resize grip (editorResizeGrip_<index>): on the selected cell only, while interactive; a 24 dp accent dot centred on the bottom-right corner with a touchSize hit area. Its MouseArea (preventStealing) maps the pointer into canvas coordinates, snaps the far corner to the cell under it (never below minSpanFor(index), which the editor supplies) and drives the ghost and preview; release emits cellResizeRequested.
  • selectedRect, rectOf(cell): canvas rectangles the editor places its toolbar and choosers by. cellWidthDp / cellHeightDp / spacingDp feed minSpan / sizeOptions.
  • Signals: emptyCellTapped(col, row), cellTapped(index), cellHeld(index), cellMoveRequested(index, col, row), cellResizeRequested(index, colSpan, rowSpan), backgroundTapped(). The canvas never mutates cells.

TemplateGallery, PalettePopup, PIDPicker, PropertiesPanel

  • TemplateGallery (templateGallery, the chooser's + New): a card per dashboardManager.templates entry (frontend/dashboards/templates/*.json, in their order), each a DashboardRenderer miniature of dashboardManager.fromTemplate(id, supported), with the PIDs the scanned car reports (or common ones with no car; the note templateGalleryNote says which). The miniatures run on the demo data while the gallery is open (restored as it was on close) and render only from aboutToShow until 1.5 s after closing (_showing; not Popup.visible, which changes more than once while opening). templatePicked(spec): OBDMenu pushes the editor with initialSpec and fitToScreen, which lays the template's 6 × 4 grid out again on the screen's default density. Built-in cards have Customize instead of Edit: the editor opens on an unsaved copy named "… (mine)" (initialSpec without an id), saved under its own id on Done.
  • PalettePopup (the widget gallery, widgetPalette): a search field (paletteSearch, focused only when tapped), category chips (paletteCategory_<name>, from the catalog's categories) and a scrolling 2–4-column grid of cards, each the real widget loaded on its catalog demo PID and props (demo data on while the gallery is open), its name and description. Tapping emits widgetPicked(type) and closes. What do you want to see? (paletteDataFirst) emits dataFirstRequested(): the editor opens the PID picker over every kind, and the pick places WidgetCatalog.suggestFor(paramId, kind) (the catalog's suggestions.byId, then byKind, else a readout) already bound (addForParam).
  • PIDPicker (pidPicker): modal, 85 % of the window height, a search field that is focused only when tapped (no keyboard over the list on a touch screen), and a flat row model from displayParameters (aliases left out) filtered by supportedKinds ("*" passes all) and by the query against title, id or group. Rows are grouped by system in parameterCatalog.groups order (Engine, Fuel & air, Temperatures, Electrical, Speed & motion, Emissions, Diagnostics, Sensors), with the last six picks under Recent first (the dashboardRecentPids setting). Each row shows its live value when there is one (pidLive_<id>). The current PID is highlighted; empty results show "No matching PIDs". Vehicle-gated (see Vehicle Gate): once a vehicle is scanned, its PIDs come first in each group and the rest are greyed (45 % opacity, "Not supported by this vehicle") but still pickable; a note under the title (pidPickerVehicleNote) explains which mode is active.
  • PropertiesPanel (the settings sheet, scrollable Flickable, editorPanelScroll): a header (glyph, name, ✕ closes the sheet), section chips (panelSection_<id>; the choice is kept across selections, falling back to the widget's first section), then one section at a time:
    1. Data (PID widgets only): the PID button (red border while unbound), its live value (panelLiveValue), a red note (panelPidUnsupported) when the connected vehicle doesn't report it.
    2. Look: Title / Unit overrides (panelOverride_<key>, placeholders show the PID's own; empty = follow it), the Color swatches (a "T" theme swatch plus WidgetCatalog.colorSwatches, panelSwatch_<hex>; a pick writes every colorProps key through setCellProps), and the editableProps with section: "look".
    3. Range & alerts: Min / Max overrides with the min ≥ max warning (panelRangeWarning), the "range" options (warning / critical limits, flash, red band, lamp triggers), and Test value (panelTestSlider, panelTestReset): a slider over the effective range that drives this widget only (editor.testValues, written over the widget's live binding by the canvas; never saved, not in undo, cleared when the sheet closes, the selection changes or the cells change).
    4. Advanced: the other options and the Column / Row / Width / Height ◄ value ► steppers (nudgeCell / resizeCell).
    5. JSON (panelJson, panelJsonApply): the props as text; Apply calls setCellPropsJson(index, text), which runs them through normalizedSpec and reports what it dropped (panelJsonStatus).
    6. "Remove widget" (deleteCell, undoable from the snackbar).
    Options are a Switch for bool, segmented buttons for enum (panelOptEnum_<key>_<value>), a text field for string, a number field for int/real (empty = unset = gauge default; clamped to the option's min/max on editingFinished). Text fields use a dark field background so typed values stay readable against the light theme text color. Because Repeater delegates survive selection changes between same-type cells and user input breaks bindings, each control re-syncs from the cell on a _cellRef change. The panel never mutates the cell; every control calls back into the editor page.

Every interactive control carries an objectName (newDashboardButton, dashCard_<id>_edit, editorAddHere, editorToolbar, editorToolSize_M, editorCloseButton, paletteCard_<type>, pidRow_<id>, stepper_Width_inc, panelOpt_<key>, ...) for the command-server test driver.

Validation and Hot Reload

WhereCheckOutcome
DashboardManagerJSON parses to an object; id present; unique vs presetsFile skipped with a warning; the rest of the list still loads.
DashboardRendererWidgetCatalog.isKnownType(type); OBDParameterModel.hasParameter(paramId)Unknown type: cell skipped, console.warn with dashboard id. Unknown PID: widget rendered unbound (shows 0 / placeholder), warned.
DashboardManager save / load / import (normalizeSpec)Schema version, allowed keys, scalar props, number types, geometry, overlaps, size caps, file-safe id on saveReduced to the known keys, moved into the grid, overlaps shrunk or dropped (see the field table); a save that fails returns ""; an import is refused with a lastShareError reason (see Sharing). A newer-schema file loads untouched for the renderer's placeholder.
OBDMenuActive id still exists after dashboardsChangedFalls back to Parameter Cards.
Hot reloadWatcher + 300 ms debounce + signatureA spec dropped into or removed from the user dir from a terminal or sync client shows up in the chooser within about a second, no restart.

Not validated: a props value's type against the widget (a scalar of the wrong type is assigned as-is and the QML property coerces or ignores it). Unknown types and PIDs are only checked in QML, since the widget catalog is QML.

Testing

Headless (QtTest under tests/cpp/, run by ctest in CI):

  • tests/cpp/tst_dashboardmanager.cpp -- the import sanitizer keeps only known keys and clamps geometry, rejects newer/bad schema, missing cells, too many cells and non-object JSON (a data-driven test); export writes OCTAVE-dashboards/<id>.json (HOME and XDG_DOWNLOAD_DIR pointed at a temp dir), importing it twice gives two new ids, and failures set lastShareError.
  • tests/cpp/tst_vehiclegate.cpp -- loads the real OBDParameterModel and DashboardRenderer against a fake obdManager and checks the gate before a scan, after a scan (only the unreported PID shows the placeholder; sensor ids exempt; vehicleParameters order) and after disconnect.
  • tests/cpp/tst_obdmanager.cpp -- the backend side of demand-driven polling (see OBD Manager).

End-to-end (gitignored dev/ tree, launches the app through dev/app_launch.py and drives it through the --profile command server; honours XDG_CONFIG_HOME for a sandboxed config):

  • dev/tests/drive_dashboard_editor.py -- opens the chooser, creates a dashboard, opens the palette, picks a PID via the searchable picker, moves and resizes with the steppers, drags with a valid and a rejected drop, toggles an option switch and edits a numeric field, removes a widget, toggles demo data, saves, reopens via Edit, copies, deletes, exercises draft rescue by leaving through go_home, verifies hot reload by writing a JSON file directly, and checks that built-in cards expose only Copy -- 42 step() assertions, screenshots under dev/screenshots/output/editor/. Taps on canvas cells deselect first (the floating panel can cover them, and a finger can't tap through it); card actions and panel controls are scrolled into view before tapping.
  • dev/tests/drive_dashboard_features.py -- reuses those helpers for 19 checks: Parameter Cards empty state with no vehicle, dashboards hidden (connect prompt, no swipe, no dots) while the link is down, then with the simulated car reconnected swipe next / back / short-drag snap-back, the picker's offline note, title / max / min overrides and the min ≥ max warning, color swatch and theme reset, the Bar orientation enum, the resize grip (valid and refused), the saved JSON, Share -> Copy as text -> Import -> Paste (new id, identical cells) and Share -> Save to Downloads. Screenshots under dev/screenshots/output/features/; its test dashboards and the exported file are removed afterwards.

Both exit 1 on any failure. Modes: xvfb (default, deterministic 1280 × 720), visible, offscreen. The command server injects mouse input through QTest (via QWindowSystemInterface), so pointer handlers such as the swipe DragHandler see a real press / move / release sequence; hand-built QMouseEvents sent with sendEvent() looked like a fresh press on every move, which made the handler steal taps. Per TODO/dashboards-roadmap.md an on-device pass on the Android tablet (touch drag feel, palette/picker sizing) is still open. See Development → Command Server.

Authoring New Gauges and Dashboards

docs/GAUGE_AUTHORING.md is the source of truth for the API and carries the step-by-step recipes; update it in the same commit as any gauge or schema change. In short:

  • New dashboard: build it in the editor, or write a schema-1 JSON by hand and drop it into frontend/dashboards/presets/ (ships as a read-only built-in, picked up at the next start) or the user dashboards directory (picked up live).
  • New primitive: create frontend/gauges/MyGauge.qml, paste the shared binding preamble verbatim, animate _animNorm (not value), use Shape/PathAngleArc for curves and rotated Rectangles for marks, declare octaveSupportedKinds, scale typography with the cell, and register it in WidgetCatalog with a url, display name, glyph, default span, matching supportedKinds and curated editableProps with honest defaults. Without the catalog entry it will neither render nor be placeable. Then document it in §3 of the authoring guide and on this page.
  • New backend parameter: add the decoder to elm327protocol.cpp (and the signal to OBDManager), and the row with a kind to parametercatalog.cpp (tst_parametercatalog fails without it); its values reach paramStore through parameterSampled, so gauges pick it up with no further work.

Design Decisions

  • A singleton aggregator between the backend and the gauges. OBDParameterModel was introduced (42bbf5f, "centralized OBD parameter definitions into a singleton") so that parameter metadata lives in one QML table and gauges bind by string id. That is what lets the same gauge run against the live manager, the dev-mode simulated car, a test fake or pure-QML demo data, and what let the IMU parameters join later with zero gauge changes.
  • Coalesced model writes, then a per-key store. Replacing a whole paramValues map per signal was correct but cost a full core on the Pi with nothing connected (486a809); a 33 ms flush kept bindings reactive without allocating per write. Open-backend #8 moved the values into the C++ paramStore (same 33 ms fold, one notifying key per id), so a value now re-evaluates only its own readers. The sparkline samples on its own timer, so the change did not alter any time axis.
  • Grid over free placement. Every dashboard anyone had written was grid-shaped, grids are far easier to edit on a touchscreen, and a 12-column grid maps cleanly onto the spans people actually want (TODO/dashboards-roadmap.md, "Key design decisions already made").
  • JSON before editor. Phase 2 (data-driven dashboards) was declared the hinge: an editor that emitted QML source would have been a mess, while an editor that emits a JSON object is a GUI over a data format. Hand-written dashboard QML files were retired once all built-ins were JSON.
  • Absolute positioning in the renderer. GridLayout could not give proportional row spans; explicit cell math also gives the editor a deterministic snap grid and makes the miniature previews pixel-consistent with the full view.
  • Built-ins read-only, "Copy" to derive. Presets are authoritative and their ids are reserved (even while hidden by Delete); a user file that reuses one is rejected at load rather than shadowing it, so an update to a preset can never be masked by a stale copy.
  • The editor is pure QML. The backend already had everything the editor needed; the only missing piece, "unique id from a label", was duplicated in QML rather than adding a slot. No CMake or main.cpp churn.
  • One catalog, three consumers. The type -> url map used to live inline in the renderer; lifting it into WidgetCatalog means the palette cannot offer a type the renderer cannot draw, and the panel cannot expose a prop the gauge does not declare.
  • Curated, honest editable props. The panel shows a deliberately small set of scalar props with the gauge's real defaults so the in-car UI stays simple; the JSON stays fully expressive for power users (the TJ preset uses min/max and sample-interval overrides, which the current panel also offers).
  • Hybrid tap-and-drag. The original plan was tap-to-place only, because drag fights the chooser's Flickable; the editor canvas is a full-screen non-scrolling page, so drag is safe there and was added for placement. Tap-to-place stays for new widgets, steppers stay for precision.
  • Visible action buttons, two-tap destructive actions. Long-press is hidden; a Delete or Discard that fires on one tap is dangerous at 60 mph. Both confirmations time out after 3 s.
  • Draft rescue on destruction. Home and Settings can pop and destroy the editor without going through its Cancel action; stashing the working spec in a setting and offering it back was the smallest fix that loses no work.
  • Demo data lives in the model and is forced off on editor destruction. A sine sweep in QML needs no backend and no mock; the destruction hook turns it off when the editor is destroyed. Because the model is global, other views can observe demo values while an editor remains alive underneath a cached page.
  • Validation logs and degrades, never throws. A typo in one cell of a hand-written spec must not take down the OBD page in a moving vehicle.
  • Hot reload with a signature, not just an event. Directory events fire for the manager's own atomic saves; comparing a name/mtime/size signature makes the external-edit path and the in-app path share one rescan without double work.