Hardware Configuration
Blackchirp’s hardware story has two halves. The configuration half — what
this page covers — describes the four singletons that decide which hardware
drivers exist, which user-visible profiles have been created from
them, which profiles are active in the running session, and which
profiles are grouped together as a named loadout. The runtime half —
Hardware Runtime — picks up where this page leaves
off: instantiating HardwareObject instances, moving them onto
the right threads, opening their communication channels, and routing their
signals through HardwareManager.
The configuration layer is built on four loosely coupled singletons. None
of them owns a live instrument; together they describe everything
HardwareManager needs to know to bring instruments online.
The split reflects different read-access policies. The registry is
populated at static-initialization time and is everywhere-readable but
write-restricted to its own registration helpers. The profile manager
is the sole authority on per-profile metadata and settings. The runtime
configuration accepts reads from any thread but restricts writes to a
short list of friend classes. The loadout manager funnels all CRUD
through a single API.
The user-facing surface of the same four layers is the Hardware Configuration dialog. The dialog’s four panels are one-to-one with the configuration singletons; orienting yourself to “panel X drives singleton Y” is the fastest way into this part of the codebase. The user-guide side of the same workflow is on Hardware and Library Configuration.
Compile-time to runtime: a layer at a time
A useful mental model is four layers of an onion, stacked from compile-time outward:
Catalog (compile time). Every concrete driver registers itself with
HardwareRegistryat static-initialization time using theREGISTER_HARDWARE_*macros. This runs beforemain(), so the catalog of factories, supported communication protocols, setting descriptors, and library dependencies is complete before any other code runs.Profile metadata (per process). When the application starts,
HardwareProfileManagerloads any persisted profile records fromQSettingsinto its in-memory cache. A profile is a(hardwareType, label, driver)triple — fixed at creation time — together with its persisted settings and, for Python drivers, a script path / class name / environment path. ThehardwareTypeandlabeltogether form the profile’s identity ("FtmwDigitizer.frontPanel","FlowController.backup"); the driver is immutable, so changing drivers means creating a new profile rather than re-pointing an existing one.Active selection (per loadout).
RuntimeHardwareConfigrecords which profiles are active in the current runtime configuration, keyed by profile identity. This is the layerHardwareManagerreads when it instantiates objects.Named hardware maps (across sessions).
LoadoutManagerstores named loadouts — each a complete hardware map plus its collection of FTMW presets — so the user can switch between fully prepared instrument configurations without manually toggling each profile.
flowchart LR
subgraph Compile[Static initialization]
Macros[REGISTER_HARDWARE_*<br/>in driver .cpp files]
end
subgraph Persist[QSettings on disk]
QS[(QSettings:<br/>HardwareProfiles/...<br/>runtimeHardware/...<br/>Loadouts/...)]
end
subgraph Singletons[Process-wide singletons]
Reg[HardwareRegistry<br/>factories · settingDefs<br/>protocols · libraries]
PM[HardwareProfileManager<br/>profile metadata · per-profile settings]
RC[RuntimeHardwareConfig<br/>active type.label → impl map]
LM[LoadoutManager<br/>named hardware maps + FTMW presets]
end
Macros --> Reg
PM <--> QS
RC -. derived from active profiles .-> PM
LM <--> QS
LM -- applyHardwareMap --> RC
Reg -. settingDefs .-> PM
PM -. profile settings .-> HM[HardwareManager]
RC -. active selections .-> HM
The four singletons are not collapsed into a single class because each serves a different boundary. The registry is the only writer of factories and setting descriptors and is read everywhere. The profile manager is the only authority on what a profile identity means. The runtime configuration mediates between the GUI’s preview state and the live hardware map. The loadout manager brackets all of the above into named records the user can save, recall, and share.
HardwareRegistry — the catalog
HardwareRegistry is a pure catalog. It does not check
availability, resolve dependencies, or fall back to alternatives; it
simply records what was registered and constructs instances on demand
through stored factory lambdas. See HardwareRegistry for
the per-method API.
Each driver file registers itself by placing one or more macros at file
scope in its .cpp file. The macros are defined in
hardware/core/hardwareregistration.h and run during
static-initialization. Eight macros cover the registration surface:
REGISTER_HARDWARE_META(CLASS, DESC)Primary registration. Declares the factory, the inheritance chain, and the description. Must appear before any other macro for the same class.
REGISTER_HARDWARE_PROTOCOLS(CLASS, ...)Lists the
CommunicationProtocol::CommTypevalues the driver supports (Rs232,Tcp,Gpib,Custom,Virtual).REGISTER_COMM_DEFAULTS(CLASS, PROTOCOL, ...)Declares
CommDefaultdescriptors — communication settings (read timeout, termination character) seeded into the driver’s per-protocol settings group when the device is first configured. Invoked once per protocol; a driver supporting several transports calls the macro several times. See Communication defaults below.REGISTER_HARDWARE_SETTINGS(CLASS, ...)Declares
HwSettingDefdescriptors for the driver’s scalar settings: key, label, description, type-aware default, optional bounds, andHwSettingPriority.REGISTER_HARDWARE_BASE(CLASS, ...)Same shape as
REGISTER_HARDWARE_SETTINGS, but for a non-instantiable base class (Clock,FtmwDigitizer,HardwareObjectitself). Settings are merged into every driver whose inheritance chain contains the base class.REGISTER_HARDWARE_ARRAY/REGISTER_HARDWARE_ARRAY_ENTRYDeclare an array setting and append entries to it. Each entry is a
SettingsStorage::SettingsMapof sub-key/value pairs (sample-rate tables, range tables, …).REGISTER_HARDWARE_BASE_ARRAY/REGISTER_HARDWARE_BASE_ARRAY_ENTRYArray equivalents for base classes. Calling
REGISTER_HARDWARE_BASE_ARRAYwith no entries reserves the array key so it is always rendered in the settings dialog, even for drivers (Python-backed drivers, virtual drivers) that supply no entries of their own.REGISTER_LIBRARY(CLASS, LIBRARY_NAME)Records a
VendorLibrarydependency, so the registry can answer “which drivers must be torn down before this library is reloaded?”.REGISTER_CUSTOM_COMM/REGISTER_CUSTOM_COMM_BASEDeclare
CustomCommDeffield descriptors for drivers whose communication type isCommunicationProtocol::Custom. The GUI reads these descriptors before construction so it can render the right input widgets without instantiating the driver.
Hardware-type and driver keys are derived from Qt’s
staticMetaObject rather than passed by hand. REGISTER_HARDWARE_META
walks the metaobject superClass() chain to find the direct child of
HardwareObject (the type key — Clock, FtmwDigitizer,
Awg, …), and uses CLASS::staticMetaObject.className() for the
driver key (the class name itself — Valon5009,
M4i2220x8). Renaming a class therefore renames its registry key for
free; there is no parallel string table to update.
Base / driver override pattern
A setting registered with REGISTER_HARDWARE_BASE is shared by every
driver that inherits from the base. A driver that needs
a different default (or different bounds, or a different priority) for
the same key re-registers the key with REGISTER_HARDWARE_SETTINGS.
HardwareRegistry::getSettingDefs() returns the
driver’s entry first and skips the base-class entry for that
key, so no duplicate row appears in the UI and
HardwareObject::applyRegisteredSettings() writes the right
default. The same precedence applies to arrays.
This is the lever for tuning a driver’s defaults without copy-pasting the whole base-class set: register only what differs.
Priority levels and UI placement
HwSettingPriority tags every setting with one of three
levels. The level determines where the setting appears in
HwSettingsWidget — the shared widget used by both the
profile creation flow and the post-creation edit dialog.
Priority |
UI placement |
|---|---|
|
Top form on the Settings tab. Editable in Create mode; read-only in Edit mode. Examples: digitizer channel count, pulse-generator channel count. |
|
Always-visible two-column table on the Settings tab. Editable in both modes. Examples: sample-rate tables, voltage ranges. |
|
Collapsible Advanced tab. Editable in both modes. Examples: rolling-data interval, “critical hardware” flag. |
Required settings carry a hard contract: they must be correct before
the hardware object is constructed, because the constructor reads them
to size internal arrays, allocate buffers, and so on. That is why
HwSettingsWidget renders them read-only in Edit mode — the
profile must be deleted and recreated to change a Required value. See
HwSettingsWidget for the per-mode behavior and
HardwareRegistry for the full
HwSettingDef field reference.
Communication defaults
Read timeout and termination character are per-protocol settings: they
live in a settings group named for the transport (rs232, tcp,
gpib, …) so one device can carry different values for each protocol
it supports. When no value has been configured, the fallbacks are
CommunicationProtocol::defaultReadTimeout (200 ms) and
CommunicationProtocol::defaultReadTermChar (\n).
Many instruments need something other than the fallback to communicate
at all — a 20 s timeout for a slow laser, a ;FF terminator for an
MKS gauge controller. REGISTER_COMM_DEFAULTS registers those values
against a protocol:
REGISTER_COMM_DEFAULTS(Mks946, CommunicationProtocol::Rs232,
{BC::Key::Comm::timeout, 100},
{BC::Key::Comm::termChar, QString(";FF")})
HardwareObject::applyRegisteredSettings() seeds the registered
values the first time the hardware object is constructed, via
SettingsStorage::setGroupDefault() — a write-if-absent
operation, so a value the user has changed in the Communication Settings
dialog is never overwritten. A protocol or key with no registered
default simply falls back to the global literal.
Defaults are deliberately per-driver only — they are not inherited from base classes. Communication framing is a property of a specific instrument’s firmware, not of a hardware category; even sibling models sharing a base class can differ (the QuantumComposers 9510 series wants a 100 ms timeout where its 9210 and 9520 siblings want 200 ms). A model that genuinely shares a value declares it explicitly.
The value’s QVariant type must match what the reader expects for the
key — int for BC::Key::Comm::timeout, QString for
BC::Key::Comm::termChar; the macro cannot enforce this.
HardwareProfileManager — profile metadata
HardwareProfileManager owns the persistent collection of
profiles. A profile is the tuple
(hardwareType, label, driver, settings, [pythonScriptPath,
pythonClassName, pythonEnvPath]). The
(hardwareType, label, driver) triple is set when the
profile is created and is immutable thereafter — only the settings
and the per-profile metadata fields (description, threading override,
Python paths) can be edited later. To use a different driver,
create a new profile.
The pair <hardwareType>:<label> is the QSettings group root and the
profile’s stable identity for everything outside the profile manager
(the runtime configuration, loadouts, the active hardware map). The
driver key is stored under that group as one of the fields, not
as part of the path, but it is fixed for the life of the profile. The
API is documented on HardwareProfileManager.
Profile metadata persists under the HardwareProfiles group:
HardwareProfiles/
<Type>/
<label>/
implementation = <impl-key>
active = true|false
created = <ISO timestamp>
modified = <ISO timestamp>
description = <user description>
threaded = true|false # optional
pythonScriptPath / pythonClassName / pythonEnvPath # python only
The CRUD surface is small — createHardwareProfile,
deleteHardwareProfile, activateHardwareProfile,
deactivateHardwareProfile, plus the per-profile field accessors and
the Python-specific accessors. Every change updates the in-memory cache
under the manager’s QReadWriteLock and is flushed back to
QSettings. Bulk import/export uses QByteArray payloads keyed by
HardwareProfileData.
Profile creation timing
The order in which a profile’s settings appear is critical.
RuntimeHardwareConfigDialog invokes
AddProfileDialog, which presents an
HwSettingsWidget in HwSettingsMode::Create,
populated from the registry’s setting descriptors with their declared
defaults. On accept, AddProfileDialog writes the chosen
communication protocol, every scalar value, and every array entry to
QSettings before calling
HardwareProfileManager::createHardwareProfile(). By the time
HardwareManager later instantiates the
HardwareObject, every setting it reads in its constructor
is already on disk; setDefault calls inside the constructor simply
fill in any keys the user did not touch.
Required settings are read-only after creation precisely because of this contract: changing a Required value after construction would desynchronize what the constructor read from what the rest of the object expects. See Hardware Profiles for the user-facing walkthrough.
System profiles
Some hardware types are required for Blackchirp to operate
(FtmwDigitizer, Clock, plus the LIF types when LIF is enabled). The
manager guarantees a system profile — a profile labeled virtual
backed by the corresponding virtual driver — for each required type via
HardwareProfileManager::ensureSystemProfiles(). System
profiles are flagged by HardwareProfileManager::isSystemProfile()
so the UI can prevent deletion of the only available profile of a
required type.
RuntimeHardwareConfig — active selections
RuntimeHardwareConfig records which profiles are currently
active, keyed by their "<HardwareType>.<label>" profile identity.
The driver key for each active profile is held alongside as a
denormalized field — a copy of the profile’s immutable driver
that supports validation and drift detection — but the profile
identity is the source of truth. This is the layer
HardwareManager::initialize() consults to decide what to
instantiate. See RuntimeHardwareConfig for the full
API.
Read access is unrestricted: any thread can call
RuntimeHardwareConfig::constInstance() and query the active
map under the internal QReadWriteLock. Write access is friend-only
— HardwareManager and RuntimeHardwareConfigDialog
are the only classes that can call setHardwareSelection,
applyConfiguration, setThreaded, and the related mutators. The
friend-class fence is the contract that “only the hardware management
layer changes the active map”; it is a stronger guarantee than a
protected interface because it survives derived classes.
The string-based and template-based query APIs are interchangeable; prefer the templates when the hardware type is statically known:
const auto &cfg = RuntimeHardwareConfig::constInstance();
QStringList labels = cfg.getActiveLabels<FtmwDigitizer>();
QString impl = cfg.getHardwareImplementation<FtmwDigitizer>("default");
The template variants resolve the type key from
T::staticMetaObject.className() so callers never spell raw strings.
RuntimeHardwareConfig::validateConfiguration() checks every
active selection against the registry and returns a
QHash<QString, HardwareValidationResult> keyed by hardware type.
The static overload validateHardwareConfiguration(map) validates an
arbitrary configuration without consulting the singleton — used by
RuntimeHardwareConfigDialog to validate the preview map before
the user commits. Neither method falls back automatically; errors must
be surfaced to the user (or the caller).
LoadoutManager — named maps and FTMW presets
LoadoutManager is the persistence layer above
RuntimeHardwareConfig. A loadout is
{name, hardwareMap, ftmwPresets, currentFtmwPresetName,
lastModified}: a name, the set of profile identities
("<Type>.<label>") that make up this configuration, the FTMW
presets that ride on top of that set, a pointer to the loadout’s
currently active preset, and a timestamp. See
LoadoutManager and HardwareLoadout for
the data model and persistence helpers, and the user-guide pages
Loadouts and
FTMW Presets for the workflow.
The hardware map inside a loadout records each member profile’s
identity together with the driver that profile carried at the
time the loadout was last saved. The driver is denormalized —
the canonical value lives on the profile in
HardwareProfileManager — but storing it lets the loadout
detect when a previously-saved member profile has been removed or
recreated under a different driver, and replay the active set
atomically when the loadout is applied. Loadouts persist under the
Loadouts/ QSettings subtree; the manager is the only writer of
that subtree. All public methods are thread-safe via an internal
QMutex.
FTMW presets
An FtmwPreset is the named operating point that lives
inside a loadout:
RfConfigSnapshot(RF chain scalars + clock table),ChirpConfig(chirp segments + markers),FtmwDigitizerConfig(record length, sample rate, channels, triggers),digiHwKey— the hardware key of the digitizer the preset was captured against.
Presets cannot exist outside a loadout; their lifetime is owned by
LoadoutManager. AWG sample rate is intentionally not
stored: it is a hardware property and is reconstructed from the active
AWG profile when the preset is applied. Switching presets within a
single loadout swaps the FTMW operating point without touching the
hardware map.
The __LastUsed__ sentinel
Each loadout reserves the preset name __LastUsed__
(BC::Store::LM::lastUsedFtmwPresetName) for an automatic
sentinel preset that captures the last fully-accepted FTMW
configuration. The sentinel is updated only on two events:
FtmwConfigDialog::accept— when the user dismisses the FTMW configuration dialog with OK;experiment start — when an experiment begins with the current FTMW configuration.
It is not updated on Apply, on Cancel, or on incidental edits
to the live configuration. The sentinel is hidden from every
user-facing dropdown; ftmwPresetNames excludes it unless its
includeLastUsed flag is set.
Drift detection
A named FTMW preset is captured against a specific hardware map. If
the map drifts — for example, the user swaps the AWG profile, or the
FTMW digitizer — applying an existing preset against the new map can
silently mis-interpret captured frequencies and clock assignments.
RuntimeHardwareConfigDialog guards against this by
comparing the FTMW-relevant subset of the preview hardware map against
the saved map of the loadout being saved. When the two differ and the
loadout has named presets, the dialog raises a three-button prompt:
Discard FTMW presets and save — keep the new hardware map, drop every named preset (and the
__LastUsed__sentinel) from the loadout.Save As instead — punt the change to a fresh loadout via the Save As flow, leaving the original loadout’s presets intact.
Cancel — leave both the loadout and the preview unchanged.
Drift detection runs on save, not on activate; the activate-time prompt is a simpler “you have unsaved changes; save / discard / cancel” dialog.
The Hardware Configuration dialog
RuntimeHardwareConfigDialog is the user-facing surface of
the entire configuration layer. It opens from Hardware → Hardware
Selection (and during the first-run onboarding sequence). The
Hardware Configuration tab carries four splitter panels — left to
right — and a Library Status tab sits alongside it. The mapping
below is the orientation point a contributor needs: “the user changed
panel X, so look at singleton Y.”
- Loadout panel (leftmost) — driven by
LoadoutManager. Lists every saved loadout and exposes the
Activate,Save,Save As…,Copy, andDeletebuttons. Activating a loadout pushes its set of profile identities throughRuntimeHardwareConfig. Save / Save As trigger the drift-detection prompt described above.- Configuration Overview panel — driven by
RuntimeHardwareConfig. A tree of the preview active set: the in-memory copy of the currently active profile identities that the dialog edits before commit. The validation status bar at the bottom of the tab reports whether the preview is valid (every active profile resolves to a registered driver, every required type has at least one active profile).
- Hardware Browser panel — driven by
HardwareRegistry. Lists the hardware types the binary supports and the count of profiles configured for each. Selecting a type populates the right-hand Configuration panel.
- Configuration panel (rightmost) — driven by
HardwareProfileManager. For the type selected in the Hardware Browser, shows every profile (with its label and driver), the Enable checkbox for single-instance types, and Add Profile / Remove Profile buttons. Add Profile invokes
AddProfileDialog, which composesHwSettingsWidgetinHwSettingsMode::Createand writes settings toQSettingsbeforeHardwareProfileManager::createHardwareProfile()runs. The collapsible Advanced section inside the Configuration panel carries the Run in own thread override (which writes toRuntimeHardwareConfig::setThreaded()on accept — the runtime effect is on Hardware Runtime) and, for Python drivers, the script path / class name / environment fields thatHardwareProfileManagerpersists.
The Library Status tab is hosted by LibraryStatusWidget;
the build-time and runtime story for vendor libraries is on
Vendor Libraries.
The dialog is a preview-state editor: every change happens against
its in-memory d_previewRuntimeConfig and d_profileOverrides
state. Hitting Apply Configuration validates the preview, calls
RuntimeHardwareConfig::applyConfiguration(), applies any
threading or Python overrides via the friend-class write path, and
emits the signals that drive the rest of the application to
re-initialize. Hitting Cancel drops the preview unchanged.
Where settings live
Four kinds of “settings” share the same QSettings backing file but
land under disjoint groups, each with a different owner:
- Registry-declared defaults.
Live in process memory only. Applied to every newly constructed
HardwareObjectbyHardwareObject::applyRegisteredSettings(), which the base constructor calls. These defaults seed the per-profile group on first access; once a profile group has a value for a key, the stored value wins on subsequent loads.- Per-profile values.
Stored under
<Type>.<label>/in the QSettings file. Owned byHardwareProfileManager(for profile metadata) and by the correspondingHardwareObjectinstance (for the driver’s own settings) viaSettingsStorage. Created at profile-creation time byAddProfileDialogand by the base constructor; modified byHWDialoginHwSettingsMode::Editand by the driver itself during normal operation.- Loadout snapshots.
Stored under
Loadouts/<name>/in the QSettings file, including the loadout’s set of member profile identities and every FTMW preset. Owned exclusively byLoadoutManager; nothing else writes this subtree.- Application-wide settings.
Stored under
appConfig/. Owned byApplicationConfigManager(data save path, vendor library paths, font, debug-logging toggle, LIF-enable toggle). Other singletons subscribe to its signals so a runtime toggle (e.g., enabling LIF) propagates without a restart where possible. See ApplicationConfigManager for the option registry and SettingsStorage for the underlying key/value store.
The four owners share one file but never one group. Tracing a setting
back to its source therefore reduces to “which group does the key live
in?”, which is the question a contributor opening
~/.config/CrabtreeLab/Blackchirp.conf actually wants answered.