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 Platforms

File 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:

PlatformPathRule in code
Windows%APPDATA%\OCTAVE\settingsConfigure.jsonAPPDATA env var; falls back to the home directory if unset
macOS~/Library/Application Support/OCTAVE/settingsConfigure.jsonhard-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.jsonsame #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 dirOwner
settingsConfigure.jsonSettingsManager (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.jsonMediaManager
Retired keys are deleted at startup
Files from older builds may carry 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:

  1. Signal declaration named <key>Changed (e.g. themeSettingChanged), carrying the new value where the value is scalar and no argument for object/list settings
  2. Read-only property with the camelCase JSON key as its name and the signal as notify
  3. Save slot named save_<snake_case> that updates the backing member, calls updateSetting(), 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.

Property types used
Properties are 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:

  1. Build m_defaultSettings with buildDefaultSettings() (96 keys) and the registry.
  2. loadSettings(): the file is opened with a shared lock, parsed and passed through validateSettings(). A missing file is created from the defaults immediately; a corrupt file (JSON parse error) is moved aside to .corrupt and restored from .bak (see Backup and Corruption Recovery).
  3. 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) becomes 1.0 and is saved straight away.
    • fontSetting == "Google Sans" (the pre-0.9 default, which Style.setFont() rejects because Main.qml derives the display name "GoogleSans Regular" from the bundled GoogleSans-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 as true (visible), matching is_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).
  4. Every backing member is populated from the loaded object with the default as fallback (populateMembers(), which reset_to_defaults() reuses).
  5. mediaFolder defaults to "" on disk and is resolved at load to QStandardPaths::MusicLocation (falling back to ~/Music); the directory is created on desktop only, never on Android.
  6. currentVolume (never persisted) is derived from startUpVolume by 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.
  7. 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():

  1. Open a QSaveFile on the target, which writes to a temporary file in the same directory so the final rename is on one filesystem
  2. Write the JSON with 4-space indentation (QJsonDocument::Indented)
  3. QSaveFile::commit() flushes and fsyncs the temp file, then renames it over settingsConfigure.json in one step; actual durability also depends on the filesystem and storage hardware
  4. Set permissions on the destination to owner read/write only (chmod 600; skipped on Windows)
  5. 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 typeRule
boolQJsonValue::isBool()
int / floatany 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()
stringisString()
object / arrayisObject() / isArray(); contents are not inspected
not an object at top levelnon-object JSON is treated as a corrupt file and triggers backup recovery; an empty object receives defaults
Float settings need float defaults
Validation does not distinguish ints from floats, so the member's type decides what survives: a setting read with 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

FieldTypeDescription
keystringThe camelCase key in settingsConfigure.json, which is also the QML property name
labelstringHuman-readable label displayed in the UI
categorystringSettings section ID: mediaSettings, displaySettings, obdSettings, gestureSensorSettings or imuSettings
controlTypestring"toggle", "slider" or "chips"
saveSlotstringName of the slot QML calls with the new value (e.g. save_ui_scale)
paramsobjectOptional. Sliders: from, to, stepSize, optional logarithmic. Chips: options (fixed list) or optionsSource (a QML-side list name)
idstringAdded only by get_pinned_settings_metadata(): the registry key itself
currentValueanyAdded only by get_pinned_settings_metadata(): the live value

Registered Settings

IDKeyControlParams
startup_volumestartUpVolumeslider0 – 1, step 0.01, logarithmic
auto_play_on_startupautoPlayOnStartuptoggle
persist_shuffle_statepersistShuffleStatetoggle
show_waveform_visualizershowWaveformVisualizertoggle
show_3d_button_tiltshow3DButtonTilttoggle
show_3d_album_previewshow3DAlbumPreviewtoggle
rounded_album_artroundedAlbumArttoggle
album_art_corner_radiusalbumArtCornerRadiusslider2 – 32, step 1
show_album_art_shadowshowAlbumArtShadowtoggle
vinyl_record_modevinylRecordModetoggle
album_art_transitionalbumArtTransitionchipsCrossfade, Slide, Vinyl Lift, Dissolve, Flip, Coverflow, Conveyor, Stack, Depth, Swing
background_overlay_opacitybackgroundOverlayOpacityslider0 – 100, step 1
side_card_opacitysideCardOpacityslider0.1 – 1.0, step 0.05
side_card_anglesideCardAngleslider5 – 60, step 1
button_tilt_durationbuttonTiltDurationslider50 – 500, step 10
text_scroll_speedtextScrollSpeedslider1000 – 10000, step 500
background_blur_radiusbackgroundBlurRadiusslider0 – 100, step 1 (category mediaSettings)
ui_scaleuiScaleslider0.5 – 2.0, step 0.05
theme_settingthemeSettingchipsoptionsSource: "themeList"
show_clockshowClocktoggle
bottom_bar_media_controlsshowBottomBarMediaControlstoggle
color_transition_mscolorTransitionMsslider0 – 2000, step 50 (same as the Display page's own slider)
obd_fast_modeobdFastModetoggle
black_box_enabledblackBoxEnabledtoggle
black_box_sweep_allblackBoxSweepAlltoggle
black_box_max_mbblackBoxMaxMBslider50–5000, step 50
gesture_sensor_enabledgestureSensorEnabledtoggle
imu_enabledimuEnabledtoggle

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

controlTypeparamsDescription
togglenoneBoolean on/off switch
sliderfrom, to, stepSize, optional logarithmicNumeric 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)
chipsoptions (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:

CategoryKeys
Device / windowdeviceName, fontSetting, windowState, screenWidth, screenHeight
DisplaythemeSetting, uiScale, colorTransitionMs, songLengthTransition, showClock, clockFormat24Hour, clockShowSeconds, clockSize, clockSizeAuto, bottomBarOrientation, showBottomBarMediaControls, settingsLayoutStyle, customThemes, albumArtColors
Media & Now PlayingstartUpVolume, audioOutputDevice, mediaFolder, autoPlayOnStartup, persistShuffleState, musicButtonDefaultPage, returnToLibraryAfterSelection, showWaveformVisualizer, show3DButtonTilt, show3DAlbumPreview, roundedAlbumArt, albumArtCornerRadius, showAlbumArtShadow, musicDeviceModel, vinylRecordMode, albumArtTransition, backgroundOverlayOpacity, backgroundBlurRadius, backgroundGrid, sideCardOpacity, sideCardAngle, buttonTiltDuration, textScrollSpeed, directoryHistory
Playback resumelastShuffleState, lastPlayedSong, lastPlayedPosition, lastPlayedPlaylist, mediaLastSource, mediaLastPlaying
OBDobdBluetoothPort, obdSavedAdapters, obdFastMode, obdAutoReconnectAttempts, fuelTankCapacity, blackBoxEnabled, blackBoxSweepAll, blackBoxMaxMB, serviceEnabled, obdParameters, homeOBDParameters, supportedOBDParameters
Android Auto / Phone MirrorandroidAutoEnabled, androidAutoBackend, androidAutoCertPath, androidAutoResolution, androidAutoFps, androidAutoDpi, airPlayEnabled, airPlayName, airPlayRequirePin, airPlayResolution, airPlayTouchEnabled, airPlayTouchScale, phoneMirrorEnabled, scrcpyDisplaySize, scrcpyAudioGain, scrcpyAudioDuckEnabled, scrcpyAudioDuckLevel, scrcpyPhoneScreenOff, scrcpyDisplayDpi, scrcpyBitRateUsb, scrcpyBitRateWifi, scrcpyAutoWifi, scrcpyBitRateAuto, scrcpyWirelessAddress
ESP32 knobesp32VolumeEnabled, esp32VolumePort, esp32VolumeStepSize, esp32AutoReconnect, esp32LedSleepEnabled, esp32LedColorMode, esp32LedStaticColor
IMUimuEnabled, sensorEmitRate, sensorDecimalPlaces, sensorTempUnit, sensorAltitudeUnit, sensorShowPitch, sensorShowRoll, sensorShowHeading, sensorShowGForce, sensorShowAltitude, sensorShowTemperature
GesturegestureSensorEnabled, gestureMapping, gestureVolumeStep, gestureCooldown
Settings UI statelastSettingsSection, 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

MethodDescription
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_defaultNever 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

MethodDescription
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), maxHomeObdParametersThe 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

MethodDescription
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 / _playlistIndividual 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

MethodDescription
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.
customThemeDataProperty: 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

MethodDescription
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

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

MethodDescription
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, or true for a section that is not in the object
  • save_settings_section_visibility(section, visible) stores the flag and emits settingsMenuVisibilityChanged (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, musicDeviceModel and the built-in Android Auto keys androidAutoBackend ("dhu"), androidAutoCertPath (""), androidAutoResolution ("1280x720"), androidAutoFps (30) and androidAutoDpi (160), and the six airPlay* keys (AirPlayManager follows genericSettingChanged for every key starting with airPlay; see Settings Reference). These are type-validated like any other default. AndroidAutoManager reads the Android Auto keys with get_setting_with_default() at every connection attempt and follows genericSettingChanged("androidAutoBackend") to emit its backendChanged; the Android Auto card writes the backend and video size with save_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 in ShiftLightManager::reload()), lastOBDPage, lastSensorPage, activeDashboard, dashboardEditorDraft, dashboardRecentPids and dashboardEditWhileDriving. 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.
QML-only keys are unvalidated
A QML-only key needs no C++ code, but it also gets no validation: a corrupted value is passed to QML as-is. Add a default in 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.

  1. Add the default value in buildDefaultSettings(). Use a float literal for float settings:
    d[QStringLiteral("myNewFeature")] = false;
  2. 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;
  3. Initialize the member in populateMembers(), so construction and reset both load it:
    m_myNewFeature = s(QStringLiteral("myNewFeature")).toBool();
  4. 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);
    }
  5. (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");
  6. (Optional) Add to keyToAttr() and the matching branch in get_pinned_settings_metadata() so currentValue resolves from the member (the settings-object fallback works without it).
  7. Emit its signal from reset_to_defaults() — the member itself is reloaded by populateMembers(), but the signal list in reset_to_defaults() is explicit.
  8. 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).
Tip
If the setting is only read by one QML component and has no meaningful validation, the generic 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:

SignalPayloadEmitted when
clockFormatChangedboolclockFormat24Hour changes (the signal name does not follow the key)
startUpVolumeChangedQStringDeclared 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
currentVolumeChangedintUnified 0–100 volume changed by any source; not persisted
obdParametersChangednoneAfter the 800 ms OBD debounce flush, or on reset
homeOBDParametersChanged, supportedOBDParametersChangednoneHome-card list / scan result saved
obdSavedAdaptersChanged—Adapter list changed
directoryHistoryChanged, customThemesChanged, gestureMappingChanged, pinnedSettingsChanged, settingsMenuVisibilityChangednoneTheir object/list changed; QML re-queries the getter
genericSettingChangedQString (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, scrcpyBitRateAutoChangeddouble / bool / double / bool / int / int / int / bool / boolConnected directly to PhoneMirrorManager setters at startup
esp32LedColorModeChanged, esp32LedStaticColorChangedQStringsrc/main.cpp connects these to the ESP32 LED sync lambdas

Behaviour Notes

Quirks a maintainer should know:

TopicBehaviour
Numeric validationany 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_adapterkeeps 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
fuelTankCapacityProperty and slot exist but nothing reads or writes them (no QML, no OBD manager use)