Settings Manager
Central settings store: one JSON file, an in-memory cache served to QML through Qt properties, a coalesced atomic disk write, and a metadata registry that drives the Quick Panel
Overview
The SettingsManager class (src/managers/settingsmanager.{h,cpp}) is the
central configuration store for all of OCTAVE. It inherits from QObject and exposes typed settings to
QML through Q_PROPERTY getters, change signals, and save_* slots. It defines 96 default
keys, 79 properties, 83 signals, 91 public slots and 51 Q_INVOKABLE methods, and reads and writes one
file: settingsConfigure.json in the OS-specific app data directory.
Beyond the typed properties, the manager provides a settings registry (25 entries) that maps a setting ID to its label, category, control type and save slot. The Quick Panel and the pin system render settings from this metadata, so a slider or toggle can be pinned without any per-setting QML.
Since the 2026-09 performance sweep the manager is memory-served: the file is read once in the constructor, every getter answers from the in-memory copy, and every setter updates that copy and arms a 500 ms single-shot timer that performs one atomic write for the whole burst. Details in Startup, In-Memory Cache and Coalesced Writes.
All PlatformsFile Location and Storage Paths
The settings file lives in the directory returned by the static SettingsManager::getAppDataDir(), which
creates the directory if it is missing and picks the path by platform:
| Platform | Path | Rule in code |
|---|---|---|
| Windows | %APPDATA%\OCTAVE\settingsConfigure.json | APPDATA env var; falls back to the home directory if unset |
| macOS | ~/Library/Application Support/OCTAVE/settingsConfigure.json | hard-coded under the home directory |
| Linux / Raspberry Pi / Orange Pi | ~/.config/OCTAVE/settingsConfigure.json | $XDG_CONFIG_HOME/OCTAVE when the variable is set |
| Android / iOS | <QDir::homePath()>/.config/OCTAVE/settingsConfigure.json | same #else branch as Linux (XDG_CONFIG_HOME honoured if set) |
QString SettingsManager::getAppDataDir()
{
const QString appName = QStringLiteral("OCTAVE");
QString dataDir;
#ifdef Q_OS_WIN
QString base = qEnvironmentVariable("APPDATA");
if (base.isEmpty())
base = QDir::homePath();
dataDir = base + QDir::separator() + appName;
#elif defined(Q_OS_MACOS)
dataDir = QDir::homePath() + QStringLiteral("/Library/Application Support/") + appName;
#else
QString base = qEnvironmentVariable("XDG_CONFIG_HOME");
if (base.isEmpty())
base = QDir::homePath() + QStringLiteral("/.config");
dataDir = base + QDir::separator() + appName;
#endif
QDir().mkpath(dataDir);
return dataDir;
}
The same directory is the root for everything else OCTAVE persists, so the helper is imported by other managers rather than duplicated:
| Path under the app data dir | Owner |
|---|---|
settingsConfigure.json | SettingsManager (this page) |
theme_art/theme_<safe name>.<ext> | SettingsManager, album art copied or downloaded for a custom theme (save_custom_theme) |
logs/ | src/util/logger.cpp, src/managers/diagnosticsmanager.cpp (see Development) |
dashboards/ | DashboardManager user dashboards (Gauges & Dashboards) |
metadata_cache.json, display_names.json | MediaManager |
spotifyClientId, spotifyClientSecret,
mediaSource (the removed Spotify integration), scrcpyAudioEnabled (the removed phone
audio toggle) and downloadSubfolder, downloadFormat, downloadBitrate (the
music downloader, removed in 2026-10). The constructor deletes them, saves, logs "Removed settings of retired features", and then
deletes the old Spotify login token, .spotify_token_cache in the app data dir.
Property / Signal / Slot System
Every setting with a dedicated property follows the same three-part pattern:
- Signal declaration named
<key>Changed(e.g.themeSettingChanged), carrying the new value where the value is scalar and no argument for object/list settings - Read-only property with the camelCase JSON key as its name and the signal as
notify - Save slot named
save_<snake_case>that updates the backing member, callsupdateSetting(), and emits the signal
In src/managers/settingsmanager.{h,cpp}:
// header
Q_PROPERTY(QString themeSetting READ themeSetting NOTIFY themeSettingChanged)
signals:
void themeSettingChanged(const QString &value);
public slots:
void save_theme_setting(const QString &theme);
// source
void SettingsManager::save_theme_setting(const QString &theme)
{
qCDebug(lcSettings) << "Saving theme setting:" << theme;
m_themeSetting = theme;
updateSetting(QStringLiteral("themeSetting"), theme);
emit themeSettingChanged(theme);
}
The slot names are snake_case (save_theme_setting, not saveThemeSetting), and the QML is
written against those names. The camelCase exceptions are setCurrentVolume(), which only VolumeController calls,
and the shutdown flush flushPendingSave(), which only src/main.cpp calls.
updateSetting() is the single funnel every simple setter goes through:
void SettingsManager::updateSetting(const QString &key, const QVariant &value)
{
QJsonObject settings = loadSettings(); // copy of the in-memory object
settings[key] = QJsonValue::fromVariant(value);
saveSettings(settings); // validate into the cache, arm the 500 ms timer
}
Setters that touch several keys at once (save_playback_state,
the gesture-mapping and section-visibility slots) call loadSettings() / saveSettings()
directly so the burst becomes one write, and setters with an enumerated or bounded domain validate before
saving: save_settings_layout_style, save_window_state,
save_music_button_default_page, save_album_art_transition,
save_esp32_led_color_mode and
set_last_settings_section silently ignore unknown values, while the numeric sliders clamp
(qBound) to the ranges listed in the
Settings Reference.
QString, bool, int, float
(uiScale, startUpVolume, fuelTankCapacity), double
(sideCardOpacity, scrcpyAudioGain, scrcpyAudioDuckLevel,
esp32VolumeStepSize) and QVariantList for homeOBDParameters, directoryHistory,
customThemes (a list of theme names), pinnedSettings and obdSavedAdapters
(the remembered adapters as {name, mac} maps, though the file stores a JSON string). One property is
a JSON string QML parses with JSON.parse: settingsMenuVisibility.
albumArtColors is a QVariantMap (the palette object; the file keeps a string) and
customThemeData holds every custom theme as name → object. Object-valued settings
(obdParameters, gestureMapping, expandedCards,
customThemes contents) have no property at all; QML reads them through dedicated getters or
get_setting_with_default().
Startup, In-Memory Cache and Coalesced Writes
The constructor performs the only unconditional disk read of the manager's lifetime:
- Build
m_defaultSettingswithbuildDefaultSettings()(96 keys) and the registry. loadSettings(): the file is opened with a shared lock, parsed and passed throughvalidateSettings(). A missing file is created from the defaults immediately; a corrupt file (JSON parse error) is moved aside to.corruptand restored from.bak(see Backup and Corruption Recovery).- Migrations. The first two and the retired-key removal rewrite the file; the rest are applied to the cached members in
populateMembers()and reach disk only with the next save of that key:uiScale == 0.6(an old default) becomes1.0and is saved straight away.fontSetting == "Google Sans"(the pre-0.9 default, whichStyle.setFont()rejects becauseMain.qmlderives the display name"GoogleSans Regular"from the bundledGoogleSans-Regular.ttf, silently leaving the app in the system font) becomes"GoogleSans Regular"and is saved straight away.obdBluetoothPort == "/dev/rfcomm0"(the former desktop placeholder, meaningless on Android where the field holds a MAC address) is cleared to"".lastSettingsSection == "clockSettings"(a removed page) becomes"displaySettings".obdParameters: every PID present in the defaults but missing from the file is added, so new PIDs appear after an upgrade without discarding the user's toggles.settingsMenuVisibility: sections missing from the stored object are filled in astrue(visible), matchingis_settings_section_visible().- Retired keys (
spotifyClientId,spotifyClientSecret,mediaSource,scrcpyAudioEnabled,downloadSubfolder,downloadFormat,downloadBitrate) are removed and saved straight away, and the old Spotify login token is deleted (see the "Retired keys" note above).
- Every backing member is populated from the loaded object with the default as fallback (
populateMembers(), whichreset_to_defaults()reuses). mediaFolderdefaults to""on disk and is resolved at load toQStandardPaths::MusicLocation(falling back to~/Music); the directory is created on desktop only, never on Android.currentVolume(never persisted) is derived fromstartUpVolumeby inverting the volume curve:VolumeController::toPercentStatic(startUpVolume)(round(sqrt(x) * 100)), e.g. the default 0.1 gives 32 %. See Architecture for the quadratic curve.- Two single-shot timers are created: the 500 ms settings write timer and the 800 ms OBD-parameter debounce timer.
After that, m_settingsLoaded is true and the manager works entirely from memory:
QML slot call ──► save_xxx()
│ update member, validate domain / clamp
▼
updateSetting() ──► loadSettings() (returns the in-memory copy, no I/O)
│ settings[key] = value
▼
saveSettings() ──► validateSettings() (types checked against defaults)
│ m_settings = validated
│ m_savePending = true ; m_saveTimer.start() (500 ms, restarted on every call)
▼
emit xxxChanged(value) ──► QML bindings update immediately
⋮
500 ms of quiet ──► flushPendingSave() ──► writeSettingsToDisk() (QSaveFile: temp + rename)
aboutToQuit ──► flushPendingSave() (src/main.cpp, after MediaManager saves playback state)
Consequences worth knowing: a slider drag that fires twenty save_* calls costs one write;
album-art colours pushed on every track change cost one write per track at most; and if the process is killed
(not quit) within 500 ms of a change, that change is lost. The OBD parameter toggles add their own layer:
save_obd_parameter_enabled() only updates the in-memory map and restarts the 800 ms timer;
when it fires, flushObdParameters() copies the map into the settings object (which arms the
500 ms timer) and emits obdParametersChanged once for the whole batch. The shutdown flush only handles the general save timer; a legacy OBD change still waiting for its 800 ms timer is not explicitly flushed.
JSON Persistence and Validation
Atomic Write
writeSettingsToDisk() receives an already validated object and never re-validates. It hands the bytes to atomicWrite():
- Open a
QSaveFileon the target, which writes to a temporary file in the same directory so the final rename is on one filesystem - Write the JSON with 4-space indentation (
QJsonDocument::Indented) QSaveFile::commit()flushes and fsyncs the temp file, then renames it oversettingsConfigure.jsonin one step; actual durability also depends on the filesystem and storage hardware- Set permissions on the destination to owner read/write only (
chmod 600; skipped on Windows) - If any step fails, the temp file is removed and the existing file is left untouched; the next save tries again. There is no direct (truncating) fallback write
Backup and Corruption Recovery
The first time a session reads a settings file that parses cleanly, it copies it to settingsConfigure.json.bak (same atomic write). If a later boot finds the file unparseable (typically an ignition cut mid-write on an SD card), the bad file is moved to settingsConfigure.json.corrupt for inspection, the .bak copy is loaded and written back as the live file, and only if the backup is also unusable are the defaults used.
Validation
validateSettings() runs at exactly two points: once on the
initial read and once inside every saveSettings() (i.e. on the in-memory copy, before the timer
is armed). It starts from a copy of the defaults and accepts a stored value only if its type matches the
default's type; anything else keeps the default. Keys that have no default are copied through untouched so
newer builds and QML-only keys survive older builds.
| Default type | Rule |
|---|---|
| bool | QJsonValue::isBool() |
| int / float | any JSON number (isDouble()); ints and floats are not distinguished, so a float stored under an int default is kept and truncated by the member's toInt() |
| string | isString() |
| object / array | isObject() / isArray(); contents are not inspected |
| not an object at top level | non-object JSON is treated as a corrupt file and triggers backup recovery; an empty object receives defaults |
toInt() truncates a stored 0.5. Give float settings a double member, a
toDouble() read and a float-literal default (1.0, not 1), as
esp32VolumeStepSize does so that 0.25 / 0.5 / 0.75 knob steps round-trip.
File Locking
static void lockFile(QFile &f, bool exclusive)
{
#ifdef Q_OS_WIN
HANDLE hFile = reinterpret_cast<HANDLE>(_get_osfhandle(f.handle()));
OVERLAPPED ov = {};
DWORD flags = LOCKFILE_FAIL_IMMEDIATELY;
if (exclusive)
flags |= LOCKFILE_EXCLUSIVE_LOCK;
LockFileEx(hFile, flags, 0, 1, 0, &ov);
#else
flock(f.handle(), exclusive ? (LOCK_EX | LOCK_NB) : (LOCK_SH | LOCK_NB));
#endif
}
Reads attempt a shared lock (parseSettingsFile() calls lockFile(f, false)). The lock is advisory and non-blocking, and reading continues if it cannot be acquired. Writes go through QSaveFile without a lock. Atomic replacement prevents partial documents, but there is no shared cross-process write lock or merge: two running instances can overwrite each other’s changes.
Settings Registry Metadata System
buildSettingsRegistry() fills the static s_settingsRegistry once; it maps 25 setting IDs to metadata. QML fetches it once with
get_settings_registry() (JSON string) and renders a control per entry; the same metadata plus a
currentValue comes back from get_pinned_settings_metadata() for the Quick Panel.
Registry Entry Structure
| Field | Type | Description |
|---|---|---|
key | string | The camelCase key in settingsConfigure.json, which is also the QML property name |
label | string | Human-readable label displayed in the UI |
category | string | Settings section ID: mediaSettings, displaySettings, obdSettings, gestureSensorSettings or imuSettings |
controlType | string | "toggle", "slider" or "chips" |
saveSlot | string | Name of the slot QML calls with the new value (e.g. save_ui_scale) |
params | object | Optional. Sliders: from, to, stepSize, optional logarithmic. Chips: options (fixed list) or optionsSource (a QML-side list name) |
id | string | Added only by get_pinned_settings_metadata(): the registry key itself |
currentValue | any | Added only by get_pinned_settings_metadata(): the live value |
Registered Settings
| ID | Key | Control | Params |
|---|---|---|---|
startup_volume | startUpVolume | slider | 0 – 1, step 0.01, logarithmic |
auto_play_on_startup | autoPlayOnStartup | toggle | |
persist_shuffle_state | persistShuffleState | toggle | |
show_waveform_visualizer | showWaveformVisualizer | toggle | |
show_3d_button_tilt | show3DButtonTilt | toggle | |
show_3d_album_preview | show3DAlbumPreview | toggle | |
rounded_album_art | roundedAlbumArt | toggle | |
album_art_corner_radius | albumArtCornerRadius | slider | 2 – 32, step 1 |
show_album_art_shadow | showAlbumArtShadow | toggle | |
vinyl_record_mode | vinylRecordMode | toggle | |
album_art_transition | albumArtTransition | chips | Crossfade, Slide, Vinyl Lift, Dissolve, Flip, Coverflow, Conveyor, Stack, Depth, Swing |
background_overlay_opacity | backgroundOverlayOpacity | slider | 0 – 100, step 1 |
side_card_opacity | sideCardOpacity | slider | 0.1 – 1.0, step 0.05 |
side_card_angle | sideCardAngle | slider | 5 – 60, step 1 |
button_tilt_duration | buttonTiltDuration | slider | 50 – 500, step 10 |
text_scroll_speed | textScrollSpeed | slider | 1000 – 10000, step 500 |
background_blur_radius | backgroundBlurRadius | slider | 0 – 100, step 1 (category mediaSettings) |
ui_scale | uiScale | slider | 0.5 – 2.0, step 0.05 |
theme_setting | themeSetting | chips | optionsSource: "themeList" |
show_clock | showClock | toggle | |
bottom_bar_media_controls | showBottomBarMediaControls | toggle | |
color_transition_ms | colorTransitionMs | slider | 0 – 2000, step 50 (same as the Display page's own slider) |
obd_fast_mode | obdFastMode | toggle | |
black_box_enabled | blackBoxEnabled | toggle | |
black_box_sweep_all | blackBoxSweepAll | toggle | |
black_box_max_mb | blackBoxMaxMB | slider | 50–5000, step 50 |
gesture_sensor_enabled | gestureSensorEnabled | toggle | |
imu_enabled | imuEnabled | toggle |
Example Registry Entries
reg[QStringLiteral("startup_volume")] = entry("startUpVolume", "Startup Volume", "mediaSettings",
"slider", "save_start_volume", sliderParams(0, 1, 0.01, true));
reg[QStringLiteral("album_art_transition")] = entry("albumArtTransition", "Track Transition", "mediaSettings",
"chips", "save_album_art_transition",
chipParams({"Crossfade", "Slide", "Vinyl Lift", "Dissolve", "Flip",
"Coverflow", "Conveyor", "Stack", "Depth", "Swing"}));
reg[QStringLiteral("theme_setting")] = entry("themeSetting", "Theme", "displaySettings",
"chips", "save_theme_setting", chipSource("themeList"));
entry(), sliderParams(), chipParams() and chipSource() are local lambdas that build the JSON objects described above (shown here with the QStringLiteral wrappers on the option names dropped).
Control Types
| controlType | params | Description |
|---|---|---|
toggle | none | Boolean on/off switch |
slider | from, to, stepSize, optional logarithmic | Numeric range slider; logarithmic is only set on startup_volume, whose 0 – 1 value is the linear gain (the quadratic curve makes the slider feel logarithmic) |
chips | options (array of strings) or optionsSource (name of a QML-side list) | Selectable chip/pill buttons; optionsSource: "themeList" lets the theme chips include custom themes that the static registry cannot list |
The registry is exposed to QML via:
QString SettingsManager::get_settings_registry()
{
return QString::fromUtf8(QJsonDocument(s_settingsRegistry).toJson(QJsonDocument::Compact));
}
Default Settings
All defaults live in buildDefaultSettings(). The object it returns is the schema: any key absent from
the loaded JSON gets its default, and top-level values with an incompatible type are replaced by defaults. It
defines 109 top-level keys.
The nested obdParameters object holds 123 parameter IDs, 20 enabled by default. The exhaustive
per-key table, including ranges and the UI page that owns each key, is the
Settings Reference; the summary:
| Category | Keys |
|---|---|
| Device / window | deviceName, fontSetting, windowState, screenWidth, screenHeight |
| Display | themeSetting, uiScale, colorTransitionMs, songLengthTransition, showClock, clockFormat24Hour, clockShowSeconds, clockSize, clockSizeAuto, bottomBarOrientation, showBottomBarMediaControls, settingsLayoutStyle, customThemes, albumArtColors |
| Media & Now Playing | startUpVolume, audioOutputDevice, mediaFolder, autoPlayOnStartup, persistShuffleState, musicButtonDefaultPage, returnToLibraryAfterSelection, showWaveformVisualizer, show3DButtonTilt, show3DAlbumPreview, roundedAlbumArt, albumArtCornerRadius, showAlbumArtShadow, musicDeviceModel, vinylRecordMode, albumArtTransition, backgroundOverlayOpacity, backgroundBlurRadius, backgroundGrid, sideCardOpacity, sideCardAngle, buttonTiltDuration, textScrollSpeed, directoryHistory |
| Playback resume | lastShuffleState, lastPlayedSong, lastPlayedPosition, lastPlayedPlaylist, mediaLastSource, mediaLastPlaying |
| OBD | obdBluetoothPort, obdSavedAdapters, obdFastMode, obdAutoReconnectAttempts, fuelTankCapacity, blackBoxEnabled, blackBoxSweepAll, blackBoxMaxMB, serviceEnabled, obdParameters, homeOBDParameters, supportedOBDParameters |
| Android Auto / Phone Mirror | androidAutoEnabled, androidAutoBackend, androidAutoCertPath, androidAutoResolution, androidAutoFps, androidAutoDpi, airPlayEnabled, airPlayName, airPlayRequirePin, airPlayResolution, airPlayTouchEnabled, airPlayTouchScale, phoneMirrorEnabled, scrcpyDisplaySize, scrcpyAudioGain, scrcpyAudioDuckEnabled, scrcpyAudioDuckLevel, scrcpyPhoneScreenOff, scrcpyDisplayDpi, scrcpyBitRateUsb, scrcpyBitRateWifi, scrcpyAutoWifi, scrcpyBitRateAuto, scrcpyWirelessAddress |
| ESP32 knob | esp32VolumeEnabled, esp32VolumePort, esp32VolumeStepSize, esp32AutoReconnect, esp32LedSleepEnabled, esp32LedColorMode, esp32LedStaticColor |
| IMU | imuEnabled, sensorEmitRate, sensorDecimalPlaces, sensorTempUnit, sensorAltitudeUnit, sensorShowPitch, sensorShowRoll, sensorShowHeading, sensorShowGForce, sensorShowAltitude, sensorShowTemperature |
| Gesture | gestureSensorEnabled, gestureMapping, gestureVolumeStep, gestureCooldown |
| Settings UI state | lastSettingsSection, settingsMenuVisibility, pinnedSettings, expandedCards |
Key Methods and Slots
The header exposes 142 callable methods (91 public slots and 51 Q_INVOKABLE methods). The groups below cover everything that is not a plain save_<key> setter.
Core Persistence
| Method | Description |
|---|---|
loadSettings() (private) | Returns a copy of the in-memory object once loaded. Before that (constructor only), it attempts a shared lock and reads the file. A missing file is initialized with defaults; a corrupt file triggers backup recovery, falling back to defaults if recovery fails. |
saveSettings(settings) (private) | Validates into the in-memory copy and arms the 500 ms timer. On the constructor path (migration, first run) it writes immediately. |
flushPendingSave() | Invokable. Writes now if a save is pending and stops the timer. Called from aboutToQuit in src/main.cpp, after MediaManager has saved playback state so that write is included. |
writeSettingsToDisk(settings) (private) | The atomic temp+rename write; takes an already validated object. |
updateSetting(key, value) (private) | Convenience funnel: copy, set key, saveSettings. The calling setter emits its own signal. |
get_setting(key), get_setting_with_default(key, default) | Read any key from memory. get_setting returns an invalid QVariant (undefined in QML) for unknown keys. |
save_media_folder(path) | Returns false and saves nothing unless path (or a file:// URL) is absolute and not the filesystem root, the rule save_media_folder_url applies; it may be absent (a card not yet inserted). |
get_setting / get_setting_with_default | Never return a file-only key (serviceEnabled, serviceWebRoot, serviceLoopbackOpen, serviceTokens); their owners read them with the C++-only fileOnlySetting. |
save_setting(key, value) | Write any key and emit genericSettingChanged(key). No type validation for keys without a default. Refuses serviceEnabled and serviceWebRoot (a warning, nothing written): what turns the service on and what it serves are set in the file, never by one of its own clients (fileOnlyKeys(), the one list save_setting, apply_settings and reset_to_defaults share; a reset keeps their file values). |
apply_settings(values) | Many settings at once, each through its own save_* slot (the registry's saveSlot, else save_ + the key in snake case: backgroundGrid → save_background_grid, show3DButtonTilt → save_show_3d_button_tilt), so each slot's validation and notify signal apply. A value must already be the slot's type (an int slot rounds a number); keys with no setter and the file-only keys are refused. Returns {applied: [keys], refused: {key: reason}}. |
nowPlayingPresets / applyNowPlayingPreset(label) | The Now Playing look presets (Minimal, Vinyl, Showcase 3D, Cinema) as [{label, settings}], and applying one through apply_settings (false for an unknown label). Media Settings and Now Playing Studio show and call these; the table used to be copied into both pages. |
reset_to_defaults() | Slot. Saves the default object, reloads every backing member through the constructor's populateMembers() and emits every property signal. Not wired to any UI or C++ caller at present. |
OBD Parameters and Adapters
| Method | Description |
|---|---|
save_obd_parameter_enabled(parameter, enabled) | Slot. Updates the in-memory map immediately and restarts the 800 ms debounce timer. Retained for compatibility and enable_all_supported(); the current UI does not use it to select polled parameters. |
flushObdParameters() (private) | Timer target. Copies the map into the settings object (one write) and emits obdParametersChanged once. It no longer drives polling: the managers refresh their poll lists from setParameterDemand() and the current vehicle scan. |
get_obd_parameter_enabled(parameter, defaultVal = true) | Invokable. Returns the state from memory; an unknown PID is cached with default as a side effect (so the next flush persists it). |
save_supported_obd_parameters(list) / get_supported_obd_parameters() | Persist and read the last vehicle-scan result; OBDManager writes it as a historical scan result; live polling uses the new connection’s scan rather than trusting this persisted list. Emits supportedOBDParametersChanged. |
save_home_obd_parameters(list) / get_home_obd_parameters(), add_home_obd_parameter(id, index = -1), remove_home_obd_parameter(id), move_home_obd_parameter(from, to), maxHomeObdParameters | The PIDs shown on the home screen cards, in order. The manager enforces the rules: no duplicates or empty ids, at most kMaxHomeObdParameters (8); add returns false when the id is already there or the grid is full, move shifts the cards in between (a drop past the end goes last). The OBD settings page's drag editor calls the three slots and reads the cap from maxHomeObdParameters; until open-backend #13 it edited a copy itself, with the cap in three places and a drop within the grid swapping two cards. Emits homeOBDParametersChanged. |
add_obd_saved_adapter(name, mac), remove_obd_saved_adapter(mac) | Maintain obdSavedAdapters: a list of {name, mac} maps for QML, stored as a compact JSON-array string (the private save_obd_saved_adapters(json) writes it). add upper-cases and trims the MAC, removes any duplicate, prepends the entry (most recent first) and caps the list at 8. OBDManager::connect_to_adapter() calls add whenever the identifier it is asked to connect to is a MAC address ("persist the choice so reconnects + restarts use it"); the OBD settings page calls remove. |
Playback State
| Method | Description |
|---|---|
save_playback_state(song, position_ms, playlist) | Slot. Sets the three resume fields in one save and emits nothing (the values are read back at startup, not bound live). MediaManager calls it from its own 1 s debounce and at shutdown. |
save_last_played_song / _position / _playlist | Individual setters with signals; available but MediaManager uses the batched call. |
save_last_shuffle_state(bool) | Called by MediaManager whenever shuffle toggles; honoured at startup only when persistShuffleState is on. |
setCurrentVolume(int) | Slot. Updates the in-memory 0–100 unified volume and emits currentVolumeChanged; never persisted. Only VolumeController calls it (see Architecture). |
Custom Themes
| Method | Description |
|---|---|
save_custom_theme(name, theme, albumArtSource = QString()) | Slot. Takes the theme object (a QVariantMap; MediaRoom passes App.Style.albumArtTheme), stores it under customThemes[name] and emits customThemesChanged immediately. A local albumArtSource (path or file:// URL) is copied synchronously to theme_art/theme_<safe name>.<ext> and recorded as theme["albumArt"]; an http(s):// source is downloaded on a background thread and patched in afterwards. The safe name replaces every character outside [\w-] with _. |
downloadThemeArtAsync() (private) | Runs the external curl -sL -o through QProcess (30 s timeout) in a QThread, saving to theme_art/theme_<safe name>.jpg. The result is handed back through a 100 ms QTimer poll on the GUI thread rather than a cross-thread signal, then save and emit customThemesChanged again. |
delete_custom_theme(name) | Slot. Removes the entry and deletes its art file if the stored URL resolves to a local file. |
customThemeData | Property: every saved theme, name → object, as on disk (notify customThemesChanged). The customThemes property only lists names. Replaced the get_custom_theme(name) JSON-string getter. |
Directory History
| Method | Description |
|---|---|
save_to_directory_history(path) | Slot. Prepends a folder (no duplicates) and trims the list to 10, then emits directoryHistoryChanged. |
remove_from_directory_history(path), get_directory_history() | Remove one entry / read the list (also exposed as the directoryHistory property). No current QML or C++ caller writes to the history; the API is retained for the media-folder picker. |
Gesture Mapping
| Method | Description |
|---|---|
save_gesture_action(gesture, action) | Slot. Rebinds one gesture and emits gestureMappingChanged. |
save_gesture_mapping(map), reset_gesture_mapping() | Replace the whole map / restore the default nine bindings. GestureManager calls save_gesture_mapping when it edits bindings itself; the Accessories page calls reset_gesture_mapping. |
get_gesture_mapping(), get_gesture_mapping_dict() | JSON string for QML / QVariantMap for the managers. |
Git Info (About page)
| Method | Description |
|---|---|
get_git_commit_hash() | Runs git rev-parse --short HEAD in the repo root with a 5 s timeout; "unknown" if no runtime or build-time value is available. Packaged builds (APK, installers, AppImage) have no .git and no git binary, so when the process fails the method returns the hash CMake baked in at configure time (OCTAVE_GIT_COMMIT, likewise OCTAVE_GIT_DATE for get_git_commit_date()). |
get_git_commit_date() | git log -1 --format=%ci, reformatted to YYYY-MM-DD at HH:MM. |
get_git_commit_message() | git log -1 --format=%s; empty string on failure. |
The repo root is derived as the parent of QCoreApplication::applicationDirPath()
(getGitRepoDir()), i.e. the parent of the build/ directory for a source build. The hash and date fall back to the commit hash and date baked in by CMake when runtime git lookup fails; the commit message has no build-time fallback. Checking whether an update is available, and the ahead/behind/diverged ancestry logic that
guards self-update, live in NetworkManager, not here.
Section Visibility System
Settings-menu sections can be hidden. Visibility state is the nested settingsMenuVisibility object,
exposed to QML as a JSON string (property and get_settings_menu_visibility()) plus a per-section
boolean query:
"settingsMenuVisibility": {
"displaySettings": true,
"mediaSettings": true,
"obdSettings": true,
"accessoriesSettings": true,
"deviceSettings": true,
"about": true // every section visible by default
}
is_settings_section_visible(section)returns the stored flag, ortruefor a section that is not in the objectsave_settings_section_visibility(section, visible)stores the flag and emitssettingsMenuVisibilityChanged(no arguments)get_settings_menu_visibility()returns the whole object as JSON
The toggles live in the settingsVisibilityPopup defined in
frontend/BottomBar.qml, opened by a press-and-hold on the settings navigation
button (a tap navigates as before), and frontend/SettingsMenu.qml filters its page model through
is_settings_section_visible(). The default visibility keys are the menu's six sections:
displaySettings, mediaSettings, obdSettings,
accessoriesSettings (Volume Knob, IMU, Gesture and Phone Dock sub-sections; desktop only),
deviceSettings and about, all true. A file written by an older build
may still carry the retired per-feature keys (androidAutoSettings, phoneMirrorSettings,
volumeKnobSettings, gestureSensorSettings); they are kept but nothing reads them, and any
current section missing from the file reads as visible. See Settings UI for the layouts.
Pinned Quick Settings
Users can pin registry entries to the Quick Panel. Pinned IDs are stored in the pinnedSettings
list (property, is_setting_pinned(id), toggle_pin_setting(id)). Only IDs present in
the registry can be pinned:
void SettingsManager::toggle_pin_setting(const QString &settingId)
{
int idx = m_pinnedSettings.indexOf(settingId);
if (idx >= 0) {
m_pinnedSettings.removeAt(idx);
} else {
if (s_settingsRegistry.contains(settingId))
m_pinnedSettings.append(settingId);
}
QJsonObject settings = loadSettings();
QJsonArray arr;
for (const auto &v : m_pinnedSettings)
arr.append(v.toString());
settings[QStringLiteral("pinnedSettings")] = arr;
saveSettings(settings);
emit pinnedSettingsChanged();
}
get_pinned_settings_metadata() returns a JSON array of the pinned registry entries, each extended
with id and currentValue. The value is resolved through
keyToAttr(), a 13-entry map from camelCase key to a snake_case member name that
get_pinned_settings_metadata() switches on; any key not in that map falls
back to the in-memory settings object, which is equally current, so the map is an optimisation, not a
requirement.
Generic Setting Access
For values that do not warrant a dedicated property, QML uses the generic accessors. They read and write the
same in-memory object, so a generic key and a property-backed key are persisted identically; the differences are
that generic keys have no type validation unless they also appear in the defaults, and that change notification
is the single genericSettingChanged(key) signal instead of a per-key signal.
QVariant SettingsManager::get_setting(const QString &key)
{
QJsonObject settings = loadSettings();
if (settings.contains(key))
return settings.value(key).toVariant();
return QVariant();
}
QVariant SettingsManager::get_setting_with_default(const QString &key, const QVariant &defaultValue)
{
QJsonObject settings = loadSettings();
if (settings.contains(key))
return settings.value(key).toVariant();
return defaultValue;
}
void SettingsManager::save_setting(const QString &key, const QVariant &value)
{
QJsonObject settings = loadSettings();
settings[key] = QJsonValue::fromVariant(value);
saveSettings(settings);
emit genericSettingChanged(key);
}
Two families of keys use this path today:
- Keys with a default but no property:
expandedCards,musicDeviceModeland the built-in Android Auto keysandroidAutoBackend("dhu"),androidAutoCertPath(""),androidAutoResolution("1280x720"),androidAutoFps(30) andandroidAutoDpi(160), and the sixairPlay*keys (AirPlayManagerfollowsgenericSettingChangedfor every key starting withairPlay; see Settings Reference). These are type-validated like any other default.AndroidAutoManagerreads the Android Auto keys withget_setting_with_default()at every connection attempt and followsgenericSettingChanged("androidAutoBackend")to emit itsbackendChanged; the Android Auto card writes the backend and video size withsave_setting(), and the other three have no control yet (Settings Reference). - QML-only keys with no C++ default: the RPM shift-light configuration (
rpm_*, defaults inShiftLightManager::reload()),lastOBDPage,lastSensorPage,activeDashboard,dashboardEditorDraft,dashboardRecentPidsanddashboardEditWhileDriving. Their defaults live in the QML call sites and they survive validation through the forward-compatibility copy. The full list with defaults is in the Settings Reference.
buildDefaultSettings() when a key needs a type check. Add a dedicated validating setter when it also needs range or allowed-value checks.
Album Art Colors
When the "Album Art Capture" theme is active, the palette extracted from the current cover is
pushed to SettingsManager as a JSON string. This is the single distribution point: MediaManager
calls set_album_art_colors(), QML reads
settingsManager.albumArtColors as a theme object (see Theme System), and
src/main.cpp sends its accent to the ESP32 knob's LEDs when
esp32LedColorMode is "theme".
Q_PROPERTY(QVariantMap albumArtColors READ albumArtColors NOTIFY albumArtColorsChanged) // parsed from the stored string
public slots:
void set_album_art_colors(const QString &colorsJson);
The JSON is persisted under the albumArtColors key so the last palette is reapplied at the next
startup before a fresh extraction finishes, which avoids flashing the placeholder theme. Because extraction runs
on every track change, this is the setter that benefits most from the coalesced write.
Connection to QML and Other Managers
SettingsManager is constructed first and registered as the QML context property settingsManager
in src/main.cpp:
SettingsManager settingsManager;
ctx->setContextProperty("settingsManager", &settingsManager);
It is then handed to nearly every other manager, either through the constructor (Clock, VolumeController, OBDManager) or a setSettingsManager() (MediaManager) or connect_settings_manager() call (ESP32VolumeManager, BerryIMUManager, GestureManager); OBDAdapterFinder takes it in its constructor too. PhoneMirrorManager is primed from the get_scrcpy_* getters and its scrcpyAudioGainChanged, scrcpyAudioDuckEnabledChanged, scrcpyAudioDuckLevelChanged, scrcpyPhoneScreenOffChanged, scrcpyDisplayDpiChanged, scrcpyBitRateUsbChanged, scrcpyBitRateWifiChanged, scrcpyBitRateAutoChanged and scrcpyAutoWifiChanged signals are wired straight into the manager's setters, so those Accessories-page sliders apply live. scrcpyWirelessAddress runs the other way: the manager owns the remembered Wi-Fi phone and its wirelessAddressChanged signal is wired to save_scrcpy_wireless_address.
QML accesses settings directly:
// Read a property (binding updates on the notify signal)
text: settingsManager.deviceName
// Write via slot
settingsManager.save_device_name(newName)
// React to changes
Connections {
target: settingsManager
function onThemeSettingChanged(theme) { /* ... */ }
function onGenericSettingChanged(key) { if (key === "lastOBDPage") { /* ... */ } }
}
// Generic key with a QML-side default
property bool shiftLightEnabled: settingsManager.get_setting_with_default("rpm_shift_light_enabled", true)
On aboutToQuit, src/main.cpp calls mediaManager._save_playback_state() before settingsManager.flushPendingSave(), so the final settings write includes playback state.
Design Decisions
One JSON file, not QSettings or a database
A single human-readable JSON document is identical on every platform, so a settings file can be copied between
the head unit, a laptop and a phone. QSettings would have put the data in the Windows registry,
macOS plists and INI files with Qt-specific escaping. JSON also carries nested objects (obdParameters
with 123 parameter IDs, gestureMapping, customThemes with embedded theme objects)
without flattening keys, and it can be edited in a text editor on the Pi.
Defaults object as the schema, validation by type
Rather than a separate schema file, the defaults object is the schema: a key's presence declares it,
its default value declares its type, and adding a setting is one line. Validation compares stored values
against that type and falls back to the default rather than refusing to load, because a head unit that boots
into defaults after a bad write is usable and one that shows an error dialog at 60 mph is not. Unknown
keys are kept ("forward compatibility", per the comment in validateSettings()) so an older build never
destroys a newer build's settings, or the QML-only keys, when both touch the same file.
Memory-served reads, one coalesced write
Before the 2026-09 sweep every getter re-read and re-validated the file and every setter wrote it. On the
Orange Pi that meant a parse+validate+write on every slider release and on every track change (album colours),
all on the GUI thread, and a steady stream of small writes to an SD card. The commit that changed this
(ee5a23e) states the rationale in the code comments: after the constructor the in-memory object is
authoritative because every setter goes through saveSettings(), so re-reading "always returned
what we had just written". The 500 ms single-shot timer is restarted on every call, which turns a burst of
any length into one atomic write and also spares the SD card. The trade-off, accepted deliberately, is that a
hard kill within the window loses the last change; a normal quit flushes.
Validation at save time, not write time
saveSettings() validates into the cache and the disk writer takes the object as given
(037f753). Validating once at the boundary where
data enters the cache checks the top-level types of keys with defaults. Unknown keys and nested values remain unvalidated, and save_setting() does not update the backing member or notify signal of a typed property, so use its dedicated setter. The deferred write does not pay
for a second pass over 96 keys plus 123 parameter IDs. Per-setter domain checks (enumerated strings, clamped sliders)
sit even earlier, in the slot, because the type check cannot express "one of four layout names" or
"2 to 32 pixels".
Debounced OBD toggles with an in-memory fast path
The legacy obdParameters map retains 123 parameter flags for settings-file compatibility. The current UI and polling system use consumer demand instead.
save_obd_parameter_enabled() writes only to the in-memory map so the toggle animates instantly,
and get_obd_parameter_enabled() reads that same map, so the page never waits for persistence. The
800 ms debounce predates the general 500 ms coalescing and is kept because it also batches the
obdParametersChanged signal. This legacy signal no longer controls polling;
setParameterDemand() determines which parameters the current consumers need.
Registry metadata instead of per-setting QML
The Quick Panel, the pin system and the dashboard widgets all need "render this setting as a control". Putting
label, control type and slot name in one registry means a new pinnable setting is a registry entry plus
the normal property/slot triple, and QML renders it generically from get_settings_registry(). The
registry lives in C++ rather than QML so the manager stays the source of truth for what is pinnable and
toggle_pin_setting() can refuse unknown IDs.
Snake_case slots, camelCase properties, string-typed JSON properties
Properties use the JSON key name so QML bindings read like the file, and slots are snake_case
save_* verbs, which keeps the two easy to tell apart at a QML call site. One piece of structured state (settingsMenuVisibility) still crosses to QML as a JSON string, which QML
parses with JSON.parse; obdSavedAdapters, albumArtColors and
customThemeData cross as lists and maps (open-backend #9). On disk,
settingsMenuVisibility is an object, while the palette and adapter list remain string values.
How to Add a New Setting
Then add the key to Settings Reference and, if it is user-facing, to Signals & Slots Reference.
-
Add the default value in
buildDefaultSettings(). Use a float literal for float settings:d[QStringLiteral("myNewFeature")] = false; -
Declare the property, getter and change signal in settingsmanager.h, plus a backing member:
Q_PROPERTY(bool myNewFeature READ myNewFeature NOTIFY myNewFeatureChanged) bool myNewFeature() const; signals: void myNewFeatureChanged(bool value); private: bool m_myNewFeature = false; -
Initialize the member in
populateMembers(), so construction and reset both load it:m_myNewFeature = s(QStringLiteral("myNewFeature")).toBool(); -
Add the save slot (clamp or whitelist here if the value has a domain):
void SettingsManager::save_my_new_feature(bool enabled) { m_myNewFeature = enabled; updateSetting(QStringLiteral("myNewFeature"), enabled); emit myNewFeatureChanged(enabled); } -
(Optional) Add to
buildSettingsRegistry()if the setting should be pinnable / appear in the Quick Panel:reg[QStringLiteral("my_new_feature")] = entry("myNewFeature", "My New Feature", "mediaSettings", "toggle", "save_my_new_feature"); -
(Optional) Add to
keyToAttr()and the matching branch inget_pinned_settings_metadata()socurrentValueresolves from the member (the settings-object fallback works without it). -
Emit its signal from
reset_to_defaults()— the member itself is reloaded bypopulateMembers(), but the signal list inreset_to_defaults()is explicit. - Wire the UI on the owning page under frontend/settings/ and, if it is a Now Playing visual, in frontend/NowPlayingStudio.qml (which carries a commented template for exactly this).
save_setting(key, value) / get_setting_with_default(key, default) pair needs no C++
change at all; genericSettingChanged(key) notifies listeners. Still add a default to
buildDefaultSettings() when the value has a type worth protecting.
Signal Reference
The header declares 83 signals. Every property-backed setting has a <key>Changed signal carrying the new value; the ones below differ from that pattern or matter beyond QML bindings:
| Signal | Payload | Emitted when |
|---|---|---|
clockFormatChanged | bool | clockFormat24Hour changes (the signal name does not follow the key) |
startUpVolumeChanged | QString | Declared with a const QString & argument and emitted with QString::number(volume), although the property is a float. Read the startUpVolume property rather than the payload |
currentVolumeChanged | int | Unified 0–100 volume changed by any source; not persisted |
obdParametersChanged | none | After the 800 ms OBD debounce flush, or on reset |
homeOBDParametersChanged, supportedOBDParametersChanged | none | Home-card list / scan result saved |
obdSavedAdaptersChanged | — | Adapter list changed |
directoryHistoryChanged, customThemesChanged, gestureMappingChanged, pinnedSettingsChanged, settingsMenuVisibilityChanged | none | Their object/list changed; QML re-queries the getter |
genericSettingChanged | QString (key) | save_setting() called |
albumArtColorsChanged | — | New palette pushed by MediaManager; also drives the ESP32 LEDs in theme mode |
scrcpyAudioGainChanged, scrcpyAudioDuckEnabledChanged, scrcpyAudioDuckLevelChanged, scrcpyPhoneScreenOffChanged, scrcpyDisplayDpiChanged, scrcpyBitRateUsbChanged, scrcpyBitRateWifiChanged, scrcpyAutoWifiChanged, scrcpyBitRateAutoChanged | double / bool / double / bool / int / int / int / bool / bool | Connected directly to PhoneMirrorManager setters at startup |
esp32LedColorModeChanged, esp32LedStaticColorChanged | QString | src/main.cpp connects these to the ESP32 LED sync lambdas |
Behaviour Notes
Quirks a maintainer should know:
| Topic | Behaviour |
|---|---|
| Numeric validation | any JSON number is accepted for an int or float default; the member's toInt() / toDouble() decides the precision |
save_obd_saved_adapters (private) | stores the string as given; only an empty string becomes "[]" |
add_obd_saved_adapter | keeps an rfcomm:// scheme lower-case while upper-casing the MAC; a missing name defaults to the MAC |
reset_to_defaults() | Writes the full default object, reloads every cached member (populateMembers()) and emits every property signal. No caller exists today |
set_last_settings_section() | Whitelists the six current section IDs (displaySettings, mediaSettings, obdSettings, accessoriesSettings, deviceSettings, about) plus the four legacy per-feature IDs an older file may carry; saves and emits only when the section changes |
fuelTankCapacity | Property and slot exist but nothing reads or writes them (no QML, no OBD manager use) |