Gauges & Dashboards
Reusable gauge primitives, the OBDParameterModel binding layer, schema-1 JSON dashboards rendered by DashboardRenderer, DashboardManager, and the in-app drag-and-tap dashboard editor
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.
| Member | Description |
|---|---|
allParameters | parameterCatalog.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). |
kind | One 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++). |
simulationActive | Demo 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, vehicleSupportedParameters | Mirrors 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). |
vehicleParameters | The 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):
| id | Title | Unit | Min | Max | Kind |
|---|---|---|---|---|---|
PITCH | Pitch | ° | -90 | 90 | bidirectional |
ROLL | Roll | ° | -180 | 180 | bidirectional |
HEADING | Heading | ° | 0 | 360 | numeric |
ALTITUDE | Altitude | m | 0 | 3000 | numeric |
ACCEL_MAG | G Magnitude | g | 0 | 3 | numeric |
LATERAL_G | Lateral G | g | -2 | 2 | bidirectional |
LONGITUDINAL_G | Longitudinal G | g | -2 | 2 | bidirectional |
BARO_TEMP | Cabin Baro Temp | °C | -20 | 60 | temperature |
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:
| Where | No vehicle scanned | Vehicle 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. |
DashboardRenderer | Every cell renders | A 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 PIDPicker | Every 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:
| Consumer | Registered by | Ids |
|---|---|---|
"dashboard" | OBDMenu while it is the active StackView page and a dashboard is showing | DashboardRenderer.paramIds (distinct ids in the spec) |
"cards" | OBDMenu while Parameter Cards is showing | every id in vehicleParameters |
"editor" | DashboardEditor for its lifetime | the canvas cells' ids (live preview while editing) |
"carMenu" | CarMenu while visible | RPM and SPEED for the rig animation |
"home" | HomeOBDView while visible | the Home tiles' ids |
"settings" | OBDSettingsPage while its list is visible | its 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
| Property | Type | Default | Notes |
|---|---|---|---|
paramId | string | "" | Any id from allParameters. Drives title/unit/min/max/value. |
title, unit | string | from paramId | Override the label / suffix (a preset can set "title" in props). |
min, max | real | from 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. |
value | real | live | Override for previews and demos. |
decimals | int | 0 | Digits after the point in numeric readouts; WarningLight has no decimals property. |
labelColor, valueColor | color | App.Style.obdLabelColor, App.Style.obdValueColor | Numeric readout colors; WarningLight has its own label colors. Other default colors come from App.Style (obdBarColor, obdBoxBackground, statusDanger, accent). |
octaveSupportedKinds | var (readonly) | per widget | Kinds the widget accepts, ["*"] = any, [] = self-binding. Must mirror WidgetCatalog.supportedKinds. |
octaveEditableProps | var (readonly) | per widget | Manifest 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).
| Property | Type | Default | Notes |
|---|---|---|---|
startAngle | real | 135 | PathAngleArc degrees, 0 = 3 o'clock, clockwise. |
sweepAngle | real | 270 | Three-quarter dial; 180 gives a half-dial. |
showNeedle | bool | false | Needle (3 dp wide, 92 % of radius, transformOrigin: Item.Bottom) + hub; the readout shifts up by 45 % of the radius to clear it. |
showTicks | bool | true | |
majorTickCount | int | 9 | Inclusive of both endpoints. Total ticks = (major - 1) * minorPerMajor + 1. |
minorTicksPerMajor | int | 5 | |
redlineStart | real | NaN | When set and strictly inside (min, max): a red arc from that value to max, and the fill turns red once value >= redlineStart. |
showCenterReadout | bool | true | Title, value, unit stacked; font sizes capped at 22 % / 30-42 % / 18 % of the arc radius. |
trackColor, fillColor, redlineColor, needleColor | color | darker 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. ["*"].
| Property | Type | Default | Notes |
|---|---|---|---|
startAngle / sweepAngle | real | 180 / 180 | 9 o'clock -> 12 -> 3 o'clock. |
showNeedle, showTicks | bool | false, true | |
majorTickCount / minorTicksPerMajor | int | 5 / 4 | |
redlineStart | real | NaN | As CircularGauge. |
showReadout | bool | true | Title / value / unit under the chord (unit row hidden when empty). |
trackColor, fillColor, redlineColor, needleColor | color | theme |
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.
| Property | Type | Default | Notes |
|---|---|---|---|
orientation | string | "horizontal" | or "vertical". |
warnAbove, critAbove, ... | real | NaN | The shared alerts (above): the fill (200 ms colour animation) and the value turn amber / red. |
showLabel, showValue | bool | true, true | |
maxThickness | real (dp) | 0 = fill | Caps the pill thickness so a bar in a tall cell stays a bar (the Sport preset uses 40). |
trackColor, fillColor, warnColor, critColor | color | theme |
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.
| Property | Type | Default | Notes |
|---|---|---|---|
majorTickCount / minorTicksPerMajor | int | 5 / 4 | |
showTicks | bool | true | |
centerOriginAtZero | bool | min < 0 && max > 0 | Overridable. |
trackColor, fillColor | color | theme |
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).
| Property | Type | Default | Notes |
|---|---|---|---|
padDigits | int | 0 | Zero-pads the integer part (sign preserved). |
valueScale | real | 3.5 | Multiplier on App.Spacing.overallText (Sport uses 7.0). |
showTitle, showUnit | bool | true, true | |
alignment | int | Qt.AlignHCenter | or AlignLeft / AlignRight. |
unitColor | color | obdLabelColor |
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).
| Property | Type | Default | Notes |
|---|---|---|---|
maxSamples | int | 60 | |
sampleIntervalMs | int | 500 | 60 × 500 ms = 30 s window by default. |
fillBelow | bool | true | Area fill at 22 % alpha of obdBarColor. |
showHeader | bool | true | "Title Value Unit" strip. |
autoScale | bool | false | Y-range from the visible window's min/max instead of the PID range -- for fuel trim, timing and other small-swing values. |
lineColor, fillColor, backgroundColor | color | theme | fillColor 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.
| Property | Type | Default | Notes |
|---|---|---|---|
triggerAbove, triggerBelow | real | NaN, NaN | |
label | string | "" | Short text inside the disc (38 % of the diameter). |
activeColor | color | App.Style.statusDanger | Presets pass hex strings (e.g. "#F1C40F" for the fuel light). |
inactiveColor | color | darker obdBoxBackground | |
activeLabelColor | color | "white" | |
pulse | bool | false | Reserve for critical alerts (overtemp, overrev). |
maxSize | real (dp) | 0 = fill | Caps 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.
| Widget | Source | Props (default) | Notes |
|---|---|---|---|
GForceGauge | liveValue("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. |
CompassGauge | liveValue("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. |
NowPlayingWidget | nowPlaying | showArt, 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. |
MediaControlsWidget | nowPlaying | none | Prev / 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. |
ClockWidget | Local clock, app 12/24-hour preference | showDate (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 } ]
}
}
}
| Field | Required | Read by | Rules |
|---|---|---|---|
id | yes | DashboardManager, OBDMenu | Missing 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). |
label | yes (for save) | DashboardManager, OBDMenu | Whitespace collapsed, capped at 80 chars. saveDashboard rejects an empty label; a file with none loads named after its id. |
schema | by convention | DashboardManager | Written 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, gridRows | no | Renderer, Editor | Not a positive JSON number (0, missing, a string) = 12 / 6; capped at 48. |
margins, spacing | no | Renderer and editor | Applied 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[].type | yes | Renderer, Editor, WidgetCatalog | Kept 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[].paramId | no | Renderer, Editor | Kept 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/rowSpan | no | Renderer, Editor | Normalised 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[].props | no | Renderer, Editor | Scalars 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.portrait | no (schema 2) | Renderer, Editor | Where 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 / label | Grid | Layout |
|---|---|---|
sport / Sport | 12 × 8 | Digital 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 / Minimal | 12 × 8 | Three full-height circular gauges: Speed, RPM (redline 6500), Fuel. |
fullgrid / Full Grid | 12 × 8 | 4 × 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 / Performance | 12 × 8 | Full-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.0 | 12 × 12 | The 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.
| Member | Kind | Behavior |
|---|---|---|
dashboards | Property (list), notify dashboardsChanged | Headers { 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 -> map | The 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 -> bool | Removes 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. |
hiddenBuiltInCount | Property (int), notify dashboardsChanged | Number of built-ins the user has deleted (hidden). |
supportedSchema | Property (int), constant | kSupportedSchema (2): the highest dashboard schema this build reads; DashboardRenderer shows "needs a newer OCTAVE" above it. |
restoreBuiltInDashboards() | Slot -> bool | Clears .hidden-builtins and rescans; false if nothing was hidden. |
uniqueIdForLabel(label) | Invokable -> id | The 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() | Slot | Full 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 -> bool | Puts 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. |
lastShareError | Property (string), notify lastShareErrorChanged | Human-readable reason the last export/import failed; "" after a success. The chooser's status banner shows it. |
lastImportReport | Property (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. |
widgetPresets | Property (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
*.jsonis fully parsed (there is no lightweight header read;readHeadercallsreadFullSpec). Non-object roots, parse errors, missing ids, duplicate preset ids and user ids colliding with a built-in are logged (octave.dashboardscategory) 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:
slugifylowercases, replaces runs of[^a-z0-9]with-, trims dashes, and falls back to"dashboard";uniqueIdForLabelappends-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): aQFileSystemWatcheron the user dir, a 300 ms single-shot debounce, and a signaturename: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.
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
cellsarray;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,cellssurvive; per cell onlytype,paramId,col,row,colSpan,rowSpan,props. Anything else -- anid, asourcefield, unknown keys -- is dropped. propskeeps 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
typeare 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):
| Key | What it does |
|---|---|
alertBanner | DashboardRenderer.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. |
thumbnail | A 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/applyStyleToAllcarry a widget's look -- itsWidgetCatalog.styleKeys(editable props in thelookandadvancedsections, not a WarningLight'slabelor a PID-unit prop) and its firstcolorPropsvalue, 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) listsWidgetCatalog.presetsfirst 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
alertBannerandshowWhen(limits typed in the gauges' unit, stored in the PID's). - Browser editor:
GET /dashboardson the service port serves sdk/web/dashboards.html (embedded at:/web-client/dashboards.html,CommandServer::addPage, a CSP that also allowsdata:images; not served when a--web-rootfrontend 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 byarrange), 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) topreviewDashboard; 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 bymain.cppfrom 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:
_knownType = WidgetCatalog.isKnownType(type)and_knownParam = !paramId || OBDParameterModel.hasParameter(paramId);Component.onCompletedemits oneconsole.warnper failure, naming the dashboard id.- The Loader is
activeonly for known types; itssourceisWidgetCatalog.urlFor(type). onLoadedbindsitem.width/heightto the Loader (Loader auto-sizing is unreliable when the loaded root has no implicit size), setsitem.paramIdfirst (only if the widget has that property and the id is known), then walkspropsapplying every key the item already declares, through_coerce()for the"NaN"sentinel. Range props (WidgetCatalog.paramUnitProps(type):min/maxwhererangeProps, and editable props flagged"paramUnit": truesuch aswarnAbove,redlineStart,triggerAbove/triggerBelow) are written in the PID's own unit and bound throughOBDParameterModel.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.Loader.Erroris logged with the source URL; it never throws into the page.- Vehicle gate:
_unsupportedis true when the cell has a knownparamId,OBDParameterModel.vehicleKnownand!isSupported(paramId); the Loader is hidden and a placeholder (dashUnsupported_<index>) shows the title (or thetitleoverride) 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:
| Field | Meaning |
|---|---|
category, description, demo | The 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, aspect | The 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. |
labelProps | true if the widget has title + unit string props that default to the PID metadata: the panel offers Title / Unit overrides. |
rangeProps | true if it has min + max real props: the panel offers Min / Max overrides and warns when min ≥ max. |
colorProps | Props 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.
| type | Palette name / glyph | Default span | supportedKinds | label / range | colorProps | editableProps |
|---|---|---|---|---|---|---|
CircularGauge | Circular ◯ | 4 × 4 | * | yes / yes | fillColor, needleColor | showNeedle, showTicks, showCenterReadout, redlineStart, decimals (0-4), majorTickCount (2-20), minorTicksPerMajor (0-10), startAngle, sweepAngle |
ArcGauge | Arc ◠ | 6 × 3 | * | yes / yes | fillColor, needleColor | showNeedle, showTicks, showReadout, redlineStart, decimals, majorTickCount, minorTicksPerMajor |
BarGauge | Bar ▮ | 3 × 2 | percentage, temperature, numeric, pressure, voltage | yes / yes | fillColor | orientation (enum horizontal/vertical), showLabel, showValue, decimals, maxThickness, the alerts |
SegmentedGauge | Segmented ▮ | 6 × 2 | * | yes / yes | fillColor | segmentCount (2–40), showLabel, showValue, decimals (0–4), the alerts |
LinearGauge | Linear ↔ | 4 × 2 | bidirectional | yes / yes | fillColor | showTicks, decimals, majorTickCount, minorTicksPerMajor |
DigitalReadout | Digital 88 | 4 × 2 | * | yes / no | valueColor | showTitle, showUnit, decimals, padDigits (0-8), valueScale ("Digit size", 0.5-12) |
SparklineGauge | Sparkline ∿ | 6 × 2 | * | yes / yes | lineColor | autoScale, fillBelow, showHeader, decimals, maxSamples (10-300), sampleIntervalMs (100-5000) |
WarningLight | Warning ⚠ | 2 × 2 | * | no / no | activeColor | label (string), triggerAbove, triggerBelow, pulse |
GForceGauge | G-Force ◎ | 4 × 4 | (none: self-binding) | no / no | dotColor | maxG (0.2-5), showTrail, showValue |
CompassGauge | Compass N | 4 × 4 | (none) | no / no | northColor | showDegrees |
NowPlayingWidget | Now Playing ♪ | 6 × 2 | (none) | no / no | progressColor | showArt, showArtist, showProgress |
MediaControlsWidget | Media Keys ▶ | 4 × 2 | (none) | no / no | accentColor | (none) |
ClockWidget | Clock 12 | 4 × 2 | (none) | no / no | valueColor | showDate, 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 aConnectionshandler ondashboardsChanged(guarded withtypeof dashboardManagerso the page loads without the manager). - Active dashboard:
activeDashboardIdis read from the generic settingactiveDashboard(default"grid") and written bysetActiveDashboard(id). The header shows the registry label; theDashboardRendererisvisibleand loadsdashboardManager.loadDashboard(activeDashboardId)only when the id is not"grid", otherwise Parameter Cards is shown. - Parameter Cards: one card per
OBDParameterModel.vehicleParametersentry -- 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 perobdCardStyleCircular; 2 / 3 / 4 columns for up to 4 / 9 / 16 cards, 5 beyond that on wide screens. The grid sits in a verticalFlickable(parameterCardsScroll) and rows keep a 120 dp minimum height, so a long list scrolls instead of shrinking. Empty states (parameterCardsEmpty): "No vehicle connected" (withobdConnectButton, "Connect Bluetooth OBD2", which opens theOBDAdapterSheetscan / 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 cyclesdashboardRegistry(Parameter Cards + every dashboard, wrapping). The content follows the finger via aTranslatebound 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 withsetActiveDashboard, 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 andswipeDashboard()are all disabled whileobdConnectedis 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-leveldeleteUserDashboard(id)becausedashboardsChangedis emitted synchronously insidedeleteDashboard(), 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 modalPopupup to 960 dp wide with a 2-column grid of cards. Each card renders a live miniature -- a secondDashboardRendererlaid out at the page's full size and scaled down withtransformOrigin: Item.Center. The miniature Loaders activate ataboutToShow(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 whilehiddenBuiltInCount > 0), Edit only whenbuiltIn === 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.qmlonto the page'sStackViewwith{ stackView, callerPage }; Edit addseditingId. ▲ Draft appears when thedashboardEditorDraftsetting is non-empty (re-read on everyaboutToShow) and pushes the editor withdraftJson. 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
| State | Meaning |
|---|---|
cells | The 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). |
cellModel | A 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, gridRows | The 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. |
sheetOpen | The 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, updatesPerValue | The 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, editWhileDriving | Driving 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. |
editingId | Non-empty when editing an existing user dashboard (the id is kept on save). |
selectedIndex / selectedCell | Drives the toolbar, the settings sheet and the PID picker (select(index), clearSelection()). |
dirty | Set by every mutation and by renaming; gates the "Save your changes?" question on Close and the draft rescue. |
draftJson | Injected by the chooser's Draft button; restored in Component.onCompleted before any editingId load. |
Mutation API called by the canvas and panel:
| Function | Behaviour |
|---|---|
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, resizeCell | Stepper 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, clearCellProp | Patch 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. |
| Loading | dashboardManager.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(undefinedkeeps 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 callsdashboardManager.canPlace(), the rule save and load use too.arrangement(index, col, row, colSpan, rowSpan)asksdashboardManager.arrange()where everything would go for a drop or resize (swapping or pushing), caching the last answer (canArrange()is itsok); 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 backgroundMouseAreaturns a tap intoemptyCellTapped(col, row)on a free cell (cellAt(),isFree()over the_occupiedmap) orbackgroundTapped()outside the grid. - Placed cells: a
Repeaterover the editor'scellModel. Each delegate has a synchronousLoaderwith the same bind/props logic as the renderer, reloaded only when its owntype/paramId/propsJsonchange; a "Choose data" badge on unbound PID widgets; a selection frame and shadow; and aMouseAreawhosedrag.targetis set only for the selected cell (or one held down;drag.threshold8 dp).onClickedemitscellTapped,onPressAndHoldcellHeld; 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;onReleasedre-binds the delegate'sx/yto its cell and emitscellMoveRequested. - Drop ghost (
editorDropGhost): accent when the drop or resize would go through,statusDangerwith the reason (editorDropGhostText) when not; shows the span while resizing. - Resize grip (
editorResizeGrip_<index>): on the selected cell only, whileinteractive; a 24 dp accent dot centred on the bottom-right corner with atouchSizehit area. ItsMouseArea(preventStealing) maps the pointer into canvas coordinates, snaps the far corner to the cell under it (never belowminSpanFor(index), which the editor supplies) and drives the ghost and preview; release emitscellResizeRequested. selectedRect,rectOf(cell): canvas rectangles the editor places its toolbar and choosers by.cellWidthDp/cellHeightDp/spacingDpfeedminSpan/sizeOptions.- Signals:
emptyCellTapped(col, row),cellTapped(index),cellHeld(index),cellMoveRequested(index, col, row),cellResizeRequested(index, colSpan, rowSpan),backgroundTapped(). The canvas never mutatescells.
TemplateGallery, PalettePopup, PIDPicker, PropertiesPanel
- TemplateGallery (
templateGallery, the chooser's + New): a card perdashboardManager.templatesentry (frontend/dashboards/templates/*.json, in theirorder), each aDashboardRendererminiature ofdashboardManager.fromTemplate(id, supported), with the PIDs the scanned car reports (or common ones with no car; the notetemplateGalleryNotesays which). The miniatures run on the demo data while the gallery is open (restored as it was on close) and render only fromaboutToShowuntil 1.5 s after closing (_showing; notPopup.visible, which changes more than once while opening).templatePicked(spec): OBDMenu pushes the editor withinitialSpecandfitToScreen, 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)" (initialSpecwithout 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'scategories) and a scrolling 2–4-column grid of cards, each the real widget loaded on its catalogdemoPID and props (demo data on while the gallery is open), its name anddescription. Tapping emitswidgetPicked(type)and closes. What do you want to see? (paletteDataFirst) emitsdataFirstRequested(): the editor opens the PID picker over every kind, and the pick placesWidgetCatalog.suggestFor(paramId, kind)(the catalog'ssuggestions.byId, thenbyKind, 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 fromdisplayParameters(aliases left out) filtered bysupportedKinds("*"passes all) and by the query against title, id or group. Rows are grouped by system inparameterCatalog.groupsorder (Engine, Fuel & air, Temperatures, Electrical, Speed & motion, Emissions, Diagnostics, Sensors), with the last six picks under Recent first (thedashboardRecentPidssetting). 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:- 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. - Look: Title / Unit overrides (
panelOverride_<key>, placeholders show the PID's own; empty = follow it), the Color swatches (a "T" theme swatch plusWidgetCatalog.colorSwatches,panelSwatch_<hex>; a pick writes everycolorPropskey throughsetCellProps), and theeditablePropswithsection: "look". - 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). - Advanced: the other options and the Column / Row / Width / Height
◄ value ►steppers (nudgeCell/resizeCell). - JSON (
panelJson,panelJsonApply): the props as text; Apply callssetCellPropsJson(index, text), which runs them throughnormalizedSpecand reports what it dropped (panelJsonStatus). - "Remove widget" (
deleteCell, undoable from the snackbar).
Switchfor 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'smin/maxoneditingFinished). Text fields use a dark field background so typed values stay readable against the light theme text color. BecauseRepeaterdelegates survive selection changes between same-type cells and user input breaks bindings, each control re-syncs from the cell on a_cellRefchange. The panel never mutates the cell; every control calls back into the editor page. - Data (PID widgets only): the PID button (red border while unbound), its live value (
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
| Where | Check | Outcome |
|---|---|---|
| DashboardManager | JSON parses to an object; id present; unique vs presets | File skipped with a warning; the rest of the list still loads. |
| DashboardRenderer | WidgetCatalog.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 save | Reduced 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. |
| OBDMenu | Active id still exists after dashboardsChanged | Falls back to Parameter Cards. |
| Hot reload | Watcher + 300 ms debounce + signature | A 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(HOMEandXDG_DOWNLOAD_DIRpointed at a temp dir), importing it twice gives two new ids, and failures setlastShareError. - tests/cpp/tst_vehiclegate.cpp -- loads the real
OBDParameterModelandDashboardRendereragainst a fakeobdManagerand checks the gate before a scan, after a scan (only the unreported PID shows the placeholder; sensor ids exempt;vehicleParametersorder) 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 -- 42step()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(notvalue), useShape/PathAngleArcfor curves and rotatedRectangles for marks, declareoctaveSupportedKinds, scale typography with the cell, and register it inWidgetCatalogwith a url, display name, glyph, default span, matchingsupportedKindsand curatededitablePropswith 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 akindto parametercatalog.cpp (tst_parametercatalogfails without it); its values reachparamStorethroughparameterSampled, so gauges pick it up with no further work.
Design Decisions
- A singleton aggregator between the backend and the gauges.
OBDParameterModelwas 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
paramValuesmap 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.
GridLayoutcould 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.cppchurn. - One catalog, three consumers. The type -> url map used to live inline in the renderer; lifting it into
WidgetCatalogmeans 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.